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 (RFC 5280 `SubjectPublicKeyInfo`) encoding helper for RSA public keys.
272struct RsaPublicKeyDer<'a> {
273    n: &'a [u8],
274    e: &'a [u8],
275}
276
277impl RsaPublicKeyDer<'_> {
278    /// Encode modulus/exponent into a DER `SubjectPublicKeyInfo` structure.
279    ///
280    /// The integer encoder strips leading zero bytes and prepends `0x00` when
281    /// the high bit is set, per DER INTEGER rules.
282    fn to_der(&self) -> Result<Vec<u8>, WebauthnError> {
283        let mut der = Vec::new();
284
285        let alg_id = Self::encode_sequence_owned({
286            let mut buf = Vec::new();
287            // OID 1.2.840.113549.1.1.1 (rsaEncryption), NULL parameters.
288            buf.extend_from_slice(&[
289                0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B,
290            ]);
291            buf.extend_from_slice(&[0x05, 0x00]);
292            buf
293        });
294
295        let rsa_key = Self::encode_sequence_owned({
296            let mut buf = Vec::new();
297            Self::encode_integer(&mut buf, self.n);
298            Self::encode_integer(&mut buf, self.e);
299            buf
300        });
301
302        der.extend_from_slice(&alg_id);
303        Self::encode_bit_string(&mut der, &rsa_key);
304        Self::encode_sequence_in_place(&mut der);
305
306        Ok(der)
307    }
308
309    /// DER-encode an INTEGER from a big-endian byte string.
310    fn encode_integer(buf: &mut Vec<u8>, value: &[u8]) {
311        // Strip leading zero octets, but keep at least one byte (a
312        // all-zero value encodes as a single 0x00 octet).
313        let start = value
314            .iter()
315            .position(|&b| b != 0)
316            .unwrap_or(value.len().saturating_sub(1));
317        let v = value.get(start..).unwrap_or(&[]);
318        if v.first().is_some_and(|&b| b & 0x80 != 0) {
319            buf.push(0x02);
320            buf.push((v.len() + 1) as u8);
321            buf.push(0x00);
322            buf.extend_from_slice(v);
323        } else {
324            buf.push(0x02);
325            buf.push(v.len() as u8);
326            buf.extend_from_slice(v);
327        }
328    }
329
330    /// DER-encode a BIT STRING wrapping `content` (unused-bits byte = 0).
331    fn encode_bit_string(buf: &mut Vec<u8>, content: &[u8]) {
332        let len = content.len() + 1;
333        buf.push(0x03);
334        Self::encode_length(buf, len);
335        buf.push(0x00);
336        buf.extend_from_slice(content);
337    }
338
339    /// DER-encode a SEQUENCE header + content into a fresh vector.
340    fn encode_sequence_owned(content: Vec<u8>) -> Vec<u8> {
341        let mut result = Vec::with_capacity(2 + content.len());
342        result.push(0x30);
343        Self::encode_length(&mut result, content.len());
344        result.extend_from_slice(&content);
345        result
346    }
347
348    /// Wrap the existing buffer content in a SEQUENCE by shifting right.
349    fn encode_sequence_in_place(buf: &mut Vec<u8>) {
350        let content_len = buf.len();
351        let header_len = if content_len < 0x80 {
352            2
353        } else if content_len < 0x100 {
354            3
355        } else {
356            4
357        };
358        buf.resize(content_len + header_len, 0);
359        buf.copy_within(..content_len, header_len);
360        if let Some(tag) = buf.first_mut() {
361            *tag = 0x30;
362        }
363        Self::encode_length_at(buf, 1, content_len);
364    }
365
366    /// DER-encode a length octet string at the end of `buf`.
367    fn encode_length(buf: &mut Vec<u8>, len: usize) {
368        if len < 0x80 {
369            buf.push(len as u8);
370        } else if len < 0x100 {
371            buf.push(0x81);
372            buf.push(len as u8);
373        } else {
374            buf.push(0x82);
375            buf.push((len >> 8) as u8);
376            buf.push((len & 0xFF) as u8);
377        }
378    }
379
380    /// DER-encode a length octet string at `offset` in `buf`.
381    ///
382    /// INVARIANT: the caller guarantees `buf.len() >= offset + needed`,
383    /// where `needed` is 1, 2, or 3 depending on `len` (callers size the
384    /// buffer via the matching `header_len` computation before calling).
385    /// The direct indexing below is therefore in-bounds by construction.
386    #[allow(clippy::indexing_slicing)]
387    fn encode_length_at(buf: &mut [u8], offset: usize, len: usize) {
388        if len < 0x80 {
389            buf[offset] = len as u8;
390        } else if len < 0x100 {
391            buf[offset] = 0x81;
392            buf[offset + 1] = len as u8;
393        } else {
394            buf[offset] = 0x82;
395            buf[offset + 1] = (len >> 8) as u8;
396            buf[offset + 2] = (len & 0xFF) as u8;
397        }
398    }
399}
400
401/// Convert a COSE algorithm ID to a human-readable name.
402pub fn alg_to_name(alg: i32) -> &'static str {
403    match alg {
404        COSE_ALG_ES256 => "ES256",
405        COSE_ALG_RS256 => "RS256",
406        _ => "unknown",
407    }
408}
409
410/// Generate 32 random bytes (256 bits of entropy) for a `WebAuthn` challenge.
411///
412/// Uses the operating system CSPRNG via `ring::rand::SystemRandom`.
413///
414/// # Security notes
415///
416/// Challenges MUST come from a cryptographically secure source. If the OS
417/// entropy source fails, this function panics rather than emit predictable
418/// challenges (a failed CSPRNG is unrecoverable for security purposes).
419///
420/// # Requirements
421/// REQ-WA-110
422pub fn generate_challenge_bytes() -> Vec<u8> {
423    use ring::rand::SecureRandom;
424    let mut bytes = [0u8; 32];
425    // INVARIANT: a failed OS CSPRNG is unrecoverable for security purposes
426    // (documented above) — panicking is the deliberate, correct behavior.
427    #[allow(clippy::expect_used)]
428    ring::rand::SystemRandom::new()
429        .fill(&mut bytes)
430        .expect("OS CSPRNG unavailable; cannot generate secure challenge");
431    bytes.to_vec()
432}
433
434/// Base64url encode (RFC 4648 §5, no padding), as required by the `WebAuthn`
435/// JSON serialization conventions.
436pub fn base64_encode_urlsafe(data: &[u8]) -> String {
437    use base64::Engine;
438    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
439}
440
441/// Base64url decode (RFC 4648 §5, no padding required; standard alphabet with
442/// padding is also accepted by the engine's forgiving handling).
443///
444/// Returns [`WebauthnError::VerificationFailed`] on malformed input.
445///
446/// # Requirements
447/// REQ-WA-004, REQ-WA-118
448pub fn base64_decode_urlsafe(data: &str) -> Result<Vec<u8>, WebauthnError> {
449    use base64::Engine;
450    base64::engine::general_purpose::URL_SAFE_NO_PAD
451        .decode(data)
452        .map_err(|e| WebauthnError::VerificationFailed(format!("base64 decode error: {e}")))
453}
454
455// Tests exercise failure paths and invariants directly; unwrap/expect,
456// slicing, and panicking asserts are acceptable here — violations
457// surface as test failures, not production panics.
458#[allow(
459    clippy::unwrap_used,
460    clippy::expect_used,
461    clippy::indexing_slicing,
462    clippy::panic
463)]
464#[cfg(test)]
465pub(crate) mod tests {
466    use super::*;
467
468    /// Build a COSE EC2/P-256 key map around the given coordinates.
469    pub(crate) fn build_cose_ec2_key(x: &[u8], y: &[u8]) -> Vec<u8> {
470        use ciborium::Value;
471        let map = vec![
472            (Value::Integer(1.into()), Value::Integer(2.into())),
473            (Value::Integer(2.into()), Value::Integer((-7).into())),
474            (Value::Integer((-1).into()), Value::Integer(1.into())),
475            (Value::Integer((-2).into()), Value::Bytes(x.to_vec())),
476            (Value::Integer((-3).into()), Value::Bytes(y.to_vec())),
477        ];
478        let mut buf = Vec::new();
479        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
480        buf
481    }
482
483    /// Build a COSE RSA key map around the given modulus/exponent.
484    pub(crate) fn build_cose_rsa_key(n: &[u8], e: &[u8]) -> Vec<u8> {
485        use ciborium::Value;
486        let map = vec![
487            (Value::Integer(1.into()), Value::Integer(3.into())),
488            (Value::Integer(2.into()), Value::Integer((-257).into())),
489            (Value::Integer((-1).into()), Value::Bytes(n.to_vec())),
490            (Value::Integer((-2).into()), Value::Bytes(e.to_vec())),
491        ];
492        let mut buf = Vec::new();
493        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
494        buf
495    }
496
497    #[test]
498    fn test_parse_cose_ec2_key() {
499        let x = vec![0xAA; 32];
500        let y = vec![0xBB; 32];
501        let cose_key = build_cose_ec2_key(&x, &y);
502
503        let (alg, key) = parse_cose_key(&cose_key).unwrap();
504        assert_eq!(alg, -7);
505        match key {
506            CosePublicKey::Ec2 { x: kx, y: ky } => {
507                assert_eq!(kx, x);
508                assert_eq!(ky, y);
509            }
510            _ => panic!("Expected EC2 key"),
511        }
512    }
513
514    #[test]
515    fn test_parse_cose_rsa_key() {
516        let n = vec![0xAA; 256];
517        let e = vec![0x01, 0x00, 0x01];
518        let cose_key = build_cose_rsa_key(&n, &e);
519
520        let (alg, key) = parse_cose_key(&cose_key).unwrap();
521        assert_eq!(alg, -257);
522        match key {
523            CosePublicKey::Rsa { n: kn, e: ke } => {
524                assert_eq!(kn, n);
525                assert_eq!(ke, e);
526            }
527            _ => panic!("Expected RSA key"),
528        }
529    }
530
531    #[test]
532    fn test_parse_cose_key_not_a_map() {
533        let buf = [0x00u8]; // CBOR integer 0
534        let result = parse_cose_key(&buf);
535        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
536    }
537
538    #[test]
539    fn test_parse_cose_key_missing_kty() {
540        use ciborium::Value;
541        let map = vec![(Value::Integer(2.into()), Value::Integer((-7).into()))];
542        let mut buf = Vec::new();
543        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
544        let result = parse_cose_key(&buf);
545        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
546    }
547
548    #[test]
549    fn test_parse_cose_key_okp_unsupported() {
550        use ciborium::Value;
551        let map = vec![
552            (Value::Integer(1.into()), Value::Integer(1.into())),
553            (Value::Integer(2.into()), Value::Integer((-8).into())),
554        ];
555        let mut buf = Vec::new();
556        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
557        let result = parse_cose_key(&buf);
558        assert!(matches!(
559            result,
560            Err(WebauthnError::UnsupportedAlgorithm(-8))
561        ));
562    }
563
564    #[test]
565    fn test_verify_cose_signature_es256() {
566        use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P256_SHA256_FIXED_SIGNING};
567
568        let rng = ring::rand::SystemRandom::new();
569        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
570        let key_pair =
571            EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
572                .unwrap();
573
574        let public_key_bytes = key_pair.public_key().as_ref().to_vec();
575        let x = public_key_bytes[1..33].to_vec();
576        let y = public_key_bytes[33..65].to_vec();
577
578        let cose_key = CosePublicKey::Ec2 { x, y };
579        let message = b"test message for WebAuthn";
580        let signature = key_pair.sign(&rng, message).unwrap();
581
582        let result = verify_cose_signature(COSE_ALG_ES256, &cose_key, message, signature.as_ref());
583        assert!(result.is_ok());
584
585        let wrong_result = verify_cose_signature(
586            COSE_ALG_ES256,
587            &cose_key,
588            b"wrong message",
589            signature.as_ref(),
590        );
591        assert!(matches!(
592            wrong_result,
593            Err(WebauthnError::SignatureVerificationFailed)
594        ));
595    }
596
597    #[test]
598    fn test_verify_cose_signature_alg_key_type_mismatch() {
599        // RSA key material presented with alg = ES256 must be rejected.
600        let n = vec![0xAA; 256];
601        let e = vec![0x01, 0x00, 0x01];
602        let cose_key = build_cose_rsa_key(&n, &e);
603        let (alg, key) = parse_cose_key(&cose_key).unwrap();
604        assert_eq!(alg, -257);
605
606        let mismatch = verify_cose_signature(COSE_ALG_ES256, &key, b"data", &[0u8; 64]);
607        assert!(matches!(
608            mismatch,
609            Err(WebauthnError::VerificationFailed(_))
610        ));
611    }
612
613    #[test]
614    fn test_verify_cose_signature_rs256() {
615        let n = vec![
616            0xC0, 0xE9, 0x5A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
617            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
618            0x00, 0x00, 0x00, 0x00,
619        ];
620        let e = vec![0x01, 0x00, 0x01];
621        let cose_key = build_cose_rsa_key(&n, &e);
622
623        let (alg, key) = parse_cose_key(&cose_key).unwrap();
624        assert_eq!(alg, -257);
625        match key {
626            CosePublicKey::Rsa { n: kn, e: ke } => {
627                assert_eq!(kn, n);
628                assert_eq!(ke, e);
629            }
630            _ => panic!("Expected RSA key"),
631        }
632
633        let rsa_key = RsaPublicKeyDer { n: &n, e: &e };
634        let der = rsa_key.to_der().unwrap();
635        assert_eq!(der[0], 0x30);
636        assert!(der.len() > 40);
637    }
638
639    #[test]
640    fn test_generate_challenge_bytes_length() {
641        let bytes = generate_challenge_bytes();
642        assert_eq!(bytes.len(), 32);
643    }
644
645    #[test]
646    fn test_generate_challenge_bytes_not_all_zero() {
647        // 256 zero bits from a CSPRNG is ~2^-256; treat as failure.
648        let bytes = generate_challenge_bytes();
649        assert!(bytes.iter().any(|&b| b != 0));
650    }
651
652    #[test]
653    fn test_base64_roundtrip() {
654        let original = b"hello world";
655        let encoded = base64_encode_urlsafe(original);
656        let decoded = base64_decode_urlsafe(&encoded).unwrap();
657        assert_eq!(decoded, original);
658    }
659
660    #[test]
661    fn test_base64_decode_urlsafe_invalid() {
662        let result = base64_decode_urlsafe("NOT_VALID_BASE64!!!");
663        assert!(result.is_err());
664    }
665
666    #[test]
667    fn test_base64_encode_decode_empty() {
668        let encoded = base64_encode_urlsafe(b"");
669        let decoded = base64_decode_urlsafe(&encoded).unwrap();
670        assert!(decoded.is_empty());
671    }
672
673    #[test]
674    fn test_cbor_bytes_from_text_is_lenient() {
675        use ciborium::Value;
676        let v = Value::Text("abc".to_string());
677        assert_eq!(cbor_bytes(&v), Some(b"abc".to_vec()));
678        let v = Value::Null;
679        assert_eq!(cbor_bytes(&v), None);
680    }
681
682    /// REQ-WA-202: DER INTEGER encoding must handle the all-zero value
683    /// (single 0x00 octet), strip leading zeros, and prepend 0x00 for
684    /// high-bit-set values — all without panicking.
685    #[test]
686    fn der_integer_encoding_edge_cases() {
687        let mut buf = Vec::new();
688        RsaPublicKeyDer::encode_integer(&mut buf, &[0u8; 8]);
689        assert_eq!(buf, vec![0x02, 0x01, 0x00]);
690
691        buf.clear();
692        RsaPublicKeyDer::encode_integer(&mut buf, &[0x80, 0x00]);
693        assert_eq!(buf, vec![0x02, 0x03, 0x00, 0x80, 0x00]);
694
695        buf.clear();
696        RsaPublicKeyDer::encode_integer(&mut buf, &[0x00, 0x00, 0x7F]);
697        assert_eq!(buf, vec![0x02, 0x01, 0x7F]);
698
699        buf.clear();
700        // Empty modulus degenerates to a zero-length INTEGER (malformed DER);
701        // the encoder stays total and `ring` rejects the key at verify time.
702        RsaPublicKeyDer::encode_integer(&mut buf, &[]);
703        assert_eq!(buf, vec![0x02, 0x00]);
704    }
705}