hns_chat_protocol/
binding.rs1use 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 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}