1use 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
12const SHORT_CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";
14
15const SHORT_CODE_LEN: usize = 6;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PairingToken {
25 pub controller_did: String,
27 pub endpoint: String,
29 pub short_code: String,
31 pub ephemeral_pubkey: String,
33 pub expires_at: DateTime<Utc>,
35 pub capabilities: Vec<String>,
37}
38
39pub struct PairingSession {
44 pub token: PairingToken,
46 ephemeral_secret: Option<EphemeralSecret>,
48}
49
50impl PairingToken {
51 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 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 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 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 pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
101 now > self.expires_at
102 }
103
104 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 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 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 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 pub fn ephemeral_pubkey_bytes(&self) -> Result<[u8; 32], PairingError> {
215 self.token.ephemeral_pubkey_bytes()
216 }
217
218 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 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
246fn 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
261pub 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 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 let result = session.complete_exchange(&fake_responder_pubkey);
361 assert!(result.is_ok());
362
363 let result = session.complete_exchange(&fake_responder_pubkey);
365 assert!(matches!(result, Err(PairingError::SessionConsumed)));
366 }
367}