Skip to main content

webauthn_kit/
crypto.rs

1//! COSE key parsing and signature verification (ES256 / ES384 / RS256 /
2//! EdDSA) via `ring`, plus base64url helpers and CSPRNG challenge
3//! generation.
4//!
5//! # Security
6//!
7//! - All cryptographic primitives come from `ring` (constant-time
8//!   ECDSA/RSA/Ed25519 verification). This crate contains no hand-rolled
9//!   cryptography; the only custom encoding is RFC 5280 DER *wrapping* of
10//!   already-validated RSA modulus/exponent byte strings, which never
11//!   touches secret material.
12//! - Signature verification is constant-result: on failure the payload and
13//!   signature are discarded and only an error variant is returned.
14
15use crate::error::WebauthnError;
16
17/// Parsed COSE public key (EC2, RSA, or OKP).
18#[derive(Debug, Clone)]
19pub enum CosePublicKey {
20    /// Elliptic-curve key with affine coordinates `x` and `y`
21    /// (P-256: 32-byte coordinates; P-384: 48-byte coordinates — the curve
22    /// is bound to the declared algorithm, see [`parse_cose_key`]).
23    Ec2 {
24        /// X coordinate, big-endian.
25        x: Vec<u8>,
26        /// Y coordinate, big-endian.
27        y: Vec<u8>,
28    },
29    /// RSA key with modulus `n` and public exponent `e`.
30    Rsa {
31        /// RSA modulus, big-endian.
32        n: Vec<u8>,
33        /// RSA public exponent, big-endian (typically 0x010001).
34        e: Vec<u8>,
35    },
36    /// Octet-key-pair key (Ed25519): the raw 32-byte public key.
37    Okp {
38        /// Raw public key bytes (exactly 32 bytes for Ed25519).
39        id: Vec<u8>,
40    },
41}
42
43/// COSE key type: Octet Key Pair (Ed25519).
44pub const COSE_KTY_OKP: i64 = 1;
45/// COSE key type: Elliptic-Curve key pair with x/y coordinates.
46pub const COSE_KTY_EC2: i64 = 2;
47/// COSE key type: RSA key.
48pub const COSE_KTY_RSA: i64 = 3;
49
50/// COSE algorithm: ECDSA w/ SHA-256 on P-256.
51pub const COSE_ALG_ES256: i32 = -7;
52/// COSE algorithm: EdDSA (Ed25519).
53pub const COSE_ALG_EDDSA: i32 = -8;
54/// COSE algorithm: ECDSA w/ SHA-384 on P-384.
55pub const COSE_ALG_ES384: i32 = -35;
56/// COSE algorithm: RSASSA-PKCS1-v1_5 w/ SHA-256.
57pub const COSE_ALG_RS256: i32 = -257;
58
59/// COSE key map parameter label: key type (`kty`).
60const COSE_KEY_KTY: i64 = 1;
61/// COSE key map parameter label: algorithm (`alg`).
62const COSE_KEY_ALG: i64 = 2;
63/// COSE key map parameter label: curve (EC2/OKP) or modulus `n` (RSA).
64const COSE_KEY_CRV_N: i64 = -1;
65/// COSE key map parameter label: x coordinate (EC2) or exponent `e` (RSA).
66const COSE_KEY_X_E: i64 = -2;
67/// COSE key map parameter label: y coordinate (EC2).
68const COSE_KEY_Y: i64 = -3;
69
70/// COSE curve identifier: NIST P-256 (secp256r1).
71const COSE_CRV_P256: i64 = 1;
72/// COSE curve identifier: NIST P-384 (secp384r1).
73pub const COSE_CRV_P384: i64 = 2;
74/// COSE curve identifier: Ed25519 (RFC 8032 / RFC 9053).
75pub const COSE_CRV_ED25519: i64 = 6;
76
77/// Parse a CBOR integer from a value, handling both positive and negative.
78fn cbor_i64(val: &ciborium::Value) -> Option<i64> {
79    use ciborium::Value;
80    match val {
81        Value::Integer(i) => (*i).try_into().ok(),
82        _ => None,
83    }
84}
85
86/// Extract a CBOR byte string from a value.
87///
88/// # Security note
89///
90/// Deliberately lenient: a CBOR text string is accepted and re-interpreted as
91/// its UTF-8 byte representation. This tolerates non-conformant authenticators
92/// that encode binary fields as text. Length/format validation of the extracted
93/// bytes happens downstream (`ring` rejects malformed keys at verify time).
94pub fn cbor_bytes(val: &ciborium::Value) -> Option<Vec<u8>> {
95    use ciborium::Value;
96    match val {
97        Value::Bytes(b) => Some(b.clone()),
98        Value::Text(t) => Some(t.as_bytes().to_vec()),
99        _ => None,
100    }
101}
102
103/// Parse a CBOR map into ordered `(i64, Value)` entries.
104///
105/// Returns `None` if the value is not a map or any key is not an integer.
106/// Duplicate keys are preserved in order; last occurrence wins when consumers
107/// iterate and assign, matching CBOR common practice.
108pub fn cbor_map_entries(val: &ciborium::Value) -> Option<Vec<(i64, ciborium::Value)>> {
109    use ciborium::Value;
110    match val {
111        Value::Map(entries) => {
112            let mut result = Vec::with_capacity(entries.len());
113            for (k, v) in entries {
114                let key = cbor_i64(k)?;
115                result.push((key, v.clone()));
116            }
117            Some(result)
118        }
119        _ => None,
120    }
121}
122
123/// Parse a COSE key from its CBOR encoding (RFC 9052).
124///
125/// Supports EC2/P-256 (`kty = 2`, `crv = 1`), EC2/P-384 (`kty = 2`,
126/// `crv = 2`), RSA (`kty = 3`), and OKP/Ed25519 (`kty = 1`, `crv = 6`).
127/// Unknown key types return [`WebauthnError::VerificationFailed`].
128///
129/// # Security notes / threat assumptions
130///
131/// - `cose_bytes` is attacker-controlled input (it arrives inside authenticator
132///   data). Parsing must never panic on arbitrary bytes; malformed input always
133///   yields `Err`.
134/// - The declared algorithm is *bound to the key material at parse time*
135///   (algorithm-confusion defense, REQ-WA-107/146): an EC2 key must declare
136///   `crv = 1` with `alg = ES256` or `crv = 2` with `alg = ES384` — any other
137///   pairing (e.g. a P-256 key claiming `alg = -35`) is rejected with
138///   [`WebauthnError::UnsupportedAlgorithm`]. OKP keys must declare
139///   `crv = 6` with `alg = EdDSA`. Callers must still pass the parsed `alg`
140///   to [`verify_cose_signature`], which re-checks key type against
141///   algorithm.
142/// - Coordinate/modulus/key lengths are not pre-validated here; `ring`
143///   rejects invalid points and keys at verify time.
144///
145/// # Requirements
146/// REQ-WA-003, REQ-WA-100, REQ-WA-107, REQ-WA-140, REQ-WA-141, REQ-WA-146
147///
148/// Returns the COSE algorithm identifier and the parsed key components.
149pub fn parse_cose_key(cose_bytes: &[u8]) -> Result<(i32, CosePublicKey), WebauthnError> {
150    use ciborium::Value;
151
152    let key_val: Value = ciborium::de::from_reader(cose_bytes).map_err(|e| {
153        WebauthnError::VerificationFailed(format!("COSE key CBOR parse error: {e}"))
154    })?;
155
156    let entries = cbor_map_entries(&key_val).ok_or_else(|| {
157        WebauthnError::VerificationFailed("COSE key is not a CBOR map".to_string())
158    })?;
159
160    let mut kty: Option<i64> = None;
161    let mut alg: Option<i32> = None;
162    let mut crv: Option<i64> = None;
163    let mut x: Option<Vec<u8>> = None;
164    let mut y: Option<Vec<u8>> = None;
165    let mut n: Option<Vec<u8>> = None;
166    let mut e: Option<Vec<u8>> = None;
167
168    for (label, val) in &entries {
169        match *label {
170            COSE_KEY_KTY => kty = cbor_i64(val),
171            COSE_KEY_ALG => alg = cbor_i64(val).map(|v| v as i32),
172            COSE_KEY_CRV_N => match kty {
173                Some(COSE_KTY_RSA) => n = cbor_bytes(val),
174                _ => crv = cbor_i64(val),
175            },
176            COSE_KEY_X_E => match kty {
177                Some(COSE_KTY_RSA) => e = cbor_bytes(val),
178                _ => x = cbor_bytes(val),
179            },
180            COSE_KEY_Y => y = cbor_bytes(val),
181            _ => {}
182        }
183    }
184
185    let kty =
186        kty.ok_or_else(|| WebauthnError::VerificationFailed("COSE key missing 'kty'".to_string()))?;
187    let alg =
188        alg.ok_or_else(|| WebauthnError::VerificationFailed("COSE key missing 'alg'".to_string()))?;
189
190    match kty {
191        COSE_KTY_EC2 => {
192            let crv = crv.ok_or_else(|| {
193                WebauthnError::VerificationFailed("EC2 key missing 'crv'".to_string())
194            })?;
195            // Algorithm/curve binding (REQ-WA-146): a P-256 key claiming
196            // ES384 — or a P-384 key claiming ES256 — is rejected here,
197            // before any signature work.
198            let curve_ok = matches!(
199                (crv, alg),
200                (COSE_CRV_P256, COSE_ALG_ES256) | (COSE_CRV_P384, COSE_ALG_ES384)
201            );
202            if !curve_ok {
203                return Err(WebauthnError::UnsupportedAlgorithm(alg));
204            }
205            let x = x.ok_or_else(|| {
206                WebauthnError::VerificationFailed("EC2 key missing 'x'".to_string())
207            })?;
208            let y = y.ok_or_else(|| {
209                WebauthnError::VerificationFailed("EC2 key missing 'y'".to_string())
210            })?;
211            Ok((alg, CosePublicKey::Ec2 { x, y }))
212        }
213        COSE_KTY_RSA => {
214            let n = n.ok_or_else(|| {
215                WebauthnError::VerificationFailed("RSA key missing 'n'".to_string())
216            })?;
217            let e = e.ok_or_else(|| {
218                WebauthnError::VerificationFailed("RSA key missing 'e'".to_string())
219            })?;
220            Ok((alg, CosePublicKey::Rsa { n, e }))
221        }
222        COSE_KTY_OKP => {
223            // OKP is only defined in this kit for Ed25519 (crv 6, alg -8).
224            if crv != Some(COSE_CRV_ED25519) || alg != COSE_ALG_EDDSA {
225                return Err(WebauthnError::UnsupportedAlgorithm(alg));
226            }
227            let id = x.ok_or_else(|| {
228                WebauthnError::VerificationFailed("OKP key missing public key bytes".to_string())
229            })?;
230            Ok((alg, CosePublicKey::Okp { id }))
231        }
232        other => Err(WebauthnError::VerificationFailed(format!(
233            "Unsupported COSE key type: {other}"
234        ))),
235    }
236}
237
238/// Verify a COSE signature using the parsed public key.
239///
240/// # Security notes / threat assumptions
241///
242/// - `alg` is matched strictly: `ES256` requires an [`CosePublicKey::Ec2`]
243///   key, `ES384` an `Ec2` key with P-384 coordinates, `EdDSA` an
244///   [`CosePublicKey::Okp`] key, `RS256` requires [`CosePublicKey::Rsa`];
245///   any mismatch is an error, never a fallback.
246/// - `signed_data` and `signature` are attacker-controlled. Verification uses
247///   `ring`'s constant-time implementations
248///   (`ECDSA_P256_SHA256_FIXED`, `ECDSA_P384_SHA384_FIXED`, `ED25519`,
249///   `RSA_PKCS1_2048_8192_SHA256`).
250/// - RSA keys smaller than 2048 bits are rejected by `ring`'s
251///   `RSA_PKCS1_2048_8192_SHA256` parameters.
252///
253/// # Requirements
254/// REQ-WA-106, REQ-WA-107, REQ-WA-140, REQ-WA-141
255///
256/// Returns `Ok(())` if and only if the signature verifies over `signed_data`.
257pub fn verify_cose_signature(
258    alg: i32,
259    public_key: &CosePublicKey,
260    signed_data: &[u8],
261    signature: &[u8],
262) -> Result<(), WebauthnError> {
263    use ring::signature;
264
265    match alg {
266        COSE_ALG_ES256 => {
267            let CosePublicKey::Ec2 { x, y } = public_key else {
268                return Err(WebauthnError::VerificationFailed(
269                    "EC2 key expected for ES256".to_string(),
270                ));
271            };
272
273            let mut public_key_bytes = Vec::with_capacity(1 + x.len() + y.len());
274            public_key_bytes.push(0x04);
275            public_key_bytes.extend_from_slice(x);
276            public_key_bytes.extend_from_slice(y);
277
278            let public_key = signature::UnparsedPublicKey::new(
279                &signature::ECDSA_P256_SHA256_FIXED,
280                &public_key_bytes,
281            );
282            public_key
283                .verify(signed_data, signature)
284                .map_err(|_| WebauthnError::SignatureVerificationFailed)?;
285            Ok(())
286        }
287        COSE_ALG_ES384 => {
288            let CosePublicKey::Ec2 { x, y } = public_key else {
289                return Err(WebauthnError::VerificationFailed(
290                    "EC2 key expected for ES384".to_string(),
291                ));
292            };
293
294            let mut public_key_bytes = Vec::with_capacity(1 + x.len() + y.len());
295            public_key_bytes.push(0x04);
296            public_key_bytes.extend_from_slice(x);
297            public_key_bytes.extend_from_slice(y);
298
299            let public_key = signature::UnparsedPublicKey::new(
300                &signature::ECDSA_P384_SHA384_FIXED,
301                &public_key_bytes,
302            );
303            public_key
304                .verify(signed_data, signature)
305                .map_err(|_| WebauthnError::SignatureVerificationFailed)?;
306            Ok(())
307        }
308        COSE_ALG_EDDSA => {
309            let CosePublicKey::Okp { id } = public_key else {
310                return Err(WebauthnError::VerificationFailed(
311                    "OKP key expected for EdDSA".to_string(),
312                ));
313            };
314
315            let public_key = signature::UnparsedPublicKey::new(&signature::ED25519, id);
316            public_key
317                .verify(signed_data, signature)
318                .map_err(|_| WebauthnError::SignatureVerificationFailed)?;
319            Ok(())
320        }
321        COSE_ALG_RS256 => {
322            let CosePublicKey::Rsa { n, e } = public_key else {
323                return Err(WebauthnError::VerificationFailed(
324                    "RSA key expected for RS256".to_string(),
325                ));
326            };
327
328            let rsa_public_key = RsaPublicKeyDer { n, e };
329            let der_bytes = rsa_public_key.to_der()?;
330
331            let public_key = signature::UnparsedPublicKey::new(
332                &signature::RSA_PKCS1_2048_8192_SHA256,
333                &der_bytes,
334            );
335            public_key
336                .verify(signed_data, signature)
337                .map_err(|_| WebauthnError::SignatureVerificationFailed)?;
338            Ok(())
339        }
340        other => Err(WebauthnError::UnsupportedAlgorithm(other)),
341    }
342}
343
344/// ASN.1 DER (PKCS#1 RFC 8017 `RSAPublicKey`) encoding helper for RSA
345/// public keys.
346struct RsaPublicKeyDer<'a> {
347    n: &'a [u8],
348    e: &'a [u8],
349}
350
351impl RsaPublicKeyDer<'_> {
352    /// Encode modulus/exponent into a DER `RSAPublicKey` structure:
353    /// `SEQUENCE { modulus INTEGER, publicExponent INTEGER }`.
354    ///
355    /// This is the encoding `ring`'s RSA signature verifiers parse
356    /// (`ring::rsa::parse_public_key`) — NOT an RFC 5280
357    /// `SubjectPublicKeyInfo`.
358    fn to_der(&self) -> Result<Vec<u8>, WebauthnError> {
359        let mut der = Vec::new();
360        Self::encode_integer(&mut der, self.n);
361        Self::encode_integer(&mut der, self.e);
362        Self::encode_sequence_in_place(&mut der);
363        Ok(der)
364    }
365
366    /// DER-encode an INTEGER from a big-endian byte string.
367    ///
368    /// Strips leading zero octets and prepends a single `0x00` when the
369    /// high bit is set, per DER INTEGER positivity rules (a form ring's
370    /// DER parser accepts).
371    fn encode_integer(buf: &mut Vec<u8>, value: &[u8]) {
372        let start = value
373            .iter()
374            .position(|&b| b != 0)
375            .unwrap_or(value.len().saturating_sub(1));
376        let v = value.get(start..).unwrap_or(&[]);
377        if v.first().is_some_and(|&b| b & 0x80 != 0) {
378            buf.push(0x02);
379            Self::encode_length(buf, v.len() + 1);
380            buf.push(0x00);
381            buf.extend_from_slice(v);
382        } else {
383            buf.push(0x02);
384            Self::encode_length(buf, v.len());
385            buf.extend_from_slice(v);
386        }
387    }
388
389    /// Wrap the existing buffer content in a SEQUENCE by shifting right.
390    fn encode_sequence_in_place(buf: &mut Vec<u8>) {
391        let content_len = buf.len();
392        let header_len = if content_len < 0x80 {
393            2
394        } else if content_len < 0x100 {
395            3
396        } else {
397            4
398        };
399        buf.resize(content_len + header_len, 0);
400        buf.copy_within(..content_len, header_len);
401        if let Some(tag) = buf.first_mut() {
402            *tag = 0x30;
403        }
404        Self::encode_length_at(buf, 1, content_len);
405    }
406
407    /// DER-encode a length octet string at the end of `buf`.
408    fn encode_length(buf: &mut Vec<u8>, len: usize) {
409        if len < 0x80 {
410            buf.push(len as u8);
411        } else if len < 0x100 {
412            buf.push(0x81);
413            buf.push(len as u8);
414        } else {
415            buf.push(0x82);
416            buf.push((len >> 8) as u8);
417            buf.push((len & 0xFF) as u8);
418        }
419    }
420
421    /// DER-encode a length octet string at `offset` in `buf`.
422    ///
423    /// INVARIANT: the caller guarantees `buf.len() >= offset + needed`,
424    /// where `needed` is 1, 2, or 3 depending on `len` (callers size the
425    /// buffer via the matching `header_len` computation before calling).
426    /// The direct indexing below is therefore in-bounds by construction.
427    #[allow(clippy::indexing_slicing)]
428    fn encode_length_at(buf: &mut [u8], offset: usize, len: usize) {
429        if len < 0x80 {
430            buf[offset] = len as u8;
431        } else if len < 0x100 {
432            buf[offset] = 0x81;
433            buf[offset + 1] = len as u8;
434        } else {
435            buf[offset] = 0x82;
436            buf[offset + 1] = (len >> 8) as u8;
437            buf[offset + 2] = (len & 0xFF) as u8;
438        }
439    }
440}
441
442/// Convert a COSE algorithm ID to a human-readable name.
443pub fn alg_to_name(alg: i32) -> &'static str {
444    match alg {
445        COSE_ALG_ES256 => "ES256",
446        COSE_ALG_ES384 => "ES384",
447        COSE_ALG_EDDSA => "EdDSA",
448        COSE_ALG_RS256 => "RS256",
449        _ => "unknown",
450    }
451}
452
453/// Generate 32 random bytes (256 bits of entropy) for a `WebAuthn` challenge.
454///
455/// Uses the operating system CSPRNG via `ring::rand::SystemRandom`.
456///
457/// # Security notes
458///
459/// Challenges MUST come from a cryptographically secure source. If the OS
460/// entropy source fails, this function panics rather than emit predictable
461/// challenges (a failed CSPRNG is unrecoverable for security purposes).
462///
463/// # Requirements
464/// REQ-WA-110
465pub fn generate_challenge_bytes() -> Vec<u8> {
466    use ring::rand::SecureRandom;
467    let mut bytes = [0u8; 32];
468    // INVARIANT: a failed OS CSPRNG is unrecoverable for security purposes
469    // (documented above) — panicking is the deliberate, correct behavior.
470    #[allow(clippy::expect_used)]
471    ring::rand::SystemRandom::new()
472        .fill(&mut bytes)
473        .expect("OS CSPRNG unavailable; cannot generate secure challenge");
474    bytes.to_vec()
475}
476
477/// Base64url encode (RFC 4648 §5, no padding), as required by the `WebAuthn`
478/// JSON serialization conventions.
479pub fn base64_encode_urlsafe(data: &[u8]) -> String {
480    use base64::Engine;
481    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
482}
483
484/// Base64url decode (RFC 4648 §5, no padding required; standard alphabet with
485/// padding is also accepted by the engine's forgiving handling).
486///
487/// Returns [`WebauthnError::VerificationFailed`] on malformed input.
488///
489/// # Requirements
490/// REQ-WA-004, REQ-WA-118
491pub fn base64_decode_urlsafe(data: &str) -> Result<Vec<u8>, WebauthnError> {
492    use base64::Engine;
493    base64::engine::general_purpose::URL_SAFE_NO_PAD
494        .decode(data)
495        .map_err(|e| WebauthnError::VerificationFailed(format!("base64 decode error: {e}")))
496}
497
498// Tests exercise failure paths and invariants directly; unwrap/expect,
499// slicing, and panicking asserts are acceptable here — violations
500// surface as test failures, not production panics.
501#[allow(
502    clippy::unwrap_used,
503    clippy::expect_used,
504    clippy::indexing_slicing,
505    clippy::panic
506)]
507#[cfg(test)]
508pub(crate) mod tests {
509    use super::*;
510
511    /// Build a COSE EC2/P-256 key map around the given coordinates.
512    pub(crate) fn build_cose_ec2_key(x: &[u8], y: &[u8]) -> Vec<u8> {
513        build_cose_ec2_key_alg(COSE_CRV_P256, COSE_ALG_ES256, x, y)
514    }
515
516    /// Build a COSE EC2 key map with an explicit curve and algorithm.
517    pub(crate) fn build_cose_ec2_key_alg(crv: i64, alg: i32, x: &[u8], y: &[u8]) -> Vec<u8> {
518        use ciborium::Value;
519        let map = vec![
520            (Value::Integer(1.into()), Value::Integer(2.into())),
521            (Value::Integer(2.into()), Value::Integer(alg.into())),
522            (Value::Integer((-1).into()), Value::Integer(crv.into())),
523            (Value::Integer((-2).into()), Value::Bytes(x.to_vec())),
524            (Value::Integer((-3).into()), Value::Bytes(y.to_vec())),
525        ];
526        let mut buf = Vec::new();
527        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
528        buf
529    }
530
531    /// Build a COSE OKP/Ed25519 key map around the raw public key bytes.
532    pub(crate) fn build_cose_okp_key(id: &[u8]) -> Vec<u8> {
533        use ciborium::Value;
534        let map = vec![
535            (Value::Integer(1.into()), Value::Integer(1.into())),
536            (Value::Integer(2.into()), Value::Integer((-8).into())),
537            (Value::Integer((-1).into()), Value::Integer(6.into())),
538            (Value::Integer((-2).into()), Value::Bytes(id.to_vec())),
539        ];
540        let mut buf = Vec::new();
541        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
542        buf
543    }
544
545    /// Build a COSE RSA key map around the given modulus/exponent.
546    pub(crate) fn build_cose_rsa_key(n: &[u8], e: &[u8]) -> Vec<u8> {
547        use ciborium::Value;
548        let map = vec![
549            (Value::Integer(1.into()), Value::Integer(3.into())),
550            (Value::Integer(2.into()), Value::Integer((-257).into())),
551            (Value::Integer((-1).into()), Value::Bytes(n.to_vec())),
552            (Value::Integer((-2).into()), Value::Bytes(e.to_vec())),
553        ];
554        let mut buf = Vec::new();
555        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
556        buf
557    }
558
559    #[test]
560    fn test_parse_cose_ec2_key() {
561        let x = vec![0xAA; 32];
562        let y = vec![0xBB; 32];
563        let cose_key = build_cose_ec2_key(&x, &y);
564
565        let (alg, key) = parse_cose_key(&cose_key).unwrap();
566        assert_eq!(alg, -7);
567        match key {
568            CosePublicKey::Ec2 { x: kx, y: ky } => {
569                assert_eq!(kx, x);
570                assert_eq!(ky, y);
571            }
572            _ => panic!("Expected EC2 key"),
573        }
574    }
575
576    #[test]
577    fn test_parse_cose_rsa_key() {
578        let n = vec![0xAA; 256];
579        let e = vec![0x01, 0x00, 0x01];
580        let cose_key = build_cose_rsa_key(&n, &e);
581
582        let (alg, key) = parse_cose_key(&cose_key).unwrap();
583        assert_eq!(alg, -257);
584        match key {
585            CosePublicKey::Rsa { n: kn, e: ke } => {
586                assert_eq!(kn, n);
587                assert_eq!(ke, e);
588            }
589            _ => panic!("Expected RSA key"),
590        }
591    }
592
593    #[test]
594    fn test_parse_cose_key_not_a_map() {
595        let buf = [0x00u8]; // CBOR integer 0
596        let result = parse_cose_key(&buf);
597        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
598    }
599
600    #[test]
601    fn test_parse_cose_key_missing_kty() {
602        use ciborium::Value;
603        let map = vec![(Value::Integer(2.into()), Value::Integer((-7).into()))];
604        let mut buf = Vec::new();
605        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
606        let result = parse_cose_key(&buf);
607        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
608    }
609
610    #[test]
611    fn test_parse_cose_key_okp_unsupported() {
612        use ciborium::Value;
613        let map = vec![
614            (Value::Integer(1.into()), Value::Integer(1.into())),
615            (Value::Integer(2.into()), Value::Integer((-8).into())),
616        ];
617        let mut buf = Vec::new();
618        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
619        let result = parse_cose_key(&buf);
620        assert!(matches!(
621            result,
622            Err(WebauthnError::UnsupportedAlgorithm(-8))
623        ));
624    }
625
626    #[test]
627    fn test_verify_cose_signature_es256() {
628        use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P256_SHA256_FIXED_SIGNING};
629
630        let rng = ring::rand::SystemRandom::new();
631        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
632        let key_pair =
633            EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
634                .unwrap();
635
636        let public_key_bytes = key_pair.public_key().as_ref().to_vec();
637        let x = public_key_bytes[1..33].to_vec();
638        let y = public_key_bytes[33..65].to_vec();
639
640        let cose_key = CosePublicKey::Ec2 { x, y };
641        let message = b"test message for WebAuthn";
642        let signature = key_pair.sign(&rng, message).unwrap();
643
644        let result = verify_cose_signature(COSE_ALG_ES256, &cose_key, message, signature.as_ref());
645        assert!(result.is_ok());
646
647        let wrong_result = verify_cose_signature(
648            COSE_ALG_ES256,
649            &cose_key,
650            b"wrong message",
651            signature.as_ref(),
652        );
653        assert!(matches!(
654            wrong_result,
655            Err(WebauthnError::SignatureVerificationFailed)
656        ));
657    }
658
659    #[test]
660    fn test_verify_cose_signature_alg_key_type_mismatch() {
661        // RSA key material presented with alg = ES256 must be rejected.
662        let n = vec![0xAA; 256];
663        let e = vec![0x01, 0x00, 0x01];
664        let cose_key = build_cose_rsa_key(&n, &e);
665        let (alg, key) = parse_cose_key(&cose_key).unwrap();
666        assert_eq!(alg, -257);
667
668        let mismatch = verify_cose_signature(COSE_ALG_ES256, &key, b"data", &[0u8; 64]);
669        assert!(matches!(
670            mismatch,
671            Err(WebauthnError::VerificationFailed(_))
672        ));
673    }
674
675    #[test]
676    fn test_verify_cose_signature_rs256() {
677        let n = vec![
678            0xC0, 0xE9, 0x5A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
679            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
680            0x00, 0x00, 0x00, 0x00,
681        ];
682        let e = vec![0x01, 0x00, 0x01];
683        let cose_key = build_cose_rsa_key(&n, &e);
684
685        let (alg, key) = parse_cose_key(&cose_key).unwrap();
686        assert_eq!(alg, -257);
687        match key {
688            CosePublicKey::Rsa { n: kn, e: ke } => {
689                assert_eq!(kn, n);
690                assert_eq!(ke, e);
691            }
692            _ => panic!("Expected RSA key"),
693        }
694
695        let rsa_key = RsaPublicKeyDer { n: &n, e: &e };
696        let der = rsa_key.to_der().unwrap();
697        assert_eq!(der[0], 0x30);
698        assert!(der.len() > 40);
699    }
700
701    #[test]
702    fn test_generate_challenge_bytes_length() {
703        let bytes = generate_challenge_bytes();
704        assert_eq!(bytes.len(), 32);
705    }
706
707    #[test]
708    fn test_generate_challenge_bytes_not_all_zero() {
709        // 256 zero bits from a CSPRNG is ~2^-256; treat as failure.
710        let bytes = generate_challenge_bytes();
711        assert!(bytes.iter().any(|&b| b != 0));
712    }
713
714    #[test]
715    fn test_base64_roundtrip() {
716        let original = b"hello world";
717        let encoded = base64_encode_urlsafe(original);
718        let decoded = base64_decode_urlsafe(&encoded).unwrap();
719        assert_eq!(decoded, original);
720    }
721
722    #[test]
723    fn test_base64_decode_urlsafe_invalid() {
724        let result = base64_decode_urlsafe("NOT_VALID_BASE64!!!");
725        assert!(result.is_err());
726    }
727
728    #[test]
729    fn test_base64_encode_decode_empty() {
730        let encoded = base64_encode_urlsafe(b"");
731        let decoded = base64_decode_urlsafe(&encoded).unwrap();
732        assert!(decoded.is_empty());
733    }
734
735    #[test]
736    fn test_cbor_bytes_from_text_is_lenient() {
737        use ciborium::Value;
738        let v = Value::Text("abc".to_string());
739        assert_eq!(cbor_bytes(&v), Some(b"abc".to_vec()));
740        let v = Value::Null;
741        assert_eq!(cbor_bytes(&v), None);
742    }
743
744    /// REQ-WA-202: DER INTEGER encoding must handle the all-zero value
745    /// (single 0x00 octet), strip leading zeros, and prepend 0x00 for
746    /// high-bit-set values — all without panicking.
747    #[test]
748    fn der_integer_encoding_edge_cases() {
749        let mut buf = Vec::new();
750        RsaPublicKeyDer::encode_integer(&mut buf, &[0u8; 8]);
751        assert_eq!(buf, vec![0x02, 0x01, 0x00]);
752
753        buf.clear();
754        buf.clear();
755        // High-bit-set value: a necessary positive-marker 0x00 is prepended.
756        RsaPublicKeyDer::encode_integer(&mut buf, &[0x80, 0x00]);
757        assert_eq!(buf, vec![0x02, 0x03, 0x00, 0x80, 0x00]);
758
759        buf.clear();
760        RsaPublicKeyDer::encode_integer(&mut buf, &[0x00, 0x00, 0x7F]);
761        assert_eq!(buf, vec![0x02, 0x01, 0x7F]);
762
763        buf.clear();
764        // Empty modulus degenerates to a zero-length INTEGER (malformed DER);
765        // the encoder stays total and `ring` rejects the key at verify time.
766        RsaPublicKeyDer::encode_integer(&mut buf, &[]);
767        assert_eq!(buf, vec![0x02, 0x00]);
768    }
769
770    // ---- error paths of parse_cose_key / verify_cose_signature ----
771
772    /// Fixed 2048-bit RSA test key (PKCS#8 DER, base64). ring cannot
773    /// generate RSA keys, so the fixture is pre-generated with openssl.
774    pub(crate) const RSA_PKCS8_B64: &str = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCWaabvRsESS9rP70iKb473SMUwLEeKvSBGd2DcB4CgcjL2b69pqy7Mgp8ZXZlbJ96cK8y0OFZFKzFs5tLGVcG3JbkYSnFAgCHtW+k29N9B7XJe5k5K1p0piKQoeth8GX2QhYTNECABAU+FeKxAwjojTqVPH2oXNs7c7rQe5y03PYFqyrr0IyMt8QIXsjH7VVnTps/ojUQFqCPSqSi8Yb8BxokjtVd1K5IOlwYsfHLYBFefg3YTigQ5J/ZHZoL1oPRihRzPIEgDu4LmIT2Xp9Hzs9QV1DdLK2DxBcaMDyR/JEL1+LhnxD/HkaNdEDA/FA28JVl9GxHkHMEw3oyvEPDRAgMBAAECggEABS5GbbTGLwHQhkZQ/VOyXO9zZfa10CA2Mwx8Qu055PeKAdWpo74dsorykpvCuH0QZy4AXa9tvpwqQPytAzUuTZyjBM8rmh56YgPpwywpXx/1RzIvktb+5XtWDCmPDgHiwqOWsL7UG19sLxtk83tn0pIsPM7G3LMqlOQz7WHmZiH5UZmNKHcs5Gu5LxGoyMTN1weXk5Nx1OG63sUaXwFbaRv10Z+w6KBSpY9+fQDOXuet/5HwOl1cLXOcQeCXBsMDVUIU78+fsyqzZzIl7WL+rD1ulwCHcEFou3JxsFS+AuGXGAo6rIaFEOXR8g5vIF3g20zLCWYEzAByTUJa1hvnMQKBgQDS6gd8pbKi1cbg75s8JrNEFmMp3C2pAA42FvFQuigVy5p+92Lv3dLFM3a+UOz3l2DFwTQEUzm6tDKCCsMr5J2W8OZCQJ+QvvmI4ErN3X4h/HF6zcM9cZScMQN6/qe/jk6UMuzBFkenajbSvvvuKqlzV9zFmSV6+qh88CDTwiwCcwKBgQC2kMSYDFKPDsHqGgHOsaytHsi4lyUUGtIOVo+kvGWKYycr3V6q/sliMJ6v/E1SdVSJCDPyZbWc0fL9+PNhHrF5PGKS+HAjXsvQ+xOzXweByDiAZVmVNKxHmf9LFiAPPzUGIMxzwT9TU823E62aXybfgz7wCCFXx3Eh4sPCjN36qwKBgDKr2QqgQG+QjoxB5HiqD41/F2naJPoiMkfacTVk0/aQiNiSFKnuEBIikBefF59QNgasqROU7xyk6DGH5mXoMdgunhMytWMwDoFM6YvV99Swco7/WjWr0PlJaT2maqTByq0eIvUspiBZizxMd/g7NaSpajfq2C9YgxwpEKnvT2VzAoGAT6SL/wCxK3N2qNe7nh3ohIV/bveQ11pz9IlSlL0TVvG2bu5dlB8eX1VyhLd+S9CflkAb2U0Bk24LoTvvgJjRN2BeaFs1IFkEdSBzEbcNIVLlQy3zjKGz3nCR7IG0brJWQVwhlQXiyEkw3wMYotWLscohtLj3QsHg2rWATOkDFY0CgYEAuc5W0GMzucY940ok8VoIBrsD/FVtpfflsx/FDGUafBjvQ/2kLH7iyfZLX0jFTACiazLpxZESVcxEWYEr2Rz4KG7GGKYkppwMoH43Xk1L+zkR2ZeEHnzTzwLdVhczqGTs90V4HCYwLKzXqgJPZeH/42ngQuA/8dUrQ/9ofV68k4A=";
775
776    /// Decode a DER TLV at `i`; returns `(content, rest)`.
777    fn der_tlv(i: &[u8]) -> (&[u8], &[u8]) {
778        let l0 = *i.get(1).unwrap();
779        let (header, len) = if l0 < 0x80 {
780            (2usize, l0 as usize)
781        } else {
782            let n = (l0 & 0x7F) as usize;
783            let mut len = 0usize;
784            for b in &i[2..2 + n] {
785                len = (len << 8) | *b as usize;
786            }
787            (2 + n, len)
788        };
789        (&i[header..header + len], &i[header + len..])
790    }
791
792    /// (modulus, exponent, PKCS#8 DER) for the fixed RSA test key.
793    pub(crate) fn rsa_test_material() -> (Vec<u8>, Vec<u8>, Vec<u8>) {
794        use base64::Engine as _;
795        let pkcs8 = base64::engine::general_purpose::STANDARD
796            .decode(RSA_PKCS8_B64)
797            .unwrap();
798        // PrivateKeyInfo ::= SEQ { version, algorithm, OCTET STRING {
799        //   RSAPrivateKey ::= SEQ { version, n, e, ... } } }
800        let (info, _) = der_tlv(&pkcs8);
801        let (_version, rest) = der_tlv(info);
802        let (_algorithm, rest) = der_tlv(rest);
803        let (octet, _) = der_tlv(rest);
804        let (rsa_key, _) = der_tlv(octet);
805        let (_version, rest) = der_tlv(rsa_key);
806        let (n, rest) = der_tlv(rest);
807        let (e, _) = der_tlv(rest);
808        (n.to_vec(), e.to_vec(), pkcs8)
809    }
810
811    /// Minimal-encoding SPKI for the fixed RSA test key, as ring's RSA
812    /// verifier requires (see `RsaPublicKeyDer::encode_integer`).
813    pub(crate) fn rsa_test_spki() -> Vec<u8> {
814        let (n, e, _) = rsa_test_material();
815        RsaPublicKeyDer { n: &n, e: &e }.to_der().unwrap()
816    }
817
818    fn cose_key_from(entries: Vec<(i64, ciborium::Value)>) -> Vec<u8> {
819        use ciborium::Value;
820        let map: Vec<(Value, Value)> = entries
821            .into_iter()
822            .map(|(k, v)| (Value::Integer(k.into()), v))
823            .collect();
824        let mut buf = Vec::new();
825        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
826        buf
827    }
828
829    #[test]
830    fn parse_cose_key_garbage_cbor_is_error() {
831        // 0xFF is a reserved CBOR major type: parse must fail, never panic.
832        let result = parse_cose_key(&[0xFF, 0xFF, 0xFF, 0xFF]);
833        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
834    }
835
836    #[test]
837    fn parse_cose_key_ec2_missing_crv() {
838        let buf = cose_key_from(vec![
839            (1, ciborium::Value::Integer(2.into())),
840            (2, ciborium::Value::Integer((-7).into())),
841            (-2, ciborium::Value::Bytes(vec![0xAA; 32])),
842            (-3, ciborium::Value::Bytes(vec![0xBB; 32])),
843        ]);
844        assert!(matches!(
845            parse_cose_key(&buf),
846            Err(WebauthnError::VerificationFailed(_))
847        ));
848    }
849
850    #[test]
851    fn parse_cose_key_ec2_wrong_curve_is_unsupported() {
852        let buf = cose_key_from(vec![
853            (1, ciborium::Value::Integer(2.into())),
854            (2, ciborium::Value::Integer((-7).into())),
855            (-1, ciborium::Value::Integer(3.into())), // P-384, not P-256
856            (-2, ciborium::Value::Bytes(vec![0xAA; 48])),
857            (-3, ciborium::Value::Bytes(vec![0xBB; 48])),
858        ]);
859        assert!(matches!(
860            parse_cose_key(&buf),
861            Err(WebauthnError::UnsupportedAlgorithm(-7))
862        ));
863    }
864
865    #[test]
866    fn parse_cose_key_ec2_missing_coordinates() {
867        let kty_alg = vec![
868            (1, ciborium::Value::Integer(2.into())),
869            (2, ciborium::Value::Integer((-7).into())),
870            (-1, ciborium::Value::Integer(1.into())),
871        ];
872        let missing_x = cose_key_from(
873            [
874                kty_alg.clone(),
875                vec![(-3, ciborium::Value::Bytes(vec![0xBB; 32]))],
876            ]
877            .concat(),
878        );
879        assert!(matches!(
880            parse_cose_key(&missing_x),
881            Err(WebauthnError::VerificationFailed(_))
882        ));
883
884        let missing_y =
885            cose_key_from([kty_alg, vec![(-2, ciborium::Value::Bytes(vec![0xAA; 32]))]].concat());
886        assert!(matches!(
887            parse_cose_key(&missing_y),
888            Err(WebauthnError::VerificationFailed(_))
889        ));
890    }
891
892    #[test]
893    fn parse_cose_key_rsa_missing_modulus_or_exponent() {
894        let missing_n = cose_key_from(vec![
895            (1, ciborium::Value::Integer(3.into())),
896            (2, ciborium::Value::Integer((-257).into())),
897            (-2, ciborium::Value::Bytes(vec![0x01, 0x00, 0x01])),
898        ]);
899        assert!(matches!(
900            parse_cose_key(&missing_n),
901            Err(WebauthnError::VerificationFailed(_))
902        ));
903
904        let missing_e = cose_key_from(vec![
905            (1, ciborium::Value::Integer(3.into())),
906            (2, ciborium::Value::Integer((-257).into())),
907            (-1, ciborium::Value::Bytes(vec![0xAA; 256])),
908        ]);
909        assert!(matches!(
910            parse_cose_key(&missing_e),
911            Err(WebauthnError::VerificationFailed(_))
912        ));
913    }
914
915    #[test]
916    fn parse_cose_key_unknown_kty_is_error() {
917        let buf = cose_key_from(vec![
918            (1, ciborium::Value::Integer(99.into())),
919            (2, ciborium::Value::Integer((-7).into())),
920        ]);
921        assert!(matches!(
922            parse_cose_key(&buf),
923            Err(WebauthnError::VerificationFailed(_))
924        ));
925    }
926
927    #[test]
928    fn cbor_map_entries_rejects_non_integer_keys() {
929        use ciborium::Value;
930        let map = Value::Map(vec![(
931            Value::Text("kty".to_string()),
932            Value::Integer(2.into()),
933        )]);
934        assert!(cbor_map_entries(&map).is_none());
935    }
936
937    #[test]
938    fn verify_rs256_with_ec_key_is_rejected() {
939        let key = CosePublicKey::Ec2 {
940            x: vec![0xAA; 32],
941            y: vec![0xBB; 32],
942        };
943        assert!(matches!(
944            verify_cose_signature(COSE_ALG_RS256, &key, b"data", &[0u8; 256]),
945            Err(WebauthnError::VerificationFailed(_))
946        ));
947    }
948
949    #[test]
950    fn verify_cose_signature_unknown_alg_is_rejected() {
951        let key = CosePublicKey::Ec2 {
952            x: vec![0xAA; 32],
953            y: vec![0xBB; 32],
954        };
955        assert!(matches!(
956            verify_cose_signature(-47, &key, b"data", &[0u8; 64]),
957            Err(WebauthnError::UnsupportedAlgorithm(-47))
958        ));
959    }
960
961    #[test]
962    fn alg_to_name_covers_known_and_unknown() {
963        assert_eq!(alg_to_name(COSE_ALG_ES256), "ES256");
964        assert_eq!(alg_to_name(COSE_ALG_ES384), "ES384");
965        assert_eq!(alg_to_name(COSE_ALG_EDDSA), "EdDSA");
966        assert_eq!(alg_to_name(COSE_ALG_RS256), "RS256");
967        assert_eq!(alg_to_name(0), "unknown");
968    }
969
970    // ---- ES384 / EdDSA (REQ-WA-140, REQ-WA-141) ----
971
972    /// ES384 roundtrip: a real P-384 signature verifies through the ES384
973    /// arm (fixed-width ECDSA + SHA-384); a tampered message fails closed.
974    #[test]
975    fn verify_es384_real_signature_roundtrip() {
976        use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P384_SHA384_FIXED_SIGNING};
977
978        let rng = ring::rand::SystemRandom::new();
979        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, &rng).unwrap();
980        let key_pair =
981            EcdsaKeyPair::from_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, pkcs8.as_ref(), &rng)
982                .unwrap();
983
984        let pub_bytes = key_pair.public_key().as_ref();
985        assert_eq!(pub_bytes.len(), 97); // 0x04 || X(48) || Y(48)
986        let x = pub_bytes[1..49].to_vec();
987        let y = pub_bytes[49..97].to_vec();
988        let key = CosePublicKey::Ec2 { x, y };
989
990        let message = b"webauthn-kit es384 vector";
991        let signature = key_pair.sign(&rng, message).unwrap();
992        assert_eq!(signature.as_ref().len(), 96); // fixed-width r||s
993
994        assert!(verify_cose_signature(COSE_ALG_ES384, &key, message, signature.as_ref()).is_ok());
995
996        let mut tampered = *message;
997        tampered[0] ^= 0x01;
998        assert!(matches!(
999            verify_cose_signature(COSE_ALG_ES384, &key, &tampered, signature.as_ref()),
1000            Err(WebauthnError::SignatureVerificationFailed)
1001        ));
1002
1003        // ES384 signature under the ES256 arm must fail (wrong curve).
1004        assert!(matches!(
1005            verify_cose_signature(COSE_ALG_ES256, &key, message, signature.as_ref()),
1006            Err(WebauthnError::SignatureVerificationFailed)
1007        ));
1008    }
1009
1010    /// EdDSA roundtrip: a real Ed25519 signature verifies through the
1011    /// EdDSA arm; a tampered message fails closed.
1012    #[test]
1013    fn verify_eddsa_real_signature_roundtrip() {
1014        use ring::signature::{Ed25519KeyPair, KeyPair as _};
1015
1016        let rng = ring::rand::SystemRandom::new();
1017        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
1018        let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
1019
1020        let public = key_pair.public_key().as_ref().to_vec();
1021        assert_eq!(public.len(), 32);
1022        let key = CosePublicKey::Okp { id: public };
1023
1024        let message = b"webauthn-kit ed25519 vector";
1025        let signature = key_pair.sign(message);
1026        assert_eq!(signature.as_ref().len(), 64);
1027
1028        assert!(verify_cose_signature(COSE_ALG_EDDSA, &key, message, signature.as_ref()).is_ok());
1029
1030        let mut tampered = *message;
1031        tampered[0] ^= 0x01;
1032        assert!(matches!(
1033            verify_cose_signature(COSE_ALG_EDDSA, &key, &tampered, signature.as_ref()),
1034            Err(WebauthnError::SignatureVerificationFailed)
1035        ));
1036    }
1037
1038    /// REQ-WA-140: an EC2 key declaring `alg = -35` must carry P-384
1039    /// coordinates; parse must succeed and round-trip them.
1040    #[test]
1041    fn parse_cose_p384_key() {
1042        let x = vec![0xCC; 48];
1043        let y = vec![0xDD; 48];
1044        let cose_key = build_cose_ec2_key_alg(COSE_CRV_P384, COSE_ALG_ES384, &x, &y);
1045
1046        let (alg, key) = parse_cose_key(&cose_key).unwrap();
1047        assert_eq!(alg, COSE_ALG_ES384);
1048        match key {
1049            CosePublicKey::Ec2 { x: kx, y: ky } => {
1050                assert_eq!(kx, x);
1051                assert_eq!(ky, y);
1052            }
1053            _ => panic!("Expected EC2 key"),
1054        }
1055    }
1056
1057    /// REQ-WA-141: an OKP key declaring EdDSA parses to the `Okp` variant.
1058    #[test]
1059    fn parse_cose_okp_ed25519_key() {
1060        let id = vec![0x42u8; 32];
1061        let cose_key = build_cose_okp_key(&id);
1062
1063        let (alg, key) = parse_cose_key(&cose_key).unwrap();
1064        assert_eq!(alg, COSE_ALG_EDDSA);
1065        match key {
1066            CosePublicKey::Okp { id: kid } => assert_eq!(kid, id),
1067            _ => panic!("Expected OKP key"),
1068        }
1069    }
1070
1071    // ---- algorithm-confusion regression tests (the RS256 bug class) ----
1072
1073    /// A P-256 key (crv 1, 32-byte coordinates) claiming `alg = -35`
1074    /// (ES384) must be rejected at parse time — never fall through to a
1075    /// P-256 verifier.
1076    #[test]
1077    fn parse_p256_key_claiming_es384_rejected() {
1078        let cose_key =
1079            build_cose_ec2_key_alg(COSE_CRV_P256, COSE_ALG_ES384, &[0xAA; 32], &[0xBB; 32]);
1080        assert!(matches!(
1081            parse_cose_key(&cose_key),
1082            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES384))
1083        ));
1084    }
1085
1086    /// A P-384 key (crv 2) claiming `alg = -7` (ES256) must be rejected.
1087    #[test]
1088    fn parse_p384_key_claiming_es256_rejected() {
1089        let cose_key =
1090            build_cose_ec2_key_alg(COSE_CRV_P384, COSE_ALG_ES256, &[0xAA; 48], &[0xBB; 48]);
1091        assert!(matches!(
1092            parse_cose_key(&cose_key),
1093            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES256))
1094        ));
1095    }
1096
1097    /// An OKP key claiming a non-EdDSA algorithm is rejected; so is an OKP
1098    /// key with a non-Ed25519 curve parameter.
1099    #[test]
1100    fn parse_okp_claiming_other_algorithms_rejected() {
1101        // OKP structure with alg = ES256.
1102        let bad_alg = cose_key_from(vec![
1103            (1, ciborium::Value::Integer(1.into())),
1104            (2, ciborium::Value::Integer((-7).into())),
1105            (-1, ciborium::Value::Integer(6.into())),
1106            (-2, ciborium::Value::Bytes(vec![0x42; 32])),
1107        ]);
1108        assert!(matches!(
1109            parse_cose_key(&bad_alg),
1110            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES256))
1111        ));
1112
1113        // EdDSA alg but wrong curve (X25519 is 4, not 6).
1114        let bad_crv = cose_key_from(vec![
1115            (1, ciborium::Value::Integer(1.into())),
1116            (2, ciborium::Value::Integer((-8).into())),
1117            (-1, ciborium::Value::Integer(4.into())),
1118            (-2, ciborium::Value::Bytes(vec![0x42; 32])),
1119        ]);
1120        assert!(matches!(
1121            parse_cose_key(&bad_crv),
1122            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_EDDSA))
1123        ));
1124    }
1125
1126    /// OKP key presented to the ES256 arm and EC2 key presented to the
1127    /// EdDSA arm: key-type/algorithm mismatches fail closed.
1128    #[test]
1129    fn verify_okp_ec2_type_mismatches_rejected() {
1130        let okp = CosePublicKey::Okp { id: vec![0x42; 32] };
1131        assert!(matches!(
1132            verify_cose_signature(COSE_ALG_ES256, &okp, b"data", &[0u8; 64]),
1133            Err(WebauthnError::VerificationFailed(_))
1134        ));
1135
1136        let ec2 = CosePublicKey::Ec2 {
1137            x: vec![0xAA; 32],
1138            y: vec![0xBB; 32],
1139        };
1140        assert!(matches!(
1141            verify_cose_signature(COSE_ALG_EDDSA, &ec2, b"data", &[0u8; 64]),
1142            Err(WebauthnError::VerificationFailed(_))
1143        ));
1144    }
1145
1146    /// ES384 key material is length-checked by `ring`: a 65-byte point
1147    /// under the P-384 verifier fails closed (no panic).
1148    #[test]
1149    fn verify_es384_short_point_fails_closed() {
1150        let short = CosePublicKey::Ec2 {
1151            x: vec![0xAA; 32], // P-256-sized coordinates
1152            y: vec![0xBB; 32],
1153        };
1154        assert!(matches!(
1155            verify_cose_signature(COSE_ALG_ES384, &short, b"data", &[0u8; 96]),
1156            Err(WebauthnError::SignatureVerificationFailed)
1157        ));
1158    }
1159
1160    /// Ed25519 keys must be exactly 32 bytes; anything else fails closed
1161    /// at `ring`.
1162    #[test]
1163    fn verify_eddsa_wrong_key_length_fails_closed() {
1164        let long = CosePublicKey::Okp { id: vec![0x42; 31] };
1165        assert!(matches!(
1166            verify_cose_signature(COSE_ALG_EDDSA, &long, b"data", &[0u8; 64]),
1167            Err(WebauthnError::SignatureVerificationFailed)
1168        ));
1169        let short_sig = CosePublicKey::Okp { id: vec![0x42; 32] };
1170        assert!(matches!(
1171            verify_cose_signature(COSE_ALG_EDDSA, &short_sig, b"data", &[0u8; 63]),
1172            Err(WebauthnError::SignatureVerificationFailed)
1173        ));
1174    }
1175
1176    /// Remaining parse/verify error paths: unknown CBOR labels in a COSE
1177    /// key map are ignored; an OKP key without key bytes is rejected; an
1178    /// OKP key under the ES384 arm is rejected.
1179    #[test]
1180    fn cose_key_unknown_labels_and_okp_error_paths() {
1181        // Unknown labels are ignored during parsing.
1182        let with_extra = cose_key_from(vec![
1183            (1, ciborium::Value::Integer(1.into())),
1184            (2, ciborium::Value::Integer((-8).into())),
1185            (-1, ciborium::Value::Integer(6.into())),
1186            (-2, ciborium::Value::Bytes(vec![0x42; 32])),
1187            (99, ciborium::Value::Text("ignored".to_string())),
1188        ]);
1189        let (alg, _) = parse_cose_key(&with_extra).unwrap();
1190        assert_eq!(alg, COSE_ALG_EDDSA);
1191
1192        // OKP/EdDSA without the raw key bytes (-2) is rejected.
1193        let no_bytes = cose_key_from(vec![
1194            (1, ciborium::Value::Integer(1.into())),
1195            (2, ciborium::Value::Integer((-8).into())),
1196            (-1, ciborium::Value::Integer(6.into())),
1197        ]);
1198        assert!(matches!(
1199            parse_cose_key(&no_bytes),
1200            Err(WebauthnError::VerificationFailed(_))
1201        ));
1202
1203        // OKP key presented to the ES384 arm: key-type mismatch.
1204        let okp = CosePublicKey::Okp { id: vec![0x42; 32] };
1205        assert!(matches!(
1206            verify_cose_signature(COSE_ALG_ES384, &okp, b"data", &[0u8; 96]),
1207            Err(WebauthnError::VerificationFailed(_))
1208        ));
1209    }
1210
1211    /// RS256 end-to-end: a real 2048-bit RSA signature verifies through the
1212    /// RS256 arm (PKCS#1 v1.5 + SHA-256), a tampered message fails closed.
1213    #[test]
1214    fn verify_rs256_real_signature_roundtrip() {
1215        use ring::signature::{RsaKeyPair, RSA_PKCS1_SHA256};
1216
1217        let (n, e, pkcs8) = rsa_test_material();
1218        let rsa = RsaKeyPair::from_pkcs8(&pkcs8).unwrap();
1219        let modulus_len = rsa.public().modulus_len();
1220        assert_eq!(modulus_len, 256);
1221
1222        // Sign with ring, verify through the COSE RS256 arm.
1223        let rng = ring::rand::SystemRandom::new();
1224        let message = b"webauthn-kit rs256 vector";
1225        let mut signature = vec![0u8; modulus_len];
1226        rsa.sign(&RSA_PKCS1_SHA256, &rng, message, &mut signature)
1227            .unwrap();
1228
1229        let key = CosePublicKey::Rsa { n, e };
1230        assert!(verify_cose_signature(COSE_ALG_RS256, &key, message, &signature).is_ok());
1231
1232        // One flipped message byte must fail closed.
1233        let mut tampered = *message;
1234        tampered[0] ^= 0x01;
1235        assert!(matches!(
1236            verify_cose_signature(COSE_ALG_RS256, &key, &tampered, &signature),
1237            Err(WebauthnError::SignatureVerificationFailed)
1238        ));
1239    }
1240
1241    /// DER long-form length encoding: a 128-byte modulus forces both the
1242    /// standalone length encoder and the in-place header writer through
1243    /// their `0x81` (single length byte) branches.
1244    #[test]
1245    fn der_long_form_length_encoding() {
1246        let n = [0x77u8; 128];
1247        let e = [0x01, 0x00, 0x01];
1248        let der = RsaPublicKeyDer { n: &n, e: &e }.to_der().unwrap();
1249
1250        // RSAPublicKey: outer SEQ header uses the 0x81 long form.
1251        assert_eq!(der[0], 0x30);
1252        assert_eq!(der[1], 0x81);
1253        assert_eq!(der[2] as usize + 3, der.len());
1254        // The 128-byte INTEGER itself also uses the long form.
1255        assert!(der.windows(3).any(|w| w == [0x02, 0x81, 0x80]));
1256    }
1257}