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(
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// ---------------------------------------------------------------------------
227// Verification
228// ---------------------------------------------------------------------------
229
230/// Inputs the frontend posts after `navigator.credentials.get()` completes.
231#[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    /// Optional userHandle — present for discoverable credentials.
238    pub user_handle: Option<&'a [u8]>,
239}
240
241/// Errors that can occur during assertion verification. We return
242/// distinct variants so test/log paths can pinpoint failures, but
243/// the HTTP layer collapses them to a single 401 to avoid oracle
244/// leaks.
245#[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
278/// Verify an assertion against a stored passkey. Updates the sign
279/// counter on success. The `expected_origin` and `expected_rp_id`
280/// are typically `https://yourapp.com` and `yourapp.com`.
281///
282/// `expected_user_id` — when set, the credential MUST belong to that
283/// user. Pass `None` for discoverable-credential ("usernameless")
284/// flows where the user is identified by the credential. Pass
285/// `Some(user_id)` for "you claim to be Alice — prove it" flows
286/// (this is the defense against a credential from user A being
287/// presented as user B during a higher-stakes second-factor step).
288pub 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    // 1. Parse clientDataJSON.
305    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    // 2. Validate authenticatorData layout.
330    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    // Authenticators that don't implement counters always send 0.
351    // Only treat regression as an error when the new value is
352    // strictly less than the stored value AND the stored value is > 0.
353    if stored.sign_count > 0 && counter <= stored.sign_count {
354        return Err(WebauthnError::CounterRegression);
355    }
356
357    // 3. Compute signature input = authenticatorData || SHA256(clientDataJSON).
358    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    // 4. Verify signature against the stored COSE_Key public key.
366    let alg = cose_key_alg(&stored.public_key).ok_or(WebauthnError::UnsupportedAlg)?;
367    match alg {
368        -7 => {
369            // ES256 = ECDSA P-256 + SHA-256 with ASN.1 (DER) signature
370            let raw = cose_es256_xy(&stored.public_key).ok_or(WebauthnError::UnsupportedAlg)?;
371            // Reconstruct uncompressed SEC1 point: 0x04 || X || Y.
372            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            // Ed25519 — just X (32 bytes).
384            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
401// ---------------------------------------------------------------------------
402// COSE_Key helpers (RFC 8152) — minimal CBOR
403// ---------------------------------------------------------------------------
404
405/// Pull `alg` (-7 ES256 or -8 EdDSA) from a COSE_Key map. Returns
406/// None for any other algorithm.
407fn 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
463/// Parse a CBOR major-type-5 map into a Rust HashMap. Sufficient
464/// for COSE_Key (which is a small map of int/text keys to int/bytes
465/// values). Doesn't try to be a full CBOR decoder.
466fn 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    /// Read a CBOR length/value combo. Returns the integer value
500    /// regardless of whether it came from the initial byte (0..=23)
501    /// or the following 1/2/4/8 bytes.
502    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                // Negative int: -1 - n.
532                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                // We don't model arrays — collapse into Other.
554                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        // Single-use.
598        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        // Posting an Assertion challenge against a Registration
608        // record would let an attacker substitute one for the other.
609        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    /// Smoke test the CBOR map parser against a hand-rolled COSE_Key
638    /// for ES256: kty=2, alg=-7, crv=1, x=<32 bytes>, y=<32 bytes>.
639    #[test]
640    fn cose_es256_xy_extracts_coords() {
641        let mut buf = Vec::new();
642        // map of length 5 → major type 5, additional 5 → 0xa5
643        buf.push(0xa5);
644        // key 1 (kty), value 2
645        buf.extend_from_slice(&[0x01, 0x02]);
646        // key 3 (alg), value -7 → major 1, arg 6 → 0x26
647        buf.extend_from_slice(&[0x03, 0x26]);
648        // key -1 (crv), value 1 → key=0x20, value=0x01
649        buf.extend_from_slice(&[0x20, 0x01]);
650        // key -2 (x), value bytes(32) → key=0x21, header 0x58 0x20, then 32 bytes of 0xAA
651        buf.extend_from_slice(&[0x21, 0x58, 0x20]);
652        buf.extend_from_slice(&[0xAA; 32]);
653        // key -3 (y), value bytes(32)
654        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); // map of 4
666        buf.extend_from_slice(&[0x01, 0x01]); // kty=1
667        buf.extend_from_slice(&[0x03, 0x27]); // alg=-8
668        buf.extend_from_slice(&[0x20, 0x06]); // crv=6 (Ed25519)
669        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    /// P3-5 (codex Wave-3 review): credential bound to user A
691    /// presented as user B must reject. Defense against an attacker
692    /// who registered a passkey on their own account then tries to
693    /// use it during a second-factor challenge framed as another user.
694    #[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}