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