1use crypto::{Ed25519Signer, Signer};
3use prost::Message;
4
5use crate::{contract::*, transport::Error};
6
7pub const INITIATION_FORMAT: &str = "heddle.pairing-initiation.v2";
8const DOMAIN: &[u8] = b"heddle.pairing-initiation.v2\0";
9pub const BROWSER_COMPLETION_FORMAT: &str = "heddle.browser-pairing-completion.v1";
10const BROWSER_DOMAIN: &[u8] = b"heddle.browser-pairing-completion.v1\0";
11pub const MAX_PAIRING_SECONDS: i64 = 600;
12
13pub fn sign_initiation(
14 subject: &impl Signer,
15 endpoint: &impl Signer,
16 host: EndpointRef,
17 operation: String,
18 now: i64,
19) -> Result<BeginPairingRequest, Error> {
20 let binding = initiation_binding(
21 subject,
22 host,
23 operation,
24 now,
25 pairing_initiation_binding::Receiver::Device(EndpointRef {
26 public_key: endpoint.public_key().to_vec(),
27 kind: EndpointKind::Device as i32,
28 }),
29 )?;
30 sign_initiation_binding(binding, subject, Some(endpoint))
31}
32pub fn sign_browser_initiation(
34 subject: &impl Signer,
35 host: EndpointRef,
36 operation: String,
37 now: i64,
38) -> Result<BeginPairingRequest, Error> {
39 let binding = initiation_binding(
40 subject,
41 host,
42 operation,
43 now,
44 pairing_initiation_binding::Receiver::Browser(BrowserPairingReceiver {}),
45 )?;
46 sign_initiation_binding(binding, subject, None::<&Ed25519Signer>)
47}
48fn initiation_binding(
49 subject: &impl Signer,
50 host: EndpointRef,
51 operation: String,
52 now: i64,
53 receiver: pairing_initiation_binding::Receiver,
54) -> Result<PairingInitiationBinding, Error> {
55 Ok(PairingInitiationBinding {
56 host: Some(host),
57 client_operation_id: operation,
58 receiver: Some(receiver),
59 subject_public_key: subject.public_key().to_vec(),
60 not_before_unix_seconds: now,
61 expires_at_unix_seconds: now
62 .checked_add(MAX_PAIRING_SECONDS)
63 .ok_or(Error::Protocol("pairing timestamp overflow"))?,
64 })
65}
66fn sign_initiation_binding(
67 binding: PairingInitiationBinding,
68 subject: &impl Signer,
69 endpoint: Option<&impl Signer>,
70) -> Result<BeginPairingRequest, Error> {
71 let canonical_record = initiation_bytes(&binding);
72 let payload = [DOMAIN, canonical_record.as_slice()].concat();
73 let mut signatures = vec![RecordSignature {
74 public_key: subject.public_key().to_vec(),
75 signature: subject.sign(&payload).map_err(io)?,
76 }];
77 if let Some(endpoint) = endpoint
78 && endpoint.public_key() != subject.public_key()
79 {
80 signatures.push(RecordSignature {
81 public_key: endpoint.public_key().to_vec(),
82 signature: endpoint.sign(&payload).map_err(io)?,
83 });
84 }
85 let receiver = binding.receiver.map(|receiver| match receiver {
86 pairing_initiation_binding::Receiver::Device(value) => {
87 begin_pairing_request::Receiver::Device(value)
88 }
89 pairing_initiation_binding::Receiver::Browser(value) => {
90 begin_pairing_request::Receiver::Browser(value)
91 }
92 });
93 Ok(BeginPairingRequest {
94 client_operation_id: binding.client_operation_id,
95 receiver,
96 subject_public_key: binding.subject_public_key,
97 subject_possession: Some(SignedRecord {
98 format: INITIATION_FORMAT.into(),
99 canonical_record,
100 signatures,
101 }),
102 })
103}
104pub fn initiation_bytes(binding: &PairingInitiationBinding) -> Vec<u8> {
108 use prost::encoding::{bytes, int64, message, string};
109 let mut encoded = Vec::new();
110 if let Some(host) = &binding.host {
111 message::encode(1, host, &mut encoded);
112 }
113 if !binding.client_operation_id.is_empty() {
114 string::encode(2, &binding.client_operation_id, &mut encoded);
115 }
116 if let Some(pairing_initiation_binding::Receiver::Device(device)) = &binding.receiver {
117 message::encode(3, device, &mut encoded);
118 }
119 if !binding.subject_public_key.is_empty() {
120 bytes::encode(4, &binding.subject_public_key, &mut encoded);
121 }
122 if binding.not_before_unix_seconds != 0 {
123 int64::encode(5, &binding.not_before_unix_seconds, &mut encoded);
124 }
125 if binding.expires_at_unix_seconds != 0 {
126 int64::encode(6, &binding.expires_at_unix_seconds, &mut encoded);
127 }
128 if let Some(pairing_initiation_binding::Receiver::Browser(browser)) = &binding.receiver {
129 message::encode(7, browser, &mut encoded);
130 }
131 encoded
132}
133
134pub fn verify_initiation(
136 request: &BeginPairingRequest,
137 host: &EndpointRef,
138 now: i64,
139) -> Result<PairingInitiationBinding, Error> {
140 let record = request
141 .subject_possession
142 .as_ref()
143 .ok_or(Error::Protocol("missing pairing possession"))?;
144 if record.format != INITIATION_FORMAT || record.canonical_record.len() > 1024 {
145 return Err(Error::Protocol("invalid pairing possession format or size"));
146 }
147 let binding = PairingInitiationBinding::decode(record.canonical_record.as_slice())?;
148 let receiver = request.receiver.as_ref().map(|receiver| match receiver {
149 begin_pairing_request::Receiver::Device(value) => {
150 pairing_initiation_binding::Receiver::Device(value.clone())
151 }
152 begin_pairing_request::Receiver::Browser(value) => {
153 pairing_initiation_binding::Receiver::Browser(*value)
154 }
155 });
156 if initiation_bytes(&binding) != record.canonical_record
157 || binding.host.as_ref() != Some(host)
158 || host.public_key.len() != 32
159 || host.kind != EndpointKind::Weft as i32
160 || binding.client_operation_id != request.client_operation_id
161 || binding.client_operation_id.is_empty()
162 || binding.client_operation_id.len() > 256
163 || binding.subject_public_key != request.subject_public_key
164 || binding.subject_public_key.len() != 32
165 || binding.receiver != receiver
166 || binding.not_before_unix_seconds > now.saturating_add(30)
167 || binding.not_before_unix_seconds <= 0
168 || binding.expires_at_unix_seconds <= now
169 || binding.expires_at_unix_seconds <= binding.not_before_unix_seconds
170 || binding
171 .expires_at_unix_seconds
172 .saturating_sub(binding.not_before_unix_seconds)
173 > MAX_PAIRING_SECONDS
174 {
175 return Err(Error::Protocol(
176 "pairing possession binding mismatch or expiry",
177 ));
178 }
179 let mut keys = vec![binding.subject_public_key.as_slice()];
180 match binding.receiver.as_ref() {
181 Some(pairing_initiation_binding::Receiver::Device(device))
182 if device.kind == EndpointKind::Device as i32 && device.public_key.len() == 32 =>
183 {
184 if device.public_key != binding.subject_public_key {
185 keys.push(device.public_key.as_slice());
186 }
187 }
188 Some(pairing_initiation_binding::Receiver::Browser(_)) => {}
189 _ => return Err(Error::Protocol("invalid pairing receiver")),
190 }
191 verify_signatures(record, DOMAIN, &keys)?;
192 Ok(binding)
193}
194
195pub fn sign_browser_completion(
196 subject: &impl Signer,
197 host: EndpointRef,
198 operation: String,
199 pairing: RecordRef,
200 approval: BrowserPairingApprovalBinding,
201) -> Result<CompletePairingRequest, Error> {
202 validate_browser_approval(&approval)?;
203 if subject.public_key() != approval.subject_public_key {
204 return Err(Error::Protocol("browser subject differs from approval"));
205 }
206 let binding = BrowserPairingCompletionBinding {
207 host: Some(host),
208 client_operation_id: operation.clone(),
209 pairing: Some(pairing.clone()),
210 approval: Some(approval),
211 };
212 let canonical_record = binding.encode_to_vec();
213 let payload = [BROWSER_DOMAIN, canonical_record.as_slice()].concat();
214 Ok(CompletePairingRequest {
215 client_operation_id: operation,
216 pairing: Some(pairing),
217 proof: Some(complete_pairing_request::Proof::BrowserPossession(
218 SignedRecord {
219 format: BROWSER_COMPLETION_FORMAT.into(),
220 canonical_record,
221 signatures: vec![RecordSignature {
222 public_key: subject.public_key().to_vec(),
223 signature: subject.sign(&payload).map_err(io)?,
224 }],
225 },
226 )),
227 })
228}
229pub fn verify_browser_completion(
233 request: &CompletePairingRequest,
234 host: &EndpointRef,
235 approval: &BrowserPairingApprovalBinding,
236 now: i64,
237) -> Result<BrowserPairingCompletionBinding, Error> {
238 validate_browser_approval(approval)?;
239 let Some(complete_pairing_request::Proof::BrowserPossession(record)) = request.proof.as_ref()
240 else {
241 return Err(Error::Protocol(
242 "browser completion requires subject possession",
243 ));
244 };
245 if record.format != BROWSER_COMPLETION_FORMAT || record.canonical_record.len() > 2048 {
246 return Err(Error::Protocol("invalid browser completion format or size"));
247 }
248 let binding = BrowserPairingCompletionBinding::decode(record.canonical_record.as_slice())?;
249 if binding.encode_to_vec() != record.canonical_record
250 || binding.host.as_ref() != Some(host)
251 || host.public_key.len() != 32
252 || host.kind != EndpointKind::Weft as i32
253 || binding.client_operation_id != request.client_operation_id
254 || request.client_operation_id.is_empty()
255 || request.client_operation_id.len() > 256
256 || binding.pairing != request.pairing
257 || request.pairing.as_ref().is_none_or(|reference| {
258 reference.spool.is_some()
259 || uuid::Uuid::parse_str(&reference.id).map_or(true, |id| id.is_nil())
260 })
261 || binding.approval.as_ref() != Some(approval)
262 || approval.not_before_unix_seconds > now
263 || approval.expires_at_unix_seconds <= now
264 {
265 return Err(Error::Protocol(
266 "browser completion does not match current pairing approval",
267 ));
268 }
269 verify_signatures(
270 record,
271 BROWSER_DOMAIN,
272 &[approval.subject_public_key.as_slice()],
273 )?;
274 Ok(binding)
275}
276fn validate_browser_approval(approval: &BrowserPairingApprovalBinding) -> Result<(), Error> {
277 if approval.format_version != 1
278 || uuid::Uuid::parse_str(&approval.account_id).map_or(true, |id| id.is_nil())
279 || approval.root_public_key.len() != 32
280 || approval.subject_public_key.len() != 32
281 || approval.credential_digest.len() != 32
282 || approval.pairing_challenge.len() != 32
283 || approval.not_before_unix_seconds <= 0
284 || approval.expires_at_unix_seconds <= approval.not_before_unix_seconds
285 {
286 return Err(Error::Protocol("invalid browser pairing approval"));
287 }
288 Ok(())
289}
290fn verify_signatures(record: &SignedRecord, domain: &[u8], keys: &[&[u8]]) -> Result<(), Error> {
291 if record.signatures.len() != keys.len() {
292 return Err(Error::Protocol("pairing requires each actual receiver key"));
293 }
294 let payload = [domain, record.canonical_record.as_slice()].concat();
295 for (signature, key) in record.signatures.iter().zip(keys) {
296 if signature.public_key != *key {
297 return Err(Error::Protocol("unexpected pairing possession signer"));
298 }
299 Ed25519Signer::verify_with_public_key(&payload, key, &signature.signature)
300 .map_err(|_| Error::Protocol("invalid pairing possession signature"))?;
301 }
302 Ok(())
303}
304fn io(error: impl std::fmt::Display) -> Error {
305 Error::Io(error.to_string())
306}
307#[cfg(test)]
308mod tests {
309 use super::*;
310 #[test]
311 fn provisional_pairing_binds_both_keys_host_operation_and_deadline() {
312 let subject = Ed25519Signer::from_seed(&[31; 32]).expect("subject");
313 let endpoint = Ed25519Signer::from_seed(&[32; 32]).expect("endpoint");
314 let host = EndpointRef {
315 public_key: vec![33; 32],
316 kind: EndpointKind::Weft as i32,
317 };
318 let request = sign_initiation(
319 &subject,
320 &endpoint,
321 host.clone(),
322 "pairing-operation".into(),
323 1_800_000_000,
324 )
325 .expect("request");
326 verify_initiation(&request, &host, 1_800_000_001).expect("provisional possession");
327 let mut missing = request.clone();
328 missing
329 .subject_possession
330 .as_mut()
331 .expect("proof")
332 .signatures
333 .pop();
334 assert!(verify_initiation(&missing, &host, 1_800_000_001).is_err());
335 let mut wrong = request.clone();
336 wrong.subject_possession.as_mut().expect("proof").signatures[1].signature[0] ^= 1;
337 assert!(
338 verify_initiation(&wrong, &host, 1_800_000_001).is_err(),
339 "endpoint signature required"
340 );
341 let mut other = request.clone();
342 other.client_operation_id = "different".into();
343 assert!(verify_initiation(&other, &host, 1_800_000_001).is_err());
344 let mut other_host = host.clone();
345 other_host.public_key[0] ^= 1;
346 assert!(verify_initiation(&request, &other_host, 1_800_000_001).is_err());
347 assert!(verify_initiation(&request, &host, 1_800_000_600).is_err());
348 }
349 #[test]
350 fn browser_receiver_proves_only_its_key_and_exact_approved_credential() {
351 let subject = Ed25519Signer::from_seed(&[41; 32]).expect("browser key");
352 let host = EndpointRef {
353 public_key: vec![42; 32],
354 kind: EndpointKind::Weft as i32,
355 };
356 let now = 1_800_000_000;
357 let start = sign_browser_initiation(&subject, host.clone(), "browser-start".into(), now)
358 .expect("browser initiation");
359 let binding = verify_initiation(&start, &host, now).expect("browser possession");
360 assert!(matches!(
361 binding.receiver,
362 Some(pairing_initiation_binding::Receiver::Browser(_))
363 ));
364 assert_eq!(
365 start
366 .subject_possession
367 .as_ref()
368 .expect("proof")
369 .signatures
370 .len(),
371 1
372 );
373 let mut substituted = start.clone();
374 substituted.receiver = Some(begin_pairing_request::Receiver::Device(EndpointRef {
375 public_key: subject.public_key().to_vec(),
376 kind: EndpointKind::Device as i32,
377 }));
378 assert!(
379 verify_initiation(&substituted, &host, now).is_err(),
380 "browser key cannot acquire a fabricated endpoint binding"
381 );
382 let approval = BrowserPairingApprovalBinding {
383 format_version: 1,
384 root_public_key: vec![43; 32],
385 subject_public_key: subject.public_key().to_vec(),
386 account_id: "00000000-0000-0000-0000-000000000044".into(),
387 credential_digest: vec![45; 32],
388 pairing_challenge: vec![46; 32],
389 not_before_unix_seconds: now,
390 expires_at_unix_seconds: now + 3600,
391 };
392 let request = sign_browser_completion(
393 &subject,
394 host.clone(),
395 "browser-complete".into(),
396 RecordRef {
397 spool: None,
398 id: "00000000-0000-0000-0000-000000000047".into(),
399 },
400 approval.clone(),
401 )
402 .expect("browser completion");
403 verify_browser_completion(&request, &host, &approval, now)
404 .expect("current subject accepts exact approval");
405 let mut changed = approval.clone();
406 changed.account_id = "00000000-0000-0000-0000-000000000048".into();
407 assert!(
408 verify_browser_completion(&request, &host, &changed, now).is_err(),
409 "approval must bind the exact account even when keys match"
410 );
411 let mut changed = request.clone();
412 changed.pairing.as_mut().expect("pairing").id =
413 "00000000-0000-0000-0000-000000000048".into();
414 assert!(verify_browser_completion(&changed, &host, &approval, now).is_err());
415 let mut changed_host = host.clone();
416 changed_host.public_key[0] ^= 1;
417 assert!(verify_browser_completion(&request, &changed_host, &approval, now).is_err());
418 assert!(verify_browser_completion(&request, &host, &approval, now + 3600).is_err());
419 let vector = format!(
420 "initiation={}\ncompletion={}\n",
421 hex::encode(start.encode_to_vec()),
422 hex::encode(request.encode_to_vec())
423 );
424 assert_eq!(
425 vector,
426 include_str!("../tests/fixtures/browser_pairing_v1.txt")
427 );
428 }
429}