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 = 3;
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(3.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(3.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(3.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(3.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(3.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: the SEQUENCE wrapper must pick the correct DER length
745    /// form at every boundary — short form below 0x80, `0x81` long form at
746    /// 0x80..=0xFF, `0x82` long form at 0x100 and above — and preserve the
747    /// wrapped content. Mutants of the header-size arithmetic produce
748    /// structurally invalid DER for real RSA keys (content length > 0x100).
749    #[test]
750    fn der_sequence_header_forms_at_length_boundaries() {
751        for (content_len, header_len) in [(0x7Fusize, 2usize), (0x80, 3), (0xFF, 3), (0x100, 4)] {
752            let mut buf = vec![0xCBu8; content_len];
753            RsaPublicKeyDer::encode_sequence_in_place(&mut buf);
754            assert_eq!(
755                buf.len(),
756                content_len + header_len,
757                "content_len {content_len:#x}: wrong total length"
758            );
759            assert_eq!(buf[0], 0x30, "content_len {content_len:#x}: SEQUENCE tag");
760            match header_len {
761                2 => assert_eq!(buf[1] as usize, content_len, "short-form length"),
762                3 => {
763                    assert_eq!(buf[1], 0x81, "content_len {content_len:#x}: 0x81 form");
764                    assert_eq!(buf[2] as usize, content_len);
765                }
766                4 => {
767                    assert_eq!(buf[1], 0x82, "content_len {content_len:#x}: 0x82 form");
768                    assert_eq!(((buf[2] as usize) << 8) | buf[3] as usize, content_len);
769                }
770                _ => unreachable!(),
771            }
772            // Content shifted right intact behind the header.
773            assert!(buf[header_len..].iter().all(|&b| b == 0xCB));
774        }
775    }
776
777    /// REQ-WA-202: DER INTEGER encoding must handle the all-zero value
778    /// (single 0x00 octet), strip leading zeros, and prepend 0x00 for
779    /// high-bit-set values — all without panicking.
780    #[test]
781    fn der_integer_encoding_edge_cases() {
782        let mut buf = Vec::new();
783        RsaPublicKeyDer::encode_integer(&mut buf, &[0u8; 8]);
784        assert_eq!(buf, vec![0x02, 0x01, 0x00]);
785
786        buf.clear();
787        buf.clear();
788        // High-bit-set value: a necessary positive-marker 0x00 is prepended.
789        RsaPublicKeyDer::encode_integer(&mut buf, &[0x80, 0x00]);
790        assert_eq!(buf, vec![0x02, 0x03, 0x00, 0x80, 0x00]);
791
792        buf.clear();
793        RsaPublicKeyDer::encode_integer(&mut buf, &[0x00, 0x00, 0x7F]);
794        assert_eq!(buf, vec![0x02, 0x01, 0x7F]);
795
796        buf.clear();
797        // Empty modulus degenerates to a zero-length INTEGER (malformed DER);
798        // the encoder stays total and `ring` rejects the key at verify time.
799        RsaPublicKeyDer::encode_integer(&mut buf, &[]);
800        assert_eq!(buf, vec![0x02, 0x00]);
801
802        buf.clear();
803        // Exactly 0x80 content bytes: the length must take the `0x81` long
804        // form (a bare 0x80 length octet would be indefinite-length DER).
805        RsaPublicKeyDer::encode_integer(&mut buf, &[0x01u8; 0x80]);
806        assert_eq!(&buf[..3], &[0x02, 0x81, 0x80]);
807        assert_eq!(buf.len(), 3 + 0x80);
808        assert!(buf[3..].iter().all(|&b| b == 0x01));
809    }
810
811    // ---- error paths of parse_cose_key / verify_cose_signature ----
812
813    /// Fixed 2048-bit RSA test key (PKCS#8 DER, base64). ring cannot
814    /// generate RSA keys, so the fixture is pre-generated with openssl.
815    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=";
816
817    /// Decode a DER TLV at `i`; returns `(content, rest)`.
818    fn der_tlv(i: &[u8]) -> (&[u8], &[u8]) {
819        let l0 = *i.get(1).unwrap();
820        let (header, len) = if l0 < 0x80 {
821            (2usize, l0 as usize)
822        } else {
823            let n = (l0 & 0x7F) as usize;
824            let mut len = 0usize;
825            for b in &i[2..2 + n] {
826                len = (len << 8) | *b as usize;
827            }
828            (2 + n, len)
829        };
830        (&i[header..header + len], &i[header + len..])
831    }
832
833    /// (modulus, exponent, PKCS#8 DER) for the fixed RSA test key.
834    pub(crate) fn rsa_test_material() -> (Vec<u8>, Vec<u8>, Vec<u8>) {
835        use base64::Engine as _;
836        let pkcs8 = base64::engine::general_purpose::STANDARD
837            .decode(RSA_PKCS8_B64)
838            .unwrap();
839        // PrivateKeyInfo ::= SEQ { version, algorithm, OCTET STRING {
840        //   RSAPrivateKey ::= SEQ { version, n, e, ... } } }
841        let (info, _) = der_tlv(&pkcs8);
842        let (_version, rest) = der_tlv(info);
843        let (_algorithm, rest) = der_tlv(rest);
844        let (octet, _) = der_tlv(rest);
845        let (rsa_key, _) = der_tlv(octet);
846        let (_version, rest) = der_tlv(rsa_key);
847        let (n, rest) = der_tlv(rest);
848        let (e, _) = der_tlv(rest);
849        (n.to_vec(), e.to_vec(), pkcs8)
850    }
851
852    /// Minimal-encoding SPKI for the fixed RSA test key, as ring's RSA
853    /// verifier requires (see `RsaPublicKeyDer::encode_integer`).
854    pub(crate) fn rsa_test_spki() -> Vec<u8> {
855        let (n, e, _) = rsa_test_material();
856        RsaPublicKeyDer { n: &n, e: &e }.to_der().unwrap()
857    }
858
859    fn cose_key_from(entries: Vec<(i64, ciborium::Value)>) -> Vec<u8> {
860        use ciborium::Value;
861        let map: Vec<(Value, Value)> = entries
862            .into_iter()
863            .map(|(k, v)| (Value::Integer(k.into()), v))
864            .collect();
865        let mut buf = Vec::new();
866        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
867        buf
868    }
869
870    #[test]
871    fn parse_cose_key_garbage_cbor_is_error() {
872        // 0xFF is a reserved CBOR major type: parse must fail, never panic.
873        let result = parse_cose_key(&[0xFF, 0xFF, 0xFF, 0xFF]);
874        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
875    }
876
877    #[test]
878    fn parse_cose_key_ec2_missing_crv() {
879        let buf = cose_key_from(vec![
880            (1, ciborium::Value::Integer(2.into())),
881            (3, ciborium::Value::Integer((-7).into())),
882            (-2, ciborium::Value::Bytes(vec![0xAA; 32])),
883            (-3, ciborium::Value::Bytes(vec![0xBB; 32])),
884        ]);
885        assert!(matches!(
886            parse_cose_key(&buf),
887            Err(WebauthnError::VerificationFailed(_))
888        ));
889    }
890
891    #[test]
892    fn parse_cose_key_ec2_wrong_curve_is_unsupported() {
893        let buf = cose_key_from(vec![
894            (1, ciborium::Value::Integer(2.into())),
895            (3, ciborium::Value::Integer((-7).into())),
896            (-1, ciborium::Value::Integer(3.into())), // P-384, not P-256
897            (-2, ciborium::Value::Bytes(vec![0xAA; 48])),
898            (-3, ciborium::Value::Bytes(vec![0xBB; 48])),
899        ]);
900        assert!(matches!(
901            parse_cose_key(&buf),
902            Err(WebauthnError::UnsupportedAlgorithm(-7))
903        ));
904    }
905
906    #[test]
907    fn parse_cose_key_ec2_missing_coordinates() {
908        let kty_alg = vec![
909            (1, ciborium::Value::Integer(2.into())),
910            (3, ciborium::Value::Integer((-7).into())),
911            (-1, ciborium::Value::Integer(1.into())),
912        ];
913        let missing_x = cose_key_from(
914            [
915                kty_alg.clone(),
916                vec![(-3, ciborium::Value::Bytes(vec![0xBB; 32]))],
917            ]
918            .concat(),
919        );
920        assert!(matches!(
921            parse_cose_key(&missing_x),
922            Err(WebauthnError::VerificationFailed(_))
923        ));
924
925        let missing_y =
926            cose_key_from([kty_alg, vec![(-2, ciborium::Value::Bytes(vec![0xAA; 32]))]].concat());
927        assert!(matches!(
928            parse_cose_key(&missing_y),
929            Err(WebauthnError::VerificationFailed(_))
930        ));
931    }
932
933    #[test]
934    fn parse_cose_key_rsa_missing_modulus_or_exponent() {
935        let missing_n = cose_key_from(vec![
936            (1, ciborium::Value::Integer(3.into())),
937            (3, ciborium::Value::Integer((-257).into())),
938            (-2, ciborium::Value::Bytes(vec![0x01, 0x00, 0x01])),
939        ]);
940        assert!(matches!(
941            parse_cose_key(&missing_n),
942            Err(WebauthnError::VerificationFailed(_))
943        ));
944
945        let missing_e = cose_key_from(vec![
946            (1, ciborium::Value::Integer(3.into())),
947            (3, ciborium::Value::Integer((-257).into())),
948            (-1, ciborium::Value::Bytes(vec![0xAA; 256])),
949        ]);
950        assert!(matches!(
951            parse_cose_key(&missing_e),
952            Err(WebauthnError::VerificationFailed(_))
953        ));
954    }
955
956    #[test]
957    fn parse_cose_key_unknown_kty_is_error() {
958        let buf = cose_key_from(vec![
959            (1, ciborium::Value::Integer(99.into())),
960            (3, ciborium::Value::Integer((-7).into())),
961        ]);
962        assert!(matches!(
963            parse_cose_key(&buf),
964            Err(WebauthnError::VerificationFailed(_))
965        ));
966    }
967
968    #[test]
969    fn cbor_map_entries_rejects_non_integer_keys() {
970        use ciborium::Value;
971        let map = Value::Map(vec![(
972            Value::Text("kty".to_string()),
973            Value::Integer(2.into()),
974        )]);
975        assert!(cbor_map_entries(&map).is_none());
976    }
977
978    #[test]
979    fn verify_rs256_with_ec_key_is_rejected() {
980        let key = CosePublicKey::Ec2 {
981            x: vec![0xAA; 32],
982            y: vec![0xBB; 32],
983        };
984        assert!(matches!(
985            verify_cose_signature(COSE_ALG_RS256, &key, b"data", &[0u8; 256]),
986            Err(WebauthnError::VerificationFailed(_))
987        ));
988    }
989
990    #[test]
991    fn verify_cose_signature_unknown_alg_is_rejected() {
992        let key = CosePublicKey::Ec2 {
993            x: vec![0xAA; 32],
994            y: vec![0xBB; 32],
995        };
996        assert!(matches!(
997            verify_cose_signature(-47, &key, b"data", &[0u8; 64]),
998            Err(WebauthnError::UnsupportedAlgorithm(-47))
999        ));
1000    }
1001
1002    #[test]
1003    fn alg_to_name_covers_known_and_unknown() {
1004        assert_eq!(alg_to_name(COSE_ALG_ES256), "ES256");
1005        assert_eq!(alg_to_name(COSE_ALG_ES384), "ES384");
1006        assert_eq!(alg_to_name(COSE_ALG_EDDSA), "EdDSA");
1007        assert_eq!(alg_to_name(COSE_ALG_RS256), "RS256");
1008        assert_eq!(alg_to_name(0), "unknown");
1009    }
1010
1011    // ---- ES384 / EdDSA (REQ-WA-140, REQ-WA-141) ----
1012
1013    /// ES384 roundtrip: a real P-384 signature verifies through the ES384
1014    /// arm (fixed-width ECDSA + SHA-384); a tampered message fails closed.
1015    #[test]
1016    fn verify_es384_real_signature_roundtrip() {
1017        use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P384_SHA384_FIXED_SIGNING};
1018
1019        let rng = ring::rand::SystemRandom::new();
1020        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, &rng).unwrap();
1021        let key_pair =
1022            EcdsaKeyPair::from_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, pkcs8.as_ref(), &rng)
1023                .unwrap();
1024
1025        let pub_bytes = key_pair.public_key().as_ref();
1026        assert_eq!(pub_bytes.len(), 97); // 0x04 || X(48) || Y(48)
1027        let x = pub_bytes[1..49].to_vec();
1028        let y = pub_bytes[49..97].to_vec();
1029        let key = CosePublicKey::Ec2 { x, y };
1030
1031        let message = b"webauthn-kit es384 vector";
1032        let signature = key_pair.sign(&rng, message).unwrap();
1033        assert_eq!(signature.as_ref().len(), 96); // fixed-width r||s
1034
1035        assert!(verify_cose_signature(COSE_ALG_ES384, &key, message, signature.as_ref()).is_ok());
1036
1037        let mut tampered = *message;
1038        tampered[0] ^= 0x01;
1039        assert!(matches!(
1040            verify_cose_signature(COSE_ALG_ES384, &key, &tampered, signature.as_ref()),
1041            Err(WebauthnError::SignatureVerificationFailed)
1042        ));
1043
1044        // ES384 signature under the ES256 arm must fail (wrong curve).
1045        assert!(matches!(
1046            verify_cose_signature(COSE_ALG_ES256, &key, message, signature.as_ref()),
1047            Err(WebauthnError::SignatureVerificationFailed)
1048        ));
1049    }
1050
1051    /// EdDSA roundtrip: a real Ed25519 signature verifies through the
1052    /// EdDSA arm; a tampered message fails closed.
1053    #[test]
1054    fn verify_eddsa_real_signature_roundtrip() {
1055        use ring::signature::{Ed25519KeyPair, KeyPair as _};
1056
1057        let rng = ring::rand::SystemRandom::new();
1058        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
1059        let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
1060
1061        let public = key_pair.public_key().as_ref().to_vec();
1062        assert_eq!(public.len(), 32);
1063        let key = CosePublicKey::Okp { id: public };
1064
1065        let message = b"webauthn-kit ed25519 vector";
1066        let signature = key_pair.sign(message);
1067        assert_eq!(signature.as_ref().len(), 64);
1068
1069        assert!(verify_cose_signature(COSE_ALG_EDDSA, &key, message, signature.as_ref()).is_ok());
1070
1071        let mut tampered = *message;
1072        tampered[0] ^= 0x01;
1073        assert!(matches!(
1074            verify_cose_signature(COSE_ALG_EDDSA, &key, &tampered, signature.as_ref()),
1075            Err(WebauthnError::SignatureVerificationFailed)
1076        ));
1077    }
1078
1079    /// REQ-WA-140: an EC2 key declaring `alg = -35` must carry P-384
1080    /// coordinates; parse must succeed and round-trip them.
1081    #[test]
1082    fn parse_cose_p384_key() {
1083        let x = vec![0xCC; 48];
1084        let y = vec![0xDD; 48];
1085        let cose_key = build_cose_ec2_key_alg(COSE_CRV_P384, COSE_ALG_ES384, &x, &y);
1086
1087        let (alg, key) = parse_cose_key(&cose_key).unwrap();
1088        assert_eq!(alg, COSE_ALG_ES384);
1089        match key {
1090            CosePublicKey::Ec2 { x: kx, y: ky } => {
1091                assert_eq!(kx, x);
1092                assert_eq!(ky, y);
1093            }
1094            _ => panic!("Expected EC2 key"),
1095        }
1096    }
1097
1098    /// REQ-WA-141: an OKP key declaring EdDSA parses to the `Okp` variant.
1099    #[test]
1100    fn parse_cose_okp_ed25519_key() {
1101        let id = vec![0x42u8; 32];
1102        let cose_key = build_cose_okp_key(&id);
1103
1104        let (alg, key) = parse_cose_key(&cose_key).unwrap();
1105        assert_eq!(alg, COSE_ALG_EDDSA);
1106        match key {
1107            CosePublicKey::Okp { id: kid } => assert_eq!(kid, id),
1108            _ => panic!("Expected OKP key"),
1109        }
1110    }
1111
1112    // ---- algorithm-confusion regression tests (the RS256 bug class) ----
1113
1114    /// A P-256 key (crv 1, 32-byte coordinates) claiming `alg = -35`
1115    /// (ES384) must be rejected at parse time — never fall through to a
1116    /// P-256 verifier.
1117    #[test]
1118    fn parse_p256_key_claiming_es384_rejected() {
1119        let cose_key =
1120            build_cose_ec2_key_alg(COSE_CRV_P256, COSE_ALG_ES384, &[0xAA; 32], &[0xBB; 32]);
1121        assert!(matches!(
1122            parse_cose_key(&cose_key),
1123            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES384))
1124        ));
1125    }
1126
1127    /// A P-384 key (crv 2) claiming `alg = -7` (ES256) must be rejected.
1128    #[test]
1129    fn parse_p384_key_claiming_es256_rejected() {
1130        let cose_key =
1131            build_cose_ec2_key_alg(COSE_CRV_P384, COSE_ALG_ES256, &[0xAA; 48], &[0xBB; 48]);
1132        assert!(matches!(
1133            parse_cose_key(&cose_key),
1134            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES256))
1135        ));
1136    }
1137
1138    /// An OKP key claiming a non-EdDSA algorithm is rejected; so is an OKP
1139    /// key with a non-Ed25519 curve parameter.
1140    #[test]
1141    fn parse_okp_claiming_other_algorithms_rejected() {
1142        // OKP structure with alg = ES256.
1143        let bad_alg = cose_key_from(vec![
1144            (1, ciborium::Value::Integer(1.into())),
1145            (3, ciborium::Value::Integer((-7).into())),
1146            (-1, ciborium::Value::Integer(6.into())),
1147            (-2, ciborium::Value::Bytes(vec![0x42; 32])),
1148        ]);
1149        assert!(matches!(
1150            parse_cose_key(&bad_alg),
1151            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES256))
1152        ));
1153
1154        // EdDSA alg but wrong curve (X25519 is 4, not 6).
1155        let bad_crv = cose_key_from(vec![
1156            (1, ciborium::Value::Integer(1.into())),
1157            (3, ciborium::Value::Integer((-8).into())),
1158            (-1, ciborium::Value::Integer(4.into())),
1159            (-2, ciborium::Value::Bytes(vec![0x42; 32])),
1160        ]);
1161        assert!(matches!(
1162            parse_cose_key(&bad_crv),
1163            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_EDDSA))
1164        ));
1165    }
1166
1167    /// OKP key presented to the ES256 arm and EC2 key presented to the
1168    /// EdDSA arm: key-type/algorithm mismatches fail closed.
1169    #[test]
1170    fn verify_okp_ec2_type_mismatches_rejected() {
1171        let okp = CosePublicKey::Okp { id: vec![0x42; 32] };
1172        assert!(matches!(
1173            verify_cose_signature(COSE_ALG_ES256, &okp, b"data", &[0u8; 64]),
1174            Err(WebauthnError::VerificationFailed(_))
1175        ));
1176
1177        let ec2 = CosePublicKey::Ec2 {
1178            x: vec![0xAA; 32],
1179            y: vec![0xBB; 32],
1180        };
1181        assert!(matches!(
1182            verify_cose_signature(COSE_ALG_EDDSA, &ec2, b"data", &[0u8; 64]),
1183            Err(WebauthnError::VerificationFailed(_))
1184        ));
1185    }
1186
1187    /// ES384 key material is length-checked by `ring`: a 65-byte point
1188    /// under the P-384 verifier fails closed (no panic).
1189    #[test]
1190    fn verify_es384_short_point_fails_closed() {
1191        let short = CosePublicKey::Ec2 {
1192            x: vec![0xAA; 32], // P-256-sized coordinates
1193            y: vec![0xBB; 32],
1194        };
1195        assert!(matches!(
1196            verify_cose_signature(COSE_ALG_ES384, &short, b"data", &[0u8; 96]),
1197            Err(WebauthnError::SignatureVerificationFailed)
1198        ));
1199    }
1200
1201    /// Ed25519 keys must be exactly 32 bytes; anything else fails closed
1202    /// at `ring`.
1203    #[test]
1204    fn verify_eddsa_wrong_key_length_fails_closed() {
1205        let long = CosePublicKey::Okp { id: vec![0x42; 31] };
1206        assert!(matches!(
1207            verify_cose_signature(COSE_ALG_EDDSA, &long, b"data", &[0u8; 64]),
1208            Err(WebauthnError::SignatureVerificationFailed)
1209        ));
1210        let short_sig = CosePublicKey::Okp { id: vec![0x42; 32] };
1211        assert!(matches!(
1212            verify_cose_signature(COSE_ALG_EDDSA, &short_sig, b"data", &[0u8; 63]),
1213            Err(WebauthnError::SignatureVerificationFailed)
1214        ));
1215    }
1216
1217    /// Remaining parse/verify error paths: unknown CBOR labels in a COSE
1218    /// key map are ignored; an OKP key without key bytes is rejected; an
1219    /// OKP key under the ES384 arm is rejected.
1220    #[test]
1221    fn cose_key_unknown_labels_and_okp_error_paths() {
1222        // Unknown labels are ignored during parsing.
1223        let with_extra = cose_key_from(vec![
1224            (1, ciborium::Value::Integer(1.into())),
1225            (3, ciborium::Value::Integer((-8).into())),
1226            (-1, ciborium::Value::Integer(6.into())),
1227            (-2, ciborium::Value::Bytes(vec![0x42; 32])),
1228            (99, ciborium::Value::Text("ignored".to_string())),
1229        ]);
1230        let (alg, _) = parse_cose_key(&with_extra).unwrap();
1231        assert_eq!(alg, COSE_ALG_EDDSA);
1232
1233        // OKP/EdDSA without the raw key bytes (-2) is rejected.
1234        let no_bytes = cose_key_from(vec![
1235            (1, ciborium::Value::Integer(1.into())),
1236            (3, ciborium::Value::Integer((-8).into())),
1237            (-1, ciborium::Value::Integer(6.into())),
1238        ]);
1239        assert!(matches!(
1240            parse_cose_key(&no_bytes),
1241            Err(WebauthnError::VerificationFailed(_))
1242        ));
1243
1244        // OKP key presented to the ES384 arm: key-type mismatch.
1245        let okp = CosePublicKey::Okp { id: vec![0x42; 32] };
1246        assert!(matches!(
1247            verify_cose_signature(COSE_ALG_ES384, &okp, b"data", &[0u8; 96]),
1248            Err(WebauthnError::VerificationFailed(_))
1249        ));
1250    }
1251
1252    /// RS256 end-to-end: a real 2048-bit RSA signature verifies through the
1253    /// RS256 arm (PKCS#1 v1.5 + SHA-256), a tampered message fails closed.
1254    #[test]
1255    fn verify_rs256_real_signature_roundtrip() {
1256        use ring::signature::{RsaKeyPair, RSA_PKCS1_SHA256};
1257
1258        let (n, e, pkcs8) = rsa_test_material();
1259        let rsa = RsaKeyPair::from_pkcs8(&pkcs8).unwrap();
1260        let modulus_len = rsa.public().modulus_len();
1261        assert_eq!(modulus_len, 256);
1262
1263        // Sign with ring, verify through the COSE RS256 arm.
1264        let rng = ring::rand::SystemRandom::new();
1265        let message = b"webauthn-kit rs256 vector";
1266        let mut signature = vec![0u8; modulus_len];
1267        rsa.sign(&RSA_PKCS1_SHA256, &rng, message, &mut signature)
1268            .unwrap();
1269
1270        let key = CosePublicKey::Rsa { n, e };
1271        assert!(verify_cose_signature(COSE_ALG_RS256, &key, message, &signature).is_ok());
1272
1273        // One flipped message byte must fail closed.
1274        let mut tampered = *message;
1275        tampered[0] ^= 0x01;
1276        assert!(matches!(
1277            verify_cose_signature(COSE_ALG_RS256, &key, &tampered, &signature),
1278            Err(WebauthnError::SignatureVerificationFailed)
1279        ));
1280    }
1281
1282    /// DER long-form length encoding: a 128-byte modulus forces both the
1283    /// standalone length encoder and the in-place header writer through
1284    /// their `0x81` (single length byte) branches.
1285    #[test]
1286    fn der_long_form_length_encoding() {
1287        let n = [0x77u8; 128];
1288        let e = [0x01, 0x00, 0x01];
1289        let der = RsaPublicKeyDer { n: &n, e: &e }.to_der().unwrap();
1290
1291        // RSAPublicKey: outer SEQ header uses the 0x81 long form.
1292        assert_eq!(der[0], 0x30);
1293        assert_eq!(der[1], 0x81);
1294        assert_eq!(der[2] as usize + 3, der.len());
1295        // The 128-byte INTEGER itself also uses the long form.
1296        assert!(der.windows(3).any(|w| w == [0x02, 0x81, 0x80]));
1297    }
1298}