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#[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
25pub struct PairingSession {
30 pub token: PairingToken,
31 ephemeral_secret: Option<EphemeralSecret>,
32}
33
34impl PairingToken {
35 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 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 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 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 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 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
231pub 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}