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(
193 &self,
194 challenge: &str,
195 kind: ChallengeKind,
196 ) -> Option<PasskeyChallenge> {
197 let mut map = self.challenges.lock().unwrap();
198 let entry = map.remove(challenge)?;
199 if entry.expires_at <= now_secs() || entry.kind != kind {
200 return None;
201 }
202 Some(entry)
203 }
204
205 pub fn store_passkey(&self, passkey: Passkey) {
206 self.backend.put(&passkey);
207 }
208
209 pub fn get_passkey(&self, id: &str) -> Option<Passkey> {
210 self.backend.get(id)
211 }
212
213 pub fn list_for_user(&self, user_id: &str) -> Vec<Passkey> {
214 self.backend.list_for_user(user_id)
215 }
216
217 pub fn delete(&self, id: &str) -> bool {
218 self.backend.delete(id)
219 }
220
221 pub fn record_use(&self, id: &str, new_count: u32) {
222 self.backend.update_counter(id, new_count, now_secs());
223 }
224}
225
226#[derive(Debug, Clone)]
232pub struct AssertionInput<'a> {
233 pub credential_id: &'a str,
234 pub authenticator_data: &'a [u8],
235 pub client_data_json: &'a [u8],
236 pub signature: &'a [u8],
237 pub user_handle: Option<&'a [u8]>,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum WebauthnError {
247 UnknownCredential,
248 BadClientData,
249 WrongType,
250 ChallengeMismatch,
251 OriginMismatch,
252 RpIdMismatch,
253 AuthenticatorDataTooShort,
254 UserNotPresent,
255 SignatureMismatch,
256 UnsupportedAlg,
257 CounterRegression,
258}
259
260impl std::fmt::Display for WebauthnError {
261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262 f.write_str(match self {
263 Self::UnknownCredential => "credential not found",
264 Self::BadClientData => "clientDataJSON malformed",
265 Self::WrongType => "clientData.type mismatch",
266 Self::ChallengeMismatch => "challenge mismatch",
267 Self::OriginMismatch => "origin mismatch",
268 Self::RpIdMismatch => "rpId hash mismatch",
269 Self::AuthenticatorDataTooShort => "authenticatorData too short",
270 Self::UserNotPresent => "user-presence flag not set",
271 Self::SignatureMismatch => "signature verification failed",
272 Self::UnsupportedAlg => "credential alg not supported (need ES256 or Ed25519)",
273 Self::CounterRegression => "sign counter regressed — possible cloned credential",
274 })
275 }
276}
277
278pub fn verify_assertion(
289 store: &PasskeyStore,
290 input: &AssertionInput,
291 expected_origin: &str,
292 expected_rp_id: &str,
293 expected_user_id: Option<&str>,
294) -> Result<Passkey, WebauthnError> {
295 let stored = store
296 .get_passkey(input.credential_id)
297 .ok_or(WebauthnError::UnknownCredential)?;
298 if let Some(uid) = expected_user_id {
299 if stored.user_id != uid {
300 return Err(WebauthnError::UnknownCredential);
301 }
302 }
303
304 let client_data: serde_json::Value =
306 serde_json::from_slice(input.client_data_json).map_err(|_| WebauthnError::BadClientData)?;
307 let kind = client_data
308 .get("type")
309 .and_then(|v| v.as_str())
310 .unwrap_or_default();
311 if kind != "webauthn.get" {
312 return Err(WebauthnError::WrongType);
313 }
314 let challenge_b64 = client_data
315 .get("challenge")
316 .and_then(|v| v.as_str())
317 .unwrap_or_default();
318 let _ = store
319 .take_challenge(challenge_b64, ChallengeKind::Assertion)
320 .ok_or(WebauthnError::ChallengeMismatch)?;
321 let origin = client_data
322 .get("origin")
323 .and_then(|v| v.as_str())
324 .unwrap_or_default();
325 if origin != expected_origin {
326 return Err(WebauthnError::OriginMismatch);
327 }
328
329 if input.authenticator_data.len() < 37 {
331 return Err(WebauthnError::AuthenticatorDataTooShort);
332 }
333 use sha2::{Digest, Sha256};
334 let mut rp_id_hash = Sha256::new();
335 rp_id_hash.update(expected_rp_id.as_bytes());
336 let expected_rp_hash = rp_id_hash.finalize();
337 if input.authenticator_data[..32] != expected_rp_hash[..] {
338 return Err(WebauthnError::RpIdMismatch);
339 }
340 let flags = input.authenticator_data[32];
341 if flags & 0x01 == 0 {
342 return Err(WebauthnError::UserNotPresent);
343 }
344 let counter = u32::from_be_bytes([
345 input.authenticator_data[33],
346 input.authenticator_data[34],
347 input.authenticator_data[35],
348 input.authenticator_data[36],
349 ]);
350 if stored.sign_count > 0 && counter <= stored.sign_count {
354 return Err(WebauthnError::CounterRegression);
355 }
356
357 let mut client_data_hash = Sha256::new();
359 client_data_hash.update(input.client_data_json);
360 let cd_hash = client_data_hash.finalize();
361 let mut signing_input = Vec::with_capacity(input.authenticator_data.len() + 32);
362 signing_input.extend_from_slice(input.authenticator_data);
363 signing_input.extend_from_slice(&cd_hash);
364
365 let alg = cose_key_alg(&stored.public_key).ok_or(WebauthnError::UnsupportedAlg)?;
367 match alg {
368 -7 => {
369 let raw = cose_es256_xy(&stored.public_key).ok_or(WebauthnError::UnsupportedAlg)?;
371 let mut spki = Vec::with_capacity(65);
373 spki.push(0x04);
374 spki.extend_from_slice(&raw.0);
375 spki.extend_from_slice(&raw.1);
376 let pubkey =
377 signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_ASN1, &spki);
378 pubkey
379 .verify(&signing_input, input.signature)
380 .map_err(|_| WebauthnError::SignatureMismatch)?;
381 }
382 -8 => {
383 let pubkey_bytes =
385 cose_eddsa_x(&stored.public_key).ok_or(WebauthnError::UnsupportedAlg)?;
386 let pubkey =
387 signature::UnparsedPublicKey::new(&signature::ED25519, &pubkey_bytes);
388 pubkey
389 .verify(&signing_input, input.signature)
390 .map_err(|_| WebauthnError::SignatureMismatch)?;
391 }
392 _ => return Err(WebauthnError::UnsupportedAlg),
393 }
394
395 store.record_use(&stored.id, counter);
396 let mut updated = stored;
397 updated.sign_count = counter;
398 Ok(updated)
399}
400
401fn cose_key_alg(bytes: &[u8]) -> Option<i64> {
408 let map = parse_cbor_map(bytes)?;
409 map.get(&CborKey::I(3)).and_then(|v| v.as_i64())
410}
411
412fn cose_es256_xy(bytes: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
413 let map = parse_cbor_map(bytes)?;
414 let x = map.get(&CborKey::I(-2))?.as_bytes().cloned()?;
415 let y = map.get(&CborKey::I(-3))?.as_bytes().cloned()?;
416 if x.len() != 32 || y.len() != 32 {
417 return None;
418 }
419 Some((x, y))
420}
421
422fn cose_eddsa_x(bytes: &[u8]) -> Option<Vec<u8>> {
423 let map = parse_cbor_map(bytes)?;
424 let x = map.get(&CborKey::I(-2))?.as_bytes().cloned()?;
425 if x.len() != 32 {
426 return None;
427 }
428 Some(x)
429}
430
431#[derive(Debug, Clone, PartialEq, Eq, Hash)]
432enum CborKey {
433 I(i64),
434 S(String),
435}
436
437#[derive(Debug, Clone)]
438enum CborVal {
439 I(i64),
440 Bytes(Vec<u8>),
441 Text(String),
442 Map(HashMap<CborKey, CborVal>),
443 Other,
444}
445
446impl CborVal {
447 fn as_i64(&self) -> Option<i64> {
448 if let CborVal::I(n) = self {
449 Some(*n)
450 } else {
451 None
452 }
453 }
454 fn as_bytes(&self) -> Option<&Vec<u8>> {
455 if let CborVal::Bytes(b) = self {
456 Some(b)
457 } else {
458 None
459 }
460 }
461}
462
463fn parse_cbor_map(bytes: &[u8]) -> Option<HashMap<CborKey, CborVal>> {
467 let mut p = CborParser { bytes, pos: 0 };
468 let val = p.read_value()?;
469 if let CborVal::Map(m) = val {
470 Some(m)
471 } else {
472 None
473 }
474}
475
476struct CborParser<'a> {
477 bytes: &'a [u8],
478 pos: usize,
479}
480
481impl<'a> CborParser<'a> {
482 fn peek(&self) -> Option<u8> {
483 self.bytes.get(self.pos).copied()
484 }
485 fn take(&mut self, n: usize) -> Option<&'a [u8]> {
486 if self.pos + n > self.bytes.len() {
487 return None;
488 }
489 let s = &self.bytes[self.pos..self.pos + n];
490 self.pos += n;
491 Some(s)
492 }
493 fn read_u8(&mut self) -> Option<u8> {
494 let b = self.peek()?;
495 self.pos += 1;
496 Some(b)
497 }
498
499 fn read_arg(&mut self, additional: u8) -> Option<u64> {
503 match additional {
504 0..=23 => Some(additional as u64),
505 24 => Some(self.read_u8()? as u64),
506 25 => {
507 let s = self.take(2)?;
508 Some(u16::from_be_bytes([s[0], s[1]]) as u64)
509 }
510 26 => {
511 let s = self.take(4)?;
512 Some(u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as u64)
513 }
514 27 => {
515 let s = self.take(8)?;
516 Some(u64::from_be_bytes([
517 s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7],
518 ]))
519 }
520 _ => None,
521 }
522 }
523
524 fn read_value(&mut self) -> Option<CborVal> {
525 let head = self.read_u8()?;
526 let major = head >> 5;
527 let additional = head & 0x1F;
528 match major {
529 0 => Some(CborVal::I(self.read_arg(additional)? as i64)),
530 1 => {
531 let n = self.read_arg(additional)?;
533 Some(CborVal::I(-1 - n as i64))
534 }
535 2 => {
536 let len = self.read_arg(additional)? as usize;
537 let s = self.take(len)?.to_vec();
538 Some(CborVal::Bytes(s))
539 }
540 3 => {
541 let len = self.read_arg(additional)? as usize;
542 let s = self.take(len)?;
543 Some(CborVal::Text(
544 std::str::from_utf8(s).ok()?.to_string(),
545 ))
546 }
547 4 => {
548 let len = self.read_arg(additional)? as usize;
549 let mut arr = Vec::with_capacity(len);
550 for _ in 0..len {
551 arr.push(self.read_value()?);
552 }
553 let _ = arr;
555 Some(CborVal::Other)
556 }
557 5 => {
558 let len = self.read_arg(additional)? as usize;
559 let mut map = HashMap::with_capacity(len);
560 for _ in 0..len {
561 let key_val = self.read_value()?;
562 let key = match key_val {
563 CborVal::I(n) => CborKey::I(n),
564 CborVal::Text(s) => CborKey::S(s),
565 _ => return None,
566 };
567 let val = self.read_value()?;
568 map.insert(key, val);
569 }
570 Some(CborVal::Map(map))
571 }
572 _ => Some(CborVal::Other),
573 }
574 }
575}
576
577fn now_secs() -> u64 {
578 use std::time::{SystemTime, UNIX_EPOCH};
579 SystemTime::now()
580 .duration_since(UNIX_EPOCH)
581 .unwrap_or_default()
582 .as_secs()
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn challenge_round_trip() {
591 let store = PasskeyStore::new();
592 let challenge = store.mint_challenge("u-1".into(), ChallengeKind::Registration);
593 let taken = store
594 .take_challenge(&challenge, ChallengeKind::Registration)
595 .unwrap();
596 assert_eq!(taken.user_id, "u-1");
597 assert!(store
599 .take_challenge(&challenge, ChallengeKind::Registration)
600 .is_none());
601 }
602
603 #[test]
604 fn challenge_kind_mismatch_rejected() {
605 let store = PasskeyStore::new();
606 let challenge = store.mint_challenge("u-1".into(), ChallengeKind::Registration);
607 assert!(store
610 .take_challenge(&challenge, ChallengeKind::Assertion)
611 .is_none());
612 }
613
614 #[test]
615 fn passkey_storage_round_trip() {
616 let store = PasskeyStore::new();
617 let p = Passkey {
618 id: "cred1".into(),
619 user_id: "u-1".into(),
620 public_key: vec![],
621 sign_count: 0,
622 name: "iPhone".into(),
623 created_at: 100,
624 last_used_at: None,
625 };
626 store.store_passkey(p.clone());
627 assert_eq!(store.get_passkey("cred1").unwrap(), p);
628 assert_eq!(store.list_for_user("u-1").len(), 1);
629 store.record_use("cred1", 5);
630 let after = store.get_passkey("cred1").unwrap();
631 assert_eq!(after.sign_count, 5);
632 assert!(after.last_used_at.is_some());
633 assert!(store.delete("cred1"));
634 assert!(store.get_passkey("cred1").is_none());
635 }
636
637 #[test]
640 fn cose_es256_xy_extracts_coords() {
641 let mut buf = Vec::new();
642 buf.push(0xa5);
644 buf.extend_from_slice(&[0x01, 0x02]);
646 buf.extend_from_slice(&[0x03, 0x26]);
648 buf.extend_from_slice(&[0x20, 0x01]);
650 buf.extend_from_slice(&[0x21, 0x58, 0x20]);
652 buf.extend_from_slice(&[0xAA; 32]);
653 buf.extend_from_slice(&[0x22, 0x58, 0x20]);
655 buf.extend_from_slice(&[0xBB; 32]);
656 let (x, y) = cose_es256_xy(&buf).expect("parse");
657 assert_eq!(x, vec![0xAA; 32]);
658 assert_eq!(y, vec![0xBB; 32]);
659 assert_eq!(cose_key_alg(&buf), Some(-7));
660 }
661
662 #[test]
663 fn cose_eddsa_extracts_x() {
664 let mut buf = Vec::new();
665 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]);
670 buf.extend_from_slice(&[0xCC; 32]);
671 let x = cose_eddsa_x(&buf).expect("parse");
672 assert_eq!(x, vec![0xCC; 32]);
673 assert_eq!(cose_key_alg(&buf), Some(-8));
674 }
675
676 #[test]
677 fn assertion_unknown_credential_rejected() {
678 let store = PasskeyStore::new();
679 let input = AssertionInput {
680 credential_id: "missing",
681 authenticator_data: &[0u8; 37],
682 client_data_json: b"{}",
683 signature: &[],
684 user_handle: None,
685 };
686 let err = verify_assertion(&store, &input, "https://app", "app", None).unwrap_err();
687 assert_eq!(err, WebauthnError::UnknownCredential);
688 }
689
690 #[test]
695 fn assertion_user_mismatch_rejected() {
696 let store = PasskeyStore::new();
697 store.store_passkey(Passkey {
698 id: "cred1".into(),
699 user_id: "alice".into(),
700 public_key: vec![],
701 sign_count: 0,
702 name: "key".into(),
703 created_at: 1,
704 last_used_at: None,
705 });
706 let input = AssertionInput {
707 credential_id: "cred1",
708 authenticator_data: &[0u8; 37],
709 client_data_json: b"{}",
710 signature: &[],
711 user_handle: None,
712 };
713 let err = verify_assertion(&store, &input, "https://app", "app", Some("bob")).unwrap_err();
714 assert_eq!(err, WebauthnError::UnknownCredential);
715 }
716}