diff --git a/Cargo.toml b/Cargo.toml index ede3775..75f265c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,8 @@ credential-id-format-v2 = ["trussed-core/aes256-gcm"] # enables support for a large-blob array longer than 1024 bytes chunked = ["dep:trussed-chunked"] +mldsa44 = ["trussed-core/mldsa44", "ctap-types/mldsa44"] + log-all = [] log-none = [] log-trace = [] @@ -54,6 +56,7 @@ cbc = { version = "0.1.2", features = ["alloc"] } ciborium = "0.2.2" ciborium-io = "0.2.2" cipher = "0.4.4" +ctap-types = { version = "=0.6.0-rc.4", features = ["mldsa44"] } ctaphid = { version = "0.3.1", default-features = false } ctaphid-dispatch = "0.4" delog = { version = "0.1.6", features = ["std-log"] } @@ -69,7 +72,7 @@ rand = "0.8.4" rand_chacha = "0.3" sha2 = "0.10" serde_test = "1.0.176" -trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "b2492feada78ee17b936c99c13df480a4fc6d2f5", features = ["virt"] } +trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "b2492feada78ee17b936c99c13df480a4fc6d2f5", features = ["mldsa44", "virt"] } trussed-staging = { git = "https://github.com/trussed-dev/trussed-staging.git", tag = "v0.4.0", features = ["chunked", "hkdf", "virt", "fs-info"] } trussed-usbip = { git = "https://github.com/trussed-dev/pc-usbip-runner.git", rev = "017921df0930707c4af68882ccb1f8b3f1bbf7c5", default-features = false, features = ["ctaphid"] } usbd-ctaphid = "0.4" @@ -79,7 +82,13 @@ x509-parser = "0.16" features = ["chunked", "dispatch"] [patch.crates-io] -trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "b2492feada78ee17b936c99c13df480a4fc6d2f5" } +ctap-types = { git = "https://github.com/0x0ece/ctap-types", rev = "1056c78b06fddc1010afebf549434ac097a6db42" } +trussed = { git = "https://github.com/0x0ece/trussed", rev = "a1b33c46eb16ef7fa5f6324f7c099ff339fe21e7" } +trussed-core = { git = "https://github.com/0x0ece/trussed", rev = "a1b33c46eb16ef7fa5f6324f7c099ff339fe21e7" } + +[patch."https://github.com/trussed-dev/trussed.git"] +trussed = { git = "https://github.com/0x0ece/trussed", rev = "a1b33c46eb16ef7fa5f6324f7c099ff339fe21e7" } +trussed-core = { git = "https://github.com/0x0ece/trussed", rev = "a1b33c46eb16ef7fa5f6324f7c099ff339fe21e7" } [profile.test] opt-level = 2 diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 6cf4efa..e4dc991 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -24,5 +24,7 @@ doc = false bench = false [patch.crates-io] -trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "b2492feada78ee17b936c99c13df480a4fc6d2f5" } +trussed = { git = "https://github.com/0x0ece/trussed", rev = "a1b33c46eb16ef7fa5f6324f7c099ff339fe21e7" } +trussed-core = { git = "https://github.com/0x0ece/trussed", rev = "a1b33c46eb16ef7fa5f6324f7c099ff339fe21e7" } trussed-staging = { git = "https://github.com/trussed-dev/trussed-staging.git", tag = "v0.4.0" } +ctap-types = { git = "https://github.com/0x0ece/ctap-types", rev = "1056c78b06fddc1010afebf549434ac097a6db42" } diff --git a/src/ctap1.rs b/src/ctap1.rs index 24494d2..aabb704 100644 --- a/src/ctap1.rs +++ b/src/ctap1.rs @@ -143,6 +143,12 @@ impl Authenticator for crate::Authenti } }; + // U2F register's `attestation_certificate` is fixed at `Bytes<1024>`. + // Real attestation certs comfortably fit; we lift it from the + // trussed `Message`-typed read so it works regardless of how the + // mldsa44 feature sizes that Message buffer. + let cert = + ctap_types::Bytes::<1024>::try_from(&*cert).map_err(|_| Error::NotEnoughMemory)?; Ok(register::Response::new( 0x05, &cose_key, diff --git a/src/ctap2.rs b/src/ctap2.rs index a3e9da8..9361636 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -133,6 +133,12 @@ impl Authenticator for crate::Authenti algorithms .push(KnownPublicKeyCredentialParameters { alg: ED_DSA }) .unwrap(); + #[cfg(feature = "mldsa44")] + algorithms + .push(KnownPublicKeyCredentialParameters { + alg: ctap_types::webauthn::ML_DSA_44, + }) + .unwrap(); let algorithms = FilteredPublicKeyCredentialParameters(algorithms); let remaining_discoverable_credentials = self.estimate_remaining(); @@ -272,8 +278,16 @@ impl Authenticator for crate::Authenti // 7. check pubKeyCredParams algorithm is valid + supported COSE identifier + // CTAP §6.1.2: walk pubKeyCredParams in order and pick the first + // supported algorithm. The guard on every arm matters — without + // it later entries silently overwrite earlier ones and we end + // up with last-match instead of first-match (caught by the + // ML-DSA-44 vs EdDSA preference test). let mut algorithm: Option = None; for param in parameters.pub_key_cred_params.0.iter() { + if algorithm.is_some() { + break; + } match param.alg { -7 => { @@ -285,6 +299,12 @@ impl Authenticator for crate::Authenti -8 => { algorithm = Some(SigningAlgorithm::Ed25519); } + #[cfg(feature = "mldsa44")] + -50 => { + if algorithm.is_none() { + algorithm = Some(SigningAlgorithm::MlDsa44); + } + } _ => {} } } @@ -575,7 +595,7 @@ impl Authenticator for crate::Authenti }; // debug_now!("authData = {:?}", &authenticator_data); - let serialized_auth_data = authenticator_data.serialize()?; + let mut serialized_auth_data = authenticator_data.serialize()?; // select attestation format or use packed attestation as default let att_stmt_fmt = parameters @@ -589,11 +609,15 @@ impl Authenticator for crate::Authenti Some(AttestationStatement::None(NoneAttestationStatement {})) } SupportedAttestationFormat::Packed => { - let mut commitment = Bytes::<1024>::new(); - commitment - .extend_from_slice(&serialized_auth_data) - .map_err(|_| Error::Other)?; - commitment + // Build the "commitment" (auth_data ‖ cdh) IN PLACE inside + // `serialized_auth_data` to avoid a separate 2.3 KB local. + // With `mldsa44`, `SerializedAuthenticatorData` has 2048 B + // capacity, comfortably fitting the ~1577 B auth_data + 32 B + // cdh = ~1609 B. After signing we truncate to restore the + // original auth_data length so the buffer can be moved into + // `response.auth_data`. + let auth_data_len = serialized_auth_data.len(); + serialized_auth_data .extend_from_slice(parameters.client_data_hash) .map_err(|_| Error::Other)?; @@ -601,8 +625,12 @@ impl Authenticator for crate::Authenti .as_ref() .map(|attestation| (attestation.0, SigningAlgorithm::P256)) .unwrap_or((private_key, algorithm)); - let signature = - attestation_algorithm.sign(&mut self.trussed, attestation_key, &commitment); + let signature = attestation_algorithm.sign( + &mut self.trussed, + attestation_key, + &serialized_auth_data, + ); + serialized_auth_data.truncate(auth_data_len); let packed = PackedAttestationStatement { alg: attestation_algorithm.into(), sig: Bytes::try_from(&*signature).map_err(|_| Error::Other)?, @@ -626,6 +654,11 @@ impl Authenticator for crate::Authenti info_now!("deleted private credential key: {}", _success); } + // Write fields directly into the caller-provided slot — avoids + // the 6 KB Response by-value return + move through the dispatch + // chain. `serialized_auth_data` still lives transiently on this + // function's stack (≈2 KB); future work could write it directly + // into `response.auth_data` via a mutable serialize sink. response.fmt = att_stmt_fmt .map(From::from) .unwrap_or(AttestationStatementFormat::None); @@ -2150,66 +2183,55 @@ impl crate::Authenticator { extensions: extensions_output, }; - let serialized_auth_data = authenticator_data.serialize()?; + let mut serialized_auth_data = authenticator_data.serialize()?; - let mut commitment = Bytes::<1024>::new(); - commitment - .extend_from_slice(&serialized_auth_data) - .map_err(|_| Error::Other)?; - commitment + // Build commitment in place: append client_data_hash to serialized_auth_data, + // sign over the concatenation, then truncate back. Mirrors the elision + // done in make_credential — avoids a separate Bytes<1024> commitment buffer. + let auth_data_len = serialized_auth_data.len(); + serialized_auth_data .extend_from_slice(&data.client_data_hash) .map_err(|_| Error::Other)?; let signing_algorithm = SigningAlgorithm::try_from(credential.algorithm()).map_err(|_| Error::Other)?; - let signature = - Bytes::try_from(&*signing_algorithm.sign(&mut self.trussed, key, &commitment)).unwrap(); + let signature = Bytes::try_from(&*signing_algorithm.sign( + &mut self.trussed, + key, + &serialized_auth_data, + )) + .unwrap(); - // select preferred format or skip attestation statement + // select preferred format or skip attestation statement. + // + // The Packed branch's `PackedAttestationStatement` carries a + // `Bytes` sig (2436 B) and an x5c + // `Bytes` cert (2052 B) — ~4.5 KB total + // with `mldsa44`. Outline the construction into a `#[inline(never)]` + // helper so those temporaries live in the helper's frame, not + // in `assert_with_credential`'s (which is preserved on the lower + // task's stack during the 72 KB libcrux_sign call above us). let att_stmt_fmt = data .attestation_formats_preference .as_ref() .and_then(SupportedAttestationFormat::select); - let att_stmt = if let Some(format) = att_stmt_fmt { - match format { - SupportedAttestationFormat::None => { - Some(AttestationStatement::None(NoneAttestationStatement {})) - } - SupportedAttestationFormat::Packed => { - let (attestation_maybe, _) = self.state.identity.attestation(&mut self.trussed); - let (signature, attestation_algorithm) = { - if let Some(attestation) = attestation_maybe.as_ref() { - let signing_algorithm = SigningAlgorithm::P256; - let signature = signing_algorithm.sign( - &mut self.trussed, - attestation.0, - &commitment, - ); - ( - Bytes::try_from(&*signature).map_err(|_| Error::Other)?, - signing_algorithm.into(), - ) - } else { - (signature.clone(), credential.algorithm()) - } - }; - let packed = PackedAttestationStatement { - alg: attestation_algorithm, - sig: signature, - x5c: attestation_maybe.as_ref().map(|attestation| { - // See: https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation-cert-requirements - let cert = attestation.1.clone(); - let mut x5c = Vec::new(); - x5c.push(cert).ok(); - x5c - }), - }; - Some(AttestationStatement::Packed(packed)) - } + match att_stmt_fmt { + Some(SupportedAttestationFormat::None) => { + response.att_stmt = Some(AttestationStatement::None(NoneAttestationStatement {})); } - } else { - None - }; + Some(SupportedAttestationFormat::Packed) => { + self.build_packed_att_stmt( + &serialized_auth_data, + &signature, + credential.algorithm(), + response, + )?; + } + None => {} + } + + // Truncate back so the response carries only authData (without cdh). + serialized_auth_data.truncate(auth_data_len); if !is_rk { syscall!(self.trussed.delete(key)); @@ -2219,7 +2241,6 @@ impl crate::Authenticator { response.auth_data = serialized_auth_data; response.signature = signature; response.number_of_credentials = num_credentials; - response.att_stmt = att_stmt; // User with empty IDs are ignored for compatibility if is_rk { @@ -2250,6 +2271,43 @@ impl crate::Authenticator { Ok(()) } + /// Build a `Packed` attestation statement for `get_assertion` and + /// write it into `response.att_stmt`. Outlined so its ~4.5 KB worth + /// of temporaries (`Bytes` re-sign output plus + /// the x5c cert clone) live here instead of inflating the caller's + /// preserved stack while libcrux_sign runs above us. + #[inline(never)] + fn build_packed_att_stmt( + &mut self, + message: &[u8], + fallback_sig: &Bytes<{ ctap_types::sizes::MAX_PACKED_SIG_LENGTH }>, + fallback_alg: i32, + response: &mut ctap2::get_assertion::Response, + ) -> Result<()> { + let (attestation_maybe, _) = self.state.identity.attestation(&mut self.trussed); + let (sig, alg) = if let Some(attestation) = attestation_maybe.as_ref() { + let signing_algorithm = SigningAlgorithm::P256; + let att_sig = signing_algorithm.sign(&mut self.trussed, attestation.0, message); + ( + Bytes::try_from(&*att_sig).map_err(|_| Error::Other)?, + signing_algorithm.into(), + ) + } else { + (fallback_sig.clone(), fallback_alg) + }; + response.att_stmt = Some(AttestationStatement::Packed(PackedAttestationStatement { + alg, + sig, + x5c: attestation_maybe.as_ref().map(|attestation| { + let cert = attestation.1.clone(); + let mut x5c = Vec::new(); + x5c.push(cert).ok(); + x5c + }), + })); + Ok(()) + } + #[inline(never)] fn delete_resident_key_by_user_id( &mut self, diff --git a/src/ctap2/credential_management.rs b/src/ctap2/credential_management.rs index bdef8b1..6a807f7 100644 --- a/src/ctap2/credential_management.rs +++ b/src/ctap2/credential_management.rs @@ -408,6 +408,13 @@ where SigningAlgorithm::Ed25519 => PublicKey::Ed25519Key( ctap_types::serde::cbor_deserialize(&cose_public_key).unwrap(), ), + // `cosey::PublicKey` doesn't have an ML-DSA variant (yet); the + // credential itself works for GA, but `credentialManagement` can't + // serialise its public key via this path. Skip rather than crash — + // the platform will see `Err(InvalidCredential)` and can fall back + // to GA + signature verification to obtain the key. + #[cfg(feature = "mldsa44")] + SigningAlgorithm::MlDsa44 => return Err(Error::InvalidCredential), }; let cred_protect = match credential.cred_protect { Some(x) => Some(x), diff --git a/src/ctap2/pin.rs b/src/ctap2/pin.rs index 096d6a3..e797de3 100644 --- a/src/ctap2/pin.rs +++ b/src/ctap2/pin.rs @@ -428,7 +428,7 @@ impl SharedSecret { } #[must_use] - pub fn encrypt(&self, trussed: &mut T, data: &[u8]) -> Bytes<1024> { + pub fn encrypt(&self, trussed: &mut T, data: &[u8]) -> Message { let key_id = self.aes_key_id(); let iv = self.generate_iv(trussed); let mut ciphertext = @@ -444,7 +444,7 @@ impl SharedSecret { } #[must_use] - fn wrap(&self, trussed: &mut T, key: KeyId) -> Bytes<1024> { + fn wrap(&self, trussed: &mut T, key: KeyId) -> Message { let wrapping_key = self.aes_key_id(); let iv = self.generate_iv(trussed); let mut wrapped_key = syscall!(trussed.wrap_key( @@ -465,7 +465,7 @@ impl SharedSecret { } #[must_use] - pub fn decrypt(&self, trussed: &mut T, data: &[u8]) -> Option> { + pub fn decrypt(&self, trussed: &mut T, data: &[u8]) -> Option { let key_id = self.aes_key_id(); let (iv, data) = match self { Self::V1 { .. } => (Default::default(), data), diff --git a/src/lib.rs b/src/lib.rs index 089c9bd..cded1bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,6 +71,7 @@ pub trait TrussedRequirements: + mechanisms::Sha256 + mechanisms::HmacSha256 + mechanisms::Ed255 + + MldsaRequirement + FsInfoClient + HkdfClient + ExtensionRequirements @@ -89,6 +90,7 @@ impl TrussedRequirements for T where + mechanisms::Sha256 + mechanisms::HmacSha256 + mechanisms::Ed255 + + MldsaRequirement + FsInfoClient + HkdfClient + ExtensionRequirements @@ -107,6 +109,22 @@ pub trait ExtensionRequirements: trussed_chunked::ChunkedClient {} #[cfg(feature = "chunked")] impl ExtensionRequirements for T where T: trussed_chunked::ChunkedClient {} +/// Marker trait that, with the `mldsa44` feature enabled, requires the +/// Trussed client to expose the ML-DSA-44 mechanism. With the feature +/// disabled it is a no-op so non-PQ builds do not pay any size or stack +/// tax for ML-DSA. +#[cfg(not(feature = "mldsa44"))] +pub trait MldsaRequirement {} + +#[cfg(not(feature = "mldsa44"))] +impl MldsaRequirement for T {} + +#[cfg(feature = "mldsa44")] +pub trait MldsaRequirement: mechanisms::Mldsa44 {} + +#[cfg(feature = "mldsa44")] +impl MldsaRequirement for T where T: mechanisms::Mldsa44 {} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] /// Externally defined configuration. pub struct Config { @@ -279,7 +297,8 @@ pub(crate) fn msp() -> u32 { 0x2000_0000 } -/// Currently Ed25519 and P256. +/// Signing algorithms we know about. COSE alg ids: Ed25519 = -8, P-256 = -7, +/// ML-DSA-44 = -50 (FIPS 204 / WebAuthn L3, behind the `mldsa44` feature). #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(i32)] #[non_exhaustive] @@ -288,6 +307,9 @@ pub enum SigningAlgorithm { Ed25519 = -8, /// The NIST P-256 signature algorithm. P256 = -7, + /// FIPS 204 ML-DSA-44 (NIST level 2 post-quantum signature). + #[cfg(feature = "mldsa44")] + MlDsa44 = -50, } impl SigningAlgorithm { @@ -295,6 +317,8 @@ impl SigningAlgorithm { match self { Self::Ed25519 => Mechanism::Ed255, Self::P256 => Mechanism::P256, + #[cfg(feature = "mldsa44")] + Self::MlDsa44 => Mechanism::Mldsa44, } } @@ -302,6 +326,8 @@ impl SigningAlgorithm { match self { Self::Ed25519 => SignatureSerialization::Raw, Self::P256 => SignatureSerialization::Asn1Der, + #[cfg(feature = "mldsa44")] + Self::MlDsa44 => SignatureSerialization::Raw, } } @@ -350,6 +376,8 @@ impl From for i32 { match alg { SigningAlgorithm::P256 => -7, SigningAlgorithm::Ed25519 => -8, + #[cfg(feature = "mldsa44")] + SigningAlgorithm::MlDsa44 => -50, } } } @@ -361,6 +389,8 @@ impl TryFrom for SigningAlgorithm { Ok(match alg { -7 => SigningAlgorithm::P256, -8 => SigningAlgorithm::Ed25519, + #[cfg(feature = "mldsa44")] + -50 => SigningAlgorithm::MlDsa44, _ => return Err(Error::UnsupportedAlgorithm), }) } diff --git a/tests/basic.rs b/tests/basic.rs index eee1835..67b22d5 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -3319,8 +3319,154 @@ fn test_enable_long_touch_for_reset_invalid_parameter() { }) } -/// CTAP 2.3 §6.11.5: `EnableLongTouchForReset` subcommand. If the feature is enabled, -/// the request must return `Ok(())` without changing state. +// ---------------------------------------------------------------------------- +// ML-DSA-44 (CTAP 2.3 / WebAuthn L3, alg = -50, FIPS 204) +// ---------------------------------------------------------------------------- +// All ML-DSA tests are gated on `mldsa44` Cargo feature. Run with: +// cargo test --test basic --features dispatch,mldsa44 + +/// Default build (no `mldsa44`): GetInfo's `algorithms` list contains only +/// ES256 + EdDSA. ML-DSA-44 is NOT advertised. Asserts the negative path so +/// the feature-gating is exercised even on the default build. +#[test] +#[cfg(not(feature = "mldsa44"))] +fn test_mldsa44_not_advertised_without_feature() { + virt::run_ctap2(|device| { + let reply = device.exec(GetInfo).unwrap(); + let algorithms = reply.algorithms.expect("algorithms missing"); + let algs: Vec = algorithms + .iter() + .map(|v| { + let m: std::collections::BTreeMap = + v.clone().deserialized().unwrap(); + m.get("alg").unwrap().clone().deserialized().unwrap() + }) + .collect(); + assert!(algs.contains(&-7), "ES256 (-7) missing"); + assert!(algs.contains(&-8), "EdDSA (-8) missing"); + assert!( + !algs.contains(&-50), + "ML-DSA-44 (-50) advertised without feature" + ); + }) +} + +/// With `mldsa44` enabled: GetInfo advertises alg=-50 in `algorithms` and +/// the credential creation path supports it. This is a smoke test: it +/// verifies the wiring, not the cryptographic correctness of ML-DSA itself +/// (that's libcrux-ml-dsa's job). +#[test] +#[cfg(feature = "mldsa44")] +fn test_mldsa44_advertised_with_feature() { + virt::run_ctap2(|device| { + let reply = device.exec(GetInfo).unwrap(); + let algorithms = reply.algorithms.expect("algorithms missing"); + let algs: Vec = algorithms + .iter() + .map(|v| { + let m: std::collections::BTreeMap = + v.clone().deserialized().unwrap(); + m.get("alg").unwrap().clone().deserialized().unwrap() + }) + .collect(); + assert!( + algs.contains(&-50), + "ML-DSA-44 (-50) not advertised: {:?}", + algs + ); + }) +} + +/// MakeCredential with `alg = -50` (ML-DSA-44) succeeds, the attested +/// credential public key uses COSE kty=AKP (7) and alg=-50, and the +/// resulting credential ID round-trips through GetAssertion. +#[test] +#[cfg(feature = "mldsa44")] +fn test_mldsa44_make_credential_and_get_assertion_roundtrip() { + let rp_id = "example.com"; + let client_data_hash = vec![0u8; 32]; + virt::run_ctap2(|device| { + let mut mc = MakeCredential::new( + client_data_hash.clone(), + Rp::new(rp_id), + User::new(vec![1; 16]), + // Send both ML-DSA-44 (preferred) and P-256 — authenticator + // should pick the first one it recognises and supports. + vec![ + PubKeyCredParam::new("public-key", -50), + PubKeyCredParam::new("public-key", -7), + ], + ); + mc.options = Some(MakeCredentialOptions::default().rk(true)); + let mc_reply = device.exec(mc).unwrap(); + let credential = mc_reply.auth_data.credential.unwrap(); + + // The COSE_Key in attestedCredentialData should be ML-DSA AKP. + // Field 1 (kty) = 7 (AKP), field 3 (alg) = -50, field -1 = pub bytes. + let pk = &credential.public_key; + let kty: i32 = pk.get(&1).unwrap().clone().deserialized().unwrap(); + let alg: i32 = pk.get(&3).unwrap().clone().deserialized().unwrap(); + let pub_bytes = pk.get(&-1).unwrap().as_bytes().unwrap(); + assert_eq!(kty, 7, "kty must be AKP (7) for ML-DSA"); + assert_eq!(alg, -50, "alg must be -50 (ML-DSA-44)"); + assert_eq!( + pub_bytes.len(), + 1312, + "ML-DSA-44 public key must be 1312 bytes" + ); + + // GA with allowList of that credential should succeed and return a + // 2420-byte ML-DSA-44 signature. + let mut ga = GetAssertion::new(rp_id.to_owned(), client_data_hash); + ga.allow_list = Some(vec![PubKeyCredDescriptor::new( + "public-key", + credential.id.clone(), + )]); + let ga_reply = device.exec(ga).unwrap(); + assert_eq!( + ga_reply.signature.len(), + 2420, + "ML-DSA-44 signature must be 2420 bytes" + ); + }) +} + +/// CTAP 2.1 §6.1.2 step 3: "If the element specifies an algorithm that is +/// supported by the authenticator, and no algorithm has yet been chosen by +/// this loop, then let the algorithm specified by the current element be +/// the chosen algorithm." This is platform-controlled preference order — +/// the authenticator picks the FIRST supported entry. Verify that with +/// `[-7, -50]` (P-256 listed first), the authenticator picks P-256 even +/// when ML-DSA-44 is enabled. +#[test] +#[cfg(feature = "mldsa44")] +fn test_mldsa44_p256_preferred_when_listed_first() { + let rp_id = "example.com"; + let client_data_hash = vec![0u8; 32]; + virt::run_ctap2(|device| { + let mut mc = MakeCredential::new( + client_data_hash, + Rp::new(rp_id), + User::new(vec![1; 16]), + // P-256 listed first → authenticator MUST pick -7. + vec![ + PubKeyCredParam::new("public-key", -7), + PubKeyCredParam::new("public-key", -50), + ], + ); + mc.options = Some(MakeCredentialOptions::default().rk(true)); + let mc_reply = device.exec(mc).unwrap(); + let credential = mc_reply.auth_data.credential.unwrap(); + let pk = &credential.public_key; + let kty: i32 = pk.get(&1).unwrap().clone().deserialized().unwrap(); + let alg: i32 = pk.get(&3).unwrap().clone().deserialized().unwrap(); + assert_eq!(kty, 2, "kty must be EC (2) for P-256, got {}", kty); + assert_eq!(alg, -7, "alg must be -7 (ES256), got {}", alg); + }) +} + +/// CTAP 2.3 §6.11.5: `EnableLongTouchForReset` subcommand. Always-on for us, +/// so the request must return `Ok(())` without changing state. #[test] fn test_enable_long_touch_for_reset_is_noop() { let options = Options { diff --git a/tests/webauthn/mod.rs b/tests/webauthn/mod.rs index 5c8c969..ea85854 100644 --- a/tests/webauthn/mod.rs +++ b/tests/webauthn/mod.rs @@ -1044,6 +1044,10 @@ pub struct GetInfoReply { pub options: Option>, pub pin_protocols: Option>, pub transports: Option>, + /// CTAP 2.1 §6.4 0x0A: `algorithms` — array of + /// `PublicKeyCredentialParameters` the authenticator supports. + /// Each entry is `{"alg": i32, "type": "public-key"}`. + pub algorithms: Option>, pub force_pin_change: Option, pub min_pin_length: Option, pub attestation_formats: Option>, @@ -1062,6 +1066,8 @@ impl From for GetInfoReply { pin_protocols: map.remove(&6).map(|value| value.deserialized().unwrap()), // 0x09: transports (CTAP 2.1) transports: map.remove(&9).map(|value| value.deserialized().unwrap()), + // 0x0A: algorithms (CTAP 2.1) + algorithms: map.remove(&0x0A).map(|value| value.deserialized().unwrap()), // 0x0C: forcePINChange (CTAP 2.1) force_pin_change: map.remove(&0x0C).map(|value| value.deserialized().unwrap()), // 0x0D: minPINLength (CTAP 2.1)