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