webauthn_rp

WebAuthn RP library.
git clone https://git.philomathiclife.com/repos/webauthn_rp
Log | Files | Refs | README

tests.rs (18981B)


      1 use super::{
      2     super::{
      3         super::{
      4             AggErr,
      5             request::{AsciiDomain, RpId},
      6         },
      7         auth::{AuthenticatorData, NonDiscoverableAuthenticatorAssertion},
      8     },
      9     AttestationFormat, AttestationObject, AuthDataContainer as _, AuthExtOutput as _,
     10     AuthTransports, AuthenticatorAttestation, AuthenticatorExtensionOutput,
     11     AuthenticatorExtensionOutputErr, Backup, CborSuccess, CredentialProtectionPolicy,
     12     FourToSixtyThree, FromCbor as _, HmacSecret, Sig, UncompressedPubKey,
     13     cbor::{
     14         BYTES, BYTES_INFO_24, MAP_1, MAP_2, MAP_3, MAP_4, SIMPLE_FALSE, SIMPLE_TRUE, TEXT_11,
     15         TEXT_12, TEXT_14,
     16     },
     17 };
     18 use ed25519_dalek::Verifier as _;
     19 use p256::ecdsa::{DerSignature as P256Sig, SigningKey as P256Key};
     20 use rsa::sha2::{Digest as _, Sha256};
     21 #[expect(clippy::panic, reason = "OK in tests")]
     22 #[expect(
     23     clippy::arithmetic_side_effects,
     24     clippy::indexing_slicing,
     25     clippy::missing_asserts_for_indexing,
     26     reason = "comments justifies correctness"
     27 )]
     28 fn hex_decode<const N: usize>(input: &[u8; N]) -> Vec<u8> {
     29     /// Value to subtract from a lowercase hex digit.
     30     const LOWER_OFFSET: u8 = b'a' - 10;
     31     assert_eq!(
     32         N & 1,
     33         0,
     34         "hex_decode must be passed a reference to an array of even length"
     35     );
     36     let mut data = Vec::with_capacity(N >> 1);
     37     input.chunks_exact(2).fold((), |(), byte| {
     38         // `byte.len() == 2`.
     39         let mut hex = byte[0];
     40         let val = match hex {
     41             // `Won't underflow`.
     42             b'0'..=b'9' => hex - b'0',
     43             // `Won't underflow`.
     44             b'a'..=b'f' => hex - LOWER_OFFSET,
     45             _ => panic!("hex_decode must be passed a valid lowercase hexadecimal array"),
     46         } << 4u8;
     47         // `byte.len() == 2`.
     48         hex = byte[1];
     49         data.push(
     50             val | match hex {
     51                 // `Won't underflow`.
     52                 b'0'..=b'9' => hex - b'0',
     53                 // `Won't underflow`.
     54                 b'a'..=b'f' => hex - LOWER_OFFSET,
     55                 _ => panic!("hex_decode must be passed a valid lowercase hexadecimal array"),
     56             },
     57         );
     58     });
     59     data
     60 }
     61 /// <https://pr-preview.s3.amazonaws.com/w3c/webauthn/pull/2209.html#sctn-test-vectors-none-es256>
     62 #[expect(
     63     clippy::panic_in_result_fn,
     64     clippy::unwrap_in_result,
     65     clippy::unwrap_used,
     66     reason = "OK in tests"
     67 )]
     68 #[test]
     69 fn es256_test_vector() -> Result<(), AggErr> {
     70     let rp_id = RpId::Domain(AsciiDomain::try_from("example.org".to_owned())?);
     71     let credential_private_key =
     72         hex_decode(b"6e68e7a58484a3264f66b77f5d6dc5bc36a47085b615c9727ab334e8c369c2ee");
     73     let aaguid = hex_decode(b"8446ccb9ab1db374750b2367ff6f3a1f");
     74     let credential_id =
     75         hex_decode(b"f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4");
     76     let client_data_json = hex_decode(b"7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a22414d4d507434557878475453746e63647134313759447742466938767049612d7077386f4f755657345441222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a20426b5165446a646354427258426941774a544c4535513d3d227d");
     77     let attestation_object = hex_decode(b"a363666d74646e6f6e656761747453746d74a068617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b559000000008446ccb9ab1db374750b2367ff6f3a1f0020f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4a5010203262001215820afefa16f97ca9b2d23eb86ccb64098d20db90856062eb249c33a9b672f26df61225820930a56b87a2fca66334b03458abf879717c12cc68ed73290af2e2664796b9220");
     78     let key = *P256Key::from_slice(credential_private_key.as_slice())
     79         .unwrap()
     80         .verifying_key();
     81     let enc_key = key.to_sec1_point(false);
     82     let auth_attest =
     83         AuthenticatorAttestation::new(client_data_json, attestation_object, AuthTransports(0));
     84     let att_obj =
     85         AttestationObject::from_data(auth_attest.attestation_object_and_c_data_hash.as_slice())?;
     86     assert_eq!(
     87         aaguid,
     88         att_obj.data.auth_data.attested_credential_data.aaguid.0
     89     );
     90     assert_eq!(
     91         credential_id,
     92         att_obj
     93             .data
     94             .auth_data
     95             .attested_credential_data
     96             .credential_id
     97             .0
     98     );
     99     assert!(
    100         matches!(att_obj.data.auth_data.attested_credential_data.credential_public_key, UncompressedPubKey::P256(pub_key) if **enc_key.x().unwrap() == *pub_key.0 && **enc_key.y().unwrap() == *pub_key.1)
    101     );
    102     assert_eq!(
    103         *att_obj.data.auth_data.rp_id_hash,
    104         *Sha256::digest(rp_id.as_ref())
    105     );
    106     assert!(att_obj.data.auth_data.flags.user_present);
    107     assert!(matches!(att_obj.data.attestation, AttestationFormat::None));
    108     let authenticator_data =
    109         hex_decode(b"bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b51900000000");
    110     let client_data_json_2 = hex_decode(b"7b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a224f63446e55685158756c5455506f334a5558543049393770767a7a59425039745a63685879617630314167222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73657d");
    111     let signature = hex_decode(b"3046022100f50a4e2e4409249c4a853ba361282f09841df4dd4547a13a87780218deffcd380221008480ac0f0b93538174f575bf11a1dd5d78c6e486013f937295ea13653e331e87");
    112     let auth_assertion = NonDiscoverableAuthenticatorAssertion::<1>::without_user(
    113         client_data_json_2,
    114         authenticator_data,
    115         signature,
    116     );
    117     let auth_data = AuthenticatorData::try_from(auth_assertion.authenticator_data())?;
    118     assert_eq!(*auth_data.rp_id_hash(), *Sha256::digest(rp_id.as_ref()));
    119     assert!(auth_data.flags().user_present);
    120     assert!(match att_obj.data.auth_data.flags.backup {
    121         Backup::NotEligible => matches!(auth_data.flags().backup, Backup::NotEligible),
    122         Backup::Eligible => !matches!(auth_data.flags().backup, Backup::NotEligible),
    123         Backup::Exists => matches!(auth_data.flags().backup, Backup::Exists),
    124     });
    125     let sig = P256Sig::from_bytes(auth_assertion.signature()).unwrap();
    126     let mut msg = auth_assertion.authenticator_data().to_owned();
    127     msg.extend_from_slice(&Sha256::digest(auth_assertion.client_data_json()));
    128     key.verify(msg.as_slice(), &sig).unwrap();
    129     Ok(())
    130 }
    131 /// <https://pr-preview.s3.amazonaws.com/w3c/webauthn/pull/2209.html#sctn-test-vectors-packed-self-es256>
    132 #[expect(
    133     clippy::panic_in_result_fn,
    134     clippy::unwrap_in_result,
    135     clippy::unwrap_used,
    136     reason = "OK in tests"
    137 )]
    138 #[expect(clippy::indexing_slicing, reason = "comment justifies correctness")]
    139 #[test]
    140 fn es256_self_attest_test_vector() -> Result<(), AggErr> {
    141     let rp_id = RpId::Domain(AsciiDomain::try_from("example.org".to_owned())?);
    142     let credential_private_key =
    143         hex_decode(b"b4bbfa5d68e1693b6ef5a19a0e60ef7ee2cbcac81f7fec7006ac3a21e0c5116a");
    144     let aaguid = hex_decode(b"df850e09db6afbdfab51697791506cfc");
    145     let credential_id =
    146         hex_decode(b"455ef34e2043a87db3d4afeb39bbcb6cc32df9347c789a865ecdca129cbef58c");
    147     let client_data_json = hex_decode(b"7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a2265476e4374334c55745936366b336a506a796e6962506b31716e666644616966715a774c33417032392d55222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a205539685458764b453255526b4d6e625f3078594856673d3d227d");
    148     let attestation_object = hex_decode(b"a363666d74667061636b65646761747453746d74a263616c67266373696758483046022100ae045923ded832b844cae4d5fc864277c0dc114ad713e271af0f0d371bd3ac540221009077a088ed51a673951ad3ba2673d5029bab65b64f4ea67b234321f86fcfac5d68617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b55d00000000df850e09db6afbdfab51697791506cfc0020455ef34e2043a87db3d4afeb39bbcb6cc32df9347c789a865ecdca129cbef58ca5010203262001215820eb151c8176b225cc651559fecf07af450fd85802046656b34c18f6cf193843c5225820927b8aa427a2be1b8834d233a2d34f61f13bfd44119c325d5896e183fee484f2");
    149     let key = *P256Key::from_slice(credential_private_key.as_slice())
    150         .unwrap()
    151         .verifying_key();
    152     let enc_key = key.to_sec1_point(false);
    153     let auth_attest =
    154         AuthenticatorAttestation::new(client_data_json, attestation_object, AuthTransports(0));
    155     let (att_obj, auth_idx) = AttestationObject::parse_data(auth_attest.attestation_object())?;
    156     assert_eq!(aaguid, att_obj.auth_data.attested_credential_data.aaguid.0);
    157     assert_eq!(
    158         credential_id,
    159         att_obj.auth_data.attested_credential_data.credential_id.0
    160     );
    161     assert!(
    162         matches!(att_obj.auth_data.attested_credential_data.credential_public_key, UncompressedPubKey::P256(pub_key) if **enc_key.x().unwrap() == *pub_key.0 && **enc_key.y().unwrap() == *pub_key.1)
    163     );
    164     assert_eq!(
    165         *att_obj.auth_data.rp_id_hash,
    166         *Sha256::digest(rp_id.as_ref())
    167     );
    168     assert!(att_obj.auth_data.flags.user_present);
    169     assert!(match att_obj.attestation {
    170         AttestationFormat::None => false,
    171         AttestationFormat::Packed(attest) => {
    172             match attest.signature {
    173                 Sig::MlDsa87(_)
    174                 | Sig::MlDsa65(_)
    175                 | Sig::MlDsa44(_)
    176                 | Sig::Ed25519(_)
    177                 | Sig::P384(_)
    178                 | Sig::Rs256(_) => false,
    179                 Sig::P256(sig) => {
    180                     let s = P256Sig::from_bytes(sig).unwrap();
    181                     key.verify(
    182                         // Won't `panic` since `auth_idx` is returned from `AttestationObject::parse_data`.
    183                         &auth_attest.attestation_object_and_c_data_hash[auth_idx..],
    184                         &s,
    185                     )
    186                     .is_ok()
    187                 }
    188             }
    189         }
    190     });
    191     let authenticator_data =
    192         hex_decode(b"bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b50900000000");
    193     let client_data_json_2 = hex_decode(b"7b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a225248696843784e534e493352594d45314f7731476d3132786e726b634a5f6666707637546e2d4a71386773222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a206754623533727a36456853576f6d58477a696d4331513d3d227d");
    194     let signature = hex_decode(b"3044022076691be76a8618976d9803c4cdc9b97d34a7af37e3bdc894a2bf54f040ffae850220448033a015296ffb09a762efd0d719a55346941e17e91ebf64c60d439d0b9744");
    195     let auth_assertion = NonDiscoverableAuthenticatorAssertion::<1>::without_user(
    196         client_data_json_2,
    197         authenticator_data,
    198         signature,
    199     );
    200     let auth_data = AuthenticatorData::try_from(auth_assertion.authenticator_data())?;
    201     assert_eq!(*auth_data.rp_id_hash(), *Sha256::digest(rp_id.as_ref()));
    202     assert!(auth_data.flags().user_present);
    203     assert!(match att_obj.auth_data.flags.backup {
    204         Backup::NotEligible => matches!(auth_data.flags().backup, Backup::NotEligible),
    205         Backup::Eligible | Backup::Exists =>
    206             !matches!(auth_data.flags().backup, Backup::NotEligible),
    207     });
    208     let sig = P256Sig::from_bytes(auth_assertion.signature()).unwrap();
    209     let mut msg = auth_assertion.authenticator_data().to_owned();
    210     msg.extend_from_slice(&Sha256::digest(auth_assertion.client_data_json()));
    211     key.verify(msg.as_slice(), &sig).unwrap();
    212     Ok(())
    213 }
    214 struct AuthExtOptions<'a> {
    215     cred_protect: Option<u8>,
    216     hmac_secret: Option<bool>,
    217     min_pin_length: Option<u8>,
    218     hmac_secret_mc: Option<&'a [u8]>,
    219 }
    220 #[expect(
    221     clippy::panic,
    222     clippy::unreachable,
    223     reason = "want to crash when there is a bug"
    224 )]
    225 #[expect(
    226     clippy::arithmetic_side_effects,
    227     clippy::as_conversions,
    228     clippy::cast_possible_truncation,
    229     reason = "comments justify correctness"
    230 )]
    231 fn generate_auth_extensions(opts: &AuthExtOptions<'_>) -> Vec<u8> {
    232     // Maxes at 4, so addition is clearly free from overflow.
    233     let map_len = u8::from(opts.cred_protect.is_some())
    234         + u8::from(opts.hmac_secret.is_some())
    235         + u8::from(opts.min_pin_length.is_some())
    236         + u8::from(opts.hmac_secret_mc.is_some());
    237     let header = match map_len {
    238         0 => return Vec::new(),
    239         1 => MAP_1,
    240         2 => MAP_2,
    241         3 => MAP_3,
    242         4 => MAP_4,
    243         _ => unreachable!("bug"),
    244     };
    245     let mut cbor = Vec::with_capacity(128);
    246     cbor.push(header);
    247     if let Some(protect) = opts.cred_protect {
    248         cbor.push(TEXT_11);
    249         cbor.extend_from_slice(b"credProtect".as_slice());
    250         if protect >= 24 {
    251             cbor.push(24);
    252         }
    253         cbor.push(protect);
    254     }
    255     if let Some(hmac) = opts.hmac_secret {
    256         cbor.push(TEXT_11);
    257         cbor.extend_from_slice(b"hmac-secret".as_slice());
    258         cbor.push(if hmac { SIMPLE_TRUE } else { SIMPLE_FALSE });
    259     }
    260     if let Some(pin) = opts.min_pin_length {
    261         cbor.push(TEXT_12);
    262         cbor.extend_from_slice(b"minPinLength".as_slice());
    263         if pin >= 24 {
    264             cbor.push(24);
    265         }
    266         cbor.push(pin);
    267     }
    268     if let Some(mc) = opts.hmac_secret_mc {
    269         cbor.push(TEXT_14);
    270         cbor.extend_from_slice(b"hmac-secret-mc".as_slice());
    271         match mc.len() {
    272             len @ ..=23 => {
    273                 // `as` is clearly OK.
    274                 cbor.push(BYTES | len as u8);
    275             }
    276             len @ 24..=255 => {
    277                 cbor.push(BYTES_INFO_24);
    278                 // `as` is clearly OK.
    279                 cbor.push(len as u8);
    280             }
    281             _ => panic!(
    282                 "AuthExtOptions does not allow hmac_secret_mc to have length greater than 255"
    283             ),
    284         }
    285         cbor.extend_from_slice(mc);
    286     }
    287     cbor
    288 }
    289 #[expect(clippy::panic_in_result_fn, reason = "not a problem for a test")]
    290 #[expect(clippy::shadow_unrelated, reason = "struct destructuring is prefered")]
    291 #[expect(clippy::too_many_lines, reason = "a lot to test")]
    292 #[test]
    293 fn auth_ext() -> Result<(), AuthenticatorExtensionOutputErr> {
    294     let mut opts = generate_auth_extensions(&AuthExtOptions {
    295         cred_protect: None,
    296         hmac_secret: None,
    297         min_pin_length: None,
    298         hmac_secret_mc: None,
    299     });
    300     let CborSuccess { value, remaining } =
    301         AuthenticatorExtensionOutput::from_cbor(opts.as_slice())?;
    302     assert!(remaining.is_empty());
    303     assert!(value.missing());
    304     opts = generate_auth_extensions(&AuthExtOptions {
    305         cred_protect: None,
    306         hmac_secret: None,
    307         min_pin_length: None,
    308         hmac_secret_mc: Some([0; 48].as_slice()),
    309     });
    310     assert!(
    311         AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else(
    312             |e| matches!(e, AuthenticatorExtensionOutputErr::Missing),
    313             |_| false,
    314         )
    315     );
    316     opts = generate_auth_extensions(&AuthExtOptions {
    317         cred_protect: None,
    318         hmac_secret: Some(true),
    319         min_pin_length: None,
    320         hmac_secret_mc: Some([0; 48].as_slice()),
    321     });
    322     let CborSuccess { value, remaining } =
    323         AuthenticatorExtensionOutput::from_cbor(opts.as_slice())?;
    324     assert!(remaining.is_empty());
    325     assert!(
    326         matches!(value.cred_protect, CredentialProtectionPolicy::None)
    327             && matches!(value.hmac_secret, HmacSecret::One)
    328             && value.min_pin_length.is_none()
    329     );
    330     opts = generate_auth_extensions(&AuthExtOptions {
    331         cred_protect: None,
    332         hmac_secret: Some(false),
    333         min_pin_length: None,
    334         hmac_secret_mc: Some([0; 48].as_slice()),
    335     });
    336     assert!(
    337         AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else(
    338             |e| matches!(e, AuthenticatorExtensionOutputErr::Missing),
    339             |_| false,
    340         )
    341     );
    342     opts = generate_auth_extensions(&AuthExtOptions {
    343         cred_protect: None,
    344         hmac_secret: Some(true),
    345         min_pin_length: None,
    346         hmac_secret_mc: Some([0; 49].as_slice()),
    347     });
    348     assert!(
    349         AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else(
    350             |e| matches!(e, AuthenticatorExtensionOutputErr::HmacSecretMcValue),
    351             |_| false,
    352         )
    353     );
    354     opts = generate_auth_extensions(&AuthExtOptions {
    355         cred_protect: None,
    356         hmac_secret: Some(true),
    357         min_pin_length: None,
    358         hmac_secret_mc: Some([0; 23].as_slice()),
    359     });
    360     assert!(
    361         AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else(
    362             |e| matches!(e, AuthenticatorExtensionOutputErr::HmacSecretMcType),
    363             |_| false,
    364         )
    365     );
    366     opts = generate_auth_extensions(&AuthExtOptions {
    367         cred_protect: Some(1),
    368         hmac_secret: Some(true),
    369         min_pin_length: Some(5),
    370         hmac_secret_mc: Some([0; 48].as_slice()),
    371     });
    372     let CborSuccess { value, remaining } =
    373         AuthenticatorExtensionOutput::from_cbor(opts.as_slice())?;
    374     assert!(remaining.is_empty());
    375     assert!(
    376         matches!(
    377             value.cred_protect,
    378             CredentialProtectionPolicy::UserVerificationOptional
    379         ) && matches!(value.hmac_secret, HmacSecret::One)
    380             && value
    381                 .min_pin_length
    382                 .is_some_and(|pin| pin == FourToSixtyThree::Five)
    383     );
    384     opts = generate_auth_extensions(&AuthExtOptions {
    385         cred_protect: Some(0),
    386         hmac_secret: None,
    387         min_pin_length: None,
    388         hmac_secret_mc: None,
    389     });
    390     assert!(
    391         AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else(
    392             |e| matches!(e, AuthenticatorExtensionOutputErr::CredProtectValue),
    393             |_| false,
    394         )
    395     );
    396     opts = generate_auth_extensions(&AuthExtOptions {
    397         cred_protect: None,
    398         hmac_secret: None,
    399         min_pin_length: Some(3),
    400         hmac_secret_mc: None,
    401     });
    402     assert!(
    403         AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else(
    404             |e| matches!(e, AuthenticatorExtensionOutputErr::MinPinLengthValue),
    405             |_| false,
    406         )
    407     );
    408     opts = generate_auth_extensions(&AuthExtOptions {
    409         cred_protect: None,
    410         hmac_secret: None,
    411         min_pin_length: Some(64),
    412         hmac_secret_mc: None,
    413     });
    414     assert!(
    415         AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else(
    416             |e| matches!(e, AuthenticatorExtensionOutputErr::MinPinLengthValue),
    417             |_| false,
    418         )
    419     );
    420     Ok(())
    421 }