Skip to main content

auths_pairing_protocol/
protocol.rs

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