1use crate::apple_jwt::base64_url;
46use ring::signature;
47use std::collections::HashMap;
48use std::sync::Mutex;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Passkey {
56 pub id: String,
59 pub user_id: String,
60 pub public_key: Vec<u8>,
63 pub sign_count: u32,
70 pub name: String,
72 pub created_at: u64,
73 pub last_used_at: Option<u64>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct PasskeyChallenge {
82 pub challenge: String,
84 pub user_id: String,
85 pub kind: ChallengeKind,
86 pub expires_at: u64,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum ChallengeKind {
91 Registration,
93 Assertion,
95}
96
97pub trait PasskeyBackend: Send + Sync {
100 fn put(&self, passkey: &Passkey);
101 fn get(&self, id: &str) -> Option<Passkey>;
102 fn list_for_user(&self, user_id: &str) -> Vec<Passkey>;
103 fn delete(&self, id: &str) -> bool;
104 fn update_counter(&self, id: &str, sign_count: u32, last_used: u64);
105}
106
107pub struct InMemoryPasskeyBackend {
108 keys: Mutex<HashMap<String, Passkey>>,
109}
110
111impl Default for InMemoryPasskeyBackend {
112 fn default() -> Self {
113 Self {
114 keys: Mutex::new(HashMap::new()),
115 }
116 }
117}
118
119impl PasskeyBackend for InMemoryPasskeyBackend {
120 fn put(&self, p: &Passkey) {
121 self.keys.lock().unwrap().insert(p.id.clone(), p.clone());
122 }
123 fn get(&self, id: &str) -> Option<Passkey> {
124 self.keys.lock().unwrap().get(id).cloned()
125 }
126 fn list_for_user(&self, user_id: &str) -> Vec<Passkey> {
127 self.keys
128 .lock()
129 .unwrap()
130 .values()
131 .filter(|k| k.user_id == user_id)
132 .cloned()
133 .collect()
134 }
135 fn delete(&self, id: &str) -> bool {
136 self.keys.lock().unwrap().remove(id).is_some()
137 }
138 fn update_counter(&self, id: &str, sign_count: u32, last_used: u64) {
139 if let Some(k) = self.keys.lock().unwrap().get_mut(id) {
140 k.sign_count = sign_count;
141 k.last_used_at = Some(last_used);
142 }
143 }
144}
145
146pub struct PasskeyStore {
147 backend: Box<dyn PasskeyBackend>,
148 challenges: Mutex<HashMap<String, PasskeyChallenge>>,
149}
150
151impl Default for PasskeyStore {
152 fn default() -> Self {
153 Self::new()
154 }
155}
156
157impl PasskeyStore {
158 pub fn new() -> Self {
159 Self::with_backend(Box::new(InMemoryPasskeyBackend::default()))
160 }
161 pub fn with_backend(backend: Box<dyn PasskeyBackend>) -> Self {
162 Self {
163 backend,
164 challenges: Mutex::new(HashMap::new()),
165 }
166 }
167
168 pub fn mint_challenge(&self, user_id: String, kind: ChallengeKind) -> String {
172 use rand::RngCore;
173 let mut bytes = [0u8; 32];
174 rand::thread_rng().fill_bytes(&mut bytes);
175 let challenge = base64_url(bytes);
176 let expires_at = now_secs() + 5 * 60;
177 self.challenges.lock().unwrap().insert(
178 challenge.clone(),
179 PasskeyChallenge {
180 challenge: challenge.clone(),
181 user_id,
182 kind,
183 expires_at,
184 },
185 );
186 challenge
187 }
188
189 pub fn take_challenge(&self, challenge: &str, kind: ChallengeKind) -> Option<PasskeyChallenge> {
193 let mut map = self.challenges.lock().unwrap();
194 let entry = map.remove(challenge)?;
195 if entry.expires_at <= now_secs() || entry.kind != kind {
196 return None;
197 }
198 Some(entry)
199 }
200
201 pub fn store_passkey(&self, passkey: Passkey) {
202 self.backend.put(&passkey);
203 }
204
205 pub fn get_passkey(&self, id: &str) -> Option<Passkey> {
206 self.backend.get(id)
207 }
208
209 pub fn list_for_user(&self, user_id: &str) -> Vec<Passkey> {
210 self.backend.list_for_user(user_id)
211 }
212
213 pub fn delete(&self, id: &str) -> bool {
214 self.backend.delete(id)
215 }
216
217 pub fn record_use(&self, id: &str, new_count: u32) {
218 self.backend.update_counter(id, new_count, now_secs());
219 }
220}
221
222#[derive(Debug, Clone)]
228pub struct AssertionInput<'a> {
229 pub credential_id: &'a str,
230 pub authenticator_data: &'a [u8],
231 pub client_data_json: &'a [u8],
232 pub signature: &'a [u8],
233 pub user_handle: Option<&'a [u8]>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum WebauthnError {
243 UnknownCredential,
244 BadClientData,
245 WrongType,
246 ChallengeMismatch,
247 OriginMismatch,
248 RpIdMismatch,
249 AuthenticatorDataTooShort,
250 UserNotPresent,
251 SignatureMismatch,
252 UnsupportedAlg,
253 CounterRegression,
254}
255
256impl std::fmt::Display for WebauthnError {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 f.write_str(match self {
259 Self::UnknownCredential => "credential not found",
260 Self::BadClientData => "clientDataJSON malformed",
261 Self::WrongType => "clientData.type mismatch",
262 Self::ChallengeMismatch => "challenge mismatch",
263 Self::OriginMismatch => "origin mismatch",
264 Self::RpIdMismatch => "rpId hash mismatch",
265 Self::AuthenticatorDataTooShort => "authenticatorData too short",
266 Self::UserNotPresent => "user-presence flag not set",
267 Self::SignatureMismatch => "signature verification failed",
268 Self::UnsupportedAlg => "credential alg not supported (need ES256 or Ed25519)",
269 Self::CounterRegression => "sign counter regressed — possible cloned credential",
270 })
271 }
272}
273
274pub fn verify_assertion(
285 store: &PasskeyStore,
286 input: &AssertionInput,
287 expected_origin: &str,
288 expected_rp_id: &str,
289 expected_user_id: Option<&str>,
290) -> Result<Passkey, WebauthnError> {
291 let stored = store
292 .get_passkey(input.credential_id)
293 .ok_or(WebauthnError::UnknownCredential)?;
294 if let Some(uid) = expected_user_id {
295 if stored.user_id != uid {
296 return Err(WebauthnError::UnknownCredential);
297 }
298 }
299
300 let client_data: serde_json::Value =
302 serde_json::from_slice(input.client_data_json).map_err(|_| WebauthnError::BadClientData)?;
303 let kind = client_data
304 .get("type")
305 .and_then(|v| v.as_str())
306 .unwrap_or_default();
307 if kind != "webauthn.get" {
308 return Err(WebauthnError::WrongType);
309 }
310 let challenge_b64 = client_data
311 .get("challenge")
312 .and_then(|v| v.as_str())
313 .unwrap_or_default();
314 let _ = store
315 .take_challenge(challenge_b64, ChallengeKind::Assertion)
316 .ok_or(WebauthnError::ChallengeMismatch)?;
317 let origin = client_data
318 .get("origin")
319 .and_then(|v| v.as_str())
320 .unwrap_or_default();
321 if origin != expected_origin {
322 return Err(WebauthnError::OriginMismatch);
323 }
324
325 if input.authenticator_data.len() < 37 {
327 return Err(WebauthnError::AuthenticatorDataTooShort);
328 }
329 use sha2::{Digest, Sha256};
330 let mut rp_id_hash = Sha256::new();
331 rp_id_hash.update(expected_rp_id.as_bytes());
332 let expected_rp_hash = rp_id_hash.finalize();
333 if input.authenticator_data[..32] != expected_rp_hash[..] {
334 return Err(WebauthnError::RpIdMismatch);
335 }
336 let flags = input.authenticator_data[32];
337 if flags & 0x01 == 0 {
338 return Err(WebauthnError::UserNotPresent);
339 }
340 let counter = u32::from_be_bytes([
341 input.authenticator_data[33],
342 input.authenticator_data[34],
343 input.authenticator_data[35],
344 input.authenticator_data[36],
345 ]);
346 if stored.sign_count > 0 && counter <= stored.sign_count {
350 return Err(WebauthnError::CounterRegression);
351 }
352
353 let mut client_data_hash = Sha256::new();
355 client_data_hash.update(input.client_data_json);
356 let cd_hash = client_data_hash.finalize();
357 let mut signing_input = Vec::with_capacity(input.authenticator_data.len() + 32);
358 signing_input.extend_from_slice(input.authenticator_data);
359 signing_input.extend_from_slice(&cd_hash);
360
361 let alg = cose_key_alg(&stored.public_key).ok_or(WebauthnError::UnsupportedAlg)?;
363 match alg {
364 -7 => {
365 let raw = cose_es256_xy(&stored.public_key).ok_or(WebauthnError::UnsupportedAlg)?;
367 let mut spki = Vec::with_capacity(65);
369 spki.push(0x04);
370 spki.extend_from_slice(&raw.0);
371 spki.extend_from_slice(&raw.1);
372 let pubkey =
373 signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_ASN1, &spki);
374 pubkey
375 .verify(&signing_input, input.signature)
376 .map_err(|_| WebauthnError::SignatureMismatch)?;
377 }
378 -8 => {
379 let pubkey_bytes =
381 cose_eddsa_x(&stored.public_key).ok_or(WebauthnError::UnsupportedAlg)?;
382 let pubkey = signature::UnparsedPublicKey::new(&signature::ED25519, &pubkey_bytes);
383 pubkey
384 .verify(&signing_input, input.signature)
385 .map_err(|_| WebauthnError::SignatureMismatch)?;
386 }
387 _ => return Err(WebauthnError::UnsupportedAlg),
388 }
389
390 store.record_use(&stored.id, counter);
391 let mut updated = stored;
392 updated.sign_count = counter;
393 Ok(updated)
394}
395
396fn cose_key_alg(bytes: &[u8]) -> Option<i64> {
403 let map = parse_cbor_map(bytes)?;
404 map.get(&CborKey::I(3)).and_then(|v| v.as_i64())
405}
406
407fn cose_es256_xy(bytes: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
408 let map = parse_cbor_map(bytes)?;
409 let x = map.get(&CborKey::I(-2))?.as_bytes().cloned()?;
410 let y = map.get(&CborKey::I(-3))?.as_bytes().cloned()?;
411 if x.len() != 32 || y.len() != 32 {
412 return None;
413 }
414 Some((x, y))
415}
416
417fn cose_eddsa_x(bytes: &[u8]) -> Option<Vec<u8>> {
418 let map = parse_cbor_map(bytes)?;
419 let x = map.get(&CborKey::I(-2))?.as_bytes().cloned()?;
420 if x.len() != 32 {
421 return None;
422 }
423 Some(x)
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Hash)]
427enum CborKey {
428 I(i64),
429 S(String),
430}
431
432#[derive(Debug, Clone)]
433enum CborVal {
434 I(i64),
435 Bytes(Vec<u8>),
436 Text(String),
437 Map(HashMap<CborKey, CborVal>),
438 Other,
439}
440
441impl CborVal {
442 fn as_i64(&self) -> Option<i64> {
443 if let CborVal::I(n) = self {
444 Some(*n)
445 } else {
446 None
447 }
448 }
449 fn as_bytes(&self) -> Option<&Vec<u8>> {
450 if let CborVal::Bytes(b) = self {
451 Some(b)
452 } else {
453 None
454 }
455 }
456}
457
458fn parse_cbor_map(bytes: &[u8]) -> Option<HashMap<CborKey, CborVal>> {
462 let mut p = CborParser { bytes, pos: 0 };
463 let val = p.read_value()?;
464 if let CborVal::Map(m) = val {
465 Some(m)
466 } else {
467 None
468 }
469}
470
471struct CborParser<'a> {
472 bytes: &'a [u8],
473 pos: usize,
474}
475
476impl<'a> CborParser<'a> {
477 fn peek(&self) -> Option<u8> {
478 self.bytes.get(self.pos).copied()
479 }
480 fn take(&mut self, n: usize) -> Option<&'a [u8]> {
481 if self.pos + n > self.bytes.len() {
482 return None;
483 }
484 let s = &self.bytes[self.pos..self.pos + n];
485 self.pos += n;
486 Some(s)
487 }
488 fn read_u8(&mut self) -> Option<u8> {
489 let b = self.peek()?;
490 self.pos += 1;
491 Some(b)
492 }
493
494 fn read_arg(&mut self, additional: u8) -> Option<u64> {
498 match additional {
499 0..=23 => Some(additional as u64),
500 24 => Some(self.read_u8()? as u64),
501 25 => {
502 let s = self.take(2)?;
503 Some(u16::from_be_bytes([s[0], s[1]]) as u64)
504 }
505 26 => {
506 let s = self.take(4)?;
507 Some(u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as u64)
508 }
509 27 => {
510 let s = self.take(8)?;
511 Some(u64::from_be_bytes([
512 s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7],
513 ]))
514 }
515 _ => None,
516 }
517 }
518
519 fn read_value(&mut self) -> Option<CborVal> {
520 let head = self.read_u8()?;
521 let major = head >> 5;
522 let additional = head & 0x1F;
523 match major {
524 0 => Some(CborVal::I(self.read_arg(additional)? as i64)),
525 1 => {
526 let n = self.read_arg(additional)?;
528 Some(CborVal::I(-1 - n as i64))
529 }
530 2 => {
531 let len = self.read_arg(additional)? as usize;
532 let s = self.take(len)?.to_vec();
533 Some(CborVal::Bytes(s))
534 }
535 3 => {
536 let len = self.read_arg(additional)? as usize;
537 let s = self.take(len)?;
538 Some(CborVal::Text(std::str::from_utf8(s).ok()?.to_string()))
539 }
540 4 => {
541 let len = self.read_arg(additional)? as usize;
542 let mut arr = Vec::with_capacity(len);
543 for _ in 0..len {
544 arr.push(self.read_value()?);
545 }
546 let _ = arr;
548 Some(CborVal::Other)
549 }
550 5 => {
551 let len = self.read_arg(additional)? as usize;
552 let mut map = HashMap::with_capacity(len);
553 for _ in 0..len {
554 let key_val = self.read_value()?;
555 let key = match key_val {
556 CborVal::I(n) => CborKey::I(n),
557 CborVal::Text(s) => CborKey::S(s),
558 _ => return None,
559 };
560 let val = self.read_value()?;
561 map.insert(key, val);
562 }
563 Some(CborVal::Map(map))
564 }
565 _ => Some(CborVal::Other),
566 }
567 }
568}
569
570fn now_secs() -> u64 {
571 use std::time::{SystemTime, UNIX_EPOCH};
572 SystemTime::now()
573 .duration_since(UNIX_EPOCH)
574 .unwrap_or_default()
575 .as_secs()
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[test]
583 fn challenge_round_trip() {
584 let store = PasskeyStore::new();
585 let challenge = store.mint_challenge("u-1".into(), ChallengeKind::Registration);
586 let taken = store
587 .take_challenge(&challenge, ChallengeKind::Registration)
588 .unwrap();
589 assert_eq!(taken.user_id, "u-1");
590 assert!(store
592 .take_challenge(&challenge, ChallengeKind::Registration)
593 .is_none());
594 }
595
596 #[test]
597 fn challenge_kind_mismatch_rejected() {
598 let store = PasskeyStore::new();
599 let challenge = store.mint_challenge("u-1".into(), ChallengeKind::Registration);
600 assert!(store
603 .take_challenge(&challenge, ChallengeKind::Assertion)
604 .is_none());
605 }
606
607 #[test]
608 fn passkey_storage_round_trip() {
609 let store = PasskeyStore::new();
610 let p = Passkey {
611 id: "cred1".into(),
612 user_id: "u-1".into(),
613 public_key: vec![],
614 sign_count: 0,
615 name: "iPhone".into(),
616 created_at: 100,
617 last_used_at: None,
618 };
619 store.store_passkey(p.clone());
620 assert_eq!(store.get_passkey("cred1").unwrap(), p);
621 assert_eq!(store.list_for_user("u-1").len(), 1);
622 store.record_use("cred1", 5);
623 let after = store.get_passkey("cred1").unwrap();
624 assert_eq!(after.sign_count, 5);
625 assert!(after.last_used_at.is_some());
626 assert!(store.delete("cred1"));
627 assert!(store.get_passkey("cred1").is_none());
628 }
629
630 #[test]
633 fn cose_es256_xy_extracts_coords() {
634 let mut buf = Vec::new();
635 buf.push(0xa5);
637 buf.extend_from_slice(&[0x01, 0x02]);
639 buf.extend_from_slice(&[0x03, 0x26]);
641 buf.extend_from_slice(&[0x20, 0x01]);
643 buf.extend_from_slice(&[0x21, 0x58, 0x20]);
645 buf.extend_from_slice(&[0xAA; 32]);
646 buf.extend_from_slice(&[0x22, 0x58, 0x20]);
648 buf.extend_from_slice(&[0xBB; 32]);
649 let (x, y) = cose_es256_xy(&buf).expect("parse");
650 assert_eq!(x, vec![0xAA; 32]);
651 assert_eq!(y, vec![0xBB; 32]);
652 assert_eq!(cose_key_alg(&buf), Some(-7));
653 }
654
655 #[test]
656 fn cose_eddsa_extracts_x() {
657 let mut buf = Vec::new();
658 buf.push(0xa4); buf.extend_from_slice(&[0x01, 0x01]); buf.extend_from_slice(&[0x03, 0x27]); buf.extend_from_slice(&[0x20, 0x06]); buf.extend_from_slice(&[0x21, 0x58, 0x20]);
663 buf.extend_from_slice(&[0xCC; 32]);
664 let x = cose_eddsa_x(&buf).expect("parse");
665 assert_eq!(x, vec![0xCC; 32]);
666 assert_eq!(cose_key_alg(&buf), Some(-8));
667 }
668
669 #[test]
670 fn assertion_unknown_credential_rejected() {
671 let store = PasskeyStore::new();
672 let input = AssertionInput {
673 credential_id: "missing",
674 authenticator_data: &[0u8; 37],
675 client_data_json: b"{}",
676 signature: &[],
677 user_handle: None,
678 };
679 let err = verify_assertion(&store, &input, "https://app", "app", None).unwrap_err();
680 assert_eq!(err, WebauthnError::UnknownCredential);
681 }
682
683 #[test]
688 fn assertion_user_mismatch_rejected() {
689 let store = PasskeyStore::new();
690 store.store_passkey(Passkey {
691 id: "cred1".into(),
692 user_id: "alice".into(),
693 public_key: vec![],
694 sign_count: 0,
695 name: "key".into(),
696 created_at: 1,
697 last_used_at: None,
698 });
699 let input = AssertionInput {
700 credential_id: "cred1",
701 authenticator_data: &[0u8; 37],
702 client_data_json: b"{}",
703 signature: &[],
704 user_handle: None,
705 };
706 let err = verify_assertion(&store, &input, "https://app", "app", Some("bob")).unwrap_err();
707 assert_eq!(err, WebauthnError::UnknownCredential);
708 }
709}