Skip to main content

webauthn_kit/
crypto.rs

1//! COSE key parsing and signature verification (ES256 / RS256) via `ring`,
2//! plus base64url helpers and CSPRNG challenge generation.
3//!
4//! # Security
5//!
6//! - All cryptographic primitives come from `ring` (constant-time ECDSA/RSA
7//!   verification). This crate contains no hand-rolled cryptography; the only
8//!   custom encoding is RFC 5280 DER *wrapping* of already-validated RSA
9//!   modulus/exponent byte strings, which never touches secret material.
10//! - Signature verification is constant-result: on failure the payload and
11//!   signature are discarded and only an error variant is returned.
12
13use crate::error::WebauthnError;
14
15/// Parsed COSE public key (EC2 or RSA).
16#[derive(Debug, Clone)]
17pub enum CosePublicKey {
18    /// Elliptic-curve (P-256) key with affine coordinates `x` and `y`.
19    Ec2 {
20        /// X coordinate, big-endian, 32 bytes for P-256.
21        x: Vec<u8>,
22        /// Y coordinate, big-endian, 32 bytes for P-256.
23        y: Vec<u8>,
24    },
25    /// RSA key with modulus `n` and public exponent `e`.
26    Rsa {
27        /// RSA modulus, big-endian.
28        n: Vec<u8>,
29        /// RSA public exponent, big-endian (typically 0x010001).
30        e: Vec<u8>,
31    },
32}
33
34/// COSE key type: Octet Key Pair (Ed25519 et al.). Not supported.
35pub const COSE_KTY_OKP: i64 = 1;
36/// COSE key type: Elliptic-Curve key pair with x/y coordinates.
37pub const COSE_KTY_EC2: i64 = 2;
38/// COSE key type: RSA key.
39pub const COSE_KTY_RSA: i64 = 3;
40
41/// COSE algorithm: ECDSA w/ SHA-256 on P-256.
42pub const COSE_ALG_ES256: i32 = -7;
43/// COSE algorithm: RSASSA-PKCS1-v1_5 w/ SHA-256.
44pub const COSE_ALG_RS256: i32 = -257;
45
46/// COSE key map parameter label: key type (`kty`).
47const COSE_KEY_KTY: i64 = 1;
48/// COSE key map parameter label: algorithm (`alg`).
49const COSE_KEY_ALG: i64 = 2;
50/// COSE key map parameter label: curve (EC2) or modulus `n` (RSA).
51const COSE_KEY_CRV_N: i64 = -1;
52/// COSE key map parameter label: x coordinate (EC2) or exponent `e` (RSA).
53const COSE_KEY_X_E: i64 = -2;
54/// COSE key map parameter label: y coordinate (EC2).
55const COSE_KEY_Y: i64 = -3;
56
57/// COSE curve identifier: NIST P-256 (secp256r1).
58const COSE_CRV_P256: i64 = 1;
59
60/// Parse a CBOR integer from a value, handling both positive and negative.
61fn cbor_i64(val: &ciborium::Value) -> Option<i64> {
62    use ciborium::Value;
63    match val {
64        Value::Integer(i) => (*i).try_into().ok(),
65        _ => None,
66    }
67}
68
69/// Extract a CBOR byte string from a value.
70///
71/// # Security note
72///
73/// Deliberately lenient: a CBOR text string is accepted and re-interpreted as
74/// its UTF-8 byte representation. This tolerates non-conformant authenticators
75/// that encode binary fields as text. Length/format validation of the extracted
76/// bytes happens downstream (`ring` rejects malformed keys at verify time).
77pub fn cbor_bytes(val: &ciborium::Value) -> Option<Vec<u8>> {
78    use ciborium::Value;
79    match val {
80        Value::Bytes(b) => Some(b.clone()),
81        Value::Text(t) => Some(t.as_bytes().to_vec()),
82        _ => None,
83    }
84}
85
86/// Parse a CBOR map into ordered `(i64, Value)` entries.
87///
88/// Returns `None` if the value is not a map or any key is not an integer.
89/// Duplicate keys are preserved in order; last occurrence wins when consumers
90/// iterate and assign, matching CBOR common practice.
91pub fn cbor_map_entries(val: &ciborium::Value) -> Option<Vec<(i64, ciborium::Value)>> {
92    use ciborium::Value;
93    match val {
94        Value::Map(entries) => {
95            let mut result = Vec::with_capacity(entries.len());
96            for (k, v) in entries {
97                let key = cbor_i64(k)?;
98                result.push((key, v.clone()));
99            }
100            Some(result)
101        }
102        _ => None,
103    }
104}
105
106/// Parse a COSE key from its CBOR encoding (RFC 9052).
107///
108/// Supports EC2/P-256 (`kty = 2`, `crv = 1`) and RSA (`kty = 3`). OKP keys
109/// return [`WebauthnError::UnsupportedAlgorithm`]; unknown key types return
110/// [`WebauthnError::VerificationFailed`].
111///
112/// # Security notes / threat assumptions
113///
114/// - `cose_bytes` is attacker-controlled input (it arrives inside authenticator
115///   data). Parsing must never panic on arbitrary bytes; malformed input always
116///   yields `Err`.
117/// - The returned `alg` is whatever the key declares. Callers must check it is
118///   one of [`COSE_ALG_ES256`] / [`COSE_ALG_RS256`] *and* pass it to
119///   [`verify_cose_signature`], which cross-checks key type against algorithm
120///   (e.g. an RSA key claiming `alg = -7` is rejected).
121/// - Coordinate/modulus lengths are not pre-validated here; `ring` rejects
122///   invalid lengths during signature verification.
123///
124/// # Requirements
125/// REQ-WA-003, REQ-WA-100, REQ-WA-107
126///
127/// Returns the COSE algorithm identifier and the parsed key components.
128pub fn parse_cose_key(cose_bytes: &[u8]) -> Result<(i32, CosePublicKey), WebauthnError> {
129    use ciborium::Value;
130
131    let key_val: Value = ciborium::de::from_reader(cose_bytes).map_err(|e| {
132        WebauthnError::VerificationFailed(format!("COSE key CBOR parse error: {e}"))
133    })?;
134
135    let entries = cbor_map_entries(&key_val).ok_or_else(|| {
136        WebauthnError::VerificationFailed("COSE key is not a CBOR map".to_string())
137    })?;
138
139    let mut kty: Option<i64> = None;
140    let mut alg: Option<i32> = None;
141    let mut crv: Option<i64> = None;
142    let mut x: Option<Vec<u8>> = None;
143    let mut y: Option<Vec<u8>> = None;
144    let mut n: Option<Vec<u8>> = None;
145    let mut e: Option<Vec<u8>> = None;
146
147    for (label, val) in &entries {
148        match *label {
149            COSE_KEY_KTY => kty = cbor_i64(val),
150            COSE_KEY_ALG => alg = cbor_i64(val).map(|v| v as i32),
151            COSE_KEY_CRV_N => match kty {
152                Some(COSE_KTY_RSA) => n = cbor_bytes(val),
153                _ => crv = cbor_i64(val),
154            },
155            COSE_KEY_X_E => match kty {
156                Some(COSE_KTY_RSA) => e = cbor_bytes(val),
157                _ => x = cbor_bytes(val),
158            },
159            COSE_KEY_Y => y = cbor_bytes(val),
160            _ => {}
161        }
162    }
163
164    let kty =
165        kty.ok_or_else(|| WebauthnError::VerificationFailed("COSE key missing 'kty'".to_string()))?;
166    let alg =
167        alg.ok_or_else(|| WebauthnError::VerificationFailed("COSE key missing 'alg'".to_string()))?;
168
169    match kty {
170        COSE_KTY_EC2 => {
171            let crv = crv.ok_or_else(|| {
172                WebauthnError::VerificationFailed("EC2 key missing 'crv'".to_string())
173            })?;
174            if crv != COSE_CRV_P256 {
175                return Err(WebauthnError::UnsupportedAlgorithm(alg));
176            }
177            let x = x.ok_or_else(|| {
178                WebauthnError::VerificationFailed("EC2 key missing 'x'".to_string())
179            })?;
180            let y = y.ok_or_else(|| {
181                WebauthnError::VerificationFailed("EC2 key missing 'y'".to_string())
182            })?;
183            Ok((alg, CosePublicKey::Ec2 { x, y }))
184        }
185        COSE_KTY_RSA => {
186            let n = n.ok_or_else(|| {
187                WebauthnError::VerificationFailed("RSA key missing 'n'".to_string())
188            })?;
189            let e = e.ok_or_else(|| {
190                WebauthnError::VerificationFailed("RSA key missing 'e'".to_string())
191            })?;
192            Ok((alg, CosePublicKey::Rsa { n, e }))
193        }
194        COSE_KTY_OKP => Err(WebauthnError::UnsupportedAlgorithm(alg)),
195        other => Err(WebauthnError::VerificationFailed(format!(
196            "Unsupported COSE key type: {other}"
197        ))),
198    }
199}
200
201/// Verify a COSE signature using the parsed public key.
202///
203/// # Security notes / threat assumptions
204///
205/// - `alg` is matched strictly: `ES256` requires an [`CosePublicKey::Ec2`]
206///   key, `RS256` requires [`CosePublicKey::Rsa`]; any mismatch is an error,
207///   never a fallback.
208/// - `signed_data` and `signature` are attacker-controlled. Verification uses
209///   `ring`'s constant-time implementations
210///   (`ECDSA_P256_SHA256_FIXED`, `RSA_PKCS1_2048_8192_SHA256`).
211/// - RSA keys smaller than 2048 bits are rejected by `ring`'s
212///   `RSA_PKCS1_2048_8192_SHA256` parameters.
213///
214/// # Requirements
215/// REQ-WA-106, REQ-WA-107
216///
217/// Returns `Ok(())` if and only if the signature verifies over `signed_data`.
218pub fn verify_cose_signature(
219    alg: i32,
220    public_key: &CosePublicKey,
221    signed_data: &[u8],
222    signature: &[u8],
223) -> Result<(), WebauthnError> {
224    use ring::signature;
225
226    match alg {
227        COSE_ALG_ES256 => {
228            let CosePublicKey::Ec2 { x, y } = public_key else {
229                return Err(WebauthnError::VerificationFailed(
230                    "EC2 key expected for ES256".to_string(),
231                ));
232            };
233
234            let mut public_key_bytes = Vec::with_capacity(1 + x.len() + y.len());
235            public_key_bytes.push(0x04);
236            public_key_bytes.extend_from_slice(x);
237            public_key_bytes.extend_from_slice(y);
238
239            let public_key = signature::UnparsedPublicKey::new(
240                &signature::ECDSA_P256_SHA256_FIXED,
241                &public_key_bytes,
242            );
243            public_key
244                .verify(signed_data, signature)
245                .map_err(|_| WebauthnError::SignatureVerificationFailed)?;
246            Ok(())
247        }
248        COSE_ALG_RS256 => {
249            let CosePublicKey::Rsa { n, e } = public_key else {
250                return Err(WebauthnError::VerificationFailed(
251                    "RSA key expected for RS256".to_string(),
252                ));
253            };
254
255            let rsa_public_key = RsaPublicKeyDer { n, e };
256            let der_bytes = rsa_public_key.to_der()?;
257
258            let public_key = signature::UnparsedPublicKey::new(
259                &signature::RSA_PKCS1_2048_8192_SHA256,
260                &der_bytes,
261            );
262            public_key
263                .verify(signed_data, signature)
264                .map_err(|_| WebauthnError::SignatureVerificationFailed)?;
265            Ok(())
266        }
267        other => Err(WebauthnError::UnsupportedAlgorithm(other)),
268    }
269}
270
271/// ASN.1 DER (PKCS#1 RFC 8017 `RSAPublicKey`) encoding helper for RSA
272/// public keys.
273struct RsaPublicKeyDer<'a> {
274    n: &'a [u8],
275    e: &'a [u8],
276}
277
278impl RsaPublicKeyDer<'_> {
279    /// Encode modulus/exponent into a DER `RSAPublicKey` structure:
280    /// `SEQUENCE { modulus INTEGER, publicExponent INTEGER }`.
281    ///
282    /// This is the encoding `ring`'s RSA signature verifiers parse
283    /// (`ring::rsa::parse_public_key`) — NOT an RFC 5280
284    /// `SubjectPublicKeyInfo`.
285    fn to_der(&self) -> Result<Vec<u8>, WebauthnError> {
286        let mut der = Vec::new();
287        Self::encode_integer(&mut der, self.n);
288        Self::encode_integer(&mut der, self.e);
289        Self::encode_sequence_in_place(&mut der);
290        Ok(der)
291    }
292
293    /// DER-encode an INTEGER from a big-endian byte string.
294    ///
295    /// Strips leading zero octets and prepends a single `0x00` when the
296    /// high bit is set, per DER INTEGER positivity rules (a form ring's
297    /// DER parser accepts).
298    fn encode_integer(buf: &mut Vec<u8>, value: &[u8]) {
299        let start = value
300            .iter()
301            .position(|&b| b != 0)
302            .unwrap_or(value.len().saturating_sub(1));
303        let v = value.get(start..).unwrap_or(&[]);
304        if v.first().is_some_and(|&b| b & 0x80 != 0) {
305            buf.push(0x02);
306            Self::encode_length(buf, v.len() + 1);
307            buf.push(0x00);
308            buf.extend_from_slice(v);
309        } else {
310            buf.push(0x02);
311            Self::encode_length(buf, v.len());
312            buf.extend_from_slice(v);
313        }
314    }
315
316    /// Wrap the existing buffer content in a SEQUENCE by shifting right.
317    fn encode_sequence_in_place(buf: &mut Vec<u8>) {
318        let content_len = buf.len();
319        let header_len = if content_len < 0x80 {
320            2
321        } else if content_len < 0x100 {
322            3
323        } else {
324            4
325        };
326        buf.resize(content_len + header_len, 0);
327        buf.copy_within(..content_len, header_len);
328        if let Some(tag) = buf.first_mut() {
329            *tag = 0x30;
330        }
331        Self::encode_length_at(buf, 1, content_len);
332    }
333
334    /// DER-encode a length octet string at the end of `buf`.
335    fn encode_length(buf: &mut Vec<u8>, len: usize) {
336        if len < 0x80 {
337            buf.push(len as u8);
338        } else if len < 0x100 {
339            buf.push(0x81);
340            buf.push(len as u8);
341        } else {
342            buf.push(0x82);
343            buf.push((len >> 8) as u8);
344            buf.push((len & 0xFF) as u8);
345        }
346    }
347
348    /// DER-encode a length octet string at `offset` in `buf`.
349    ///
350    /// INVARIANT: the caller guarantees `buf.len() >= offset + needed`,
351    /// where `needed` is 1, 2, or 3 depending on `len` (callers size the
352    /// buffer via the matching `header_len` computation before calling).
353    /// The direct indexing below is therefore in-bounds by construction.
354    #[allow(clippy::indexing_slicing)]
355    fn encode_length_at(buf: &mut [u8], offset: usize, len: usize) {
356        if len < 0x80 {
357            buf[offset] = len as u8;
358        } else if len < 0x100 {
359            buf[offset] = 0x81;
360            buf[offset + 1] = len as u8;
361        } else {
362            buf[offset] = 0x82;
363            buf[offset + 1] = (len >> 8) as u8;
364            buf[offset + 2] = (len & 0xFF) as u8;
365        }
366    }
367}
368
369/// Convert a COSE algorithm ID to a human-readable name.
370pub fn alg_to_name(alg: i32) -> &'static str {
371    match alg {
372        COSE_ALG_ES256 => "ES256",
373        COSE_ALG_RS256 => "RS256",
374        _ => "unknown",
375    }
376}
377
378/// Generate 32 random bytes (256 bits of entropy) for a `WebAuthn` challenge.
379///
380/// Uses the operating system CSPRNG via `ring::rand::SystemRandom`.
381///
382/// # Security notes
383///
384/// Challenges MUST come from a cryptographically secure source. If the OS
385/// entropy source fails, this function panics rather than emit predictable
386/// challenges (a failed CSPRNG is unrecoverable for security purposes).
387///
388/// # Requirements
389/// REQ-WA-110
390pub fn generate_challenge_bytes() -> Vec<u8> {
391    use ring::rand::SecureRandom;
392    let mut bytes = [0u8; 32];
393    // INVARIANT: a failed OS CSPRNG is unrecoverable for security purposes
394    // (documented above) — panicking is the deliberate, correct behavior.
395    #[allow(clippy::expect_used)]
396    ring::rand::SystemRandom::new()
397        .fill(&mut bytes)
398        .expect("OS CSPRNG unavailable; cannot generate secure challenge");
399    bytes.to_vec()
400}
401
402/// Base64url encode (RFC 4648 §5, no padding), as required by the `WebAuthn`
403/// JSON serialization conventions.
404pub fn base64_encode_urlsafe(data: &[u8]) -> String {
405    use base64::Engine;
406    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
407}
408
409/// Base64url decode (RFC 4648 §5, no padding required; standard alphabet with
410/// padding is also accepted by the engine's forgiving handling).
411///
412/// Returns [`WebauthnError::VerificationFailed`] on malformed input.
413///
414/// # Requirements
415/// REQ-WA-004, REQ-WA-118
416pub fn base64_decode_urlsafe(data: &str) -> Result<Vec<u8>, WebauthnError> {
417    use base64::Engine;
418    base64::engine::general_purpose::URL_SAFE_NO_PAD
419        .decode(data)
420        .map_err(|e| WebauthnError::VerificationFailed(format!("base64 decode error: {e}")))
421}
422
423// Tests exercise failure paths and invariants directly; unwrap/expect,
424// slicing, and panicking asserts are acceptable here — violations
425// surface as test failures, not production panics.
426#[allow(
427    clippy::unwrap_used,
428    clippy::expect_used,
429    clippy::indexing_slicing,
430    clippy::panic
431)]
432#[cfg(test)]
433pub(crate) mod tests {
434    use super::*;
435
436    /// Build a COSE EC2/P-256 key map around the given coordinates.
437    pub(crate) fn build_cose_ec2_key(x: &[u8], y: &[u8]) -> Vec<u8> {
438        use ciborium::Value;
439        let map = vec![
440            (Value::Integer(1.into()), Value::Integer(2.into())),
441            (Value::Integer(2.into()), Value::Integer((-7).into())),
442            (Value::Integer((-1).into()), Value::Integer(1.into())),
443            (Value::Integer((-2).into()), Value::Bytes(x.to_vec())),
444            (Value::Integer((-3).into()), Value::Bytes(y.to_vec())),
445        ];
446        let mut buf = Vec::new();
447        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
448        buf
449    }
450
451    /// Build a COSE RSA key map around the given modulus/exponent.
452    pub(crate) fn build_cose_rsa_key(n: &[u8], e: &[u8]) -> Vec<u8> {
453        use ciborium::Value;
454        let map = vec![
455            (Value::Integer(1.into()), Value::Integer(3.into())),
456            (Value::Integer(2.into()), Value::Integer((-257).into())),
457            (Value::Integer((-1).into()), Value::Bytes(n.to_vec())),
458            (Value::Integer((-2).into()), Value::Bytes(e.to_vec())),
459        ];
460        let mut buf = Vec::new();
461        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
462        buf
463    }
464
465    #[test]
466    fn test_parse_cose_ec2_key() {
467        let x = vec![0xAA; 32];
468        let y = vec![0xBB; 32];
469        let cose_key = build_cose_ec2_key(&x, &y);
470
471        let (alg, key) = parse_cose_key(&cose_key).unwrap();
472        assert_eq!(alg, -7);
473        match key {
474            CosePublicKey::Ec2 { x: kx, y: ky } => {
475                assert_eq!(kx, x);
476                assert_eq!(ky, y);
477            }
478            _ => panic!("Expected EC2 key"),
479        }
480    }
481
482    #[test]
483    fn test_parse_cose_rsa_key() {
484        let n = vec![0xAA; 256];
485        let e = vec![0x01, 0x00, 0x01];
486        let cose_key = build_cose_rsa_key(&n, &e);
487
488        let (alg, key) = parse_cose_key(&cose_key).unwrap();
489        assert_eq!(alg, -257);
490        match key {
491            CosePublicKey::Rsa { n: kn, e: ke } => {
492                assert_eq!(kn, n);
493                assert_eq!(ke, e);
494            }
495            _ => panic!("Expected RSA key"),
496        }
497    }
498
499    #[test]
500    fn test_parse_cose_key_not_a_map() {
501        let buf = [0x00u8]; // CBOR integer 0
502        let result = parse_cose_key(&buf);
503        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
504    }
505
506    #[test]
507    fn test_parse_cose_key_missing_kty() {
508        use ciborium::Value;
509        let map = vec![(Value::Integer(2.into()), Value::Integer((-7).into()))];
510        let mut buf = Vec::new();
511        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
512        let result = parse_cose_key(&buf);
513        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
514    }
515
516    #[test]
517    fn test_parse_cose_key_okp_unsupported() {
518        use ciborium::Value;
519        let map = vec![
520            (Value::Integer(1.into()), Value::Integer(1.into())),
521            (Value::Integer(2.into()), Value::Integer((-8).into())),
522        ];
523        let mut buf = Vec::new();
524        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
525        let result = parse_cose_key(&buf);
526        assert!(matches!(
527            result,
528            Err(WebauthnError::UnsupportedAlgorithm(-8))
529        ));
530    }
531
532    #[test]
533    fn test_verify_cose_signature_es256() {
534        use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P256_SHA256_FIXED_SIGNING};
535
536        let rng = ring::rand::SystemRandom::new();
537        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
538        let key_pair =
539            EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
540                .unwrap();
541
542        let public_key_bytes = key_pair.public_key().as_ref().to_vec();
543        let x = public_key_bytes[1..33].to_vec();
544        let y = public_key_bytes[33..65].to_vec();
545
546        let cose_key = CosePublicKey::Ec2 { x, y };
547        let message = b"test message for WebAuthn";
548        let signature = key_pair.sign(&rng, message).unwrap();
549
550        let result = verify_cose_signature(COSE_ALG_ES256, &cose_key, message, signature.as_ref());
551        assert!(result.is_ok());
552
553        let wrong_result = verify_cose_signature(
554            COSE_ALG_ES256,
555            &cose_key,
556            b"wrong message",
557            signature.as_ref(),
558        );
559        assert!(matches!(
560            wrong_result,
561            Err(WebauthnError::SignatureVerificationFailed)
562        ));
563    }
564
565    #[test]
566    fn test_verify_cose_signature_alg_key_type_mismatch() {
567        // RSA key material presented with alg = ES256 must be rejected.
568        let n = vec![0xAA; 256];
569        let e = vec![0x01, 0x00, 0x01];
570        let cose_key = build_cose_rsa_key(&n, &e);
571        let (alg, key) = parse_cose_key(&cose_key).unwrap();
572        assert_eq!(alg, -257);
573
574        let mismatch = verify_cose_signature(COSE_ALG_ES256, &key, b"data", &[0u8; 64]);
575        assert!(matches!(
576            mismatch,
577            Err(WebauthnError::VerificationFailed(_))
578        ));
579    }
580
581    #[test]
582    fn test_verify_cose_signature_rs256() {
583        let n = vec![
584            0xC0, 0xE9, 0x5A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
585            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
586            0x00, 0x00, 0x00, 0x00,
587        ];
588        let e = vec![0x01, 0x00, 0x01];
589        let cose_key = build_cose_rsa_key(&n, &e);
590
591        let (alg, key) = parse_cose_key(&cose_key).unwrap();
592        assert_eq!(alg, -257);
593        match key {
594            CosePublicKey::Rsa { n: kn, e: ke } => {
595                assert_eq!(kn, n);
596                assert_eq!(ke, e);
597            }
598            _ => panic!("Expected RSA key"),
599        }
600
601        let rsa_key = RsaPublicKeyDer { n: &n, e: &e };
602        let der = rsa_key.to_der().unwrap();
603        assert_eq!(der[0], 0x30);
604        assert!(der.len() > 40);
605    }
606
607    #[test]
608    fn test_generate_challenge_bytes_length() {
609        let bytes = generate_challenge_bytes();
610        assert_eq!(bytes.len(), 32);
611    }
612
613    #[test]
614    fn test_generate_challenge_bytes_not_all_zero() {
615        // 256 zero bits from a CSPRNG is ~2^-256; treat as failure.
616        let bytes = generate_challenge_bytes();
617        assert!(bytes.iter().any(|&b| b != 0));
618    }
619
620    #[test]
621    fn test_base64_roundtrip() {
622        let original = b"hello world";
623        let encoded = base64_encode_urlsafe(original);
624        let decoded = base64_decode_urlsafe(&encoded).unwrap();
625        assert_eq!(decoded, original);
626    }
627
628    #[test]
629    fn test_base64_decode_urlsafe_invalid() {
630        let result = base64_decode_urlsafe("NOT_VALID_BASE64!!!");
631        assert!(result.is_err());
632    }
633
634    #[test]
635    fn test_base64_encode_decode_empty() {
636        let encoded = base64_encode_urlsafe(b"");
637        let decoded = base64_decode_urlsafe(&encoded).unwrap();
638        assert!(decoded.is_empty());
639    }
640
641    #[test]
642    fn test_cbor_bytes_from_text_is_lenient() {
643        use ciborium::Value;
644        let v = Value::Text("abc".to_string());
645        assert_eq!(cbor_bytes(&v), Some(b"abc".to_vec()));
646        let v = Value::Null;
647        assert_eq!(cbor_bytes(&v), None);
648    }
649
650    /// REQ-WA-202: DER INTEGER encoding must handle the all-zero value
651    /// (single 0x00 octet), strip leading zeros, and prepend 0x00 for
652    /// high-bit-set values — all without panicking.
653    #[test]
654    fn der_integer_encoding_edge_cases() {
655        let mut buf = Vec::new();
656        RsaPublicKeyDer::encode_integer(&mut buf, &[0u8; 8]);
657        assert_eq!(buf, vec![0x02, 0x01, 0x00]);
658
659        buf.clear();
660        buf.clear();
661        // High-bit-set value: a necessary positive-marker 0x00 is prepended.
662        RsaPublicKeyDer::encode_integer(&mut buf, &[0x80, 0x00]);
663        assert_eq!(buf, vec![0x02, 0x03, 0x00, 0x80, 0x00]);
664
665        buf.clear();
666        RsaPublicKeyDer::encode_integer(&mut buf, &[0x00, 0x00, 0x7F]);
667        assert_eq!(buf, vec![0x02, 0x01, 0x7F]);
668
669        buf.clear();
670        // Empty modulus degenerates to a zero-length INTEGER (malformed DER);
671        // the encoder stays total and `ring` rejects the key at verify time.
672        RsaPublicKeyDer::encode_integer(&mut buf, &[]);
673        assert_eq!(buf, vec![0x02, 0x00]);
674    }
675
676    // ---- error paths of parse_cose_key / verify_cose_signature ----
677
678    /// Fixed 2048-bit RSA test key (PKCS#8 DER, base64). ring cannot
679    /// generate RSA keys, so the fixture is pre-generated with openssl.
680    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=";
681
682    /// Decode a DER TLV at `i`; returns `(content, rest)`.
683    fn der_tlv(i: &[u8]) -> (&[u8], &[u8]) {
684        let l0 = *i.get(1).unwrap();
685        let (header, len) = if l0 < 0x80 {
686            (2usize, l0 as usize)
687        } else {
688            let n = (l0 & 0x7F) as usize;
689            let mut len = 0usize;
690            for b in &i[2..2 + n] {
691                len = (len << 8) | *b as usize;
692            }
693            (2 + n, len)
694        };
695        (&i[header..header + len], &i[header + len..])
696    }
697
698    /// (modulus, exponent, PKCS#8 DER) for the fixed RSA test key.
699    pub(crate) fn rsa_test_material() -> (Vec<u8>, Vec<u8>, Vec<u8>) {
700        use base64::Engine as _;
701        let pkcs8 = base64::engine::general_purpose::STANDARD
702            .decode(RSA_PKCS8_B64)
703            .unwrap();
704        // PrivateKeyInfo ::= SEQ { version, algorithm, OCTET STRING {
705        //   RSAPrivateKey ::= SEQ { version, n, e, ... } } }
706        let (info, _) = der_tlv(&pkcs8);
707        let (_version, rest) = der_tlv(info);
708        let (_algorithm, rest) = der_tlv(rest);
709        let (octet, _) = der_tlv(rest);
710        let (rsa_key, _) = der_tlv(octet);
711        let (_version, rest) = der_tlv(rsa_key);
712        let (n, rest) = der_tlv(rest);
713        let (e, _) = der_tlv(rest);
714        (n.to_vec(), e.to_vec(), pkcs8)
715    }
716
717    /// Minimal-encoding SPKI for the fixed RSA test key, as ring's RSA
718    /// verifier requires (see `RsaPublicKeyDer::encode_integer`).
719    pub(crate) fn rsa_test_spki() -> Vec<u8> {
720        let (n, e, _) = rsa_test_material();
721        RsaPublicKeyDer { n: &n, e: &e }.to_der().unwrap()
722    }
723
724    fn cose_key_from(entries: Vec<(i64, ciborium::Value)>) -> Vec<u8> {
725        use ciborium::Value;
726        let map: Vec<(Value, Value)> = entries
727            .into_iter()
728            .map(|(k, v)| (Value::Integer(k.into()), v))
729            .collect();
730        let mut buf = Vec::new();
731        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
732        buf
733    }
734
735    #[test]
736    fn parse_cose_key_garbage_cbor_is_error() {
737        // 0xFF is a reserved CBOR major type: parse must fail, never panic.
738        let result = parse_cose_key(&[0xFF, 0xFF, 0xFF, 0xFF]);
739        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
740    }
741
742    #[test]
743    fn parse_cose_key_ec2_missing_crv() {
744        let buf = cose_key_from(vec![
745            (1, ciborium::Value::Integer(2.into())),
746            (2, ciborium::Value::Integer((-7).into())),
747            (-2, ciborium::Value::Bytes(vec![0xAA; 32])),
748            (-3, ciborium::Value::Bytes(vec![0xBB; 32])),
749        ]);
750        assert!(matches!(
751            parse_cose_key(&buf),
752            Err(WebauthnError::VerificationFailed(_))
753        ));
754    }
755
756    #[test]
757    fn parse_cose_key_ec2_wrong_curve_is_unsupported() {
758        let buf = cose_key_from(vec![
759            (1, ciborium::Value::Integer(2.into())),
760            (2, ciborium::Value::Integer((-7).into())),
761            (-1, ciborium::Value::Integer(3.into())), // P-384, not P-256
762            (-2, ciborium::Value::Bytes(vec![0xAA; 48])),
763            (-3, ciborium::Value::Bytes(vec![0xBB; 48])),
764        ]);
765        assert!(matches!(
766            parse_cose_key(&buf),
767            Err(WebauthnError::UnsupportedAlgorithm(-7))
768        ));
769    }
770
771    #[test]
772    fn parse_cose_key_ec2_missing_coordinates() {
773        let kty_alg = vec![
774            (1, ciborium::Value::Integer(2.into())),
775            (2, ciborium::Value::Integer((-7).into())),
776            (-1, ciborium::Value::Integer(1.into())),
777        ];
778        let missing_x = cose_key_from(
779            [
780                kty_alg.clone(),
781                vec![(-3, ciborium::Value::Bytes(vec![0xBB; 32]))],
782            ]
783            .concat(),
784        );
785        assert!(matches!(
786            parse_cose_key(&missing_x),
787            Err(WebauthnError::VerificationFailed(_))
788        ));
789
790        let missing_y =
791            cose_key_from([kty_alg, vec![(-2, ciborium::Value::Bytes(vec![0xAA; 32]))]].concat());
792        assert!(matches!(
793            parse_cose_key(&missing_y),
794            Err(WebauthnError::VerificationFailed(_))
795        ));
796    }
797
798    #[test]
799    fn parse_cose_key_rsa_missing_modulus_or_exponent() {
800        let missing_n = cose_key_from(vec![
801            (1, ciborium::Value::Integer(3.into())),
802            (2, ciborium::Value::Integer((-257).into())),
803            (-2, ciborium::Value::Bytes(vec![0x01, 0x00, 0x01])),
804        ]);
805        assert!(matches!(
806            parse_cose_key(&missing_n),
807            Err(WebauthnError::VerificationFailed(_))
808        ));
809
810        let missing_e = cose_key_from(vec![
811            (1, ciborium::Value::Integer(3.into())),
812            (2, ciborium::Value::Integer((-257).into())),
813            (-1, ciborium::Value::Bytes(vec![0xAA; 256])),
814        ]);
815        assert!(matches!(
816            parse_cose_key(&missing_e),
817            Err(WebauthnError::VerificationFailed(_))
818        ));
819    }
820
821    #[test]
822    fn parse_cose_key_unknown_kty_is_error() {
823        let buf = cose_key_from(vec![
824            (1, ciborium::Value::Integer(99.into())),
825            (2, ciborium::Value::Integer((-7).into())),
826        ]);
827        assert!(matches!(
828            parse_cose_key(&buf),
829            Err(WebauthnError::VerificationFailed(_))
830        ));
831    }
832
833    #[test]
834    fn cbor_map_entries_rejects_non_integer_keys() {
835        use ciborium::Value;
836        let map = Value::Map(vec![(
837            Value::Text("kty".to_string()),
838            Value::Integer(2.into()),
839        )]);
840        assert!(cbor_map_entries(&map).is_none());
841    }
842
843    #[test]
844    fn verify_rs256_with_ec_key_is_rejected() {
845        let key = CosePublicKey::Ec2 {
846            x: vec![0xAA; 32],
847            y: vec![0xBB; 32],
848        };
849        assert!(matches!(
850            verify_cose_signature(COSE_ALG_RS256, &key, b"data", &[0u8; 256]),
851            Err(WebauthnError::VerificationFailed(_))
852        ));
853    }
854
855    #[test]
856    fn verify_cose_signature_unknown_alg_is_rejected() {
857        let key = CosePublicKey::Ec2 {
858            x: vec![0xAA; 32],
859            y: vec![0xBB; 32],
860        };
861        assert!(matches!(
862            verify_cose_signature(-47, &key, b"data", &[0u8; 64]),
863            Err(WebauthnError::UnsupportedAlgorithm(-47))
864        ));
865    }
866
867    #[test]
868    fn alg_to_name_covers_known_and_unknown() {
869        assert_eq!(alg_to_name(COSE_ALG_ES256), "ES256");
870        assert_eq!(alg_to_name(COSE_ALG_RS256), "RS256");
871        assert_eq!(alg_to_name(0), "unknown");
872    }
873
874    /// RS256 end-to-end: a real 2048-bit RSA signature verifies through the
875    /// RS256 arm (PKCS#1 v1.5 + SHA-256), a tampered message fails closed.
876    #[test]
877    fn verify_rs256_real_signature_roundtrip() {
878        use ring::signature::{RsaKeyPair, RSA_PKCS1_SHA256};
879
880        let (n, e, pkcs8) = rsa_test_material();
881        let rsa = RsaKeyPair::from_pkcs8(&pkcs8).unwrap();
882        let modulus_len = rsa.public().modulus_len();
883        assert_eq!(modulus_len, 256);
884
885        // Sign with ring, verify through the COSE RS256 arm.
886        let rng = ring::rand::SystemRandom::new();
887        let message = b"webauthn-kit rs256 vector";
888        let mut signature = vec![0u8; modulus_len];
889        rsa.sign(&RSA_PKCS1_SHA256, &rng, message, &mut signature)
890            .unwrap();
891
892        let key = CosePublicKey::Rsa { n, e };
893        assert!(verify_cose_signature(COSE_ALG_RS256, &key, message, &signature).is_ok());
894
895        // One flipped message byte must fail closed.
896        let mut tampered = *message;
897        tampered[0] ^= 0x01;
898        assert!(matches!(
899            verify_cose_signature(COSE_ALG_RS256, &key, &tampered, &signature),
900            Err(WebauthnError::SignatureVerificationFailed)
901        ));
902    }
903
904    /// DER long-form length encoding: a 128-byte modulus forces both the
905    /// standalone length encoder and the in-place header writer through
906    /// their `0x81` (single length byte) branches.
907    #[test]
908    fn der_long_form_length_encoding() {
909        let n = [0x77u8; 128];
910        let e = [0x01, 0x00, 0x01];
911        let der = RsaPublicKeyDer { n: &n, e: &e }.to_der().unwrap();
912
913        // RSAPublicKey: outer SEQ header uses the 0x81 long form.
914        assert_eq!(der[0], 0x30);
915        assert_eq!(der[1], 0x81);
916        assert_eq!(der[2] as usize + 3, der.len());
917        // The 128-byte INTEGER itself also uses the long form.
918        assert!(der.windows(3).any(|w| w == [0x02, 0x81, 0x80]));
919    }
920}