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