Skip to main content

auths_core/pairing/
token.rs

1//! Pairing token generation and handling.
2
3use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
4use chrono::{DateTime, Duration, Utc};
5use rand::rngs::OsRng;
6use serde::{Deserialize, Serialize};
7use x25519_dalek::{EphemeralSecret, PublicKey};
8use zeroize::Zeroizing;
9
10use super::error::PairingError;
11
12/// Alphabet for short codes: excludes ambiguous characters 0/O, 1/I/L.
13const SHORT_CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";
14
15/// Length of the alphanumeric short code.
16const SHORT_CODE_LEN: usize = 6;
17
18/// A pairing token for initiating cross-device identity linking.
19///
20/// The initiating device generates this token and displays it as a QR code
21/// or short code. The responding device scans/enters it and sends back
22/// a `PairingResponse` with its device public key plus X25519 ECDH exchange.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PairingToken {
25    /// Identity DID of the initiator (controller).
26    pub controller_did: String,
27    /// Registry endpoint URL.
28    pub endpoint: String,
29    /// 6-char alphanumeric short code (no ambiguous chars).
30    pub short_code: String,
31    /// X25519 ephemeral public key (base64url encoded).
32    pub ephemeral_pubkey: String,
33    /// Token expiration time.
34    pub expires_at: DateTime<Utc>,
35    /// Capabilities to grant to the paired device.
36    pub capabilities: Vec<String>,
37}
38
39/// Ephemeral keypair for a pairing session.
40///
41/// The X25519 secret is used once during ECDH key exchange with the
42/// responding device. After `complete_exchange()`, the secret is consumed.
43pub struct PairingSession {
44    /// The pairing token to share with the responding device.
45    pub token: PairingToken,
46    /// The X25519 ephemeral secret (consumed once during DH).
47    ephemeral_secret: Option<EphemeralSecret>,
48}
49
50impl PairingToken {
51    /// Generate a new pairing token with a 5-minute expiry.
52    pub fn generate(
53        now: DateTime<Utc>,
54        controller_did: String,
55        endpoint: String,
56        capabilities: Vec<String>,
57    ) -> Result<PairingSession, PairingError> {
58        Self::generate_with_expiry(
59            now,
60            controller_did,
61            endpoint,
62            capabilities,
63            Duration::minutes(5),
64        )
65    }
66
67    /// Generate a new pairing token with custom expiry.
68    pub fn generate_with_expiry(
69        now: DateTime<Utc>,
70        controller_did: String,
71        endpoint: String,
72        capabilities: Vec<String>,
73        expiry: Duration,
74    ) -> Result<PairingSession, PairingError> {
75        // Generate X25519 ephemeral keypair
76        let ephemeral_secret = EphemeralSecret::random_from_rng(OsRng);
77        let ephemeral_public = PublicKey::from(&ephemeral_secret);
78
79        let ephemeral_pubkey = URL_SAFE_NO_PAD.encode(ephemeral_public.as_bytes());
80
81        // Generate short code
82        let short_code = generate_short_code()?;
83
84        let token = PairingToken {
85            controller_did,
86            endpoint,
87            short_code,
88            ephemeral_pubkey,
89            expires_at: now + expiry,
90            capabilities,
91        };
92
93        Ok(PairingSession {
94            token,
95            ephemeral_secret: Some(ephemeral_secret),
96        })
97    }
98
99    /// Check if the token has expired.
100    pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
101        now > self.expires_at
102    }
103
104    /// Convert to an `auths://` URI for QR code or deep linking.
105    ///
106    /// Format: `auths://pair?d=<did>&e=<endpoint_b64>&k=<pubkey>&sc=<code>&x=<expires>&c=<caps>`
107    pub fn to_uri(&self) -> String {
108        let expires_unix = self.expires_at.timestamp();
109        let endpoint_b64 = URL_SAFE_NO_PAD.encode(self.endpoint.as_bytes());
110        let caps = self.capabilities.join(",");
111        format!(
112            "auths://pair?d={}&e={}&k={}&sc={}&x={}&c={}",
113            self.controller_did,
114            endpoint_b64,
115            self.ephemeral_pubkey,
116            self.short_code,
117            expires_unix,
118            caps
119        )
120    }
121
122    /// Parse a pairing token from an `auths://` URI.
123    pub fn from_uri(uri: &str) -> Result<Self, PairingError> {
124        let rest = uri
125            .strip_prefix("auths://pair?")
126            .ok_or_else(|| PairingError::InvalidUri("Expected auths://pair? scheme".to_string()))?;
127
128        let mut controller_did = None;
129        let mut endpoint_b64 = None;
130        let mut ephemeral_pubkey = None;
131        let mut short_code = None;
132        let mut expires_unix = None;
133        let mut caps_str = None;
134
135        for param in rest.split('&') {
136            if let Some((key, value)) = param.split_once('=') {
137                match key {
138                    "d" => controller_did = Some(value.to_string()),
139                    "e" => endpoint_b64 = Some(value.to_string()),
140                    "k" => ephemeral_pubkey = Some(value.to_string()),
141                    "sc" => short_code = Some(value.to_string()),
142                    "x" => expires_unix = value.parse::<i64>().ok(),
143                    "c" => caps_str = Some(value.to_string()),
144                    _ => {}
145                }
146            }
147        }
148
149        let controller_did = controller_did
150            .ok_or_else(|| PairingError::InvalidUri("Missing controller_did".to_string()))?;
151        let endpoint_b64 =
152            endpoint_b64.ok_or_else(|| PairingError::InvalidUri("Missing endpoint".to_string()))?;
153        let endpoint_bytes = URL_SAFE_NO_PAD
154            .decode(&endpoint_b64)
155            .map_err(|e| PairingError::InvalidUri(format!("Invalid endpoint encoding: {}", e)))?;
156        let endpoint = String::from_utf8(endpoint_bytes)
157            .map_err(|e| PairingError::InvalidUri(format!("Invalid endpoint UTF-8: {}", e)))?;
158        let ephemeral_pubkey = ephemeral_pubkey
159            .ok_or_else(|| PairingError::InvalidUri("Missing ephemeral_pubkey".to_string()))?;
160        let short_code =
161            short_code.ok_or_else(|| PairingError::InvalidUri("Missing short_code".to_string()))?;
162        let expires_unix = expires_unix
163            .ok_or_else(|| PairingError::InvalidUri("Missing or invalid expires_at".to_string()))?;
164
165        let expires_at = DateTime::from_timestamp(expires_unix, 0)
166            .ok_or_else(|| PairingError::InvalidUri("Invalid timestamp".to_string()))?;
167
168        let capabilities = caps_str
169            .filter(|s| !s.is_empty())
170            .map(|s| s.split(',').map(|c| c.to_string()).collect())
171            .unwrap_or_default();
172
173        Ok(PairingToken {
174            controller_did,
175            endpoint,
176            short_code,
177            ephemeral_pubkey,
178            expires_at,
179            capabilities,
180        })
181    }
182
183    /// Get the ephemeral public key as bytes.
184    pub fn ephemeral_pubkey_bytes(&self) -> Result<[u8; 32], PairingError> {
185        let bytes = URL_SAFE_NO_PAD
186            .decode(&self.ephemeral_pubkey)
187            .map_err(|e| PairingError::InvalidUri(format!("Invalid pubkey encoding: {}", e)))?;
188        bytes.try_into().map_err(|_| {
189            PairingError::KeyExchangeFailed("Invalid X25519 pubkey length".to_string())
190        })
191    }
192}
193
194impl PairingSession {
195    /// Complete the ECDH exchange with the responder's X25519 public key.
196    ///
197    /// Consumes the ephemeral secret (one-time use). Returns the 32-byte shared secret.
198    pub fn complete_exchange(
199        &mut self,
200        responder_x25519_pubkey: &[u8; 32],
201    ) -> Result<Zeroizing<[u8; 32]>, PairingError> {
202        let secret = self
203            .ephemeral_secret
204            .take()
205            .ok_or(PairingError::SessionConsumed)?;
206
207        let responder_pubkey = PublicKey::from(*responder_x25519_pubkey);
208        let shared = secret.diffie_hellman(&responder_pubkey);
209
210        Ok(Zeroizing::new(*shared.as_bytes()))
211    }
212
213    /// Get the ephemeral public key bytes.
214    pub fn ephemeral_pubkey_bytes(&self) -> Result<[u8; 32], PairingError> {
215        self.token.ephemeral_pubkey_bytes()
216    }
217
218    /// Verify a pairing response's Ed25519 signature.
219    ///
220    /// The signed message is: short_code || initiator_x25519_pubkey || device_x25519_pubkey
221    pub fn verify_response(
222        &self,
223        device_ed25519_pubkey: &[u8],
224        device_x25519_pubkey: &[u8; 32],
225        signature: &[u8],
226    ) -> Result<(), PairingError> {
227        let initiator_pubkey = self.token.ephemeral_pubkey_bytes()?;
228
229        // Build the signed message: short_code || initiator_x25519 || device_x25519
230        let mut message = Vec::new();
231        message.extend_from_slice(self.token.short_code.as_bytes());
232        message.extend_from_slice(&initiator_pubkey);
233        message.extend_from_slice(device_x25519_pubkey);
234
235        crate::crypto::provider_bridge::verify_ed25519_sync(
236            device_ed25519_pubkey,
237            &message,
238            signature,
239        )
240        .map_err(|_| PairingError::InvalidSignature)?;
241
242        Ok(())
243    }
244}
245
246/// Generate a random alphanumeric short code.
247fn generate_short_code() -> Result<String, PairingError> {
248    use rand::RngCore;
249
250    let mut rng = OsRng;
251    let mut code = String::with_capacity(SHORT_CODE_LEN);
252
253    for _ in 0..SHORT_CODE_LEN {
254        let idx = (rng.next_u32() as usize) % SHORT_CODE_ALPHABET.len();
255        code.push(SHORT_CODE_ALPHABET[idx] as char);
256    }
257
258    Ok(code)
259}
260
261/// Normalize a short code: uppercase, strip spaces/dashes.
262pub fn normalize_short_code(code: &str) -> String {
263    code.chars()
264        .filter(|c| !c.is_whitespace() && *c != '-')
265        .flat_map(|c| c.to_uppercase())
266        .collect()
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn make_session() -> PairingSession {
274        PairingToken::generate(
275            Utc::now(),
276            "did:keri:test123".to_string(),
277            "http://localhost:3000".to_string(),
278            vec!["sign_commit".to_string()],
279        )
280        .unwrap()
281    }
282
283    #[test]
284    fn test_generate_token() {
285        let session = make_session();
286        assert!(!session.token.controller_did.is_empty());
287        assert!(!session.token.short_code.is_empty());
288        assert!(!session.token.ephemeral_pubkey.is_empty());
289        assert!(!session.token.is_expired(Utc::now()));
290        assert_eq!(session.token.short_code.len(), 6);
291        assert_eq!(session.token.capabilities, vec!["sign_commit"]);
292    }
293
294    #[test]
295    fn test_token_uri_roundtrip() {
296        let session = make_session();
297        let uri = session.token.to_uri();
298
299        assert!(uri.starts_with("auths://pair?"));
300
301        let parsed = PairingToken::from_uri(&uri).unwrap();
302        assert_eq!(parsed.controller_did, session.token.controller_did);
303        assert_eq!(parsed.endpoint, session.token.endpoint);
304        assert_eq!(parsed.ephemeral_pubkey, session.token.ephemeral_pubkey);
305        assert_eq!(parsed.short_code, session.token.short_code);
306        assert_eq!(parsed.capabilities, session.token.capabilities);
307    }
308
309    #[test]
310    fn test_short_code_no_ambiguous_chars() {
311        // Generate many codes and verify no ambiguous characters
312        let ambiguous: &[char] = &['0', 'O', '1', 'I', 'L'];
313        for _ in 0..100 {
314            let code = generate_short_code().unwrap();
315            assert_eq!(code.len(), SHORT_CODE_LEN);
316            for ch in code.chars() {
317                assert!(
318                    !ambiguous.contains(&ch),
319                    "Short code '{}' contains ambiguous char '{}'",
320                    code,
321                    ch
322                );
323                assert!(
324                    SHORT_CODE_ALPHABET.contains(&(ch as u8)),
325                    "Short code '{}' contains invalid char '{}'",
326                    code,
327                    ch
328                );
329            }
330        }
331    }
332
333    #[test]
334    fn test_expiry() {
335        let now = Utc::now();
336        let session = PairingToken::generate_with_expiry(
337            now,
338            "did:keri:test".to_string(),
339            "http://localhost:3000".to_string(),
340            vec![],
341            Duration::seconds(-1),
342        )
343        .unwrap();
344        assert!(session.token.is_expired(now));
345    }
346
347    #[test]
348    fn test_normalize_short_code() {
349        assert_eq!(normalize_short_code("abc def"), "ABCDEF");
350        assert_eq!(normalize_short_code("AB-CD-EF"), "ABCDEF");
351        assert_eq!(normalize_short_code("  a b c  "), "ABC");
352    }
353
354    #[test]
355    fn test_session_consumed_prevents_reuse() {
356        let mut session = make_session();
357        let fake_responder_pubkey = [42u8; 32];
358
359        // First exchange succeeds
360        let result = session.complete_exchange(&fake_responder_pubkey);
361        assert!(result.is_ok());
362
363        // Second exchange fails (secret consumed)
364        let result = session.complete_exchange(&fake_responder_pubkey);
365        assert!(matches!(result, Err(PairingError::SessionConsumed)));
366    }
367}