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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(tag = "algo", rename_all = "snake_case")]
30pub enum KemSlot {
31 #[serde(rename = "ml_kem_768")]
34 MlKem768 {
35 public_key: String,
38 },
39}
40
41#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub kem_slot: Option<KemSlot>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub daemon_spki_sha256: Option<String>,
66}
67
68pub struct PairingSession {
74 pub token: PairingToken,
75 ephemeral_secret: Option<p256::ecdh::EphemeralSecret>,
76}
77
78impl PairingToken {
79 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 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 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 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 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 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 pub fn ephemeral_pubkey_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
268 self.token.ephemeral_pubkey_bytes()
269 }
270
271 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
334pub 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 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}