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::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<String>,
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<String>,
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<String>,
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.capabilities.join(",");
143        format!(
144            "auths://pair?d={}&e={}&k={}&sc={}&sid={}&x={}&c={}",
145            self.controller_did,
146            endpoint_b64,
147            self.ephemeral_pubkey,
148            self.short_code,
149            self.session_id,
150            expires_unix,
151            caps
152        )
153    }
154
155    /// Parse a pairing token from an `auths://` URI.
156    pub fn from_uri(uri: &str) -> Result<Self, ProtocolError> {
157        let rest = uri.strip_prefix("auths://pair?").ok_or_else(|| {
158            ProtocolError::InvalidUri("Expected auths://pair? scheme".to_string())
159        })?;
160
161        let mut controller_did = None;
162        let mut endpoint_b64 = None;
163        let mut ephemeral_pubkey = None;
164        let mut short_code = None;
165        let mut session_id = None;
166        let mut expires_unix = None;
167        let mut caps_str = None;
168
169        for param in rest.split('&') {
170            if let Some((key, value)) = param.split_once('=') {
171                match key {
172                    "d" => controller_did = Some(value.to_string()),
173                    "e" => endpoint_b64 = Some(value.to_string()),
174                    "k" => ephemeral_pubkey = Some(value.to_string()),
175                    "sc" => short_code = Some(value.to_string()),
176                    "sid" => session_id = Some(value.to_string()),
177                    "x" => expires_unix = value.parse::<i64>().ok(),
178                    "c" => caps_str = Some(value.to_string()),
179                    _ => {}
180                }
181            }
182        }
183
184        let controller_did = controller_did
185            .ok_or_else(|| ProtocolError::InvalidUri("Missing controller_did".to_string()))?;
186        let endpoint_b64 = endpoint_b64
187            .ok_or_else(|| ProtocolError::InvalidUri("Missing endpoint".to_string()))?;
188        let endpoint_bytes = URL_SAFE_NO_PAD
189            .decode(&endpoint_b64)
190            .map_err(|e| ProtocolError::InvalidUri(format!("Invalid endpoint encoding: {}", e)))?;
191        let endpoint = String::from_utf8(endpoint_bytes)
192            .map_err(|e| ProtocolError::InvalidUri(format!("Invalid endpoint UTF-8: {}", e)))?;
193        let ephemeral_pubkey = ephemeral_pubkey
194            .ok_or_else(|| ProtocolError::InvalidUri("Missing ephemeral_pubkey".to_string()))?;
195        let short_code = short_code
196            .ok_or_else(|| ProtocolError::InvalidUri("Missing short_code".to_string()))?;
197        let session_id = session_id
198            .ok_or_else(|| ProtocolError::InvalidUri("Missing session_id".to_string()))?;
199        let expires_unix = expires_unix.ok_or_else(|| {
200            ProtocolError::InvalidUri("Missing or invalid expires_at".to_string())
201        })?;
202
203        let expires_at = DateTime::from_timestamp(expires_unix, 0)
204            .ok_or_else(|| ProtocolError::InvalidUri("Invalid timestamp".to_string()))?;
205
206        let capabilities = caps_str
207            .filter(|s| !s.is_empty())
208            .map(|s| s.split(',').map(|c| c.to_string()).collect())
209            .unwrap_or_default();
210
211        Ok(PairingToken {
212            controller_did,
213            endpoint,
214            short_code,
215            session_id,
216            ephemeral_pubkey,
217            expires_at,
218            capabilities,
219            kem_slot: None,
220            daemon_spki_sha256: None,
221        })
222    }
223
224    /// Decode the ephemeral P-256 ECDH public key from the token's base64url field.
225    pub fn ephemeral_pubkey_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
226        URL_SAFE_NO_PAD
227            .decode(&self.ephemeral_pubkey)
228            .map_err(|e| ProtocolError::InvalidUri(format!("Invalid pubkey encoding: {}", e)))
229    }
230}
231
232impl PairingSession {
233    /// Complete the P-256 ECDH exchange with the responder's ephemeral public key.
234    ///
235    /// Consumes the ephemeral secret (one-time use). Returns the 32-byte shared secret.
236    /// The ECDH curve (P-256) is independent of the device's signing curve — ephemeral
237    /// keys are fresh per session and never reused.
238    pub fn complete_exchange(
239        &mut self,
240        responder_ephemeral_pubkey: &[u8],
241    ) -> Result<Zeroizing<[u8; 32]>, ProtocolError> {
242        let secret = self
243            .ephemeral_secret
244            .take()
245            .ok_or(ProtocolError::SessionConsumed)?;
246
247        let responder_pk =
248            p256::PublicKey::from_sec1_bytes(responder_ephemeral_pubkey).map_err(|_| {
249                ProtocolError::KeyExchangeFailed(
250                    "Invalid P-256 ephemeral pubkey (SEC1 decode failed)".to_string(),
251                )
252            })?;
253        let shared = secret.diffie_hellman(&responder_pk);
254        let shared_bytes: [u8; 32] =
255            shared
256                .raw_secret_bytes()
257                .as_slice()
258                .try_into()
259                .map_err(|_| {
260                    ProtocolError::KeyExchangeFailed("Shared secret not 32 bytes".to_string())
261                })?;
262
263        Ok(Zeroizing::new(shared_bytes))
264    }
265
266    /// Decode the ephemeral P-256 ECDH public key from the token's base64url field.
267    pub fn ephemeral_pubkey_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
268        self.token.ephemeral_pubkey_bytes()
269    }
270
271    /// Verify a pairing response's signature.
272    ///
273    /// The `curve` argument carries the signing curve in-band — callers must
274    /// read it from the wire (e.g. `PairingResponse.curve`) or from a sibling
275    /// typed source, never infer from pubkey byte length.
276    pub fn verify_response(
277        &self,
278        device_signing_pubkey: &[u8],
279        device_ephemeral_pubkey: &[u8],
280        signature: &[u8],
281        curve: CurveType,
282    ) -> Result<(), ProtocolError> {
283        let initiator_pubkey = self.token.ephemeral_pubkey_bytes()?;
284
285        let mut message = Vec::new();
286        message.extend_from_slice(self.token.session_id.as_bytes());
287        message.extend_from_slice(self.token.short_code.as_bytes());
288        message.extend_from_slice(&initiator_pubkey);
289        message.extend_from_slice(device_ephemeral_pubkey);
290
291        let key = match curve {
292            CurveType::Ed25519 => {
293                let arr: [u8; 32] = device_signing_pubkey.try_into().map_err(|_| {
294                    ProtocolError::KeyExchangeFailed(format!(
295                        "Ed25519 pubkey must be 32 bytes, got {}",
296                        device_signing_pubkey.len()
297                    ))
298                })?;
299                KeriPublicKey::Ed25519(arr)
300            }
301            CurveType::P256 => {
302                let arr: [u8; 33] = device_signing_pubkey.try_into().map_err(|_| {
303                    ProtocolError::KeyExchangeFailed(format!(
304                        "P-256 compressed pubkey must be 33 bytes, got {}",
305                        device_signing_pubkey.len()
306                    ))
307                })?;
308                KeriPublicKey::P256 {
309                    key: arr,
310                    transferable: true,
311                }
312            }
313        };
314
315        key.verify_signature(&message, signature)
316            .map_err(|_| ProtocolError::InvalidSignature)
317    }
318}
319
320fn generate_short_code() -> Result<String, ProtocolError> {
321    use rand::RngCore;
322
323    let mut rng = OsRng;
324    let mut code = String::with_capacity(SHORT_CODE_LEN);
325
326    for _ in 0..SHORT_CODE_LEN {
327        let idx = (rng.next_u32() as usize) % SHORT_CODE_ALPHABET.len();
328        code.push(SHORT_CODE_ALPHABET[idx] as char);
329    }
330
331    Ok(code)
332}
333
334/// Normalize a short code: uppercase, strip spaces/dashes.
335pub fn normalize_short_code(code: &str) -> String {
336    code.chars()
337        .filter(|c| !c.is_whitespace() && *c != '-')
338        .flat_map(|c| c.to_uppercase())
339        .collect()
340}
341
342#[cfg(test)]
343#[allow(clippy::disallowed_methods)]
344mod tests {
345    use super::*;
346
347    fn make_session() -> PairingSession {
348        PairingToken::generate(
349            Utc::now(),
350            "did:keri:test123".to_string(),
351            "http://localhost:3000".to_string(),
352            vec!["sign_commit".to_string()],
353        )
354        .unwrap()
355    }
356
357    #[test]
358    fn test_generate_token() {
359        let session = make_session();
360        assert!(!session.token.controller_did.is_empty());
361        assert!(!session.token.short_code.is_empty());
362        assert!(!session.token.ephemeral_pubkey.is_empty());
363        assert!(!session.token.is_expired(Utc::now()));
364        assert_eq!(session.token.short_code.len(), 6);
365        assert_eq!(session.token.capabilities, vec!["sign_commit"]);
366    }
367
368    #[test]
369    fn test_token_uri_roundtrip() {
370        let session = make_session();
371        let uri = session.token.to_uri();
372
373        assert!(uri.starts_with("auths://pair?"));
374
375        let parsed = PairingToken::from_uri(&uri).unwrap();
376        assert_eq!(parsed.controller_did, session.token.controller_did);
377        assert_eq!(parsed.endpoint, session.token.endpoint);
378        assert_eq!(parsed.ephemeral_pubkey, session.token.ephemeral_pubkey);
379        assert_eq!(parsed.short_code, session.token.short_code);
380        assert_eq!(parsed.capabilities, session.token.capabilities);
381    }
382
383    #[test]
384    fn test_short_code_no_ambiguous_chars() {
385        let ambiguous: &[char] = &['0', 'O', '1', 'I', 'L'];
386        for _ in 0..100 {
387            let code = generate_short_code().unwrap();
388            assert_eq!(code.len(), SHORT_CODE_LEN);
389            for ch in code.chars() {
390                assert!(
391                    !ambiguous.contains(&ch),
392                    "Short code '{}' contains ambiguous char '{}'",
393                    code,
394                    ch
395                );
396            }
397        }
398    }
399
400    #[test]
401    fn test_expiry() {
402        let now = Utc::now();
403        let session = PairingToken::generate_with_expiry(
404            now,
405            "did:keri:test".to_string(),
406            "http://localhost:3000".to_string(),
407            vec![],
408            Duration::seconds(-1),
409        )
410        .unwrap();
411        assert!(session.token.is_expired(now));
412    }
413
414    #[test]
415    fn test_normalize_short_code() {
416        assert_eq!(normalize_short_code("abc def"), "ABCDEF");
417        assert_eq!(normalize_short_code("AB-CD-EF"), "ABCDEF");
418        assert_eq!(normalize_short_code("  a b c  "), "ABC");
419    }
420
421    #[test]
422    fn test_session_consumed_prevents_reuse() {
423        use p256::elliptic_curve::rand_core::OsRng as P256Rng;
424        use p256::elliptic_curve::sec1::ToEncodedPoint as _;
425
426        let mut session = make_session();
427        // Generate a valid P-256 ephemeral pubkey (SEC1 compressed, 33 bytes)
428        let fake_secret = p256::ecdh::EphemeralSecret::random(&mut P256Rng);
429        let fake_pubkey = fake_secret.public_key().to_encoded_point(true);
430        let fake_pubkey_bytes = fake_pubkey.as_bytes();
431
432        let result = session.complete_exchange(fake_pubkey_bytes);
433        assert!(result.is_ok());
434
435        let result = session.complete_exchange(fake_pubkey_bytes);
436        assert!(matches!(result, Err(ProtocolError::SessionConsumed)));
437    }
438}