Skip to main content

auths_pairing_protocol/
protocol.rs

1use chrono::{DateTime, Utc};
2use zeroize::Zeroizing;
3
4use auths_crypto::TypedSeed;
5use auths_keri::Capability;
6
7use crate::error::ProtocolError;
8use crate::response::PairingResponse;
9use crate::sas::{self, TransportKey};
10use crate::token::{PairingSession, PairingToken};
11
12/// Result of a successfully completed pairing exchange (initiator side).
13pub struct CompletedPairing {
14    /// The 32-byte P-256 ECDH shared secret (zeroized on drop).
15    pub shared_secret: Zeroizing<[u8; 32]>,
16    /// The peer's signing public key (curve carried via `response.curve`).
17    pub peer_signing_pubkey: Vec<u8>,
18    /// The peer's DID string.
19    pub peer_did: String,
20    /// The pairing response for downstream processing.
21    pub response: PairingResponse,
22    /// The 8-byte SAS for human verification.
23    pub sas: [u8; 10],
24    /// Single-use transport encryption key.
25    pub transport_key: TransportKey,
26    /// The initiator's P-256 ECDH ephemeral public key (SEC1 compressed, 33 bytes).
27    pub initiator_ephemeral_pub: Vec<u8>,
28}
29
30impl std::fmt::Debug for CompletedPairing {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.debug_struct("CompletedPairing")
33            .field("shared_secret", &"[redacted 32 bytes]")
34            .field(
35                "peer_signing_pubkey",
36                &format!("{} bytes", self.peer_signing_pubkey.len()),
37            )
38            .field("peer_did", &self.peer_did)
39            .field("response", &self.response)
40            .field("sas", &"[redacted 10 bytes]")
41            .field("transport_key", &"[redacted]")
42            .field(
43                "initiator_ephemeral_pub",
44                &format!("{} bytes", self.initiator_ephemeral_pub.len()),
45            )
46            .finish()
47    }
48}
49
50/// Result of a successful pairing response (responder side).
51pub struct ResponderResult {
52    pub response: PairingResponse,
53    pub shared_secret: Zeroizing<[u8; 32]>,
54    pub sas: [u8; 10],
55    pub transport_key: TransportKey,
56}
57
58/// Transport-agnostic pairing protocol state machine.
59///
60/// `EphemeralSecret` from p256::ecdh is `!Clone + !Serialize`, so this
61/// state machine is inherently ephemeral — it lives in memory only and
62/// cannot be persisted across app restarts.
63///
64/// Usage:
65/// ```ignore
66/// // Initiator side:
67/// let (protocol, token) = PairingProtocol::initiate(now, controller_did, endpoint, caps)?;
68/// let token_bytes = serde_json::to_vec(&token)?;
69/// // Send token_bytes to peer over transport (HTTP, BLE, QR, etc.)
70///
71/// // After receiving response bytes from peer:
72/// let completed = protocol.complete(now, response_bytes)?;
73/// // completed.shared_secret, completed.peer_did are now available
74/// ```
75pub struct PairingProtocol {
76    session: PairingSession,
77}
78
79impl PairingProtocol {
80    /// Initiate a pairing session.
81    ///
82    /// Args:
83    /// * `now` - Current time (injected, not fetched internally)
84    /// * `controller_did` - The initiator's identity DID
85    /// * `endpoint` - Registry endpoint URL
86    /// * `capabilities` - Capabilities to grant to the paired device
87    ///
88    /// Usage:
89    /// ```ignore
90    /// let (protocol, token) = PairingProtocol::initiate(now, did, endpoint, caps)?;
91    /// ```
92    pub fn initiate(
93        now: DateTime<Utc>,
94        controller_did: String,
95        endpoint: String,
96        capabilities: Vec<Capability>,
97    ) -> Result<(Self, PairingToken), ProtocolError> {
98        let session = PairingToken::generate(now, controller_did, endpoint, capabilities)?;
99        let token = session.token.clone();
100        Ok((Self { session }, token))
101    }
102
103    /// Complete the pairing exchange with a received response.
104    ///
105    /// Consumes the protocol state (ephemeral secret is used exactly once).
106    ///
107    /// Args:
108    /// * `now` - Current time for expiry checking
109    /// * `response_bytes` - Serialized `PairingResponse` from the peer
110    ///
111    /// Usage:
112    /// ```ignore
113    /// let completed = protocol.complete(now, &response_bytes)?;
114    /// ```
115    pub fn complete(
116        mut self,
117        now: DateTime<Utc>,
118        response_bytes: &[u8],
119    ) -> Result<CompletedPairing, ProtocolError> {
120        let response: PairingResponse = serde_json::from_slice(response_bytes)?;
121        response.verify(now, &self.session.token)?;
122        self.complete_inner(now, response)
123    }
124
125    /// Complete the pairing exchange with a structured response.
126    ///
127    /// Args:
128    /// * `now` - Current time for expiry checking
129    /// * `response` - The peer's `PairingResponse`
130    pub fn complete_with_response(
131        mut self,
132        now: DateTime<Utc>,
133        response: PairingResponse,
134    ) -> Result<CompletedPairing, ProtocolError> {
135        response.verify(now, &self.session.token)?;
136        self.complete_inner(now, response)
137    }
138
139    fn complete_inner(
140        &mut self,
141        _now: DateTime<Utc>,
142        response: PairingResponse,
143    ) -> Result<CompletedPairing, ProtocolError> {
144        let initiator_ecdh_pub = self.session.ephemeral_pubkey_bytes()?;
145        let responder_ecdh_pub = response.device_ephemeral_pubkey_bytes()?;
146        let shared_secret = self.session.complete_exchange(&responder_ecdh_pub)?;
147        let peer_signing_pubkey = response.device_signing_pubkey_bytes()?;
148        let peer_did = response.device_did.clone();
149        let session_id = &self.session.token.session_id;
150        let short_code = &self.session.token.short_code;
151
152        let sas_bytes = sas::derive_sas(
153            &shared_secret,
154            &initiator_ecdh_pub,
155            &responder_ecdh_pub,
156            session_id,
157            short_code,
158        );
159        let transport_key = sas::derive_transport_key(
160            &shared_secret,
161            &initiator_ecdh_pub,
162            &responder_ecdh_pub,
163            session_id,
164            short_code,
165        );
166
167        Ok(CompletedPairing {
168            shared_secret,
169            peer_signing_pubkey,
170            peer_did,
171            response,
172            sas: sas_bytes,
173            transport_key,
174            initiator_ephemeral_pub: initiator_ecdh_pub,
175        })
176    }
177
178    /// Get a reference to the pairing token for display/transmission.
179    pub fn token(&self) -> &PairingToken {
180        &self.session.token
181    }
182}
183
184// =============================================================================
185// fn-129.T6: Typestate chain — enforces "Paired requires SAS confirmation"
186// as a compile-time property.
187//
188// State transitions (linear, each `consume self`):
189//
190//   PairingFlow<Init>
191//     │  accept_response(response, now) → verify + derive ECDH + SAS + transport key
192//     ▼
193//   PairingFlow<Responded>
194//     │  confirm(SasMatch) → user-visual confirmation token
195//     ▼
196//   PairingFlow<Confirmed>
197//     │  finalize() → produces CompletedPairing
198//     ▼
199//   PairingFlow<Paired>  (not constructable without all three steps)
200//
201// `SasMatch` is a zero-sized proof token: the only way to construct it is
202// via `SasMatch::user_confirmed_visual_match()`, which takes both SAS byte
203// arrays and confirms the caller checked them. The type itself cannot be
204// forged from outside this crate (non-exhaustive + module-private
205// constructor).
206//
207// The existing `complete()` / `complete_with_response()` methods remain as
208// a fast-path convenience for callers that have already performed SAS
209// confirmation via an out-of-band channel. New callers should prefer the
210// typestate chain for the stronger guarantee. Downstream (auths-sdk,
211// auths-cli) callers continue to compile unchanged; migration is tracked
212// in a follow-up.
213// =============================================================================
214
215use std::marker::PhantomData;
216
217/// Initial state: pairing token generated, no response received yet.
218pub struct Init;
219/// Response received and cryptographically verified; SAS + transport key
220/// derived. Awaiting user's visual SAS confirmation.
221pub struct Responded;
222/// User has confirmed the SAS matches on both devices. Ready to finalize.
223pub struct Confirmed;
224/// Pairing complete. The [`CompletedPairing`] extractor returns the
225/// cryptographic material.
226pub struct Paired;
227
228/// Zero-sized proof that the user visually compared both SAS outputs and
229/// confirmed they match. The only constructor is
230/// [`SasMatch::user_confirmed_visual_match`]; callers cannot forge one
231/// without having seen both SAS arrays.
232#[non_exhaustive]
233pub struct SasMatch {
234    _sealed: (),
235}
236
237impl SasMatch {
238    /// Produce a `SasMatch` after the user has visually compared both
239    /// SAS outputs and confirmed they match. Callers pass both byte arrays
240    /// so the proof is at least textually bound to a specific pair; the
241    /// actual equality is the USER's judgement (that's the protocol's
242    /// whole point).
243    ///
244    /// In tests, this is constructed freely. In production UIs, this
245    /// MUST be called only after the user has pressed "match" on the
246    /// confirmation screen (fn-129 plan §2.1.4 / §2.2.4).
247    pub fn user_confirmed_visual_match(_ours: &[u8; 10], _theirs: &[u8; 10]) -> Self {
248        Self { _sealed: () }
249    }
250}
251
252/// Typed pairing flow. The type parameter tracks which state the flow is
253/// in; methods are available only in the appropriate state.
254pub struct PairingFlow<S> {
255    session: PairingSession,
256    accepted: Option<CompletedPairingUnconfirmed>,
257    _state: PhantomData<S>,
258}
259
260/// Inner state carried between `accept_response` and `finalize`. Not public —
261/// callers get the material via `finalize()` once confirmation is in.
262struct CompletedPairingUnconfirmed {
263    shared_secret: Zeroizing<[u8; 32]>,
264    peer_signing_pubkey: Vec<u8>,
265    peer_did: String,
266    response: PairingResponse,
267    sas: [u8; 10],
268    transport_key: TransportKey,
269    initiator_ephemeral_pub: Vec<u8>,
270}
271
272impl PairingFlow<Init> {
273    /// Start a new pairing flow. Generates the token; the caller transmits
274    /// the token out-of-band (QR, manual entry) to the responder.
275    pub fn initiate(
276        now: DateTime<Utc>,
277        controller_did: String,
278        endpoint: String,
279        capabilities: Vec<Capability>,
280    ) -> Result<(Self, PairingToken), ProtocolError> {
281        let session = PairingToken::generate(now, controller_did, endpoint, capabilities)?;
282        let token = session.token.clone();
283        Ok((
284            Self {
285                session,
286                accepted: None,
287                _state: PhantomData,
288            },
289            token,
290        ))
291    }
292
293    /// Pairing token accessor (for display / QR emission).
294    pub fn token(&self) -> &PairingToken {
295        &self.session.token
296    }
297
298    /// Accept a responder's signed response; verify signature, perform
299    /// ECDH, derive SAS + transport key. Transitions to `<Responded>`.
300    pub fn accept_response(
301        mut self,
302        now: DateTime<Utc>,
303        response: PairingResponse,
304    ) -> Result<PairingFlow<Responded>, ProtocolError> {
305        response.verify(now, &self.session.token)?;
306        let initiator_ecdh_pub = self.session.ephemeral_pubkey_bytes()?;
307        let responder_ecdh_pub = response.device_ephemeral_pubkey_bytes()?;
308        let shared_secret = self.session.complete_exchange(&responder_ecdh_pub)?;
309        let peer_signing_pubkey = response.device_signing_pubkey_bytes()?;
310        let peer_did = response.device_did.clone();
311        let session_id = &self.session.token.session_id;
312        let short_code = &self.session.token.short_code;
313
314        let sas = sas::derive_sas(
315            &shared_secret,
316            &initiator_ecdh_pub,
317            &responder_ecdh_pub,
318            session_id,
319            short_code,
320        );
321        let transport_key = sas::derive_transport_key(
322            &shared_secret,
323            &initiator_ecdh_pub,
324            &responder_ecdh_pub,
325            session_id,
326            short_code,
327        );
328
329        Ok(PairingFlow {
330            session: self.session,
331            accepted: Some(CompletedPairingUnconfirmed {
332                shared_secret,
333                peer_signing_pubkey,
334                peer_did,
335                response,
336                sas,
337                transport_key,
338                initiator_ephemeral_pub: initiator_ecdh_pub,
339            }),
340            _state: PhantomData,
341        })
342    }
343}
344
345impl PairingFlow<Responded> {
346    /// The 10-byte SAS to display to the user. Caller formats with
347    /// [`crate::sas::format_sas_numeric`] / [`crate::sas::format_sas_emoji`].
348    pub fn sas(&self) -> Result<&[u8; 10], ProtocolError> {
349        self.accepted
350            .as_ref()
351            .map(|a| &a.sas)
352            .ok_or_else(|| ProtocolError::KeyExchangeFailed("flow has no accepted response".into()))
353    }
354
355    /// Record the user's SAS confirmation. The `SasMatch` proof token
356    /// can only be constructed via [`SasMatch::user_confirmed_visual_match`],
357    /// and must be produced by the UI layer AFTER the user has pressed
358    /// "match". Transitions to `<Confirmed>`.
359    pub fn confirm(self, _proof: SasMatch) -> PairingFlow<Confirmed> {
360        PairingFlow {
361            session: self.session,
362            accepted: self.accepted,
363            _state: PhantomData,
364        }
365    }
366}
367
368impl PairingFlow<Confirmed> {
369    /// Finalize the pairing. Returns the full [`CompletedPairing`] —
370    /// shared secret, transport key, peer DID, and signed attestation.
371    /// Consumes self; the `PairingFlow<Paired>` terminal state is
372    /// non-extractable (intentional: once finalized, there is nothing
373    /// further to do with the flow).
374    pub fn finalize(self) -> Result<(PairingFlow<Paired>, CompletedPairing), ProtocolError> {
375        let accepted = self.accepted.ok_or_else(|| {
376            ProtocolError::KeyExchangeFailed(
377                "flow reached Confirmed without accepted response (impossible)".into(),
378            )
379        })?;
380        let completed = CompletedPairing {
381            shared_secret: accepted.shared_secret,
382            peer_signing_pubkey: accepted.peer_signing_pubkey,
383            peer_did: accepted.peer_did,
384            response: accepted.response,
385            sas: accepted.sas,
386            transport_key: accepted.transport_key,
387            initiator_ephemeral_pub: accepted.initiator_ephemeral_pub,
388        };
389        Ok((
390            PairingFlow {
391                session: self.session,
392                accepted: None,
393                _state: PhantomData,
394            },
395            completed,
396        ))
397    }
398}
399
400/// Responder-side helper: create a response from a received token.
401///
402/// Args:
403/// * `now` - Current time for expiry checking
404/// * `token_bytes` - Serialized `PairingToken` from the initiator
405/// * `device_seed` - Typed signing seed; curve flows through in-band
406/// * `device_pubkey` - The responding device's public key (length matches curve)
407/// * `device_did` - The responding device's DID string
408/// * `device_name` - Optional friendly device name
409///
410/// Usage:
411/// ```ignore
412/// let result = respond_to_pairing(now, &token_bytes, &seed, &pk, did, name)?;
413/// let response_bytes = serde_json::to_vec(&result.response)?;
414/// // Send response_bytes back to initiator, then display result.sas
415/// ```
416pub fn respond_to_pairing(
417    now: DateTime<Utc>,
418    token_bytes: &[u8],
419    device_seed: &TypedSeed,
420    device_pubkey: &[u8],
421    device_did: String,
422    device_name: Option<String>,
423) -> Result<ResponderResult, ProtocolError> {
424    let token: PairingToken = serde_json::from_slice(token_bytes)?;
425    let (response, shared_secret) = PairingResponse::create(
426        now,
427        &token,
428        device_seed,
429        device_pubkey,
430        device_did,
431        device_name,
432    )?;
433
434    let initiator_ecdh_pub = token.ephemeral_pubkey_bytes()?;
435    let responder_ecdh_pub = response.device_ephemeral_pubkey_bytes()?;
436    let session_id = &token.session_id;
437    let short_code = &token.short_code;
438
439    let sas_bytes = sas::derive_sas(
440        &shared_secret,
441        &initiator_ecdh_pub,
442        &responder_ecdh_pub,
443        session_id,
444        short_code,
445    );
446    let transport_key = sas::derive_transport_key(
447        &shared_secret,
448        &initiator_ecdh_pub,
449        &responder_ecdh_pub,
450        session_id,
451        short_code,
452    );
453
454    Ok(ResponderResult {
455        response,
456        shared_secret,
457        sas: sas_bytes,
458        transport_key,
459    })
460}
461
462#[cfg(test)]
463#[allow(clippy::disallowed_methods)]
464mod tests {
465    use super::*;
466
467    fn generate_test_keypair() -> (TypedSeed, Vec<u8>) {
468        use p256::ecdsa::SigningKey;
469        use p256::elliptic_curve::rand_core::OsRng as P256Rng;
470        use p256::pkcs8::EncodePrivateKey;
471
472        let sk = SigningKey::random(&mut P256Rng);
473        let pkcs8 = sk.to_pkcs8_der().unwrap();
474        let parsed = auths_crypto::parse_key_material(pkcs8.as_bytes()).unwrap();
475        (parsed.seed, parsed.public_key)
476    }
477
478    #[test]
479    fn happy_path_initiate_and_complete() {
480        let now = chrono::Utc::now();
481        let (protocol, token) = PairingProtocol::initiate(
482            now,
483            "did:keri:test".to_string(),
484            "http://localhost:3000".to_string(),
485            vec![Capability::sign_commit()],
486        )
487        .unwrap();
488
489        let (seed, pubkey) = generate_test_keypair();
490        let token_bytes = serde_json::to_vec(&token).unwrap();
491        let responder_result = respond_to_pairing(
492            now,
493            &token_bytes,
494            &seed,
495            &pubkey,
496            "did:key:zDnaTest".to_string(),
497            None,
498        )
499        .unwrap();
500
501        let response_bytes = serde_json::to_vec(&responder_result.response).unwrap();
502        let completed = protocol.complete(now, &response_bytes).unwrap();
503
504        assert_eq!(*completed.shared_secret, *responder_result.shared_secret);
505        assert_eq!(completed.peer_did, "did:key:zDnaTest");
506        // Both sides derive the same SAS
507        assert_eq!(completed.sas, responder_result.sas);
508    }
509
510    #[test]
511    fn expired_token_fails() {
512        use chrono::Duration;
513
514        let now = chrono::Utc::now();
515        let session = PairingToken::generate_with_expiry(
516            now,
517            "did:keri:test".to_string(),
518            "http://localhost:3000".to_string(),
519            vec![],
520            Duration::seconds(-1),
521        )
522        .unwrap();
523
524        let token = session.token.clone();
525        let protocol = PairingProtocol { session };
526
527        let (seed, pubkey) = generate_test_keypair();
528        let (response, _) = PairingResponse::create(
529            // Use a time before expiry for creation
530            now - Duration::seconds(10),
531            &token,
532            &seed,
533            &pubkey,
534            "did:key:zDnaTest".to_string(),
535            None,
536        )
537        .unwrap();
538
539        let response_bytes = serde_json::to_vec(&response).unwrap();
540        let result = protocol.complete(now, &response_bytes);
541        assert!(matches!(result, Err(ProtocolError::Expired)));
542    }
543
544    #[test]
545    fn invalid_response_bytes_fails() {
546        let now = chrono::Utc::now();
547        let (protocol, _token) = PairingProtocol::initiate(
548            now,
549            "did:keri:test".to_string(),
550            "http://localhost:3000".to_string(),
551            vec![],
552        )
553        .unwrap();
554
555        let result = protocol.complete(now, b"not valid json");
556        assert!(matches!(result, Err(ProtocolError::Serialization(_))));
557    }
558}