Skip to main content

pylon_auth/
webauthn.rs

1//! WebAuthn / passkeys — minimal subset focused on getting passkey
2//! sign-in working with the platforms users actually use (iOS,
3//! macOS, Android, Windows Hello, 1Password, hardware keys).
4//!
5//! **Status: library only — HTTP endpoints not yet wired.**
6//! `verify_assertion` + `PasskeyStore` are production-quality and
7//! exposed for apps that want to roll their own register/login
8//! handlers. Pylon-shipped `/api/auth/passkey/*` routes are queued
9//! for the next wave; until then, treat this module as primitives
10//! to compose into your own routes.
11//!
12//! This implementation supports:
13//!   - Registration with `none` attestation (passkeys generally use
14//!     `none` to avoid the privacy issues of platform attestation)
15//!   - Public key in COSE_Key form: ES256 (alg=-7), Ed25519 (alg=-8).
16//!     RS256 omitted to keep the COSE parser small — every passkey
17//!     authenticator in the wild does ES256 or Ed25519.
18//!   - Assertion verification against a stored public key + counter
19//!   - Origin / RP ID validation
20//!
21//! Out of scope (Wave 5 may extend):
22//!   - Attestation statement verification (we accept `none` only)
23//!   - Conditional UI / discoverable credentials list (frontend
24//!     handles; pylon stores `userHandle` so it works)
25//!   - Resident-key enforcement / extensions
26//!   - RS256 / RS1 / EdDSA curve negotiation beyond Ed25519
27//!
28//! Storage shape — pylon stores one row per credential (not per
29//! user; users can have multiple passkeys):
30//!
31//! ```text
32//! Passkey {
33//!   id (= credentialId base64url),
34//!   user_id,
35//!   public_key (COSE_Key bytes),
36//!   sign_count (u32),
37//!   created_at,
38//!   last_used_at,
39//! }
40//! ```
41//!
42//! The runtime persists this in SQLite + Postgres via the
43//! `crate::passkey_backend` module (in `pylon-runtime`).
44
45use crate::apple_jwt::base64_url;
46use ring::signature;
47use std::collections::HashMap;
48use std::sync::Mutex;
49
50/// Per-user, per-credential passkey record. `id` is the credentialId
51/// the authenticator returns at registration; the relying party
52/// (= pylon) hands it back in the `allowCredentials` list at
53/// assertion time so the authenticator knows which key to use.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Passkey {
56    /// Base64url-encoded credentialId — what the authenticator
57    /// returns as `rawId` and the RP echoes in `allowCredentials`.
58    pub id: String,
59    pub user_id: String,
60    /// COSE_Key bytes. Format depends on the chosen algorithm —
61    /// we extract `x`+`y`+`alg` at verify time.
62    pub public_key: Vec<u8>,
63    /// Authenticator's sign counter — increments on every successful
64    /// assertion. RP MUST reject assertions where the new counter
65    /// is `<=` the stored one (cloned-credential detection per
66    /// WebAuthn §6.1.1). `0` means the authenticator doesn't
67    /// implement counters (Touch ID, Face ID — they use secure
68    /// enclave isolation instead).
69    pub sign_count: u32,
70    /// Optional friendly name set by the user ("iPhone", "Yubikey 5").
71    pub name: String,
72    pub created_at: u64,
73    pub last_used_at: Option<u64>,
74}
75
76/// Pending registration challenge — pylon hands a random 32-byte
77/// challenge to the frontend, the authenticator signs it, we verify
78/// the signature against the stored challenge. Single-use, 5-minute
79/// expiry.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct PasskeyChallenge {
82    /// 32 random bytes, base64url-encoded — what we sent.
83    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    /// `navigator.credentials.create()` flow.
92    Registration,
93    /// `navigator.credentials.get()` flow.
94    Assertion,
95}
96
97/// Pluggable storage. Same in-memory default + runtime SQLite/PG
98/// pattern as ApiKeyBackend.
99pub 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    /// Mint a fresh challenge — called by `/api/auth/passkey/register/begin`
169    /// and `/api/auth/passkey/login/begin`. Returns the base64url
170    /// challenge bytes for the frontend to pass to the authenticator.
171    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    /// Take + validate a stored challenge. Returns the matching record
190    /// iff the challenge exists, hasn't expired, and matches the
191    /// expected `kind`.
192    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// ---------------------------------------------------------------------------
223// Verification
224// ---------------------------------------------------------------------------
225
226/// Inputs the frontend posts after `navigator.credentials.get()` completes.
227#[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    /// Optional userHandle — present for discoverable credentials.
234    pub user_handle: Option<&'a [u8]>,
235}
236
237/// Errors that can occur during assertion verification. We return
238/// distinct variants so test/log paths can pinpoint failures, but
239/// the HTTP layer collapses them to a single 401 to avoid oracle
240/// leaks.
241#[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
274/// Verify an assertion against a stored passkey. Updates the sign
275/// counter on success. The `expected_origin` and `expected_rp_id`
276/// are typically `https://yourapp.com` and `yourapp.com`.
277///
278/// `expected_user_id` — when set, the credential MUST belong to that
279/// user. Pass `None` for discoverable-credential ("usernameless")
280/// flows where the user is identified by the credential. Pass
281/// `Some(user_id)` for "you claim to be Alice — prove it" flows
282/// (this is the defense against a credential from user A being
283/// presented as user B during a higher-stakes second-factor step).
284pub 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    // 1. Parse clientDataJSON.
301    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    // 2. Validate authenticatorData layout.
326    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    // Authenticators that don't implement counters always send 0.
347    // Only treat regression as an error when the new value is
348    // strictly less than the stored value AND the stored value is > 0.
349    if stored.sign_count > 0 && counter <= stored.sign_count {
350        return Err(WebauthnError::CounterRegression);
351    }
352
353    // 3. Compute signature input = authenticatorData || SHA256(clientDataJSON).
354    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    // 4. Verify signature against the stored COSE_Key public key.
362    let alg = cose_key_alg(&stored.public_key).ok_or(WebauthnError::UnsupportedAlg)?;
363    match alg {
364        -7 => {
365            // ES256 = ECDSA P-256 + SHA-256 with ASN.1 (DER) signature
366            let raw = cose_es256_xy(&stored.public_key).ok_or(WebauthnError::UnsupportedAlg)?;
367            // Reconstruct uncompressed SEC1 point: 0x04 || X || Y.
368            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            // Ed25519 — just X (32 bytes).
380            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
396// ---------------------------------------------------------------------------
397// COSE_Key helpers (RFC 8152) — minimal CBOR
398// ---------------------------------------------------------------------------
399
400/// Pull `alg` (-7 ES256 or -8 EdDSA) from a COSE_Key map. Returns
401/// None for any other algorithm.
402fn 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
458/// Parse a CBOR major-type-5 map into a Rust HashMap. Sufficient
459/// for COSE_Key (which is a small map of int/text keys to int/bytes
460/// values). Doesn't try to be a full CBOR decoder.
461fn 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    /// Read a CBOR length/value combo. Returns the integer value
495    /// regardless of whether it came from the initial byte (0..=23)
496    /// or the following 1/2/4/8 bytes.
497    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                // Negative int: -1 - n.
527                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                // We don't model arrays — collapse into Other.
547                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        // Single-use.
591        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        // Posting an Assertion challenge against a Registration
601        // record would let an attacker substitute one for the other.
602        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    /// Smoke test the CBOR map parser against a hand-rolled COSE_Key
631    /// for ES256: kty=2, alg=-7, crv=1, x=<32 bytes>, y=<32 bytes>.
632    #[test]
633    fn cose_es256_xy_extracts_coords() {
634        let mut buf = Vec::new();
635        // map of length 5 → major type 5, additional 5 → 0xa5
636        buf.push(0xa5);
637        // key 1 (kty), value 2
638        buf.extend_from_slice(&[0x01, 0x02]);
639        // key 3 (alg), value -7 → major 1, arg 6 → 0x26
640        buf.extend_from_slice(&[0x03, 0x26]);
641        // key -1 (crv), value 1 → key=0x20, value=0x01
642        buf.extend_from_slice(&[0x20, 0x01]);
643        // key -2 (x), value bytes(32) → key=0x21, header 0x58 0x20, then 32 bytes of 0xAA
644        buf.extend_from_slice(&[0x21, 0x58, 0x20]);
645        buf.extend_from_slice(&[0xAA; 32]);
646        // key -3 (y), value bytes(32)
647        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); // map of 4
659        buf.extend_from_slice(&[0x01, 0x01]); // kty=1
660        buf.extend_from_slice(&[0x03, 0x27]); // alg=-8
661        buf.extend_from_slice(&[0x20, 0x06]); // crv=6 (Ed25519)
662        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    /// P3-5 (codex Wave-3 review): credential bound to user A
684    /// presented as user B must reject. Defense against an attacker
685    /// who registered a passkey on their own account then tries to
686    /// use it during a second-factor challenge framed as another user.
687    #[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}