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