Skip to main content

auths_pairing_protocol/
token.rs

1use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2use chrono::{DateTime, Duration, Utc};
3use p256::elliptic_curve::rand_core::OsRng;
4use p256::elliptic_curve::sec1::ToEncodedPoint;
5use serde::{Deserialize, Serialize};
6use zeroize::Zeroizing;
7
8use auths_crypto::CurveType;
9use auths_keri::{Capability, KeriPublicKey};
10
11use crate::error::ProtocolError;
12
13const SHORT_CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";
14const SHORT_CODE_LEN: usize = 6;
15
16/// fn-129.T10: post-quantum KEM slot advertised by the initiator.
17///
18/// Serde-tagged on `algo` so the tag IS the version — adding a new
19/// parameter set is a new variant, not a schema break. Parsers on
20/// builds without `pq-hybrid` still see the field (it's a plain
21/// `Option<KemSlot>` on `PairingToken` regardless of feature); they
22/// deserialize `None` when absent and preserve unknown algos as-is.
23///
24/// # Prelaunch
25///
26/// No version discriminant field; the serde tag is the version. If this
27/// changes after launch, the add-a-variant plan stays additive.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(tag = "algo", rename_all = "snake_case")]
30pub enum KemSlot {
31    /// ML-KEM-768 (FIPS 203 Category 3). `public_key` is the 1184-byte
32    /// encapsulation key, base64url-encoded without padding.
33    #[serde(rename = "ml_kem_768")]
34    MlKem768 {
35        /// Base64url-no-pad encoded ML-KEM-768 encapsulation key
36        /// (1184 raw bytes → 1580 encoded chars).
37        public_key: String,
38    },
39}
40
41/// A pairing token for initiating cross-device identity linking.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct PairingToken {
44    pub controller_did: String,
45    pub endpoint: String,
46    pub short_code: String,
47    pub session_id: String,
48    pub ephemeral_pubkey: String,
49    pub expires_at: DateTime<Utc>,
50    pub capabilities: Vec<Capability>,
51    /// Optional hybrid-KEM slot. `None` on classical builds and
52    /// classical sessions; `Some(KemSlot::MlKem768 { .. })` when the
53    /// initiator is running a `pq-hybrid`-enabled build. Parsers on
54    /// default builds accept but do not act on a populated slot
55    /// (they cannot — they have no ML-KEM code). An active hybrid
56    /// peer MUST NOT silently downgrade when this field is `Some`.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub kem_slot: Option<KemSlot>,
59    /// Optional SPKI SHA-256 fingerprint (base64url, 32 bytes
60    /// decoded) of the daemon's TLS certificate. Populated when the
61    /// daemon is built with the `tls` feature; the phone pins
62    /// against this value on connect so no CA is required. `None`
63    /// means the daemon is serving plaintext.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub daemon_spki_sha256: Option<String>,
66}
67
68/// Ephemeral keypair for a pairing session.
69///
70/// The P-256 ephemeral secret is consumed once during ECDH key exchange.
71/// `EphemeralSecret` is `!Clone + !Serialize` — sessions cannot be persisted.
72/// The ECDH curve (P-256) is independent of the device's signing curve.
73pub struct PairingSession {
74    pub token: PairingToken,
75    ephemeral_secret: Option<p256::ecdh::EphemeralSecret>,
76}
77
78impl PairingToken {
79    /// Generate a new pairing token with a 5-minute expiry.
80    pub fn generate(
81        now: DateTime<Utc>,
82        controller_did: String,
83        endpoint: String,
84        capabilities: Vec<Capability>,
85    ) -> Result<PairingSession, ProtocolError> {
86        Self::generate_with_expiry(
87            now,
88            controller_did,
89            endpoint,
90            capabilities,
91            Duration::minutes(5),
92        )
93    }
94
95    /// Generate a new pairing token with custom expiry.
96    pub fn generate_with_expiry(
97        now: DateTime<Utc>,
98        controller_did: String,
99        endpoint: String,
100        capabilities: Vec<Capability>,
101        expiry: Duration,
102    ) -> Result<PairingSession, ProtocolError> {
103        let ephemeral_secret = p256::ecdh::EphemeralSecret::random(&mut OsRng);
104        let ephemeral_public = ephemeral_secret.public_key();
105        let ephemeral_pubkey =
106            URL_SAFE_NO_PAD.encode(ephemeral_public.to_encoded_point(true).as_bytes());
107        let short_code = generate_short_code()?;
108
109        let session_id = {
110            let mut bytes = [0u8; 16];
111            use rand::RngCore;
112            OsRng.fill_bytes(&mut bytes);
113            hex::encode(bytes)
114        };
115
116        let token = PairingToken {
117            controller_did,
118            endpoint,
119            short_code,
120            session_id,
121            ephemeral_pubkey,
122            expires_at: now + expiry,
123            capabilities,
124            kem_slot: None,
125            daemon_spki_sha256: None,
126        };
127
128        Ok(PairingSession {
129            token,
130            ephemeral_secret: Some(ephemeral_secret),
131        })
132    }
133
134    pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
135        now > self.expires_at
136    }
137
138    /// Convert to an `auths://` URI for QR code or deep linking.
139    pub fn to_uri(&self) -> String {
140        let expires_unix = self.expires_at.timestamp();
141        let endpoint_b64 = URL_SAFE_NO_PAD.encode(self.endpoint.as_bytes());
142        let caps = self
143            .capabilities
144            .iter()
145            .map(Capability::as_str)
146            .collect::<Vec<_>>()
147            .join(",");
148        format!(
149            "auths://pair?d={}&e={}&k={}&sc={}&sid={}&x={}&c={}",
150            self.controller_did,
151            endpoint_b64,
152            self.ephemeral_pubkey,
153            self.short_code,
154            self.session_id,
155            expires_unix,
156            caps
157        )
158    }
159
160    /// Parse a pairing token from an `auths://` URI.
161    pub fn from_uri(uri: &str) -> Result<Self, ProtocolError> {
162        let rest = uri.strip_prefix("auths://pair?").ok_or_else(|| {
163            ProtocolError::InvalidUri("Expected auths://pair? scheme".to_string())
164        })?;
165
166        let mut controller_did = None;
167        let mut endpoint_b64 = None;
168        let mut ephemeral_pubkey = None;
169        let mut short_code = None;
170        let mut session_id = None;
171        let mut expires_unix = None;
172        let mut caps_str = None;
173
174        for param in rest.split('&') {
175            if let Some((key, value)) = param.split_once('=') {
176                match key {
177                    "d" => controller_did = Some(value.to_string()),
178                    "e" => endpoint_b64 = Some(value.to_string()),
179                    "k" => ephemeral_pubkey = Some(value.to_string()),
180                    "sc" => short_code = Some(value.to_string()),
181                    "sid" => session_id = Some(value.to_string()),
182                    "x" => expires_unix = value.parse::<i64>().ok(),
183                    "c" => caps_str = Some(value.to_string()),
184                    _ => {}
185                }
186            }
187        }
188
189        let controller_did = controller_did
190            .ok_or_else(|| ProtocolError::InvalidUri("Missing controller_did".to_string()))?;
191        let endpoint_b64 = endpoint_b64
192            .ok_or_else(|| ProtocolError::InvalidUri("Missing endpoint".to_string()))?;
193        let endpoint_bytes = URL_SAFE_NO_PAD
194            .decode(&endpoint_b64)
195            .map_err(|e| ProtocolError::InvalidUri(format!("Invalid endpoint encoding: {}", e)))?;
196        let endpoint = String::from_utf8(endpoint_bytes)
197            .map_err(|e| ProtocolError::InvalidUri(format!("Invalid endpoint UTF-8: {}", e)))?;
198        let ephemeral_pubkey = ephemeral_pubkey
199            .ok_or_else(|| ProtocolError::InvalidUri("Missing ephemeral_pubkey".to_string()))?;
200        let short_code = short_code
201            .ok_or_else(|| ProtocolError::InvalidUri("Missing short_code".to_string()))?;
202        let session_id = session_id
203            .ok_or_else(|| ProtocolError::InvalidUri("Missing session_id".to_string()))?;
204        let expires_unix = expires_unix.ok_or_else(|| {
205            ProtocolError::InvalidUri("Missing or invalid expires_at".to_string())
206        })?;
207
208        let expires_at = DateTime::from_timestamp(expires_unix, 0)
209            .ok_or_else(|| ProtocolError::InvalidUri("Invalid timestamp".to_string()))?;
210
211        let capabilities = caps_str
212            .filter(|s| !s.is_empty())
213            .map(|s| {
214                s.split(',')
215                    .map(Capability::parse)
216                    .collect::<Result<Vec<_>, _>>()
217            })
218            .transpose()
219            .map_err(|e| ProtocolError::InvalidUri(format!("Invalid capability: {}", e)))?
220            .unwrap_or_default();
221
222        Ok(PairingToken {
223            controller_did,
224            endpoint,
225            short_code,
226            session_id,
227            ephemeral_pubkey,
228            expires_at,
229            capabilities,
230            kem_slot: None,
231            daemon_spki_sha256: None,
232        })
233    }
234
235    /// Decode the ephemeral P-256 ECDH public key from the token's base64url field.
236    pub fn ephemeral_pubkey_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
237        URL_SAFE_NO_PAD
238            .decode(&self.ephemeral_pubkey)
239            .map_err(|e| ProtocolError::InvalidUri(format!("Invalid pubkey encoding: {}", e)))
240    }
241}
242
243impl PairingSession {
244    /// Complete the P-256 ECDH exchange with the responder's ephemeral public key.
245    ///
246    /// Consumes the ephemeral secret (one-time use). Returns the 32-byte shared secret.
247    /// The ECDH curve (P-256) is independent of the device's signing curve — ephemeral
248    /// keys are fresh per session and never reused.
249    pub fn complete_exchange(
250        &mut self,
251        responder_ephemeral_pubkey: &[u8],
252    ) -> Result<Zeroizing<[u8; 32]>, ProtocolError> {
253        let secret = self
254            .ephemeral_secret
255            .take()
256            .ok_or(ProtocolError::SessionConsumed)?;
257
258        let responder_pk =
259            p256::PublicKey::from_sec1_bytes(responder_ephemeral_pubkey).map_err(|_| {
260                ProtocolError::KeyExchangeFailed(
261                    "Invalid P-256 ephemeral pubkey (SEC1 decode failed)".to_string(),
262                )
263            })?;
264        let shared = secret.diffie_hellman(&responder_pk);
265        let shared_bytes: [u8; 32] =
266            shared
267                .raw_secret_bytes()
268                .as_slice()
269                .try_into()
270                .map_err(|_| {
271                    ProtocolError::KeyExchangeFailed("Shared secret not 32 bytes".to_string())
272                })?;
273
274        Ok(Zeroizing::new(shared_bytes))
275    }
276
277    /// Decode the ephemeral P-256 ECDH public key from the token's base64url field.
278    pub fn ephemeral_pubkey_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
279        self.token.ephemeral_pubkey_bytes()
280    }
281
282    /// Verify a pairing response's signature.
283    ///
284    /// The `curve` argument carries the signing curve in-band — callers must
285    /// read it from the wire (e.g. `PairingResponse.curve`) or from a sibling
286    /// typed source, never infer from pubkey byte length.
287    pub fn verify_response(
288        &self,
289        device_signing_pubkey: &[u8],
290        device_ephemeral_pubkey: &[u8],
291        signature: &[u8],
292        curve: CurveType,
293    ) -> Result<(), ProtocolError> {
294        let initiator_pubkey = self.token.ephemeral_pubkey_bytes()?;
295
296        let mut message = Vec::new();
297        message.extend_from_slice(self.token.session_id.as_bytes());
298        message.extend_from_slice(self.token.short_code.as_bytes());
299        message.extend_from_slice(&initiator_pubkey);
300        message.extend_from_slice(device_ephemeral_pubkey);
301
302        let key = match curve {
303            CurveType::Ed25519 => {
304                let arr: [u8; 32] = device_signing_pubkey.try_into().map_err(|_| {
305                    ProtocolError::KeyExchangeFailed(format!(
306                        "Ed25519 pubkey must be 32 bytes, got {}",
307                        device_signing_pubkey.len()
308                    ))
309                })?;
310                KeriPublicKey::Ed25519(arr)
311            }
312            CurveType::P256 => {
313                let arr: [u8; 33] = device_signing_pubkey.try_into().map_err(|_| {
314                    ProtocolError::KeyExchangeFailed(format!(
315                        "P-256 compressed pubkey must be 33 bytes, got {}",
316                        device_signing_pubkey.len()
317                    ))
318                })?;
319                KeriPublicKey::P256 {
320                    key: arr,
321                    transferable: true,
322                }
323            }
324        };
325
326        key.verify_signature(&message, signature)
327            .map_err(|_| ProtocolError::InvalidSignature)
328    }
329}
330
331fn generate_short_code() -> Result<String, ProtocolError> {
332    use rand::RngCore;
333
334    let mut rng = OsRng;
335    let mut code = String::with_capacity(SHORT_CODE_LEN);
336
337    for _ in 0..SHORT_CODE_LEN {
338        let idx = (rng.next_u32() as usize) % SHORT_CODE_ALPHABET.len();
339        code.push(SHORT_CODE_ALPHABET[idx] as char);
340    }
341
342    Ok(code)
343}
344
345/// Normalize a short code: uppercase, strip spaces/dashes.
346pub fn normalize_short_code(code: &str) -> String {
347    code.chars()
348        .filter(|c| !c.is_whitespace() && *c != '-')
349        .flat_map(|c| c.to_uppercase())
350        .collect()
351}
352
353#[cfg(test)]
354#[allow(clippy::disallowed_methods)]
355mod tests {
356    use super::*;
357
358    fn make_session() -> PairingSession {
359        PairingToken::generate(
360            Utc::now(),
361            "did:keri:test123".to_string(),
362            "http://localhost:3000".to_string(),
363            vec![Capability::sign_commit()],
364        )
365        .unwrap()
366    }
367
368    #[test]
369    fn test_generate_token() {
370        let session = make_session();
371        assert!(!session.token.controller_did.is_empty());
372        assert!(!session.token.short_code.is_empty());
373        assert!(!session.token.ephemeral_pubkey.is_empty());
374        assert!(!session.token.is_expired(Utc::now()));
375        assert_eq!(session.token.short_code.len(), 6);
376        assert_eq!(session.token.capabilities, vec![Capability::sign_commit()]);
377    }
378
379    #[test]
380    fn test_token_uri_roundtrip() {
381        let session = make_session();
382        let uri = session.token.to_uri();
383
384        assert!(uri.starts_with("auths://pair?"));
385
386        let parsed = PairingToken::from_uri(&uri).unwrap();
387        assert_eq!(parsed.controller_did, session.token.controller_did);
388        assert_eq!(parsed.endpoint, session.token.endpoint);
389        assert_eq!(parsed.ephemeral_pubkey, session.token.ephemeral_pubkey);
390        assert_eq!(parsed.short_code, session.token.short_code);
391        assert_eq!(parsed.capabilities, session.token.capabilities);
392    }
393
394    #[test]
395    fn test_short_code_no_ambiguous_chars() {
396        let ambiguous: &[char] = &['0', 'O', '1', 'I', 'L'];
397        for _ in 0..100 {
398            let code = generate_short_code().unwrap();
399            assert_eq!(code.len(), SHORT_CODE_LEN);
400            for ch in code.chars() {
401                assert!(
402                    !ambiguous.contains(&ch),
403                    "Short code '{}' contains ambiguous char '{}'",
404                    code,
405                    ch
406                );
407            }
408        }
409    }
410
411    #[test]
412    fn test_expiry() {
413        let now = Utc::now();
414        let session = PairingToken::generate_with_expiry(
415            now,
416            "did:keri:test".to_string(),
417            "http://localhost:3000".to_string(),
418            vec![],
419            Duration::seconds(-1),
420        )
421        .unwrap();
422        assert!(session.token.is_expired(now));
423    }
424
425    #[test]
426    fn test_normalize_short_code() {
427        assert_eq!(normalize_short_code("abc def"), "ABCDEF");
428        assert_eq!(normalize_short_code("AB-CD-EF"), "ABCDEF");
429        assert_eq!(normalize_short_code("  a b c  "), "ABC");
430    }
431
432    #[test]
433    fn test_session_consumed_prevents_reuse() {
434        use p256::elliptic_curve::rand_core::OsRng as P256Rng;
435        use p256::elliptic_curve::sec1::ToEncodedPoint as _;
436
437        let mut session = make_session();
438        // Generate a valid P-256 ephemeral pubkey (SEC1 compressed, 33 bytes)
439        let fake_secret = p256::ecdh::EphemeralSecret::random(&mut P256Rng);
440        let fake_pubkey = fake_secret.public_key().to_encoded_point(true);
441        let fake_pubkey_bytes = fake_pubkey.as_bytes();
442
443        let result = session.complete_exchange(fake_pubkey_bytes);
444        assert!(result.is_ok());
445
446        let result = session.complete_exchange(fake_pubkey_bytes);
447        assert!(matches!(result, Err(ProtocolError::SessionConsumed)));
448    }
449}