Skip to main content

hns_chat_protocol/
binding.rs

1use hns_covenants::{Resource, ResourceRecord};
2use k256::ecdsa::VerifyingKey;
3
4use crate::ChatProtocolError;
5
6const PREFIX: &str = "hnschat=";
7const CANONICAL_PREFIX: &str = "hnschat=v1;key=owner;pk=";
8const GENERATION_FIELD: &str = ";generation=";
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum ChatKeyMode {
12    Owner,
13}
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub struct ChatIdentityBindingV1 {
17    pub key_mode: ChatKeyMode,
18    pub xonly_public_key: [u8; 32],
19    pub generation: u32,
20}
21
22impl ChatIdentityBindingV1 {
23    /// Validate a programmatically constructed version-1 identity binding.
24    ///
25    /// Parsing and encoding are canonical; this method lets downstream users
26    /// enforce the same invariant without serializing the value first.
27    pub fn validate(&self) -> Result<(), ChatProtocolError> {
28        if self.key_mode != ChatKeyMode::Owner || self.generation == 0 {
29            return Err(ChatProtocolError::Invalid(
30                "version 1 requires owner key mode and nonzero generation",
31            ));
32        }
33        validate_xonly_public_key(&self.xonly_public_key)
34    }
35}
36
37pub fn parse_chat_binding(text: &str) -> Result<ChatIdentityBindingV1, ChatProtocolError> {
38    if !text.is_ascii() || text.is_empty() || text.bytes().any(|byte| !byte.is_ascii_graphic()) {
39        return Err(ChatProtocolError::Invalid(
40            "resource binding must contain only visible ASCII without whitespace",
41        ));
42    }
43    let remainder = text
44        .strip_prefix(CANONICAL_PREFIX)
45        .ok_or(ChatProtocolError::Invalid(
46            "resource fields are missing, unknown, duplicated, or out of order",
47        ))?;
48    let (public_key, generation) = match remainder.split_once(GENERATION_FIELD) {
49        Some((public_key, generation)) => {
50            if generation.contains(';') {
51                return Err(ChatProtocolError::Invalid(
52                    "resource has duplicate, unknown, or trailing fields",
53                ));
54            }
55            (public_key, parse_generation(generation)?)
56        }
57        None => {
58            if remainder.contains(';') {
59                return Err(ChatProtocolError::Invalid(
60                    "resource has duplicate, unknown, or trailing fields",
61                ));
62            }
63            (remainder, 1)
64        }
65    };
66    let xonly_public_key = parse_xonly_public_key(public_key)?;
67    Ok(ChatIdentityBindingV1 {
68        key_mode: ChatKeyMode::Owner,
69        xonly_public_key,
70        generation,
71    })
72}
73
74pub fn encode_chat_binding(binding: &ChatIdentityBindingV1) -> Result<String, ChatProtocolError> {
75    binding.validate()?;
76    Ok(format!(
77        "{CANONICAL_PREFIX}{}{GENERATION_FIELD}{}",
78        hex::encode(binding.xonly_public_key),
79        binding.generation
80    ))
81}
82
83pub fn select_chat_binding<'a>(
84    records: impl IntoIterator<Item = &'a str>,
85) -> Result<ChatIdentityBindingV1, ChatProtocolError> {
86    let mut selected = None;
87    for record in records {
88        if !record.starts_with(PREFIX) {
89            continue;
90        }
91        let parsed = parse_chat_binding(record)?;
92        if selected.replace(parsed).is_some() {
93            return Err(ChatProtocolError::AmbiguousBinding);
94        }
95    }
96    selected.ok_or(ChatProtocolError::MissingBinding)
97}
98
99pub fn select_chat_binding_from_resource(
100    resource: &Resource,
101) -> Result<ChatIdentityBindingV1, ChatProtocolError> {
102    let mut selected = None;
103    for record in resource.records() {
104        let ResourceRecord::Txt { strings } = record else {
105            continue;
106        };
107        let length = strings.iter().try_fold(0_usize, |total, string| {
108            total
109                .checked_add(string.len())
110                .ok_or(ChatProtocolError::TooLarge {
111                    actual: usize::MAX,
112                    maximum: hns_covenants::MAX_RESOURCE_SIZE,
113                })
114        })?;
115        if length > hns_covenants::MAX_RESOURCE_SIZE {
116            return Err(ChatProtocolError::TooLarge {
117                actual: length,
118                maximum: hns_covenants::MAX_RESOURCE_SIZE,
119            });
120        }
121        let mut text = Vec::with_capacity(length);
122        for string in strings {
123            text.extend_from_slice(string);
124        }
125        if !text.starts_with(PREFIX.as_bytes()) {
126            continue;
127        }
128        let text = std::str::from_utf8(&text)
129            .map_err(|_| ChatProtocolError::Invalid("hnschat TXT record is not canonical ASCII"))?;
130        let parsed = parse_chat_binding(text)?;
131        if selected.replace(parsed).is_some() {
132            return Err(ChatProtocolError::AmbiguousBinding);
133        }
134    }
135    selected.ok_or(ChatProtocolError::MissingBinding)
136}
137
138fn parse_generation(text: &str) -> Result<u32, ChatProtocolError> {
139    if text.is_empty()
140        || (text.len() > 1 && text.starts_with('0'))
141        || !text.bytes().all(|byte| byte.is_ascii_digit())
142    {
143        return Err(ChatProtocolError::Invalid(
144            "generation is not canonical decimal",
145        ));
146    }
147    let generation = text
148        .parse::<u32>()
149        .map_err(|_| ChatProtocolError::Invalid("generation exceeds u32"))?;
150    if generation == 0 {
151        return Err(ChatProtocolError::Invalid("generation must be nonzero"));
152    }
153    Ok(generation)
154}
155
156fn parse_xonly_public_key(text: &str) -> Result<[u8; 32], ChatProtocolError> {
157    if text.len() != 64
158        || !text
159            .bytes()
160            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
161    {
162        return Err(ChatProtocolError::Invalid(
163            "public key must be exactly 64 lowercase hexadecimal characters",
164        ));
165    }
166    let decoded = hex::decode(text)
167        .map_err(|_| ChatProtocolError::Invalid("public key is not hexadecimal"))?;
168    let public_key: [u8; 32] = decoded
169        .try_into()
170        .map_err(|_| ChatProtocolError::Invalid("public key is not 32 bytes"))?;
171    validate_xonly_public_key(&public_key)?;
172    Ok(public_key)
173}
174
175fn validate_xonly_public_key(public_key: &[u8; 32]) -> Result<(), ChatProtocolError> {
176    let mut compressed = [0_u8; 33];
177    compressed[0] = 0x02;
178    compressed[1..].copy_from_slice(public_key);
179    VerifyingKey::from_sec1_bytes(&compressed)
180        .map(|_| ())
181        .map_err(|_| ChatProtocolError::Invalid("invalid secp256k1 x-only public key"))
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    const KEY: &str = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917";
189    const FIXTURES: &str = include_str!("../fixtures/chat-v1/hns-chat-resource-v1.txt");
190
191    fn fixture(name: &str) -> &str {
192        FIXTURES
193            .lines()
194            .filter_map(|line| line.split_once('='))
195            .find_map(|(key, value)| (key == name).then_some(value))
196            .unwrap_or_else(|| panic!("missing fixture {name}"))
197    }
198
199    #[test]
200    fn canonical_and_compatibility_forms_parse() {
201        let explicit = parse_chat_binding(fixture("valid_explicit")).expect("explicit generation");
202        assert_eq!(explicit.generation, 7);
203        assert_eq!(explicit.key_mode, ChatKeyMode::Owner);
204        let omitted = parse_chat_binding(&format!("hnschat=v1;key=owner;pk={KEY}"))
205            .expect("compatibility form");
206        assert_eq!(omitted.generation, 1);
207        assert_eq!(
208            encode_chat_binding(&omitted).expect("canonical encoding"),
209            format!("hnschat=v1;key=owner;pk={KEY};generation=1")
210        );
211    }
212
213    #[test]
214    fn malformed_bindings_fail_closed() {
215        for value in [
216            fixture("invalid_uppercase").to_owned(),
217            fixture("invalid_duplicate").to_owned(),
218            fixture("invalid_unknown").to_owned(),
219            fixture("invalid_zero_generation").to_owned(),
220            fixture("invalid_leading_zero_generation").to_owned(),
221            fixture("invalid_x_coordinate").to_owned(),
222            format!("hnschat=v1;key=owner;key=owner;pk={KEY};generation=1"),
223            format!("hnschat=v1;key=owner;pk={KEY};generation=1;"),
224            format!("hnschat=v1;key=owner;pk={KEY};generation=1 "),
225        ] {
226            assert!(parse_chat_binding(&value).is_err(), "accepted {value}");
227        }
228    }
229
230    #[test]
231    fn selection_rejects_multiple_candidate_records() {
232        let canonical = format!("hnschat=v1;key=owner;pk={KEY};generation=1");
233        assert_eq!(
234            select_chat_binding(["unrelated", canonical.as_str()])
235                .expect("one binding")
236                .generation,
237            1
238        );
239        assert_eq!(
240            select_chat_binding([canonical.as_str(), canonical.as_str()]),
241            Err(ChatProtocolError::AmbiguousBinding)
242        );
243    }
244
245    #[test]
246    fn authenticated_resource_txt_selection_concatenates_chunks_and_rejects_ambiguity() {
247        let binding = fixture("valid_explicit");
248        let split = binding.len() / 2;
249        let mut raw = vec![0, 6, 2, split as u8];
250        raw.extend_from_slice(&binding.as_bytes()[..split]);
251        raw.push((binding.len() - split) as u8);
252        raw.extend_from_slice(&binding.as_bytes()[split..]);
253        let resource = Resource::decode(&raw).expect("resource");
254        assert_eq!(
255            select_chat_binding_from_resource(&resource)
256                .expect("binding")
257                .generation,
258            7
259        );
260        raw.extend_from_slice(&[6, 1, binding.len() as u8]);
261        raw.extend_from_slice(binding.as_bytes());
262        let resource = Resource::decode(&raw).expect("resource");
263        assert_eq!(
264            select_chat_binding_from_resource(&resource),
265            Err(ChatProtocolError::AmbiguousBinding)
266        );
267    }
268}