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    })
103}
104/// Normative signed format: ascending protobuf tags, preserving oneof presence.
105/// Prost emits a oneof at its declaration position whereas protobuf-es emits
106/// tag order; ordinary RPC bodies need not agree, but signed records must.
107pub 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
134/// A fresh exact RPC proof and host-side nonce admission remain mandatory.
135pub 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}
229/// Verify only subject possession over the exact stored approval. The host must
230/// separately reverify the private derived Biscuit, account, revocations and expiry.
231/// This creates neither an endpoint attachment nor an independent mint issuer.
232pub 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}