Skip to main content

auths_pairing_protocol/
protocol.rs

1use chrono::{DateTime, Utc};
2use zeroize::Zeroizing;
3
4use auths_crypto::SecureSeed;
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 X25519 shared secret (zeroized on drop).
14    pub shared_secret: Zeroizing<[u8; 32]>,
15    /// The peer's Ed25519 signing public key.
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; 8],
23    /// Single-use transport encryption key.
24    pub transport_key: TransportKey,
25    /// The initiator's X25519 ephemeral public key.
26    pub initiator_x25519_pub: [u8; 32],
27}
28
29/// Result of a successful pairing response (responder side).
30pub struct ResponderResult {
31    pub response: PairingResponse,
32    pub shared_secret: Zeroizing<[u8; 32]>,
33    pub sas: [u8; 8],
34    pub transport_key: TransportKey,
35}
36
37/// Transport-agnostic pairing protocol state machine.
38///
39/// `EphemeralSecret` from x25519-dalek is `!Clone + !Serialize`, so this
40/// state machine is inherently ephemeral — it lives in memory only and
41/// cannot be persisted across app restarts.
42///
43/// Usage:
44/// ```ignore
45/// // Initiator side:
46/// let (protocol, token) = PairingProtocol::initiate(now, controller_did, endpoint, caps)?;
47/// let token_bytes = serde_json::to_vec(&token)?;
48/// // Send token_bytes to peer over transport (HTTP, BLE, QR, etc.)
49///
50/// // After receiving response bytes from peer:
51/// let completed = protocol.complete(now, response_bytes)?;
52/// // completed.shared_secret, completed.peer_did are now available
53/// ```
54pub struct PairingProtocol {
55    session: PairingSession,
56}
57
58impl PairingProtocol {
59    /// Initiate a pairing session.
60    ///
61    /// Args:
62    /// * `now` - Current time (injected, not fetched internally)
63    /// * `controller_did` - The initiator's identity DID
64    /// * `endpoint` - Registry endpoint URL
65    /// * `capabilities` - Capabilities to grant to the paired device
66    ///
67    /// Usage:
68    /// ```ignore
69    /// let (protocol, token) = PairingProtocol::initiate(now, did, endpoint, caps)?;
70    /// ```
71    pub fn initiate(
72        now: DateTime<Utc>,
73        controller_did: String,
74        endpoint: String,
75        capabilities: Vec<String>,
76    ) -> Result<(Self, PairingToken), ProtocolError> {
77        let session = PairingToken::generate(now, controller_did, endpoint, capabilities)?;
78        let token = session.token.clone();
79        Ok((Self { session }, token))
80    }
81
82    /// Complete the pairing exchange with a received response.
83    ///
84    /// Consumes the protocol state (ephemeral secret is used exactly once).
85    ///
86    /// Args:
87    /// * `now` - Current time for expiry checking
88    /// * `response_bytes` - Serialized `PairingResponse` from the peer
89    ///
90    /// Usage:
91    /// ```ignore
92    /// let completed = protocol.complete(now, &response_bytes)?;
93    /// ```
94    pub fn complete(
95        mut self,
96        now: DateTime<Utc>,
97        response_bytes: &[u8],
98    ) -> Result<CompletedPairing, ProtocolError> {
99        let response: PairingResponse = serde_json::from_slice(response_bytes)?;
100        response.verify(now, &self.session.token)?;
101        self.complete_inner(now, response)
102    }
103
104    /// Complete the pairing exchange with a structured response.
105    ///
106    /// Args:
107    /// * `now` - Current time for expiry checking
108    /// * `response` - The peer's `PairingResponse`
109    pub fn complete_with_response(
110        mut self,
111        now: DateTime<Utc>,
112        response: PairingResponse,
113    ) -> Result<CompletedPairing, ProtocolError> {
114        response.verify(now, &self.session.token)?;
115        self.complete_inner(now, response)
116    }
117
118    fn complete_inner(
119        &mut self,
120        _now: DateTime<Utc>,
121        response: PairingResponse,
122    ) -> Result<CompletedPairing, ProtocolError> {
123        let initiator_x25519_pub = self.session.ephemeral_pubkey_bytes()?;
124        let responder_x25519_pub = response.device_x25519_pubkey_bytes()?;
125        let shared_secret = self.session.complete_exchange(&responder_x25519_pub)?;
126        let peer_signing_pubkey = response.device_signing_pubkey_bytes()?;
127        let peer_did = response.device_did.clone();
128        let short_code = &self.session.token.short_code;
129
130        let sas_bytes = sas::derive_sas(
131            &shared_secret,
132            &initiator_x25519_pub,
133            &responder_x25519_pub,
134            short_code,
135        );
136        let transport_key = sas::derive_transport_key(
137            &shared_secret,
138            &initiator_x25519_pub,
139            &responder_x25519_pub,
140            short_code,
141        );
142
143        Ok(CompletedPairing {
144            shared_secret,
145            peer_signing_pubkey,
146            peer_did,
147            response,
148            sas: sas_bytes,
149            transport_key,
150            initiator_x25519_pub,
151        })
152    }
153
154    /// Get a reference to the pairing token for display/transmission.
155    pub fn token(&self) -> &PairingToken {
156        &self.session.token
157    }
158}
159
160/// Responder-side helper: create a response from a received token.
161///
162/// Args:
163/// * `now` - Current time for expiry checking
164/// * `token_bytes` - Serialized `PairingToken` from the initiator
165/// * `device_seed` - The responding device's Ed25519 seed
166/// * `device_pubkey` - The responding device's Ed25519 public key
167/// * `device_did` - The responding device's DID string
168/// * `device_name` - Optional friendly device name
169///
170/// Usage:
171/// ```ignore
172/// let result = respond_to_pairing(now, &token_bytes, &seed, &pk, did, name)?;
173/// let response_bytes = serde_json::to_vec(&result.response)?;
174/// // Send response_bytes back to initiator, then display result.sas
175/// ```
176pub fn respond_to_pairing(
177    now: DateTime<Utc>,
178    token_bytes: &[u8],
179    device_seed: &SecureSeed,
180    device_pubkey: &[u8; 32],
181    device_did: String,
182    device_name: Option<String>,
183) -> Result<ResponderResult, ProtocolError> {
184    let token: PairingToken = serde_json::from_slice(token_bytes)?;
185    let (response, shared_secret) = PairingResponse::create(
186        now,
187        &token,
188        device_seed,
189        device_pubkey,
190        device_did,
191        device_name,
192    )?;
193
194    let initiator_x25519_pub = token.ephemeral_pubkey_bytes()?;
195    let responder_x25519_pub = response.device_x25519_pubkey_bytes()?;
196    let short_code = &token.short_code;
197
198    let sas_bytes = sas::derive_sas(
199        &shared_secret,
200        &initiator_x25519_pub,
201        &responder_x25519_pub,
202        short_code,
203    );
204    let transport_key = sas::derive_transport_key(
205        &shared_secret,
206        &initiator_x25519_pub,
207        &responder_x25519_pub,
208        short_code,
209    );
210
211    Ok(ResponderResult {
212        response,
213        shared_secret,
214        sas: sas_bytes,
215        transport_key,
216    })
217}
218
219#[cfg(test)]
220#[allow(clippy::disallowed_methods)]
221mod tests {
222    use super::*;
223    use ring::rand::SystemRandom;
224    use ring::signature::{Ed25519KeyPair, KeyPair};
225
226    fn generate_test_keypair() -> (SecureSeed, [u8; 32]) {
227        let rng = SystemRandom::new();
228        let pkcs8_doc = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
229        let keypair = Ed25519KeyPair::from_pkcs8(pkcs8_doc.as_ref()).unwrap();
230        let public_key: [u8; 32] = keypair.public_key().as_ref().try_into().unwrap();
231        let seed: [u8; 32] = pkcs8_doc.as_ref()[16..48].try_into().unwrap();
232        (SecureSeed::new(seed), public_key)
233    }
234
235    #[test]
236    fn happy_path_initiate_and_complete() {
237        let now = chrono::Utc::now();
238        let (protocol, token) = PairingProtocol::initiate(
239            now,
240            "did:keri:test".to_string(),
241            "http://localhost:3000".to_string(),
242            vec!["sign_commit".to_string()],
243        )
244        .unwrap();
245
246        let (seed, pubkey) = generate_test_keypair();
247        let token_bytes = serde_json::to_vec(&token).unwrap();
248        let responder_result = respond_to_pairing(
249            now,
250            &token_bytes,
251            &seed,
252            &pubkey,
253            "did:key:z6MkTest".to_string(),
254            None,
255        )
256        .unwrap();
257
258        let response_bytes = serde_json::to_vec(&responder_result.response).unwrap();
259        let completed = protocol.complete(now, &response_bytes).unwrap();
260
261        assert_eq!(*completed.shared_secret, *responder_result.shared_secret);
262        assert_eq!(completed.peer_did, "did:key:z6MkTest");
263        // Both sides derive the same SAS
264        assert_eq!(completed.sas, responder_result.sas);
265    }
266
267    #[test]
268    fn expired_token_fails() {
269        use chrono::Duration;
270
271        let now = chrono::Utc::now();
272        let session = PairingToken::generate_with_expiry(
273            now,
274            "did:keri:test".to_string(),
275            "http://localhost:3000".to_string(),
276            vec![],
277            Duration::seconds(-1),
278        )
279        .unwrap();
280
281        let token = session.token.clone();
282        let protocol = PairingProtocol { session };
283
284        let (seed, pubkey) = generate_test_keypair();
285        let (response, _) = PairingResponse::create(
286            // Use a time before expiry for creation
287            now - Duration::seconds(10),
288            &token,
289            &seed,
290            &pubkey,
291            "did:key:z6MkTest".to_string(),
292            None,
293        )
294        .unwrap();
295
296        let response_bytes = serde_json::to_vec(&response).unwrap();
297        let result = protocol.complete(now, &response_bytes);
298        assert!(matches!(result, Err(ProtocolError::Expired)));
299    }
300
301    #[test]
302    fn invalid_response_bytes_fails() {
303        let now = chrono::Utc::now();
304        let (protocol, _token) = PairingProtocol::initiate(
305            now,
306            "did:keri:test".to_string(),
307            "http://localhost:3000".to_string(),
308            vec![],
309        )
310        .unwrap();
311
312        let result = protocol.complete(now, b"not valid json");
313        assert!(matches!(result, Err(ProtocolError::Serialization(_))));
314    }
315}