Skip to main content

heddle_thread_api/
pairing.rs

1//! Pairing proves each actual receiver key, never independent account authority.
2use 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}
32/// Browser receivers retain only a credential key, with no fabricated Iroh endpoint.
33pub 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        // Not part of the signed binding: the server checks the host against
103        // its own policy. Callers set it after signing; empty is the default.
104        web_origin: String::new(),
105    })
106}
107/// Normative signed format: ascending protobuf tags, preserving oneof presence.
108/// Prost emits a oneof at its declaration position whereas protobuf-es emits
109/// tag order; ordinary RPC bodies need not agree, but signed records must.
110pub 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
137/// A fresh exact RPC proof and host-side nonce admission remain mandatory.
138pub 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}
232/// Verify only subject possession over the exact stored approval. The host must
233/// separately reverify the private derived Biscuit, account, revocations and expiry.
234/// This creates neither an endpoint attachment nor an independent mint issuer.
235pub 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}