hns-chat-protocol 0.2.0

Owner-bound HNS Chat identity and opaque mailbox protocol values
Documentation
use hns_covenants::{Resource, ResourceRecord};
use k256::ecdsa::VerifyingKey;

use crate::ChatProtocolError;

const PREFIX: &str = "hnschat=";
const CANONICAL_PREFIX: &str = "hnschat=v1;key=owner;pk=";
const GENERATION_FIELD: &str = ";generation=";

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ChatKeyMode {
    Owner,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ChatIdentityBindingV1 {
    pub key_mode: ChatKeyMode,
    pub xonly_public_key: [u8; 32],
    pub generation: u32,
}

impl ChatIdentityBindingV1 {
    /// Validate a programmatically constructed version-1 identity binding.
    ///
    /// Parsing and encoding are canonical; this method lets downstream users
    /// enforce the same invariant without serializing the value first.
    pub fn validate(&self) -> Result<(), ChatProtocolError> {
        if self.key_mode != ChatKeyMode::Owner || self.generation == 0 {
            return Err(ChatProtocolError::Invalid(
                "version 1 requires owner key mode and nonzero generation",
            ));
        }
        validate_xonly_public_key(&self.xonly_public_key)
    }
}

pub fn parse_chat_binding(text: &str) -> Result<ChatIdentityBindingV1, ChatProtocolError> {
    if !text.is_ascii() || text.is_empty() || text.bytes().any(|byte| !byte.is_ascii_graphic()) {
        return Err(ChatProtocolError::Invalid(
            "resource binding must contain only visible ASCII without whitespace",
        ));
    }
    let remainder = text
        .strip_prefix(CANONICAL_PREFIX)
        .ok_or(ChatProtocolError::Invalid(
            "resource fields are missing, unknown, duplicated, or out of order",
        ))?;
    let (public_key, generation) = match remainder.split_once(GENERATION_FIELD) {
        Some((public_key, generation)) => {
            if generation.contains(';') {
                return Err(ChatProtocolError::Invalid(
                    "resource has duplicate, unknown, or trailing fields",
                ));
            }
            (public_key, parse_generation(generation)?)
        }
        None => {
            if remainder.contains(';') {
                return Err(ChatProtocolError::Invalid(
                    "resource has duplicate, unknown, or trailing fields",
                ));
            }
            (remainder, 1)
        }
    };
    let xonly_public_key = parse_xonly_public_key(public_key)?;
    Ok(ChatIdentityBindingV1 {
        key_mode: ChatKeyMode::Owner,
        xonly_public_key,
        generation,
    })
}

pub fn encode_chat_binding(binding: &ChatIdentityBindingV1) -> Result<String, ChatProtocolError> {
    binding.validate()?;
    Ok(format!(
        "{CANONICAL_PREFIX}{}{GENERATION_FIELD}{}",
        hex::encode(binding.xonly_public_key),
        binding.generation
    ))
}

pub fn select_chat_binding<'a>(
    records: impl IntoIterator<Item = &'a str>,
) -> Result<ChatIdentityBindingV1, ChatProtocolError> {
    let mut selected = None;
    for record in records {
        if !record.starts_with(PREFIX) {
            continue;
        }
        let parsed = parse_chat_binding(record)?;
        if selected.replace(parsed).is_some() {
            return Err(ChatProtocolError::AmbiguousBinding);
        }
    }
    selected.ok_or(ChatProtocolError::MissingBinding)
}

pub fn select_chat_binding_from_resource(
    resource: &Resource,
) -> Result<ChatIdentityBindingV1, ChatProtocolError> {
    let mut selected = None;
    for record in resource.records() {
        let ResourceRecord::Txt { strings } = record else {
            continue;
        };
        let length = strings.iter().try_fold(0_usize, |total, string| {
            total
                .checked_add(string.len())
                .ok_or(ChatProtocolError::TooLarge {
                    actual: usize::MAX,
                    maximum: hns_covenants::MAX_RESOURCE_SIZE,
                })
        })?;
        if length > hns_covenants::MAX_RESOURCE_SIZE {
            return Err(ChatProtocolError::TooLarge {
                actual: length,
                maximum: hns_covenants::MAX_RESOURCE_SIZE,
            });
        }
        let mut text = Vec::with_capacity(length);
        for string in strings {
            text.extend_from_slice(string);
        }
        if !text.starts_with(PREFIX.as_bytes()) {
            continue;
        }
        let text = std::str::from_utf8(&text)
            .map_err(|_| ChatProtocolError::Invalid("hnschat TXT record is not canonical ASCII"))?;
        let parsed = parse_chat_binding(text)?;
        if selected.replace(parsed).is_some() {
            return Err(ChatProtocolError::AmbiguousBinding);
        }
    }
    selected.ok_or(ChatProtocolError::MissingBinding)
}

fn parse_generation(text: &str) -> Result<u32, ChatProtocolError> {
    if text.is_empty()
        || (text.len() > 1 && text.starts_with('0'))
        || !text.bytes().all(|byte| byte.is_ascii_digit())
    {
        return Err(ChatProtocolError::Invalid(
            "generation is not canonical decimal",
        ));
    }
    let generation = text
        .parse::<u32>()
        .map_err(|_| ChatProtocolError::Invalid("generation exceeds u32"))?;
    if generation == 0 {
        return Err(ChatProtocolError::Invalid("generation must be nonzero"));
    }
    Ok(generation)
}

fn parse_xonly_public_key(text: &str) -> Result<[u8; 32], ChatProtocolError> {
    if text.len() != 64
        || !text
            .bytes()
            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
    {
        return Err(ChatProtocolError::Invalid(
            "public key must be exactly 64 lowercase hexadecimal characters",
        ));
    }
    let decoded = hex::decode(text)
        .map_err(|_| ChatProtocolError::Invalid("public key is not hexadecimal"))?;
    let public_key: [u8; 32] = decoded
        .try_into()
        .map_err(|_| ChatProtocolError::Invalid("public key is not 32 bytes"))?;
    validate_xonly_public_key(&public_key)?;
    Ok(public_key)
}

fn validate_xonly_public_key(public_key: &[u8; 32]) -> Result<(), ChatProtocolError> {
    let mut compressed = [0_u8; 33];
    compressed[0] = 0x02;
    compressed[1..].copy_from_slice(public_key);
    VerifyingKey::from_sec1_bytes(&compressed)
        .map(|_| ())
        .map_err(|_| ChatProtocolError::Invalid("invalid secp256k1 x-only public key"))
}

#[cfg(test)]
mod tests {
    use super::*;

    const KEY: &str = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917";
    const FIXTURES: &str = include_str!("../fixtures/chat-v1/hns-chat-resource-v1.txt");

    fn fixture(name: &str) -> &str {
        FIXTURES
            .lines()
            .filter_map(|line| line.split_once('='))
            .find_map(|(key, value)| (key == name).then_some(value))
            .unwrap_or_else(|| panic!("missing fixture {name}"))
    }

    #[test]
    fn canonical_and_compatibility_forms_parse() {
        let explicit = parse_chat_binding(fixture("valid_explicit")).expect("explicit generation");
        assert_eq!(explicit.generation, 7);
        assert_eq!(explicit.key_mode, ChatKeyMode::Owner);
        let omitted = parse_chat_binding(&format!("hnschat=v1;key=owner;pk={KEY}"))
            .expect("compatibility form");
        assert_eq!(omitted.generation, 1);
        assert_eq!(
            encode_chat_binding(&omitted).expect("canonical encoding"),
            format!("hnschat=v1;key=owner;pk={KEY};generation=1")
        );
    }

    #[test]
    fn malformed_bindings_fail_closed() {
        for value in [
            fixture("invalid_uppercase").to_owned(),
            fixture("invalid_duplicate").to_owned(),
            fixture("invalid_unknown").to_owned(),
            fixture("invalid_zero_generation").to_owned(),
            fixture("invalid_leading_zero_generation").to_owned(),
            fixture("invalid_x_coordinate").to_owned(),
            format!("hnschat=v1;key=owner;key=owner;pk={KEY};generation=1"),
            format!("hnschat=v1;key=owner;pk={KEY};generation=1;"),
            format!("hnschat=v1;key=owner;pk={KEY};generation=1 "),
        ] {
            assert!(parse_chat_binding(&value).is_err(), "accepted {value}");
        }
    }

    #[test]
    fn selection_rejects_multiple_candidate_records() {
        let canonical = format!("hnschat=v1;key=owner;pk={KEY};generation=1");
        assert_eq!(
            select_chat_binding(["unrelated", canonical.as_str()])
                .expect("one binding")
                .generation,
            1
        );
        assert_eq!(
            select_chat_binding([canonical.as_str(), canonical.as_str()]),
            Err(ChatProtocolError::AmbiguousBinding)
        );
    }

    #[test]
    fn authenticated_resource_txt_selection_concatenates_chunks_and_rejects_ambiguity() {
        let binding = fixture("valid_explicit");
        let split = binding.len() / 2;
        let mut raw = vec![0, 6, 2, split as u8];
        raw.extend_from_slice(&binding.as_bytes()[..split]);
        raw.push((binding.len() - split) as u8);
        raw.extend_from_slice(&binding.as_bytes()[split..]);
        let resource = Resource::decode(&raw).expect("resource");
        assert_eq!(
            select_chat_binding_from_resource(&resource)
                .expect("binding")
                .generation,
            7
        );
        raw.extend_from_slice(&[6, 1, binding.len() as u8]);
        raw.extend_from_slice(binding.as_bytes());
        let resource = Resource::decode(&raw).expect("resource");
        assert_eq!(
            select_chat_binding_from_resource(&resource),
            Err(ChatProtocolError::AmbiguousBinding)
        );
    }
}