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/// Returns the COSE algorithm identifier and the parsed key components.
125pub fn parse_cose_key(cose_bytes: &[u8]) -> Result<(i32, CosePublicKey), WebauthnError> {
126    use ciborium::Value;
127
128    let key_val: Value = ciborium::de::from_reader(cose_bytes).map_err(|e| {
129        WebauthnError::VerificationFailed(format!("COSE key CBOR parse error: {e}"))
130    })?;
131
132    let entries = cbor_map_entries(&key_val).ok_or_else(|| {
133        WebauthnError::VerificationFailed("COSE key is not a CBOR map".to_string())
134    })?;
135
136    let mut kty: Option<i64> = None;
137    let mut alg: Option<i32> = None;
138    let mut crv: Option<i64> = None;
139    let mut x: Option<Vec<u8>> = None;
140    let mut y: Option<Vec<u8>> = None;
141    let mut n: Option<Vec<u8>> = None;
142    let mut e: Option<Vec<u8>> = None;
143
144    for (label, val) in &entries {
145        match *label {
146            COSE_KEY_KTY => kty = cbor_i64(val),
147            COSE_KEY_ALG => alg = cbor_i64(val).map(|v| v as i32),
148            COSE_KEY_CRV_N => match kty {
149                Some(COSE_KTY_RSA) => n = cbor_bytes(val),
150                _ => crv = cbor_i64(val),
151            },
152            COSE_KEY_X_E => match kty {
153                Some(COSE_KTY_RSA) => e = cbor_bytes(val),
154                _ => x = cbor_bytes(val),
155            },
156            COSE_KEY_Y => y = cbor_bytes(val),
157            _ => {}
158        }
159    }
160
161    let kty =
162        kty.ok_or_else(|| WebauthnError::VerificationFailed("COSE key missing 'kty'".to_string()))?;
163    let alg =
164        alg.ok_or_else(|| WebauthnError::VerificationFailed("COSE key missing 'alg'".to_string()))?;
165
166    match kty {
167        COSE_KTY_EC2 => {
168            let crv = crv.ok_or_else(|| {
169                WebauthnError::VerificationFailed("EC2 key missing 'crv'".to_string())
170            })?;
171            if crv != COSE_CRV_P256 {
172                return Err(WebauthnError::UnsupportedAlgorithm(alg));
173            }
174            let x = x.ok_or_else(|| {
175                WebauthnError::VerificationFailed("EC2 key missing 'x'".to_string())
176            })?;
177            let y = y.ok_or_else(|| {
178                WebauthnError::VerificationFailed("EC2 key missing 'y'".to_string())
179            })?;
180            Ok((alg, CosePublicKey::Ec2 { x, y }))
181        }
182        COSE_KTY_RSA => {
183            let n = n.ok_or_else(|| {
184                WebauthnError::VerificationFailed("RSA key missing 'n'".to_string())
185            })?;
186            let e = e.ok_or_else(|| {
187                WebauthnError::VerificationFailed("RSA key missing 'e'".to_string())
188            })?;
189            Ok((alg, CosePublicKey::Rsa { n, e }))
190        }
191        COSE_KTY_OKP => Err(WebauthnError::UnsupportedAlgorithm(alg)),
192        other => Err(WebauthnError::VerificationFailed(format!(
193            "Unsupported COSE key type: {other}"
194        ))),
195    }
196}
197
198/// Verify a COSE signature using the parsed public key.
199///
200/// # Security notes / threat assumptions
201///
202/// - `alg` is matched strictly: `ES256` requires an [`CosePublicKey::Ec2`]
203///   key, `RS256` requires [`CosePublicKey::Rsa`]; any mismatch is an error,
204///   never a fallback.
205/// - `signed_data` and `signature` are attacker-controlled. Verification uses
206///   `ring`'s constant-time implementations
207///   (`ECDSA_P256_SHA256_FIXED`, `RSA_PKCS1_2048_8192_SHA256`).
208/// - RSA keys smaller than 2048 bits are rejected by `ring`'s
209///   `RSA_PKCS1_2048_8192_SHA256` parameters.
210///
211/// Returns `Ok(())` if and only if the signature verifies over `signed_data`.
212pub fn verify_cose_signature(
213    alg: i32,
214    public_key: &CosePublicKey,
215    signed_data: &[u8],
216    signature: &[u8],
217) -> Result<(), WebauthnError> {
218    use ring::signature;
219
220    match alg {
221        COSE_ALG_ES256 => {
222            let CosePublicKey::Ec2 { x, y } = public_key else {
223                return Err(WebauthnError::VerificationFailed(
224                    "EC2 key expected for ES256".to_string(),
225                ));
226            };
227
228            let mut public_key_bytes = Vec::with_capacity(1 + x.len() + y.len());
229            public_key_bytes.push(0x04);
230            public_key_bytes.extend_from_slice(x);
231            public_key_bytes.extend_from_slice(y);
232
233            let public_key = signature::UnparsedPublicKey::new(
234                &signature::ECDSA_P256_SHA256_FIXED,
235                &public_key_bytes,
236            );
237            public_key
238                .verify(signed_data, signature)
239                .map_err(|_| WebauthnError::SignatureVerificationFailed)?;
240            Ok(())
241        }
242        COSE_ALG_RS256 => {
243            let CosePublicKey::Rsa { n, e } = public_key else {
244                return Err(WebauthnError::VerificationFailed(
245                    "RSA key expected for RS256".to_string(),
246                ));
247            };
248
249            let rsa_public_key = RsaPublicKeyDer { n, e };
250            let der_bytes = rsa_public_key.to_der()?;
251
252            let public_key = signature::UnparsedPublicKey::new(
253                &signature::RSA_PKCS1_2048_8192_SHA256,
254                &der_bytes,
255            );
256            public_key
257                .verify(signed_data, signature)
258                .map_err(|_| WebauthnError::SignatureVerificationFailed)?;
259            Ok(())
260        }
261        other => Err(WebauthnError::UnsupportedAlgorithm(other)),
262    }
263}
264
265/// ASN.1 DER (RFC 5280 `SubjectPublicKeyInfo`) encoding helper for RSA public keys.
266struct RsaPublicKeyDer<'a> {
267    n: &'a [u8],
268    e: &'a [u8],
269}
270
271impl RsaPublicKeyDer<'_> {
272    /// Encode modulus/exponent into a DER `SubjectPublicKeyInfo` structure.
273    ///
274    /// The integer encoder strips leading zero bytes and prepends `0x00` when
275    /// the high bit is set, per DER INTEGER rules.
276    fn to_der(&self) -> Result<Vec<u8>, WebauthnError> {
277        let mut der = Vec::new();
278
279        let alg_id = Self::encode_sequence_owned({
280            let mut buf = Vec::new();
281            // OID 1.2.840.113549.1.1.1 (rsaEncryption), NULL parameters.
282            buf.extend_from_slice(&[
283                0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B,
284            ]);
285            buf.extend_from_slice(&[0x05, 0x00]);
286            buf
287        });
288
289        let rsa_key = Self::encode_sequence_owned({
290            let mut buf = Vec::new();
291            Self::encode_integer(&mut buf, self.n);
292            Self::encode_integer(&mut buf, self.e);
293            buf
294        });
295
296        der.extend_from_slice(&alg_id);
297        Self::encode_bit_string(&mut der, &rsa_key);
298        Self::encode_sequence_in_place(&mut der);
299
300        Ok(der)
301    }
302
303    /// DER-encode an INTEGER from a big-endian byte string.
304    fn encode_integer(buf: &mut Vec<u8>, value: &[u8]) {
305        let mut v = value;
306        while v.len() > 1 && v[0] == 0 {
307            v = &v[1..];
308        }
309        if v.first().is_some_and(|&b| b & 0x80 != 0) {
310            buf.push(0x02);
311            buf.push((v.len() + 1) as u8);
312            buf.push(0x00);
313            buf.extend_from_slice(v);
314        } else {
315            buf.push(0x02);
316            buf.push(v.len() as u8);
317            buf.extend_from_slice(v);
318        }
319    }
320
321    /// DER-encode a BIT STRING wrapping `content` (unused-bits byte = 0).
322    fn encode_bit_string(buf: &mut Vec<u8>, content: &[u8]) {
323        let len = content.len() + 1;
324        buf.push(0x03);
325        Self::encode_length(buf, len);
326        buf.push(0x00);
327        buf.extend_from_slice(content);
328    }
329
330    /// DER-encode a SEQUENCE header + content into a fresh vector.
331    fn encode_sequence_owned(content: Vec<u8>) -> Vec<u8> {
332        let mut result = Vec::with_capacity(2 + content.len());
333        result.push(0x30);
334        Self::encode_length(&mut result, content.len());
335        result.extend_from_slice(&content);
336        result
337    }
338
339    /// Wrap the existing buffer content in a SEQUENCE by shifting right.
340    fn encode_sequence_in_place(buf: &mut Vec<u8>) {
341        let content_len = buf.len();
342        let header_len = if content_len < 0x80 {
343            2
344        } else if content_len < 0x100 {
345            3
346        } else {
347            4
348        };
349        buf.resize(content_len + header_len, 0);
350        for i in (0..content_len).rev() {
351            buf[i + header_len] = buf[i];
352        }
353        buf[0] = 0x30;
354        Self::encode_length_at(buf, 1, content_len);
355    }
356
357    /// DER-encode a length octet string at the end of `buf`.
358    fn encode_length(buf: &mut Vec<u8>, len: usize) {
359        if len < 0x80 {
360            buf.push(len as u8);
361        } else if len < 0x100 {
362            buf.push(0x81);
363            buf.push(len as u8);
364        } else {
365            buf.push(0x82);
366            buf.push((len >> 8) as u8);
367            buf.push((len & 0xFF) as u8);
368        }
369    }
370
371    /// DER-encode a length octet string at `offset` in `buf`.
372    fn encode_length_at(buf: &mut [u8], offset: usize, len: usize) {
373        if len < 0x80 {
374            buf[offset] = len as u8;
375        } else if len < 0x100 {
376            buf[offset] = 0x81;
377            buf[offset + 1] = len as u8;
378        } else {
379            buf[offset] = 0x82;
380            buf[offset + 1] = (len >> 8) as u8;
381            buf[offset + 2] = (len & 0xFF) as u8;
382        }
383    }
384}
385
386/// Convert a COSE algorithm ID to a human-readable name.
387pub fn alg_to_name(alg: i32) -> &'static str {
388    match alg {
389        COSE_ALG_ES256 => "ES256",
390        COSE_ALG_RS256 => "RS256",
391        _ => "unknown",
392    }
393}
394
395/// Generate 32 random bytes (256 bits of entropy) for a `WebAuthn` challenge.
396///
397/// Uses the operating system CSPRNG via `ring::rand::SystemRandom`.
398///
399/// # Security notes
400///
401/// Challenges MUST come from a cryptographically secure source. If the OS
402/// entropy source fails, this function panics rather than emit predictable
403/// challenges (a failed CSPRNG is unrecoverable for security purposes).
404pub fn generate_challenge_bytes() -> Vec<u8> {
405    use ring::rand::SecureRandom;
406    let mut bytes = [0u8; 32];
407    ring::rand::SystemRandom::new()
408        .fill(&mut bytes)
409        .expect("OS CSPRNG unavailable; cannot generate secure challenge");
410    bytes.to_vec()
411}
412
413/// Base64url encode (RFC 4648 §5, no padding), as required by the `WebAuthn`
414/// JSON serialization conventions.
415pub fn base64_encode_urlsafe(data: &[u8]) -> String {
416    use base64::Engine;
417    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
418}
419
420/// Base64url decode (RFC 4648 §5, no padding required; standard alphabet with
421/// padding is also accepted by the engine's forgiving handling).
422///
423/// Returns [`WebauthnError::VerificationFailed`] on malformed input.
424pub fn base64_decode_urlsafe(data: &str) -> Result<Vec<u8>, WebauthnError> {
425    use base64::Engine;
426    base64::engine::general_purpose::URL_SAFE_NO_PAD
427        .decode(data)
428        .map_err(|e| WebauthnError::VerificationFailed(format!("base64 decode error: {e}")))
429}
430
431#[cfg(test)]
432pub(crate) mod tests {
433    use super::*;
434
435    /// Build a COSE EC2/P-256 key map around the given coordinates.
436    pub(crate) fn build_cose_ec2_key(x: &[u8], y: &[u8]) -> Vec<u8> {
437        use ciborium::Value;
438        let map = vec![
439            (Value::Integer(1.into()), Value::Integer(2.into())),
440            (Value::Integer(2.into()), Value::Integer((-7).into())),
441            (Value::Integer((-1).into()), Value::Integer(1.into())),
442            (Value::Integer((-2).into()), Value::Bytes(x.to_vec())),
443            (Value::Integer((-3).into()), Value::Bytes(y.to_vec())),
444        ];
445        let mut buf = Vec::new();
446        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
447        buf
448    }
449
450    /// Build a COSE RSA key map around the given modulus/exponent.
451    pub(crate) fn build_cose_rsa_key(n: &[u8], e: &[u8]) -> Vec<u8> {
452        use ciborium::Value;
453        let map = vec![
454            (Value::Integer(1.into()), Value::Integer(3.into())),
455            (Value::Integer(2.into()), Value::Integer((-257).into())),
456            (Value::Integer((-1).into()), Value::Bytes(n.to_vec())),
457            (Value::Integer((-2).into()), Value::Bytes(e.to_vec())),
458        ];
459        let mut buf = Vec::new();
460        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
461        buf
462    }
463
464    #[test]
465    fn test_parse_cose_ec2_key() {
466        let x = vec![0xAA; 32];
467        let y = vec![0xBB; 32];
468        let cose_key = build_cose_ec2_key(&x, &y);
469
470        let (alg, key) = parse_cose_key(&cose_key).unwrap();
471        assert_eq!(alg, -7);
472        match key {
473            CosePublicKey::Ec2 { x: kx, y: ky } => {
474                assert_eq!(kx, x);
475                assert_eq!(ky, y);
476            }
477            _ => panic!("Expected EC2 key"),
478        }
479    }
480
481    #[test]
482    fn test_parse_cose_rsa_key() {
483        let n = vec![0xAA; 256];
484        let e = vec![0x01, 0x00, 0x01];
485        let cose_key = build_cose_rsa_key(&n, &e);
486
487        let (alg, key) = parse_cose_key(&cose_key).unwrap();
488        assert_eq!(alg, -257);
489        match key {
490            CosePublicKey::Rsa { n: kn, e: ke } => {
491                assert_eq!(kn, n);
492                assert_eq!(ke, e);
493            }
494            _ => panic!("Expected RSA key"),
495        }
496    }
497
498    #[test]
499    fn test_parse_cose_key_not_a_map() {
500        let buf = [0x00u8]; // CBOR integer 0
501        let result = parse_cose_key(&buf);
502        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
503    }
504
505    #[test]
506    fn test_parse_cose_key_missing_kty() {
507        use ciborium::Value;
508        let map = vec![(Value::Integer(2.into()), Value::Integer((-7).into()))];
509        let mut buf = Vec::new();
510        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
511        let result = parse_cose_key(&buf);
512        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
513    }
514
515    #[test]
516    fn test_parse_cose_key_okp_unsupported() {
517        use ciborium::Value;
518        let map = vec![
519            (Value::Integer(1.into()), Value::Integer(1.into())),
520            (Value::Integer(2.into()), Value::Integer((-8).into())),
521        ];
522        let mut buf = Vec::new();
523        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
524        let result = parse_cose_key(&buf);
525        assert!(matches!(
526            result,
527            Err(WebauthnError::UnsupportedAlgorithm(-8))
528        ));
529    }
530
531    #[test]
532    fn test_verify_cose_signature_es256() {
533        use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P256_SHA256_FIXED_SIGNING};
534
535        let rng = ring::rand::SystemRandom::new();
536        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
537        let key_pair =
538            EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
539                .unwrap();
540
541        let public_key_bytes = key_pair.public_key().as_ref().to_vec();
542        let x = public_key_bytes[1..33].to_vec();
543        let y = public_key_bytes[33..65].to_vec();
544
545        let cose_key = CosePublicKey::Ec2 { x, y };
546        let message = b"test message for WebAuthn";
547        let signature = key_pair.sign(&rng, message).unwrap();
548
549        let result = verify_cose_signature(COSE_ALG_ES256, &cose_key, message, signature.as_ref());
550        assert!(result.is_ok());
551
552        let wrong_result = verify_cose_signature(
553            COSE_ALG_ES256,
554            &cose_key,
555            b"wrong message",
556            signature.as_ref(),
557        );
558        assert!(matches!(
559            wrong_result,
560            Err(WebauthnError::SignatureVerificationFailed)
561        ));
562    }
563
564    #[test]
565    fn test_verify_cose_signature_alg_key_type_mismatch() {
566        // RSA key material presented with alg = ES256 must be rejected.
567        let n = vec![0xAA; 256];
568        let e = vec![0x01, 0x00, 0x01];
569        let cose_key = build_cose_rsa_key(&n, &e);
570        let (alg, key) = parse_cose_key(&cose_key).unwrap();
571        assert_eq!(alg, -257);
572
573        let mismatch = verify_cose_signature(COSE_ALG_ES256, &key, b"data", &[0u8; 64]);
574        assert!(matches!(
575            mismatch,
576            Err(WebauthnError::VerificationFailed(_))
577        ));
578    }
579
580    #[test]
581    fn test_verify_cose_signature_rs256() {
582        let n = vec![
583            0xC0, 0xE9, 0x5A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
584            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
585            0x00, 0x00, 0x00, 0x00,
586        ];
587        let e = vec![0x01, 0x00, 0x01];
588        let cose_key = build_cose_rsa_key(&n, &e);
589
590        let (alg, key) = parse_cose_key(&cose_key).unwrap();
591        assert_eq!(alg, -257);
592        match key {
593            CosePublicKey::Rsa { n: kn, e: ke } => {
594                assert_eq!(kn, n);
595                assert_eq!(ke, e);
596            }
597            _ => panic!("Expected RSA key"),
598        }
599
600        let rsa_key = RsaPublicKeyDer { n: &n, e: &e };
601        let der = rsa_key.to_der().unwrap();
602        assert_eq!(der[0], 0x30);
603        assert!(der.len() > 40);
604    }
605
606    #[test]
607    fn test_generate_challenge_bytes_length() {
608        let bytes = generate_challenge_bytes();
609        assert_eq!(bytes.len(), 32);
610    }
611
612    #[test]
613    fn test_generate_challenge_bytes_not_all_zero() {
614        // 256 zero bits from a CSPRNG is ~2^-256; treat as failure.
615        let bytes = generate_challenge_bytes();
616        assert!(bytes.iter().any(|&b| b != 0));
617    }
618
619    #[test]
620    fn test_base64_roundtrip() {
621        let original = b"hello world";
622        let encoded = base64_encode_urlsafe(original);
623        let decoded = base64_decode_urlsafe(&encoded).unwrap();
624        assert_eq!(decoded, original);
625    }
626
627    #[test]
628    fn test_base64_decode_urlsafe_invalid() {
629        let result = base64_decode_urlsafe("NOT_VALID_BASE64!!!");
630        assert!(result.is_err());
631    }
632
633    #[test]
634    fn test_base64_encode_decode_empty() {
635        let encoded = base64_encode_urlsafe(b"");
636        let decoded = base64_decode_urlsafe(&encoded).unwrap();
637        assert!(decoded.is_empty());
638    }
639
640    #[test]
641    fn test_cbor_bytes_from_text_is_lenient() {
642        use ciborium::Value;
643        let v = Value::Text("abc".to_string());
644        assert_eq!(cbor_bytes(&v), Some(b"abc".to_vec()));
645        let v = Value::Null;
646        assert_eq!(cbor_bytes(&v), None);
647    }
648}