From 7a6fffb5acba69f2d28b71e449f27768fec5aa20 Mon Sep 17 00:00:00 2001 From: Phil Schmieder Date: Mon, 17 Aug 2026 08:39:26 +0100 Subject: [PATCH 1/3] Add support for ML-DSA signatures --- Cargo.toml | 4 +- README.md | 3 + src/algorithms.rs | 72 +++++ src/crypto/aws_lc/ml_dsa.rs | 90 ++++++ src/crypto/aws_lc/mod.rs | 28 +- src/crypto/mod.rs | 6 + src/crypto/rust_crypto/ml_dsa.rs | 171 +++++++++++ src/crypto/rust_crypto/mod.rs | 41 +++ src/decoding.rs | 88 +++++- src/encoding.rs | 16 + src/jwk.rs | 476 ++++++++++++++++++++++++++++- src/pem/decoder.rs | 43 ++- tests/lib.rs | 1 + tests/ml_dsa/mod.rs | 199 ++++++++++++ tests/ml_dsa/private_ml_dsa_44.der | Bin 0 -> 54 bytes tests/ml_dsa/private_ml_dsa_44.pem | 4 + tests/ml_dsa/private_ml_dsa_65.der | Bin 0 -> 54 bytes tests/ml_dsa/private_ml_dsa_65.pem | 4 + tests/ml_dsa/private_ml_dsa_87.der | Bin 0 -> 54 bytes tests/ml_dsa/private_ml_dsa_87.pem | 4 + tests/ml_dsa/public_ml_dsa_44.der | Bin 0 -> 1334 bytes tests/ml_dsa/public_ml_dsa_44.pem | 30 ++ tests/ml_dsa/public_ml_dsa_44.raw | Bin 0 -> 1312 bytes tests/ml_dsa/public_ml_dsa_65.der | Bin 0 -> 1974 bytes tests/ml_dsa/public_ml_dsa_65.pem | 44 +++ tests/ml_dsa/public_ml_dsa_65.raw | Bin 0 -> 1952 bytes tests/ml_dsa/public_ml_dsa_87.der | Bin 0 -> 2614 bytes tests/ml_dsa/public_ml_dsa_87.pem | 57 ++++ tests/ml_dsa/public_ml_dsa_87.raw | Bin 0 -> 2592 bytes 29 files changed, 1361 insertions(+), 20 deletions(-) create mode 100644 src/crypto/aws_lc/ml_dsa.rs create mode 100644 src/crypto/rust_crypto/ml_dsa.rs create mode 100644 tests/ml_dsa/mod.rs create mode 100644 tests/ml_dsa/private_ml_dsa_44.der create mode 100644 tests/ml_dsa/private_ml_dsa_44.pem create mode 100644 tests/ml_dsa/private_ml_dsa_65.der create mode 100644 tests/ml_dsa/private_ml_dsa_65.pem create mode 100644 tests/ml_dsa/private_ml_dsa_87.der create mode 100644 tests/ml_dsa/private_ml_dsa_87.pem create mode 100644 tests/ml_dsa/public_ml_dsa_44.der create mode 100644 tests/ml_dsa/public_ml_dsa_44.pem create mode 100644 tests/ml_dsa/public_ml_dsa_44.raw create mode 100644 tests/ml_dsa/public_ml_dsa_65.der create mode 100644 tests/ml_dsa/public_ml_dsa_65.pem create mode 100644 tests/ml_dsa/public_ml_dsa_65.raw create mode 100644 tests/ml_dsa/public_ml_dsa_87.der create mode 100644 tests/ml_dsa/public_ml_dsa_87.pem create mode 100644 tests/ml_dsa/public_ml_dsa_87.raw diff --git a/Cargo.toml b/Cargo.toml index db809c05..88739565 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ pem = { version = "3", optional = true } simple_asn1 = { version = "0.6", optional = true } # "aws_lc_rs" feature -aws-lc-rs = { version = "1.15.0", optional = true } +aws-lc-rs = { version = "1.18.0", optional = true } # "rust_crypto" feature ed25519-dalek = { version = "2.1.1", optional = true, features = ["pkcs8"] } @@ -46,6 +46,7 @@ rand = { version = "0.8.5", optional = true, features = [ rsa = { version = "0.9.6", optional = true } sha2 = { version = "0.10.7", optional = true, features = ["oid"] } zeroize = { version = "1.8.2", features = ["derive"] } +ml-dsa = { version = "0.1", optional = true, features = ["pkcs8", "rand_core", "zeroize"] } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" @@ -77,6 +78,7 @@ rust_crypto = [ "dep:rand", "dep:rsa", "dep:sha2", + "dep:ml-dsa", ] aws_lc_rs = ["dep:aws-lc-rs"] diff --git a/README.md b/README.md index 38c7b66a..a81b2ced 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ This library currently supports the following: - ES256 - ES384 - EdDSA +- ML-DSA-44 +- ML-DSA-65 +- ML-DSA-87 ## How to use diff --git a/src/algorithms.rs b/src/algorithms.rs index 906d9a63..b8d681bd 100644 --- a/src/algorithms.rs +++ b/src/algorithms.rs @@ -1,9 +1,16 @@ +use std::fmt; use std::str::FromStr; use serde::{Deserialize, Serialize}; use crate::errors::{Error, ErrorKind, Result}; +/// Public-key lengths (in bytes) for the ML-DSA parameter sets, as fixed by +/// US NIST FIPS 204. +pub(crate) const ML_DSA_44_PUBLIC_KEY_LEN: usize = 1312; +pub(crate) const ML_DSA_65_PUBLIC_KEY_LEN: usize = 1952; +pub(crate) const ML_DSA_87_PUBLIC_KEY_LEN: usize = 2592; + #[derive(Debug, Eq, PartialEq, Copy, Clone, Serialize, Deserialize)] /// Supported families of algorithms. pub enum AlgorithmFamily { @@ -15,6 +22,8 @@ pub enum AlgorithmFamily { Ec, /// Edwards curve public key family. Ed, + /// ML-DSA public key family. + Mldsa, } impl AlgorithmFamily { @@ -32,6 +41,7 @@ impl AlgorithmFamily { ], Self::Ec => &[Algorithm::ES256, Algorithm::ES384], Self::Ed => &[Algorithm::EdDSA], + Self::Mldsa => &[Algorithm::MLDSA44, Algorithm::MLDSA65, Algorithm::MLDSA87], } } } @@ -70,6 +80,16 @@ pub enum Algorithm { /// Edwards-curve Digital Signature Algorithm (EdDSA) EdDSA, + + /// ML-DSA-44 as described in US NIST FIPS 204 + #[serde(rename = "ML-DSA-44")] + MLDSA44, + /// ML-DSA-65 as described in US NIST FIPS 204 + #[serde(rename = "ML-DSA-65")] + MLDSA65, + /// ML-DSA-87 as described in US NIST FIPS 204 + #[serde(rename = "ML-DSA-87")] + MLDSA87, } impl FromStr for Algorithm { @@ -88,11 +108,37 @@ impl FromStr for Algorithm { "PS512" => Ok(Algorithm::PS512), "RS512" => Ok(Algorithm::RS512), "EdDSA" => Ok(Algorithm::EdDSA), + "ML-DSA-44" => Ok(Algorithm::MLDSA44), + "ML-DSA-65" => Ok(Algorithm::MLDSA65), + "ML-DSA-87" => Ok(Algorithm::MLDSA87), _ => Err(ErrorKind::InvalidAlgorithmName.into()), } } } +impl fmt::Display for Algorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Algorithm::HS256 => "HS256", + Algorithm::HS384 => "HS384", + Algorithm::HS512 => "HS512", + Algorithm::ES256 => "ES256", + Algorithm::ES384 => "ES384", + Algorithm::RS256 => "RS256", + Algorithm::RS384 => "RS384", + Algorithm::RS512 => "RS512", + Algorithm::PS256 => "PS256", + Algorithm::PS384 => "PS384", + Algorithm::PS512 => "PS512", + Algorithm::EdDSA => "EdDSA", + Algorithm::MLDSA44 => "ML-DSA-44", + Algorithm::MLDSA65 => "ML-DSA-65", + Algorithm::MLDSA87 => "ML-DSA-87", + }; + f.write_str(s) + } +} + impl Algorithm { /// The family of the algorithm. pub fn family(self) -> AlgorithmFamily { @@ -106,6 +152,7 @@ impl Algorithm { | Algorithm::PS512 => AlgorithmFamily::Rsa, Algorithm::ES256 | Algorithm::ES384 => AlgorithmFamily::Ec, Algorithm::EdDSA => AlgorithmFamily::Ed, + Algorithm::MLDSA44 | Algorithm::MLDSA65 | Algorithm::MLDSA87 => AlgorithmFamily::Mldsa, } } } @@ -128,6 +175,31 @@ mod tests { assert!(Algorithm::from_str("PS256").is_ok()); assert!(Algorithm::from_str("PS384").is_ok()); assert!(Algorithm::from_str("PS512").is_ok()); + assert!(Algorithm::from_str("EdDSA").is_ok()); + assert!(Algorithm::from_str("ML-DSA-44").is_ok()); + assert!(Algorithm::from_str("ML-DSA-65").is_ok()); + assert!(Algorithm::from_str("ML-DSA-87").is_ok()); assert!(Algorithm::from_str("").is_err()); } + + #[test] + #[wasm_bindgen_test] + fn ml_dsa_wire_format_roundtrip() { + // Locks the JWT `alg` header wire-format for ML-DSA variants + // (RFC 9964 names use hyphens, not the Rust identifier spelling). + let pairs = [ + (Algorithm::MLDSA44, "ML-DSA-44"), + (Algorithm::MLDSA65, "ML-DSA-65"), + (Algorithm::MLDSA87, "ML-DSA-87"), + ]; + + for (alg, wire) in pairs { + // Serialize -> exact wire string. + assert_eq!(serde_json::to_string(&alg).unwrap(), format!("\"{wire}\"")); + // Deserialize -> back to the same variant. + assert_eq!(serde_json::from_str::(&format!("\"{wire}\"")).unwrap(), alg); + // FromStr round-trip. + assert_eq!(Algorithm::from_str(wire).unwrap(), alg); + } + } } diff --git a/src/crypto/aws_lc/ml_dsa.rs b/src/crypto/aws_lc/ml_dsa.rs new file mode 100644 index 00000000..d8226e26 --- /dev/null +++ b/src/crypto/aws_lc/ml_dsa.rs @@ -0,0 +1,90 @@ +//! Implementations of the [`JwtSigner`] and [`JwtVerifier`] traits for the +//! ML-DSA family of algorithms (US NIST FIPS 204) using [`aws_lc_rs`] + +use crate::algorithms::AlgorithmFamily; +use crate::crypto::{JwtSigner, JwtVerifier}; +use crate::errors::{ErrorKind, Result, new_error}; +use crate::{Algorithm, DecodingKey, EncodingKey}; +use aws_lc_rs::signature::{ + ML_DSA_44, ML_DSA_44_SIGNING, ML_DSA_65, ML_DSA_65_SIGNING, ML_DSA_87, ML_DSA_87_SIGNING, + PqdsaKeyPair, VerificationAlgorithm, +}; +use signature::{Error, Signer, Verifier}; + +macro_rules! define_ml_dsa_signer { + ($name:ident, $alg:expr, $signing_alg:expr) => { + pub struct $name(PqdsaKeyPair); + + impl $name { + pub(crate) fn new(encoding_key: &EncodingKey) -> Result { + if encoding_key.family() != AlgorithmFamily::Mldsa { + return Err(new_error(ErrorKind::InvalidKeyFormat)); + } + + Ok(Self( + PqdsaKeyPair::from_pkcs8($signing_alg, encoding_key.as_bytes()) + .map_err(|_| ErrorKind::InvalidKeyFormat)?, + )) + } + } + + impl Signer> for $name { + fn try_sign(&self, msg: &[u8]) -> std::result::Result, Error> { + let mut signature = vec![0u8; self.0.algorithm().signature_len()]; + let len = self.0.sign(msg, &mut signature).map_err(Error::from_source)?; + signature.truncate(len); + Ok(signature) + } + } + + impl JwtSigner for $name { + fn algorithm(&self) -> Algorithm { + $alg + } + } + }; +} + +macro_rules! define_ml_dsa_verifier { + ($name:ident, $alg:expr, $verification_alg:expr) => { + pub struct $name(DecodingKey); + + impl $name { + pub(crate) fn new(decoding_key: &DecodingKey) -> Result { + if decoding_key.family() != AlgorithmFamily::Mldsa { + return Err(new_error(ErrorKind::InvalidKeyFormat)); + } + + Ok(Self(decoding_key.clone())) + } + } + + impl Verifier> for $name { + fn verify(&self, msg: &[u8], signature: &Vec) -> std::result::Result<(), Error> { + $verification_alg + .verify_sig( + self.0.try_get_as_bytes().map_err(Error::from_source)?, + msg, + signature, + ) + .map_err(Error::from_source)?; + Ok(()) + } + } + + impl JwtVerifier for $name { + fn algorithm(&self) -> Algorithm { + $alg + } + } + }; +} + +define_ml_dsa_signer!(MlDsa44Signer, Algorithm::MLDSA44, &ML_DSA_44_SIGNING); +define_ml_dsa_verifier!(MlDsa44Verifier, Algorithm::MLDSA44, ML_DSA_44); + +define_ml_dsa_signer!(MlDsa65Signer, Algorithm::MLDSA65, &ML_DSA_65_SIGNING); +define_ml_dsa_verifier!(MlDsa65Verifier, Algorithm::MLDSA65, ML_DSA_65); + +define_ml_dsa_signer!(MlDsa87Signer, Algorithm::MLDSA87, &ML_DSA_87_SIGNING); +define_ml_dsa_verifier!(MlDsa87Verifier, Algorithm::MLDSA87, ML_DSA_87); diff --git a/src/crypto/aws_lc/mod.rs b/src/crypto/aws_lc/mod.rs index 502d2a87..10e5ff3d 100644 --- a/src/crypto/aws_lc/mod.rs +++ b/src/crypto/aws_lc/mod.rs @@ -2,7 +2,8 @@ use aws_lc_rs::{ digest, signature::{ self as aws_sig, ECDSA_P256_SHA256_FIXED_SIGNING, ECDSA_P384_SHA384_FIXED_SIGNING, - EcdsaKeyPair, Ed25519KeyPair, KeyPair, + EcdsaKeyPair, Ed25519KeyPair, KeyPair, ML_DSA_44_SIGNING, ML_DSA_65_SIGNING, + ML_DSA_87_SIGNING, PqdsaKeyPair, }, }; @@ -16,6 +17,7 @@ use crate::{ mod ecdsa; mod eddsa; mod hmac; +mod ml_dsa; mod rsa; fn rsa_components_from_private_key(key_content: &[u8]) -> errors::Result<(Vec, Vec)> { @@ -70,6 +72,23 @@ fn ed_pub_components_from_private_key( } } +fn mldsa_pub_components_from_private_key( + encoding_key: &[u8], + alg: Algorithm, +) -> errors::Result> { + let signing_alg = match alg { + Algorithm::MLDSA44 => &ML_DSA_44_SIGNING, + Algorithm::MLDSA65 => &ML_DSA_65_SIGNING, + Algorithm::MLDSA87 => &ML_DSA_87_SIGNING, + _ => return Err(ErrorKind::InvalidAlgorithm.into()), + }; + + let key_pair = PqdsaKeyPair::from_pkcs8(signing_alg, encoding_key) + .map_err(|_| ErrorKind::InvalidKeyFormat)?; + + Ok(key_pair.public_key().as_ref().to_vec()) +} + fn compute_digest(data: &[u8], hash_function: ThumbprintHash) -> errors::Result> { let algorithm = match hash_function { ThumbprintHash::SHA256 => &digest::SHA256, @@ -93,6 +112,9 @@ fn new_signer(algorithm: &Algorithm, key: &EncodingKey) -> Result Box::new(rsa::RsaPss384Signer::new(key)?) as Box, Algorithm::PS512 => Box::new(rsa::RsaPss512Signer::new(key)?) as Box, Algorithm::EdDSA => Box::new(eddsa::EdDSASigner::new(key)?) as Box, + Algorithm::MLDSA44 => Box::new(ml_dsa::MlDsa44Signer::new(key)?) as Box, + Algorithm::MLDSA65 => Box::new(ml_dsa::MlDsa65Signer::new(key)?) as Box, + Algorithm::MLDSA87 => Box::new(ml_dsa::MlDsa87Signer::new(key)?) as Box, }; Ok(jwt_signer) @@ -115,6 +137,9 @@ fn new_verifier( Algorithm::PS384 => Box::new(rsa::RsaPss384Verifier::new(key)?) as Box, Algorithm::PS512 => Box::new(rsa::RsaPss512Verifier::new(key)?) as Box, Algorithm::EdDSA => Box::new(eddsa::EdDSAVerifier::new(key)?) as Box, + Algorithm::MLDSA44 => Box::new(ml_dsa::MlDsa44Verifier::new(key)?) as Box, + Algorithm::MLDSA65 => Box::new(ml_dsa::MlDsa65Verifier::new(key)?) as Box, + Algorithm::MLDSA87 => Box::new(ml_dsa::MlDsa87Verifier::new(key)?) as Box, }; Ok(jwt_verifier) @@ -129,6 +154,7 @@ pub static DEFAULT_PROVIDER: CryptoProvider = CryptoProvider { rsa_pub_components_from_public_key: rsa_components_from_public_key, ec_pub_components_from_private_key: ec_components_from_private_key, ed_pub_components_from_private_key, + mldsa_pub_components_from_private_key, compute_digest, }, }; diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index bc8344c4..bb244eda 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -148,6 +148,9 @@ pub struct KeyUtils { fn(&[u8], Algorithm) -> Result<(EllipticCurve, Vec, Vec)>, /// Given a DER encoded private key and the curve type, extract the ED public key component (x) pub ed_pub_components_from_private_key: fn(&[u8], &EllipticCurve) -> Result>, + /// Given a PKCS#8 DER encoded ML-DSA private key, extract the raw ML-DSA public key + /// (the fixed-size encoding used by RFC 9964). + pub mldsa_pub_components_from_private_key: fn(&[u8], Algorithm) -> Result>, /// Given some data and a name of a hash function, compute hash_function(data) pub compute_digest: fn(&[u8], ThumbprintHash) -> Result>, } @@ -174,6 +177,9 @@ See the documentation of the CryptoProvider type for more information. ed_pub_components_from_private_key: |_, _| { panic!("{}", NOT_INSTALLED_OR_UNIMPLEMENTED_ERROR) }, + mldsa_pub_components_from_private_key: |_, _| { + panic!("{}", NOT_INSTALLED_OR_UNIMPLEMENTED_ERROR) + }, compute_digest: |_, _| panic!("{}", NOT_INSTALLED_OR_UNIMPLEMENTED_ERROR), } } diff --git a/src/crypto/rust_crypto/ml_dsa.rs b/src/crypto/rust_crypto/ml_dsa.rs new file mode 100644 index 00000000..3a9c0ab5 --- /dev/null +++ b/src/crypto/rust_crypto/ml_dsa.rs @@ -0,0 +1,171 @@ +//! Implementations of the [`JwtSigner`] and [`JwtVerifier`] traits for the +//! ML-DSA family of algorithms (US NIST FIPS 204) using the RustCrypto +//! [`ml_dsa`] crate. +//! +//! Signing uses the deterministic variant with an empty context string, as +//! required for JOSE per [RFC 9964](https://datatracker.ietf.org/doc/html/rfc9964). +//! Public keys and signatures use the raw fixed-size encodings mandated by +//! RFC 9964 (i.e. no SPKI/DER wrapping on the verification path). + +use crate::algorithms::AlgorithmFamily; +use crate::crypto::{JwtSigner, JwtVerifier}; +use crate::errors::{ErrorKind, Result, new_error}; +use crate::{Algorithm, DecodingKey, EncodingKey}; +use ml_dsa::signature::{Signer as MlDsaSigner, Verifier as MlDsaVerifier}; +use ml_dsa::{ + EncodedSignature, EncodedVerifyingKey, MlDsa44, MlDsa65, MlDsa87, Signature, SigningKey, + VerifyingKey, + pkcs8::DecodePrivateKey, +}; +use signature::{Error, Signer, Verifier}; + +macro_rules! define_ml_dsa_signer { + ($name:ident, $alg:expr, $params:ty) => { + pub struct $name(SigningKey<$params>); + + impl $name { + pub(crate) fn new(encoding_key: &EncodingKey) -> Result { + if encoding_key.family() != AlgorithmFamily::Mldsa { + return Err(new_error(ErrorKind::InvalidKeyFormat)); + } + + Ok(Self( + SigningKey::<$params>::from_pkcs8_der(encoding_key.as_bytes()) + .map_err(|_| ErrorKind::InvalidKeyFormat)?, + )) + } + } + + impl Signer> for $name { + fn try_sign(&self, msg: &[u8]) -> std::result::Result, Error> { + // The `Signer` impl uses the deterministic variant with an + // empty context, which is what RFC 9964 requires. + let signature: Signature<$params> = self.0.sign(msg); + Ok(signature.encode().to_vec()) + } + } + + impl JwtSigner for $name { + fn algorithm(&self) -> Algorithm { + $alg + } + } + }; +} + +macro_rules! define_ml_dsa_verifier { + ($name:ident, $alg:expr, $params:ty) => { + pub struct $name(VerifyingKey<$params>); + + impl $name { + pub(crate) fn new(decoding_key: &DecodingKey) -> Result { + if decoding_key.family() != AlgorithmFamily::Mldsa { + return Err(new_error(ErrorKind::InvalidKeyFormat)); + } + + // RFC 9964 carries the raw fixed-size public key encoding. + let encoded = EncodedVerifyingKey::<$params>::try_from( + decoding_key.try_get_as_bytes()?, + ) + .map_err(|_| ErrorKind::InvalidKeyFormat)?; + + Ok(Self(VerifyingKey::<$params>::decode(&encoded))) + } + } + + impl Verifier> for $name { + fn verify(&self, msg: &[u8], signature: &Vec) -> std::result::Result<(), Error> { + let encoded = EncodedSignature::<$params>::try_from(signature.as_slice()) + .map_err(Error::from_source)?; + let signature = + Signature::<$params>::decode(&encoded).ok_or_else(Error::new)?; + self.0.verify(msg, &signature).map_err(Error::from_source) + } + } + + impl JwtVerifier for $name { + fn algorithm(&self) -> Algorithm { + $alg + } + } + }; +} + +define_ml_dsa_signer!(MlDsa44Signer, Algorithm::MLDSA44, MlDsa44); +define_ml_dsa_verifier!(MlDsa44Verifier, Algorithm::MLDSA44, MlDsa44); + +define_ml_dsa_signer!(MlDsa65Signer, Algorithm::MLDSA65, MlDsa65); +define_ml_dsa_verifier!(MlDsa65Verifier, Algorithm::MLDSA65, MlDsa65); + +define_ml_dsa_signer!(MlDsa87Signer, Algorithm::MLDSA87, MlDsa87); +define_ml_dsa_verifier!(MlDsa87Verifier, Algorithm::MLDSA87, MlDsa87); + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::{sign, verify}; + use crate::jwk::Jwk; + use ml_dsa::signature::Keypair; + use ml_dsa::{Generate, pkcs8::EncodePrivateKey}; + + macro_rules! round_trip_test { + ($test_name:ident, $params:ty, $alg:expr) => { + #[test] + fn $test_name() { + // Generate a signing key via the getrandom-backed default RNG. + let signing_key = SigningKey::<$params>::generate(); + + // Private key -> PKCS#8 DER for the EncodingKey. + let pkcs8 = signing_key.to_pkcs8_der().unwrap(); + let encoding_key = EncodingKey::from_mldsa_der(pkcs8.as_bytes()); + + // Public key -> raw fixed-size encoding for the DecodingKey (RFC 9964). + let raw_pub = signing_key.verifying_key().encode(); + let decoding_key = DecodingKey::from_mldsa_der(&raw_pub); + + let msg = b"hello ml-dsa world"; + let sig = sign(msg, &encoding_key, $alg).unwrap(); + + assert!(verify(&sig, msg, &decoding_key, $alg).unwrap()); + // A tampered message must not verify. + assert!(!verify(&sig, b"tampered", &decoding_key, $alg).unwrap()); + } + }; + } + + round_trip_test!(round_trip_ml_dsa_44, MlDsa44, Algorithm::MLDSA44); + round_trip_test!(round_trip_ml_dsa_65, MlDsa65, Algorithm::MLDSA65); + round_trip_test!(round_trip_ml_dsa_87, MlDsa87, Algorithm::MLDSA87); + + macro_rules! jwk_round_trip_test { + ($test_name:ident, $params:ty, $alg:expr) => { + #[test] + fn $test_name() { + let signing_key = SigningKey::<$params>::generate(); + let pkcs8 = signing_key.to_pkcs8_der().unwrap(); + let encoding_key = EncodingKey::from_mldsa_der(pkcs8.as_bytes()); + + // EncodingKey -> AKP JWK (derives `pub` via KeyUtils). + let jwk = Jwk::from_encoding_key(&encoding_key, $alg).unwrap(); + assert!(jwk.is_supported()); + + // The AKP JWK derived from the public part of the decoding key + // must be identical. + let raw_pub = signing_key.verifying_key().encode(); + let decoding_key = DecodingKey::from_mldsa_der(&raw_pub); + let jwk_from_dec = Jwk::from_decoding_key(&decoding_key, Some($alg)).unwrap(); + assert_eq!(jwk.algorithm, jwk_from_dec.algorithm); + + // JWK -> DecodingKey -> verify a signature made with the encoding key. + let decoding_key_from_jwk = DecodingKey::from_jwk(&jwk).unwrap(); + let msg = b"hello ml-dsa jwk"; + let sig = sign(msg, &encoding_key, $alg).unwrap(); + assert!(verify(&sig, msg, &decoding_key_from_jwk, $alg).unwrap()); + } + }; + } + + jwk_round_trip_test!(jwk_round_trip_ml_dsa_44, MlDsa44, Algorithm::MLDSA44); + jwk_round_trip_test!(jwk_round_trip_ml_dsa_65, MlDsa65, Algorithm::MLDSA65); + jwk_round_trip_test!(jwk_round_trip_ml_dsa_87, MlDsa87, Algorithm::MLDSA87); +} diff --git a/src/crypto/rust_crypto/mod.rs b/src/crypto/rust_crypto/mod.rs index 1dd5bec4..a406d33c 100644 --- a/src/crypto/rust_crypto/mod.rs +++ b/src/crypto/rust_crypto/mod.rs @@ -4,6 +4,11 @@ use ::rsa::{ traits::PublicKeyParts, }; use ed25519_dalek::SigningKey as Ed25519SigningKey; +use ::ml_dsa::signature::Keypair as MlDsaKeypair; +use ::ml_dsa::{ + MlDsa44, MlDsa65, MlDsa87, SigningKey as MlDsaSigningKey, + pkcs8::DecodePrivateKey as MlDsaDecodePrivateKey, +}; use p256::{ecdsa::SigningKey as P256SigningKey, pkcs8::DecodePrivateKey}; use p384::ecdsa::SigningKey as P384SigningKey; use sha2::{Digest, Sha256, Sha384, Sha512}; @@ -18,6 +23,7 @@ use crate::{ mod ecdsa; mod eddsa; mod hmac; +mod ml_dsa; mod rsa; fn rsa_components_from_private_key(key_content: &[u8]) -> errors::Result<(Vec, Vec)> { @@ -80,6 +86,34 @@ fn ed_pub_components_from_private_key( } } +fn mldsa_pub_components_from_private_key( + encoding_key: &[u8], + alg: Algorithm, +) -> errors::Result> { + // Decode the PKCS#8 private key for the matching parameter set and emit the + // raw fixed-size public key encoding used by RFC 9964. + let public_key = match alg { + Algorithm::MLDSA44 => MlDsaSigningKey::::from_pkcs8_der(encoding_key) + .map_err(|_| ErrorKind::InvalidKeyFormat)? + .verifying_key() + .encode() + .to_vec(), + Algorithm::MLDSA65 => MlDsaSigningKey::::from_pkcs8_der(encoding_key) + .map_err(|_| ErrorKind::InvalidKeyFormat)? + .verifying_key() + .encode() + .to_vec(), + Algorithm::MLDSA87 => MlDsaSigningKey::::from_pkcs8_der(encoding_key) + .map_err(|_| ErrorKind::InvalidKeyFormat)? + .verifying_key() + .encode() + .to_vec(), + _ => return Err(ErrorKind::InvalidAlgorithm.into()), + }; + + Ok(public_key) +} + fn compute_digest(data: &[u8], hash_function: ThumbprintHash) -> errors::Result> { Ok(match hash_function { ThumbprintHash::SHA256 => Sha256::digest(data).to_vec(), @@ -102,6 +136,9 @@ fn new_signer(algorithm: &Algorithm, key: &EncodingKey) -> Result Box::new(rsa::RsaPss384Signer::new(key)?) as Box, Algorithm::PS512 => Box::new(rsa::RsaPss512Signer::new(key)?) as Box, Algorithm::EdDSA => Box::new(eddsa::EdDSASigner::new(key)?) as Box, + Algorithm::MLDSA44 => Box::new(ml_dsa::MlDsa44Signer::new(key)?) as Box, + Algorithm::MLDSA65 => Box::new(ml_dsa::MlDsa65Signer::new(key)?) as Box, + Algorithm::MLDSA87 => Box::new(ml_dsa::MlDsa87Signer::new(key)?) as Box, }; Ok(jwt_signer) @@ -124,6 +161,9 @@ fn new_verifier( Algorithm::PS384 => Box::new(rsa::RsaPss384Verifier::new(key)?) as Box, Algorithm::PS512 => Box::new(rsa::RsaPss512Verifier::new(key)?) as Box, Algorithm::EdDSA => Box::new(eddsa::EdDSAVerifier::new(key)?) as Box, + Algorithm::MLDSA44 => Box::new(ml_dsa::MlDsa44Verifier::new(key)?) as Box, + Algorithm::MLDSA65 => Box::new(ml_dsa::MlDsa65Verifier::new(key)?) as Box, + Algorithm::MLDSA87 => Box::new(ml_dsa::MlDsa87Verifier::new(key)?) as Box, }; Ok(jwt_verifier) @@ -138,6 +178,7 @@ pub static DEFAULT_PROVIDER: CryptoProvider = CryptoProvider { rsa_pub_components_from_public_key: rsa_components_from_public_key, ec_pub_components_from_private_key: ec_components_from_private_key, ed_pub_components_from_private_key, + mldsa_pub_components_from_private_key, compute_digest, }, }; diff --git a/src/decoding.rs b/src/decoding.rs index 154cf2b3..f27757fe 100644 --- a/src/decoding.rs +++ b/src/decoding.rs @@ -4,7 +4,10 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use serde::de::DeserializeOwned; use zeroize::{Zeroize, ZeroizeOnDrop}; -use crate::algorithms::AlgorithmFamily; +use crate::algorithms::{ + Algorithm, AlgorithmFamily, ML_DSA_44_PUBLIC_KEY_LEN, ML_DSA_65_PUBLIC_KEY_LEN, + ML_DSA_87_PUBLIC_KEY_LEN, +}; use crate::crypto::{CryptoProvider, JwtVerifier}; use crate::errors::{ErrorKind, Result, new_error}; use crate::header::Header; @@ -200,6 +203,41 @@ impl DecodingKey { } } + /// If you know what you're doing and have the raw ML-DSA public key bytes + /// (the fixed-size encoding used by RFC 9964), use this. + pub fn from_mldsa_der(der: &[u8]) -> Self { + DecodingKey { + family: AlgorithmFamily::Mldsa, + kind: DecodingKeyKind::SecretOrDer(der.to_vec()), + } + } + + /// If you have a ML-DSA public key in PEM (SPKI) format, use this. + /// Only exists if the feature `use_pem` is enabled. + #[cfg(feature = "use_pem")] + pub fn from_mldsa_pem(key: &[u8]) -> Result { + let pem_key = PemEncodedKey::new(key)?; + let content = pem_key.as_mldsa_public_key()?; + Ok(DecodingKey { + family: AlgorithmFamily::Mldsa, + kind: DecodingKeyKind::SecretOrDer(content.to_vec()), + }) + } + + /// From the `pub` part (base64url encoded) of an RFC 9964 AKP JWK. + pub fn from_mldsa_components(pub_key: &str) -> Result { + let decoded = b64_decode(pub_key)?; + // The raw public key must be one of the fixed FIPS 204 sizes. + match decoded.len() { + ML_DSA_44_PUBLIC_KEY_LEN | ML_DSA_65_PUBLIC_KEY_LEN | ML_DSA_87_PUBLIC_KEY_LEN => {} + _ => return Err(new_error(ErrorKind::InvalidKeyFormat)), + } + Ok(DecodingKey { + family: AlgorithmFamily::Mldsa, + kind: DecodingKeyKind::SecretOrDer(decoded), + }) + } + /// From x part (base64 encoded) of the JWK encoding pub fn from_ed_components(x: &str) -> Result { let x_decoded = b64_decode(x)?; @@ -226,6 +264,54 @@ impl DecodingKey { kind: DecodingKeyKind::SecretOrDer(out), }) } + AlgorithmParameters::AlgorithmKeyPair(params) => { + // RFC 9964 requires the "alg" parameter for AKP keys, and it is + // authoritative for the ML-DSA parameter set. Do not trust the + // key material without a matching valid algorithm. + // + // A `Jwk` can carry the algorithm in two places: + // the per-parameter `AKPKeyParameters::alg` and the shared + // top-level `common.key_algorithm`. Reconcile them: use whichever + // is present, and if both are present but disagree, reject. + let param_alg: Option = if params.alg.is_empty() { + None + } else { + Some(params.alg.parse().map_err(|_| new_error(ErrorKind::InvalidAlgorithm))?) + }; + let common_alg: Option = + jwk.common.key_algorithm.map(Algorithm::try_from).transpose()?; + + let alg = match (param_alg, common_alg) { + (Some(a), Some(b)) if a != b => { + return Err(new_error(ErrorKind::InvalidAlgorithm)); + } + (Some(a), _) | (None, Some(a)) => a, + (None, None) => return Err(new_error(ErrorKind::InvalidAlgorithm)), + }; + + if alg.family() != AlgorithmFamily::Mldsa { + return Err(new_error(ErrorKind::InvalidAlgorithm)); + } + + // The declared parameter set fixes the exact public-key length + // (FIPS 204). Reject keys whose `pub` does not match. + let decoded = b64_decode(¶ms.pub_)?; + let expected_len = match alg { + Algorithm::MLDSA44 => ML_DSA_44_PUBLIC_KEY_LEN, + Algorithm::MLDSA65 => ML_DSA_65_PUBLIC_KEY_LEN, + Algorithm::MLDSA87 => ML_DSA_87_PUBLIC_KEY_LEN, + // Unreachable: family check above guarantees an ML-DSA alg. + _ => return Err(new_error(ErrorKind::InvalidAlgorithm)), + }; + if decoded.len() != expected_len { + return Err(new_error(ErrorKind::InvalidKeyFormat)); + } + + Ok(DecodingKey { + family: AlgorithmFamily::Mldsa, + kind: DecodingKeyKind::SecretOrDer(decoded), + }) + } AlgorithmParameters::Other(_) => Err(ErrorKind::UnsupportedAlgorithm.into()), } } diff --git a/src/encoding.rs b/src/encoding.rs index 746be053..c25d8ad6 100644 --- a/src/encoding.rs +++ b/src/encoding.rs @@ -94,6 +94,17 @@ impl EncodingKey { Ok(EncodingKey { family: AlgorithmFamily::Ed, content: content.to_vec() }) } + /// If you are loading a ML-DSA key from a .pem file. + /// This errors if the key is not a valid ML-DSA key. + /// Only exists if the feature `use_pem` is enabled. + /// + #[cfg(feature = "use_pem")] + pub fn from_mldsa_pem(key: &[u8]) -> Result { + let pem_key = PemEncodedKey::new(key)?; + let content = pem_key.as_mldsa_private_key()?; + Ok(EncodingKey { family: AlgorithmFamily::Mldsa, content: content.to_vec() }) + } + /// If you know what you're doing and have the DER-encoded key, for RSA only pub fn from_rsa_der(der: &[u8]) -> Self { EncodingKey { family: AlgorithmFamily::Rsa, content: der.to_vec() } @@ -109,6 +120,11 @@ impl EncodingKey { EncodingKey { family: AlgorithmFamily::Ed, content: der.to_vec() } } + /// If you know what you're doing and have the DER-encoded key, for ML-DSA + pub fn from_mldsa_der(der: &[u8]) -> Self { + EncodingKey { family: AlgorithmFamily::Mldsa, content: der.to_vec() } + } + /// Get the value of the key. /// /// To be used for defining your own `CryptoProvider`. diff --git a/src/jwk.rs b/src/jwk.rs index c15d83a2..48c02b78 100644 --- a/src/jwk.rs +++ b/src/jwk.rs @@ -8,6 +8,9 @@ use std::{fmt, str::FromStr}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use crate::algorithms::{ + ML_DSA_44_PUBLIC_KEY_LEN, ML_DSA_65_PUBLIC_KEY_LEN, ML_DSA_87_PUBLIC_KEY_LEN, +}; use crate::crypto::{CryptoProvider, ec_pub_components_from_public_key}; use crate::errors::{self, Error, ErrorKind, new_error}; use crate::serialization::b64_encode; @@ -192,6 +195,16 @@ pub enum KeyAlgorithm { #[serde(rename = "RSA-OAEP-256")] RSA_OAEP_256, + /// ML-DSA-44 as described in US NIST FIPS 204 + #[serde(rename = "ML-DSA-44")] + MLDSA44, + /// ML-DSA-65 as described in US NIST FIPS 204 + #[serde(rename = "ML-DSA-65")] + MLDSA65, + /// ML-DSA-87 as described in US NIST FIPS 204 + #[serde(rename = "ML-DSA-87")] + MLDSA87, + /// Catch-All for when the key algorithm can not be determined or is not supported #[serde(other)] UNKNOWN_ALGORITHM, @@ -216,6 +229,9 @@ impl FromStr for KeyAlgorithm { "RSA1_5" => Ok(KeyAlgorithm::RSA1_5), "RSA-OAEP" => Ok(KeyAlgorithm::RSA_OAEP), "RSA-OAEP-256" => Ok(KeyAlgorithm::RSA_OAEP_256), + "ML-DSA-44" => Ok(KeyAlgorithm::MLDSA44), + "ML-DSA-65" => Ok(KeyAlgorithm::MLDSA65), + "ML-DSA-87" => Ok(KeyAlgorithm::MLDSA87), _ => Err(ErrorKind::InvalidAlgorithmName.into()), } } @@ -236,6 +252,9 @@ impl From for KeyAlgorithm { Algorithm::PS384 => KeyAlgorithm::PS384, Algorithm::PS512 => KeyAlgorithm::PS512, Algorithm::EdDSA => KeyAlgorithm::EdDSA, + Algorithm::MLDSA44 => KeyAlgorithm::MLDSA44, + Algorithm::MLDSA65 => KeyAlgorithm::MLDSA65, + Algorithm::MLDSA87 => KeyAlgorithm::MLDSA87, } } } @@ -257,6 +276,9 @@ impl TryFrom for Algorithm { KeyAlgorithm::PS384 => Ok(Algorithm::PS384), KeyAlgorithm::PS512 => Ok(Algorithm::PS512), KeyAlgorithm::EdDSA => Ok(Algorithm::EdDSA), + KeyAlgorithm::MLDSA44 => Ok(Algorithm::MLDSA44), + KeyAlgorithm::MLDSA65 => Ok(Algorithm::MLDSA65), + KeyAlgorithm::MLDSA87 => Ok(Algorithm::MLDSA87), _ => Err(new_error(ErrorKind::UnsupportedAlgorithm)), } } @@ -264,7 +286,12 @@ impl TryFrom for Algorithm { impl fmt::Display for KeyAlgorithm { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{:?}", self) + match self { + KeyAlgorithm::MLDSA44 => write!(f, "ML-DSA-44"), + KeyAlgorithm::MLDSA65 => write!(f, "ML-DSA-65"), + KeyAlgorithm::MLDSA87 => write!(f, "ML-DSA-87"), + other => write!(f, "{:?}", other), + } } } @@ -449,6 +476,44 @@ pub struct OtherKeyParameters { pub fields: BTreeMap, } +/// Key type value for an AKP. +/// This single value enum is a workaround for Rust not supporting associated constants. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Hash)] +pub enum AKPKeyType { + /// Key type value for an AKP. + #[default] + AKP, +} + +/// Parameters for an AKP +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Default, Hash)] +pub struct AKPKeyParameters { + /// Key type value for an AKP + #[serde(rename = "kty")] + pub key_type: AKPKeyType, + + /// The "alg" parameter contains the algorithm name. + /// + /// On the wire this member is shared with the top-level JWK `alg` + /// (see `CommonParameters::key_algorithm`). To avoid emitting a duplicate + /// `alg` JSON member when both `common` and `algorithm` are flattened, this + /// field is skipped by serde and is instead populated/emitted by the custom + /// `Serialize`/`Deserialize` implementations on `Jwk`. + #[serde(default, skip)] + pub alg: String, + + /// The "priv" parameter contains the private key. + /// It is optional since public JWKs do not carry it. + /// Underscore is used since "priv" is a rust keyword. + #[serde(rename = "priv", skip_serializing_if = "Option::is_none", default)] + pub priv_: Option, + + /// The "pub" parameter contains the public key. + /// Underscore is used since "pub" is a rust keyword. + #[serde(rename = "pub")] + pub pub_: String, +} + /// Algorithm specific parameters #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)] #[serde(untagged)] @@ -459,6 +524,7 @@ pub enum AlgorithmParameters { RSA(RSAKeyParameters), OctetKey(OctetKeyParameters), OctetKeyPair(OctetKeyPairParameters), + AlgorithmKeyPair(AKPKeyParameters), Other(OtherKeyParameters), } @@ -472,22 +538,88 @@ pub enum ThumbprintHash { SHA512, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)] +#[derive(Clone, Debug, Eq, PartialEq, Hash)] #[allow(missing_docs)] pub struct Jwk { - #[serde(flatten)] pub common: CommonParameters, /// Key algorithm specific parameters - #[serde(flatten)] pub algorithm: AlgorithmParameters, } +/// Serde helper mirroring the flattened wire layout of a [`Jwk`]. +/// +/// All fields other than the AKP `alg` are handled entirely by serde. The AKP +/// `alg` member is shared with the top-level `alg` (`CommonParameters`), so it +/// is skipped inside `AKPKeyParameters` and reconciled here in [`Jwk`]'s +/// `Serialize`/`Deserialize` implementations. +#[derive(Serialize, Deserialize)] +struct JwkWire { + #[serde(flatten)] + common: CommonParameters, + #[serde(flatten)] + algorithm: AlgorithmParameters, +} + +impl Serialize for Jwk { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if let AlgorithmParameters::AlgorithmKeyPair(akp) = &self.algorithm { + let alg = self.reconciled_akp_alg(akp).map_err(serde::ser::Error::custom)?; + let mut common = self.common.clone(); + common.key_algorithm = None; + let mut value = + serde_json::to_value(JwkWire { common, algorithm: self.algorithm.clone() }) + .map_err(serde::ser::Error::custom)?; + value + .as_object_mut() + .ok_or_else(|| serde::ser::Error::custom("JWK must serialize as an object"))? + .insert("alg".to_owned(), serde_json::Value::String(alg)); + return value.serialize(serializer); + } + + JwkWire { common: self.common.clone(), algorithm: self.algorithm.clone() } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Jwk { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let raw_alg = value.get("alg").and_then(serde_json::Value::as_str).map(str::to_owned); + let JwkWire { common, mut algorithm } = + serde_json::from_value(value).map_err(de::Error::custom)?; + + // The AKP `alg` is skipped by serde (shared with the top-level `alg`), + // so it can only arrive via `common.key_algorithm`. Backfill the + // per-parameter copy so both authoritative values agree. Without this + // the field would be an empty string and thumbprint/decoding would be + // wrong. RFC 9964 requires `alg` for AKP keys, so its absence is an + // error. + if let AlgorithmParameters::AlgorithmKeyPair(akp) = &mut algorithm { + akp.alg = raw_alg.ok_or_else(|| de::Error::missing_field("alg"))?; + } + + Ok(Jwk { common, algorithm }) + } +} + impl Jwk { /// Find whether the Algorithm is implemented and supported pub fn is_supported(&self) -> bool { - match self.common.key_algorithm { - Some(alg) => alg.to_algorithm().is_ok(), - _ => false, + match &self.algorithm { + AlgorithmParameters::AlgorithmKeyPair(akp) => self + .reconciled_akp_alg(akp) + .and_then(|alg| Algorithm::from_str(&alg)) + .is_ok_and(|alg| alg.family() == AlgorithmFamily::Mldsa), + _ => match self.common.key_algorithm { + Some(alg) => alg.to_algorithm().is_ok(), + None => false, + }, } } @@ -547,6 +679,20 @@ impl Jwk { x: b64_encode(public_key_bytes), }) } + AlgorithmFamily::Mldsa => { + let public_key_bytes = (CryptoProvider::get_default() + .key_utils + .mldsa_pub_components_from_private_key)( + key.as_bytes(), alg + )?; + + AlgorithmParameters::AlgorithmKeyPair(AKPKeyParameters { + key_type: AKPKeyType::AKP, + alg: alg.to_string(), + priv_: None, + pub_: b64_encode(public_key_bytes), + }) + } }, }) } @@ -605,10 +751,54 @@ impl Jwk { x: b64_encode(x), }) } + crate::algorithms::AlgorithmFamily::Mldsa => { + let alg = alg.ok_or_else(|| new_error(ErrorKind::InvalidAlgorithm))?; + let expected_len = match alg { + Algorithm::MLDSA44 => ML_DSA_44_PUBLIC_KEY_LEN, + Algorithm::MLDSA65 => ML_DSA_65_PUBLIC_KEY_LEN, + Algorithm::MLDSA87 => ML_DSA_87_PUBLIC_KEY_LEN, + _ => return Err(new_error(ErrorKind::InvalidAlgorithm)), + }; + let pub_bytes = key.try_get_as_bytes()?; + if pub_bytes.len() != expected_len { + return Err(new_error(ErrorKind::InvalidKeyFormat)); + } + + AlgorithmParameters::AlgorithmKeyPair(AKPKeyParameters { + key_type: AKPKeyType::AKP, + alg: alg.to_string(), + priv_: None, + pub_: b64_encode(pub_bytes), + }) + } }, }) } + /// Reconcile the two authoritative copies of an AKP algorithm. + /// + /// The algorithm of an AKP key can be stored both in + /// [`AKPKeyParameters::alg`] and in [`CommonParameters::key_algorithm`]. + /// This returns the single agreed wire name, preferring whichever is + /// present and erroring if both are present but disagree, or if neither is + /// (RFC 9964 requires `alg` for AKP keys). + fn reconciled_akp_alg(&self, akp: &AKPKeyParameters) -> errors::Result { + let param_alg = (!akp.alg.is_empty()).then(|| akp.alg.clone()); + let common_alg = self + .common + .key_algorithm + .filter(|alg| *alg != KeyAlgorithm::UNKNOWN_ALGORITHM) + .map(|alg| serde_json::to_value(alg)) + .transpose()? + .and_then(|value| value.as_str().map(str::to_owned)); + + match (common_alg, param_alg) { + (Some(a), Some(b)) if a != b => Err(new_error(ErrorKind::InvalidAlgorithm)), + (Some(a), _) | (None, Some(a)) => Ok(a), + (None, None) => Err(new_error(ErrorKind::InvalidKeyFormat)), + } + } + /// Compute the thumbprint of the JWK. /// /// Per [RFC-7638](https://datatracker.ietf.org/doc/html/rfc7638) @@ -656,6 +846,18 @@ impl Jwk { ) } }, + AlgorithmParameters::AlgorithmKeyPair(a) => { + // Reconcile the two authoritative algorithm copies and use the + // agreed value for the thumbprint (RFC 9964 requires `alg`). + let alg = self.reconciled_akp_alg(a)?; + // Members must appear in lexicographic order: alg, kty, pub. + format!( + r#"{{"alg":{},"kty":{},"pub":"{}"}}"#, + serde_json::to_string(&alg).unwrap(), + serde_json::to_string(&a.key_type).unwrap(), + a.pub_, + ) + } AlgorithmParameters::Other(_) => return Err(ErrorKind::UnsupportedAlgorithm.into()), }; @@ -690,10 +892,13 @@ mod tests { use wasm_bindgen_test::wasm_bindgen_test; use crate::Algorithm; + use crate::algorithms::ML_DSA_44_PUBLIC_KEY_LEN; + use crate::crypto::CryptoProvider; use crate::errors::ErrorKind; use crate::jwk::{ - AlgorithmParameters, CommonParameters, EllipticCurve, Jwk, JwkSet, KeyAlgorithm, - OctetKeyPairParameters, OctetKeyPairType, OctetKeyType, RSAKeyParameters, ThumbprintHash, + AKPKeyParameters, AKPKeyType, AlgorithmParameters, CommonParameters, EllipticCurve, Jwk, + JwkSet, KeyAlgorithm, OctetKeyPairParameters, OctetKeyPairType, OctetKeyType, + RSAKeyParameters, ThumbprintHash, }; use crate::serialization::b64_encode; use crate::{DecodingKey, EncodingKey}; @@ -740,7 +945,7 @@ mod tests { #[test] fn deserialize_unknown_kty() { let parameters_json = json!({ - "kty": "AKP", + "kty": "UKN", "foo": "bar", "solution": 42 }); @@ -749,7 +954,7 @@ mod tests { match parameters_result { AlgorithmParameters::Other(other_key_parameters) => { let mut expected = BTreeMap::new(); - expected.insert("kty".to_owned(), serde_json::to_value("AKP").unwrap()); + expected.insert("kty".to_owned(), serde_json::to_value("UKN").unwrap()); expected.insert("foo".to_owned(), serde_json::to_value("bar").unwrap()); expected.insert("solution".to_owned(), serde_json::to_value(42).unwrap()); assert_eq!(other_key_parameters.fields, expected); @@ -758,19 +963,81 @@ mod tests { panic!("Unexpected deserialization result"); } } + } - // RFC 9964 Appendix A.1 JWK + #[test] + fn deserialize_public_akp_jwk_without_priv() { + // A public AKP JWK omits the `priv` member entirely. let jwk: Jwk = serde_json::from_value(json!({ - "kid": "T4xl70S7MT6Zeq6r9V9fPJGVn76wfnXJ21-gyo0Gu6o", "kty": "AKP", "alg": "ML-DSA-44", - "pub": "...", - "priv": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "pub": "abc", })) .expect("Could not deserialize json"); + // The top-level `alg` is shared with `common.key_algorithm`. + assert_eq!(jwk.common.key_algorithm, Some(KeyAlgorithm::MLDSA44)); + + match jwk.algorithm { + AlgorithmParameters::AlgorithmKeyPair(params) => { + assert_eq!(params.key_type, AKPKeyType::AKP); + assert_eq!(params.pub_, "abc"); + assert!(params.priv_.is_none()); + // `alg` is skipped by serde and backfilled from the shared + // top-level `alg` member during deserialization. + assert_eq!(params.alg, "ML-DSA-44"); + } + _ => panic!("Expected AlgorithmKeyPair"), + } + } + + #[test] + fn akp_jwk_roundtrip_single_alg_member() { + // Encode -> decode round-trip must preserve the AKP parameters and emit + // exactly one `alg` member on the wire (RFC 9964). + let input = json!({ + "kty": "AKP", + "alg": "ML-DSA-44", + "pub": "abc", + }); + + let jwk: Jwk = serde_json::from_value(input).expect("deserialize"); + let value = serde_json::to_value(&jwk).expect("serialize"); + + let obj = value.as_object().expect("object"); + assert_eq!(obj.get("alg").and_then(|v| v.as_str()), Some("ML-DSA-44")); + assert_eq!(obj.get("kty").and_then(|v| v.as_str()), Some("AKP")); + assert_eq!(obj.get("pub").and_then(|v| v.as_str()), Some("abc")); + // No duplicate/nested encoding of `alg`. + assert_eq!(serde_json::to_string(&jwk).unwrap().matches("\"alg\"").count(), 1); + } + + #[test] + fn unknown_akp_alg_roundtrips_and_thumbprints() { + let input = json!({ + "kty": "AKP", + "alg": "future-signature-algorithm", + "pub": "abc", + }); + + let jwk: Jwk = serde_json::from_value(input.clone()).expect("deserialize"); + assert_eq!(jwk.common.key_algorithm, Some(KeyAlgorithm::UNKNOWN_ALGORITHM)); + let AlgorithmParameters::AlgorithmKeyPair(akp) = &jwk.algorithm else { + panic!("expected AlgorithmKeyPair"); + }; + assert_eq!(akp.alg, "future-signature-algorithm"); assert!(!jwk.is_supported()); - assert!(matches!(jwk.algorithm, AlgorithmParameters::Other(_))); + assert_eq!(serde_json::to_value(&jwk).expect("serialize"), input); + + let canonical = r#"{"alg":"future-signature-algorithm","kty":"AKP","pub":"abc"}"#; + let expected = b64_encode( + (CryptoProvider::get_default().key_utils.compute_digest)( + canonical.as_bytes(), + ThumbprintHash::SHA256, + ) + .unwrap(), + ); + assert_eq!(jwk.thumbprint(ThumbprintHash::SHA256).unwrap(), expected); } #[test] @@ -810,6 +1077,165 @@ mod tests { ); } + #[test] + #[wasm_bindgen_test] + fn check_thumbprint_akp() { + // RFC 9964 Section 6: the AKP thumbprint hashes the members + // "alg", "kty", "pub" in lexicographic order. + let jwk = Jwk { + common: CommonParameters { + key_algorithm: Some(KeyAlgorithm::MLDSA44), + ..Default::default() + }, + algorithm: AlgorithmParameters::AlgorithmKeyPair(AKPKeyParameters { + key_type: AKPKeyType::AKP, + alg: "ML-DSA-44".to_owned(), + priv_: None, + pub_: "abc".to_string(), + }), + }; + + let tp = jwk.thumbprint(ThumbprintHash::SHA256).unwrap(); + + // Expected digest computed over the exact canonical JSON string, + // locking both the member ordering and the wire-format of `alg`. + let canonical = r#"{"alg":"ML-DSA-44","kty":"AKP","pub":"abc"}"#; + let expected = b64_encode( + (CryptoProvider::get_default().key_utils.compute_digest)( + canonical.as_bytes(), + ThumbprintHash::SHA256, + ) + .unwrap(), + ); + + assert_eq!(tp, expected); + } + + #[test] + fn deserialize_akp_jwk_missing_alg_fails() { + // RFC 9964 requires `alg` for AKP keys. Deserialization must reject a + // JWK that omits it rather than silently producing an empty `alg`. + let result: Result = serde_json::from_value(json!({ + "kty": "AKP", + "pub": "abc", + })); + assert!(result.is_err()); + } + + #[test] + fn serialize_akp_jwk_conflicting_alg_fails() { + // The two authoritative algorithm copies disagree: serialization must + // refuse rather than emit a JWK that decodes/thumbprints inconsistently. + let jwk = Jwk { + common: CommonParameters { + key_algorithm: Some(KeyAlgorithm::MLDSA44), + ..Default::default() + }, + algorithm: AlgorithmParameters::AlgorithmKeyPair(AKPKeyParameters { + key_type: AKPKeyType::AKP, + alg: "ML-DSA-65".to_owned(), + priv_: None, + pub_: "abc".to_string(), + }), + }; + + assert!(serde_json::to_string(&jwk).is_err()); + } + + #[test] + fn serialize_akp_jwk_backfills_alg_from_params() { + // Only the per-parameter `alg` is set; serialization must backfill the + // shared top-level `alg` so the wire form stays RFC 9964 compliant. + let jwk = Jwk { + common: CommonParameters::default(), + algorithm: AlgorithmParameters::AlgorithmKeyPair(AKPKeyParameters { + key_type: AKPKeyType::AKP, + alg: "ML-DSA-87".to_owned(), + priv_: None, + pub_: "abc".to_string(), + }), + }; + + let value = serde_json::to_value(&jwk).unwrap(); + assert_eq!(value.get("alg").and_then(|v| v.as_str()), Some("ML-DSA-87")); + // Exactly one `alg` member on the wire. + assert_eq!(serde_json::to_string(&jwk).unwrap().matches("\"alg\"").count(), 1); + } + + #[test] + fn is_supported_reconciles_akp_alg() { + let mut jwk = Jwk { + common: CommonParameters::default(), + algorithm: AlgorithmParameters::AlgorithmKeyPair(AKPKeyParameters { + key_type: AKPKeyType::AKP, + alg: "ML-DSA-44".to_owned(), + priv_: None, + pub_: "abc".to_string(), + }), + }; + + assert!(jwk.is_supported()); + + jwk.common.key_algorithm = Some(KeyAlgorithm::MLDSA65); + assert!(!jwk.is_supported()); + } + + #[test] + fn thumbprint_akp_conflicting_alg_fails() { + // A manually constructed JWK with disagreeing algorithm copies must not + // silently produce a thumbprint. + let jwk = Jwk { + common: CommonParameters { + key_algorithm: Some(KeyAlgorithm::MLDSA44), + ..Default::default() + }, + algorithm: AlgorithmParameters::AlgorithmKeyPair(AKPKeyParameters { + key_type: AKPKeyType::AKP, + alg: "ML-DSA-65".to_owned(), + priv_: None, + pub_: "abc".to_string(), + }), + }; + + assert_eq!( + jwk.thumbprint(ThumbprintHash::SHA256).unwrap_err().into_kind(), + ErrorKind::InvalidAlgorithm + ); + } + + #[test] + fn thumbprint_akp_backfills_alg_from_common() { + // Only `common.key_algorithm` is set (per-parameter `alg` empty). The + // thumbprint must still use the agreed algorithm. + let with_common = Jwk { + common: CommonParameters { + key_algorithm: Some(KeyAlgorithm::MLDSA44), + ..Default::default() + }, + algorithm: AlgorithmParameters::AlgorithmKeyPair(AKPKeyParameters { + key_type: AKPKeyType::AKP, + alg: String::new(), + priv_: None, + pub_: "abc".to_string(), + }), + }; + + let with_param = Jwk { + common: CommonParameters::default(), + algorithm: AlgorithmParameters::AlgorithmKeyPair(AKPKeyParameters { + key_type: AKPKeyType::AKP, + alg: "ML-DSA-44".to_owned(), + priv_: None, + pub_: "abc".to_string(), + }), + }; + + assert_eq!( + with_common.thumbprint(ThumbprintHash::SHA256).unwrap(), + with_param.thumbprint(ThumbprintHash::SHA256).unwrap() + ); + } + #[test] #[wasm_bindgen_test] fn check_alg_key_alg_conversion() { @@ -826,6 +1252,9 @@ mod tests { (Algorithm::PS384, KeyAlgorithm::PS384), (Algorithm::PS512, KeyAlgorithm::PS512), (Algorithm::EdDSA, KeyAlgorithm::EdDSA), + (Algorithm::MLDSA44, KeyAlgorithm::MLDSA44), + (Algorithm::MLDSA65, KeyAlgorithm::MLDSA65), + (Algorithm::MLDSA87, KeyAlgorithm::MLDSA87), ]; for (alg, k_alg) in pairs { @@ -889,6 +1318,21 @@ mod tests { assert_eq!(jwk, expected_jwk); } + #[test] + fn check_jwk_from_decoding_key_mldsa_validates_algorithm_and_size() { + let dec_key = DecodingKey::from_mldsa_der(&[0; ML_DSA_44_PUBLIC_KEY_LEN]); + + assert!(Jwk::from_decoding_key(&dec_key, Some(Algorithm::MLDSA44)).is_ok()); + assert_eq!( + Jwk::from_decoding_key(&dec_key, Some(Algorithm::HS256)).unwrap_err().into_kind(), + ErrorKind::InvalidAlgorithm + ); + assert_eq!( + Jwk::from_decoding_key(&dec_key, Some(Algorithm::MLDSA65)).unwrap_err().into_kind(), + ErrorKind::InvalidKeyFormat + ); + } + #[test] fn check_jwkset_default() { #[derive(Default)] diff --git a/src/pem/decoder.rs b/src/pem/decoder.rs index 7fa67c9e..aa7439d0 100644 --- a/src/pem/decoder.rs +++ b/src/pem/decoder.rs @@ -9,6 +9,8 @@ enum PemType { RsaPrivate, EdPublic, EdPrivate, + MldsaPublic, + MldsaPrivate, } #[derive(Debug, PartialEq)] @@ -24,6 +26,7 @@ enum Classification { Ec, Ed, Rsa, + Mldsa, } /// The return type of a successful PEM encoded key with `decode_pem` @@ -103,6 +106,13 @@ impl PemEncodedKey { PemType::RsaPublic } } + Classification::Mldsa => { + if is_private { + PemType::MldsaPrivate + } else { + PemType::MldsaPublic + } + } }; Ok(PemEncodedKey { content: content.into_contents(), @@ -178,6 +188,30 @@ impl PemEncodedKey { }, } } + + /// Can only be PKCS8. Returns the full PKCS#8 DER, as expected by the + /// ML-DSA key parsers in both backends. + pub fn as_mldsa_private_key(&self) -> Result<&[u8]> { + match self.standard { + Standard::Pkcs1 => Err(ErrorKind::InvalidKeyFormat.into()), + Standard::Pkcs8 => match self.pem_type { + PemType::MldsaPrivate => Ok(self.content.as_slice()), + _ => Err(ErrorKind::InvalidKeyFormat.into()), + }, + } + } + + /// Can only be PKCS8. Returns the raw fixed-size public key encoding + /// (the bit string content of the SPKI structure). + pub fn as_mldsa_public_key(&self) -> Result<&[u8]> { + match self.standard { + Standard::Pkcs1 => Err(ErrorKind::InvalidKeyFormat.into()), + Standard::Pkcs8 => match self.pem_type { + PemType::MldsaPublic => extract_first_bitstring(&self.asn1), + _ => Err(ErrorKind::InvalidKeyFormat.into()), + }, + } + } } // This really just finds and returns the first bitstring or octet string @@ -206,7 +240,7 @@ fn extract_first_bitstring(asn1: &[simple_asn1::ASN1Block]) -> Result<&[u8]> { Err(ErrorKind::InvalidEcdsaKey.into()) } -/// Find whether this is EC, RSA, or Ed +/// Find whether this is EC, RSA, Ed, or ML-DSA /// Note: Ed448 keys are not supported fn classify_pem(asn1: &[simple_asn1::ASN1Block]) -> Option { // These should be constant but the macro requires @@ -215,6 +249,10 @@ fn classify_pem(asn1: &[simple_asn1::ASN1Block]) -> Option { let rsa_public_key_oid = simple_asn1::oid!(1, 2, 840, 113_549, 1, 1, 1); // Defined: https://datatracker.ietf.org/doc/html/rfc8410#section-3 id-Ed25519) let ed25519_oid = simple_asn1::oid!(1, 3, 101, 112); + // US NIST standardized ML-DSA variants have one OID each + let mldsa44_oid = simple_asn1::oid!(2, 16, 840, 1, 101, 3, 4, 3, 17); + let mldsa65_oid = simple_asn1::oid!(2, 16, 840, 1, 101, 3, 4, 3, 18); + let mldsa87_oid = simple_asn1::oid!(2, 16, 840, 1, 101, 3, 4, 3, 19); for asn1_entry in asn1 { match asn1_entry { @@ -233,6 +271,9 @@ fn classify_pem(asn1: &[simple_asn1::ASN1Block]) -> Option { if oid == ed25519_oid { return Some(Classification::Ed); } + if oid == mldsa44_oid || oid == mldsa65_oid || oid == mldsa87_oid { + return Some(Classification::Mldsa); + } } _ => {} } diff --git a/tests/lib.rs b/tests/lib.rs index c49bdeca..04a79912 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -2,4 +2,5 @@ mod dangerous; mod ecdsa; mod eddsa; mod header; +mod ml_dsa; mod rsa; diff --git a/tests/ml_dsa/mod.rs b/tests/ml_dsa/mod.rs new file mode 100644 index 00000000..b779891d --- /dev/null +++ b/tests/ml_dsa/mod.rs @@ -0,0 +1,199 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "use_pem")] +use time::OffsetDateTime; +use wasm_bindgen_test::wasm_bindgen_test; + +use jsonwebtoken::{ + Algorithm, DecodingKey, EncodingKey, + crypto::{sign, verify}, +}; +#[cfg(feature = "use_pem")] +use jsonwebtoken::{Header, Validation, decode, encode}; + +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +pub struct Claims { + sub: String, + company: String, + exp: i64, +} + +macro_rules! ml_dsa_der_round_trip { + ($name:ident, $alg:expr, $priv:literal, $pub_raw:literal) => { + #[test] + #[wasm_bindgen_test] + fn $name() { + let privkey = include_bytes!($priv); + let pubkey = include_bytes!($pub_raw); + + let signed = sign(b"hello world", &EncodingKey::from_mldsa_der(privkey), $alg).unwrap(); + let is_valid = + verify(&signed, b"hello world", &DecodingKey::from_mldsa_der(pubkey), $alg).unwrap(); + assert!(is_valid); + + // Wrong message must not verify. + let is_valid = + verify(&signed, b"goodbye world", &DecodingKey::from_mldsa_der(pubkey), $alg) + .unwrap(); + assert!(!is_valid); + } + }; +} + +ml_dsa_der_round_trip!( + round_trip_der_44, + Algorithm::MLDSA44, + "private_ml_dsa_44.der", + "public_ml_dsa_44.raw" +); +ml_dsa_der_round_trip!( + round_trip_der_65, + Algorithm::MLDSA65, + "private_ml_dsa_65.der", + "public_ml_dsa_65.raw" +); +ml_dsa_der_round_trip!( + round_trip_der_87, + Algorithm::MLDSA87, + "private_ml_dsa_87.der", + "public_ml_dsa_87.raw" +); + +#[cfg(feature = "use_pem")] +macro_rules! ml_dsa_pem_round_trip_claim { + ($name:ident, $alg:expr, $priv:literal, $pub:literal) => { + #[cfg(feature = "use_pem")] + #[test] + #[wasm_bindgen_test] + fn $name() { + let privkey_pem = include_bytes!($priv); + let pubkey_pem = include_bytes!($pub); + let my_claims = Claims { + sub: "b@b.com".to_string(), + company: "ACME".to_string(), + exp: OffsetDateTime::now_utc().unix_timestamp() + 10000, + }; + let token = encode( + &Header::new($alg), + &my_claims, + &EncodingKey::from_mldsa_pem(privkey_pem).unwrap(), + ) + .unwrap(); + let token_data = decode::( + &token, + &DecodingKey::from_mldsa_pem(pubkey_pem).unwrap(), + &Validation::new($alg), + ) + .unwrap(); + assert_eq!(my_claims, token_data.claims); + } + }; +} + +#[cfg(feature = "use_pem")] +ml_dsa_pem_round_trip_claim!( + round_trip_pem_claim_44, + Algorithm::MLDSA44, + "private_ml_dsa_44.pem", + "public_ml_dsa_44.pem" +); +#[cfg(feature = "use_pem")] +ml_dsa_pem_round_trip_claim!( + round_trip_pem_claim_65, + Algorithm::MLDSA65, + "private_ml_dsa_65.pem", + "public_ml_dsa_65.pem" +); +#[cfg(feature = "use_pem")] +ml_dsa_pem_round_trip_claim!( + round_trip_pem_claim_87, + Algorithm::MLDSA87, + "private_ml_dsa_87.pem", + "public_ml_dsa_87.pem" +); + +#[cfg(feature = "use_pem")] +#[test] +#[wasm_bindgen_test] +fn ml_dsa_jwk_round_trip() { + use jsonwebtoken::jwk::Jwk; + + let privkey_pem = include_bytes!("private_ml_dsa_65.pem"); + let encoding_key = EncodingKey::from_mldsa_pem(privkey_pem).unwrap(); + + // EncodingKey -> AKP JWK -> DecodingKey, then verify a real token. + let jwk = Jwk::from_encoding_key(&encoding_key, Algorithm::MLDSA65).unwrap(); + assert!(jwk.is_supported()); + + let my_claims = Claims { + sub: "b@b.com".to_string(), + company: "ACME".to_string(), + exp: OffsetDateTime::now_utc().unix_timestamp() + 10000, + }; + let token = + encode(&Header::new(Algorithm::MLDSA65), &my_claims, &encoding_key).unwrap(); + let token_data = decode::( + &token, + &DecodingKey::from_jwk(&jwk).unwrap(), + &Validation::new(Algorithm::MLDSA65), + ) + .unwrap(); + assert_eq!(my_claims, token_data.claims); +} + +// Helper: base64url (no pad) encode, matching the JWK `pub` encoding. +fn b64url(bytes: &[u8]) -> String { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + URL_SAFE_NO_PAD.encode(bytes) +} + +#[test] +#[wasm_bindgen_test] +fn from_jwk_akp_valid_succeeds() { + use jsonwebtoken::jwk::Jwk; + + let pub_raw = include_bytes!("public_ml_dsa_44.raw"); + let jwk: Jwk = serde_json::from_value(serde_json::json!({ + "kty": "AKP", + "alg": "ML-DSA-44", + "pub": b64url(pub_raw), + })) + .unwrap(); + + assert!(DecodingKey::from_jwk(&jwk).is_ok()); +} + +#[test] +#[wasm_bindgen_test] +fn from_jwk_akp_non_mldsa_alg_fails() { + use jsonwebtoken::jwk::Jwk; + + let pub_raw = include_bytes!("public_ml_dsa_44.raw"); + // An AKP JWK declaring a non-ML-DSA algorithm must be rejected. + let jwk: Jwk = serde_json::from_value(serde_json::json!({ + "kty": "AKP", + "alg": "SLH-DSA-SHA2-128s", + "pub": b64url(pub_raw), + })) + .unwrap(); + + assert!(!jwk.is_supported()); + assert!(DecodingKey::from_jwk(&jwk).is_err()); +} + +#[test] +#[wasm_bindgen_test] +fn from_jwk_akp_wrong_param_set_fails() { + use jsonwebtoken::jwk::Jwk; + + // Declares ML-DSA-44 (expects a 1312-byte key) but carries an + // ML-DSA-87 public key (2592 bytes). + let pub_raw = include_bytes!("public_ml_dsa_87.raw"); + let jwk: Jwk = serde_json::from_value(serde_json::json!({ + "kty": "AKP", + "alg": "ML-DSA-44", + "pub": b64url(pub_raw), + })) + .unwrap(); + + assert!(DecodingKey::from_jwk(&jwk).is_err()); +} diff --git a/tests/ml_dsa/private_ml_dsa_44.der b/tests/ml_dsa/private_ml_dsa_44.der new file mode 100644 index 0000000000000000000000000000000000000000..d4708be3a685f6f539e991cfc1f0d9a5a8c6023e GIT binary patch literal 54 zcmXpoVPa%3;AZ1YX!Br9WoBU(WKn8RsJv*IdF|-WY20f*Zw+OCSDn+s%6evMQZ=Ld K(fst~?n(fi@Dhvw literal 0 HcmV?d00001 diff --git a/tests/ml_dsa/private_ml_dsa_44.pem b/tests/ml_dsa/private_ml_dsa_44.pem new file mode 100644 index 00000000..cd6d854e --- /dev/null +++ b/tests/ml_dsa/private_ml_dsa_44.pem @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MDQCAQAwCwYJYIZIAWUDBAMRBCKAIHnROWnWxfmWC6zztVUH7ntshAUFzJViewFH +xW9np0ci +-----END PRIVATE KEY----- diff --git a/tests/ml_dsa/private_ml_dsa_65.der b/tests/ml_dsa/private_ml_dsa_65.der new file mode 100644 index 0000000000000000000000000000000000000000..8fd7cbf0ddcd0fa809f28e8b3ddb2196646e093c GIT binary patch literal 54 zcmXpoVPa%3;AZ1YX!Br9WoBU(Vo_>PIQYCVLNp`(pjvrWadhC)%86RXYI;=LQ;y$N KVdfUh*8%{0F%Y8w literal 0 HcmV?d00001 diff --git a/tests/ml_dsa/private_ml_dsa_65.pem b/tests/ml_dsa/private_ml_dsa_65.pem new file mode 100644 index 00000000..a4b9ca86 --- /dev/null +++ b/tests/ml_dsa/private_ml_dsa_65.pem @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MDQCAQAwCwYJYIZIAWUDBAMSBCKAIMHngVgVaF/BJndqc1tRpXmRKsZ8jCWHZMfd +JAMLEW8q +-----END PRIVATE KEY----- diff --git a/tests/ml_dsa/private_ml_dsa_87.der b/tests/ml_dsa/private_ml_dsa_87.der new file mode 100644 index 0000000000000000000000000000000000000000..d0d2d9200bc9cd1a9990ea3b75e729eac96e53db GIT binary patch literal 54 zcmV-60LlL_Gy(wtFbf6=V1`HmWdj5Q69giFAQ32N*$mtc=1MNN2JW==sI6nTB2#1N M0Uh%_qryulzzvqy$0H6#{+iVJLvPr4_$XXAmG zuV4YRx$vg^KsE+$+NvYY#njZ|*ws~)7{AL;thEcD!Dtq6Pq7OPGc?Z>g#1S}WdzCp zOqBJ=V(dfb)ZZU~uYuWlBgR%l=f8aufZm%a>n%~i?O~W9hTdLdxeki4b~C6Oy{5G* z7Br4d4-N2G<$R(>iWjb8uSZDdY$il}LbOLNz7S0=L~zGb-@)`;GfSHwG<{Ln)nPPv z6yDy;>xc~Z_jcWb0AY(Q64gG<`6R{+v}wPOJ(Y`i7$h2F$$%P>?6h$lDWORCN?CEN zgHKuRN(%gXAh6lLMin)_$$>?K);xn5nWU%XQg2}Y^2XJPi^R=*VI+DOrN%`ea`LAw zZb6ZGwg-irF*)4Ipex$S4~pw((0=nHe5-c?)Ov{@q+%Io4>864&nJ9}8*LwiidJFNDE9|VA{ z51Ia)b^uW&p^f@@kj}~W1gJ^2PxAW6Le)Mvw4eb^SskG(>){5h48g9Z8rY^B7Ozhc zQKfJvwXadtJWFal7V#q|$=(z*?|p;l)Y4g5U&Y?~;z`o$IJFG#u;i1P;eYa7ue=Kw zG!JhTxu6MY8b3&h+g3&Z3BGjbpb(h$=QI`|Rd{xfyxLNDWToqS88pt>agU8--*^NB zWatk1UDTbC4)4b3tcty}yBVogI_=iaCzv2;d~gPV=DE3-wYWn1-Jw8fj@T?}kV6kT zBqa2jI=`u-z4RJ8ubW7r!zyTYVBoR$RI&8y{G<#MJ?+e*4A^kK3Yhx%cvHCGeQl~} zMIrtN1IDF)qy_@%RD96+IKRSXhjSXg5Iwx*c$Y*Y$YIO85+epxMsThY$`UK`C<4b% zlEWyG+^1yx$N>I5{~w7z@ZGk0&0G4VVs6|J*+};V*$)h9*(e)DQM`@Tq&=>vhKy7? zs#lVI%cIN+H168!)cV9P>bbCRr6nRE;h{C#Fb(>P;7ysAlAl`vZ5WnGLXyvlJGCUT z>P@~CK%ZjD<|q_Q`1Gq#UXg}i33bIgN<35p1)909*S4QL%R{~5&74VR9eThQ>Q#jD zSHZjjknl{h6RQc8AJ*Tx&Jli4C6OKXsJ}+vzeC^ICKxvUBf1J0O!`ApYz#*^q;m{@ s_PN1r&;X{l#`Bc_T>Zo`D9`M_>q|g;XiLw)KW!PBRJ@XHsFZH2LC;E+f&c&j literal 0 HcmV?d00001 diff --git a/tests/ml_dsa/public_ml_dsa_44.pem b/tests/ml_dsa/public_ml_dsa_44.pem new file mode 100644 index 00000000..158261ab --- /dev/null +++ b/tests/ml_dsa/public_ml_dsa_44.pem @@ -0,0 +1,30 @@ +-----BEGIN PUBLIC KEY----- +MIIFMjALBglghkgBZQMEAxEDggUhAMY+JbbYlL5b1d3YoKEfx1B+WcKfYllxGwQG ++jQbobvNGpieoHsqhNMm/CaS17ncER4+S9P47mYRFLcfhR/ZmjfuUWqBpwknjh5V +UhAIYuunsRwBOOv3Se70KjdPlH1V3j7jaSEsynyHHWFWdy2pvw9g04Bpu6UeOAnU +MLbbIHZZRYMyJ9a+4EDQd62yZqLYZBnMoiaMgz870ZbXiGktc2FzgAlofiCLWlil +iL6t5OwDt+lOJP9PwL0G2mfHA87U09eFWDAT2RG53DVCyheKmBliAGDgXgbVV+o7 +yIoLbIxPuie5Z+OBmK9gAbS58Kb8QDYGbtqqI87F1NTi2NVVlRi/y0+stQufwWgW +cE+xCw0zNM8UhPxHNWUEyf9MlPXIYuxD59TfH4Cvgdl5I8ZWROe/fROA3psp6y1R +we1hmCGG3l5juQ6KsXYzqBu9prUrFjSOTg8N8FjlfKJGiheuYq9HSOdsJkR8QrRH +Lr4QTS5EcMdT38H0XDNLmyA0fVHY1WE0eBTe3svriAz393bdgwBhiy0S1T7N+STG +DLRpv489lYt4GCQaY8mAGpHstHEcKaFI+EpZcayDT1nuSgr8eiCw2b9GFTW+yYFF +g9Y8gxmZpKflUm9g//LG1YmLxM18YSR6GKXGRSFy8qctbkGRebYHhZwxOdzKoCva +yg+K62jQfvMjfKt2/8NTOOVohKAB+ZRRiLHOmyYxh6QLb8Z4VtNTJNMl5deSLfBg +c/zJ8wgneCUbz+3ISochQZ39CbGnXyvbmqUnOJL0eXudW3Ta+Rn/JEE1dZDefE69 +xlk66A6erZpWQ52553NupMfEyy7NfoziLCnYWycqL6RGnl4mt5VxjZnwWUvhx6zH +5Fm+k0k7sX2kN73Zok5MoRkZp6wrYaEHPuCesxVs5TY3dHRijfQpo/+xYnK6xAQJ +6pEn5Xa+4q7F5UidEHx8DDjEiKC7MXMNZrRZIxXK6+JcGRzmFTt7WOBDe0zGO6z2 +hB8EgK0Pmf6cdgBRJaGN+niQzsn2BKhJtk/y+shC1T44tKABTVkdoSvr4QasDMGu +phrYphwWr08RUaVwJ7WvUdU8S2o9FvEjJ8neFDPvfYPo1NJZWV/F3vriSdLrOLUM +77Dkk5rhf/Jdr7wLGDQPbxW5oAlpGj9IidtWRgAJvnTooBCY9uc0FiBVeHaPvNpS +eGSl63sZNM7ZcY+NY994BARk6A76XdSdkQ7vxuisir2zuxmpVjrt1s8nmCBofHAG +gea5uZe1uEL63aFAaY7YLGqQQw86JCT0mjq/qaO99Bo7r5tIocMqaHZg4LH3VLH0 +6/ykDBM97cyiDNhwvgqY+vh4U7jgfW2qaEUh/gcDxqV/pAYC6VR80Pk4v8Jmh3Ma +vxA9vOV4l0QjyGHLvBIjBlVGcK4SyhIr8igCx0+SwyiR3Kdk/MgA/j3/H4k/8N22 +es1b+qVibtwQ2Uj3BdkPDGnZKBtFUbyN1qQ9rqiGjFQ6qleSfcujzAo07trq1PrE +L+q5sHClJSIh4aE12zAN+ovgTZmXkp9bAW0YlklCks+KO7UksupNvhVAn2LK5igU +TPj0q1BekYZgCXXFO0o8VAMFmrmv17afPMtDveLNnElnHXrAF+pVhPJXwbwCkPBM +shOrCZUf1t+6zhF+UCWRHfeov0bfv0Pf2SYYNv4jugoYTPpDU2wMRzmkcwx+9rnB +bdAAprfG85T/XP3EMSjP7L/rS0B7aEvPwD9tGZpUvJJtqJRuq0E= +-----END PUBLIC KEY----- diff --git a/tests/ml_dsa/public_ml_dsa_44.raw b/tests/ml_dsa/public_ml_dsa_44.raw new file mode 100644 index 0000000000000000000000000000000000000000..bfe833ea0fe9940cc4bd4343728fb8088eb9666a GIT binary patch literal 1312 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 I4b%$(i*-!cFaQ7m literal 0 HcmV?d00001 diff --git a/tests/ml_dsa/public_ml_dsa_65.pem b/tests/ml_dsa/public_ml_dsa_65.pem new file mode 100644 index 00000000..17a59990 --- /dev/null +++ b/tests/ml_dsa/public_ml_dsa_65.pem @@ -0,0 +1,44 @@ +-----BEGIN PUBLIC KEY----- +MIIHsjALBglghkgBZQMEAxIDggehALEPI2nLW+2DSNv4hO+TaFjAaLbMgv3TRGoi +3p1zgqXro43ZSAWEM1A9eKI2NDeJzbnI5svAtgEQXptNLKRg0gxpDj0MAo6k0Xi1 +12qMGReQPt7NgxUCejz1UNUaWVhmHCe2k21nYGySaI+gcdKa8OXIFItOJ3dfIUQC +ji4lX0MA2Qlzzq2YRi2FBygeoJphZVCnCC72BgBX/IMnB3MOM5rH9I6nA2QFgnSM +q7RNVBzMg2hyp4M2ZyQQp1WzrVNItTliZf+iznI4TXTvIokWahsF1aBlIe8/Nsv4 +Xnn2fLVtKvAMYgxL20pnvCHvY2oeBiuVlzkfbqWV0WNKiB5eAF4A8dRiGIU4Qaby +YnbJ879fFDLDuVPMY4AhfssMTRXqv4lgpJWg8OW1nl1uwO8VXNpbF8Pum9f7c2nY +JymRoKcpQ2RTW1pf7/hjXNzp2JPI0KF0sY5+BafGe+coCknhQZAQRjKObptNzv3j +4ZhaqWKUAP/5bJ1Iz2dck/S4KWeDId60toUGkYdET7Ku1UBhUNWUU8OGqPNG7c25 +vKrq08WMnFrNwhXixobduQ8pLjhGYS7Iwq9hV9lzhA7vqJ4q4joCjVwBxtmBb/4G +hVWiaF3tyQzNouaNWvRPPZSUedcWL40iltz7uYg5O8RfTPi8hY9zCXApeMc3Wekx +8hY3xt6lBwRRDMkFuNabJC9em95hFPzMY7z3G6HZVTSAXEVSGpY/N6JCJnMZetrB +1nUMgqgGAyZynMybyQdcMqaztxfpd2c6XrlHBxQ/6YoVIYpRLcbSri78YVfOdV4L +HUF7xNmAUAO863WbqySt4EL5ChDzpD+KXqa9x1hlUN5OXL7zPa/A3VV4e/5XtAhS +Gj1LFirU2xYvoUy543xE9IHhuySsTXAKtLYqo9Lexqu0vhVOVlacivBp77tDhEY4 +5fyRsdr+4o3cTOVnR8LWjwqtp8E1nfzRGvQE7WOkg9rNs/rJ9x2eaZ+yu2LF3gO5 +jl/zzCsTlI+GmtcCQeb2Jd0EbmfO9q3S6i/Uc6rF1NJIYoZZ6BRBNZgNCEYCgb0x +Jx8fBP8Ba9QTkrXgpLbXmDZXuOpm3O18yHfwK1AWELZduBMRJgeu/35658k7m0og +ERVa0X0gM51ZpvFMwXSpQahGIrhv9V0VSc7wK1rgiNNSEtPB65N3x6FVjLcGovyV +H7M6S7YXNAP9AXhkrmK5O3qHz3sPYfQd1GJAmJv/VGFJ0LZXEFSAuNVqSeOg/jhU +45c14kT+FN+Hx3J02dr2H0G4Kmtz4PjqFJMzrMIo4V6A1jR98DfY8u5VvmorRE2S +RnKg0W7N4kybr4cd+GVTAcUeV8hgn1kKBG8DjNSXmsevTOzlLcvl/JJ762yN9811 +RurARv7mjxsfTLXLmAFmcRNP0K24PH10abDxPkWbdYy54PHjnaXc6/QbDYVYdI/i +WJDdAJvbns9rgXe17kEs4P4W2xu1I6WB8A/s7Uls6SfkbeK8JrlCJvm1qgs+gXEe +6KenP0IZPp1CcbbB0VRUxfw6y38ZYakxHwBOGSSKkKhIlB1iA19oSG0btP6MuyCH +5C8RTrsV/HRA9nmzsoHVclEBqOq5JvpEBW9BwRUQ2kLhExror3JZrdjHPWmpp+uz +0omZ98zhEJcETAmYfr/uYW/ZlRjNKaSQIB3F7FUTqY1OIoHcFUHap8gUDo5ZpgOn +cPMSMk/fSQzLG4NqLRc5FAVRkjTgDvJrQP6aGOdrSZ/L0Qc+CNNaN8SSoi/NOUnj +58JcX6lIM4+jfvaqSKHgQzh28UaROeRqUEhfn6Xmas+EANLfwpyggQvUcSHEfQvq +Ec0RHYbg1SCLxSTGGbCMSFiURjO7Z+QQNib48cjc1pfITiXynzcHpWY5kPam/J2E +reMBmZjwtN78HNtyEvcWAjKUiFADc0PSDOnysxjRE8o+rmN1yK7Fh1bqy7kujfX4 +OzSAyKkMTcI2ci2mxf5NIxOBTpb0Vace6ejtaqfTfWd7bI5HUI2Md9nbvRhAZJPd +wZTkIHkvCL6GIzyaOq+Smh4fwVkC7IQIAHg8L2ZUUOIRHnIuP6QXpTNDyC5ab9bg +hd5IltzxCOAEU2XRrvjctmUexSUwaw+OBcsggEDyanS3mZFm+KMSIv8vNOy+MRuy +9nPNxet6DvUoeFKmb9TmrCGKuHc7pde2WRDXPbB+1hzrsjQnRXpZuNv/AsWYhCQU +o5mNrDBMsD51hE8yFrb46Gq/cS25sI6tOVonVzgbtGkSXI0s3teAHVkTONEs/4+h +pdia4yXcpW7y0Ia4ff93Yx30jbuiLuwIJaLM7sn5bA0v81LpKeJbucm+4Vgp/UnD +gzXbEfdIkn1a9dmFaQhvpCJXBG4TwmNhIelegSYZMfyxYFsFn+vpX+gcV5GulBJj +h6u469pyWa0pJMg5kA+U5XVD2Urb1OFC9pTFREXN1OG69bbuhT2s3OLHxFdUvGhD +cF345JVUyXN5BmpbprEyRPx/CRFklrtQyI+SNckYT9iZ+3lzhjbyQOrAa5KKRwhd +BVcPC8dw1a7Vh384JFtchdnOJFJOoDWnNuAmbFETzrC+ouATrOj3UCiq2dEhVhes ++g3UCwGL +-----END PUBLIC KEY----- diff --git a/tests/ml_dsa/public_ml_dsa_65.raw b/tests/ml_dsa/public_ml_dsa_65.raw new file mode 100644 index 0000000000000000000000000000000000000000..3725b020eaba6c36dbb04ced5df678ec11e484ab GIT binary patch literal 1952 zcmV;R2VeNH4If~D)DjoC;Agfmb*c%n8m zH;K);$mYwywgC`cn@udFVA2d}4m}J4j-=6ewbyEl85fW~-pzv*0(w04P}Le)SY{k2 zwv%mVU~H0TkDzhVn(*bw6pKzLcV8hy0*)>vUqb-d33JY^m_{vy2Pht(nqg&7rwA_g z1^`$5gC_@b4l|m^^p2+kWCenBjH|RwR2)u3e|?>{!n_+EMTe6?*V@C;%MOWR6kydm#nY90nFm6tgmZl#scV@ik~ zUI1PI@zi1%g*ZW`@?v($^S@sdGQ+u3%wvEde#;C^73#l|M_g4NY7_nlk~VLXM-W$w6=u?k%vT2vaZ!YVNlhSQ^SU+^G5B> zxxA|C)5VOOTFt^0;>L#ExeqBWI7VSE$ilB-SJ`uf4)3U*D&jfQ7y*Ot}gsxSI%`_3mrjw#MyvQ1H9{Xo2w+P z;6nKd5c8xzie9F@$5>@h-cDS;^F6P?-Boyd{#UdJQW`x=7An-+7B8Vpx#N69^nu~K zB&So;Se8_k3D^L~? zwq3Xr5he$&|9*Ps$vc}$AQ2T>(S0B@omr;wOu=-iL8wL|xNr4c6-mzUD_Y=)(^3-C z!RwQE$Dvh>w+5p8l^?S@OSTs@1N{MbWUgYlJ9>xDdkJ*bRtimYaUVzp#eegHf^6pi> zYAZxdl16f%(QeJ+Oq;KV9r$Ha0mU9y$Y7sY3IuNhjMSH!$FEH6oHL zbw=vIM*ilH8y`%y%a{RXaT8C_t++gWbZM~hK1G{#jJe?P+~B9g;;ct;#iQ~ z0Gr#M&uf8qweCSI;Qkid8?_^)f$$IP?MZCuC**D7ye7FqCi%6h3qFBy9_Xj1KSCKk zokDT8!O>Jy#r!(Ue;Hw^F&_X<86=93s7RC@Vgp}jNNpRm{*1dIhvY92PP-NSbU^lb zv$BEJa!~=O>bWNRL{Sz~jZPwg+!aCEr^plzj#;Jyr*QKUGEd)049gpXYAqKz6a`U| zG~f>MYe4>*80TwApUcq)J_yrVH^h>nFU>hg zYEVdDpQYw%&x8Qd-@=@rfeX}eA;f(P>JiNm9fsi5AdAH$#u>1TNLZ9cGrMQx5H=?G z@yOiPm&i^f@}D;crDi#h_NM%ugstNNnV9gj-uxWfauW9z0y30{Py=&A(hTYHvl!76 z%08}Rb;z#8hgRy#xh{?M_&YR!$f*oX!Zva(rp5kEBNKs6mh@Gp9_i@qYNykEXM1dp zM^KH7ciG#$7(ir`-NBUPAbBqczJ?<_nmVtNnjRm)Spw{Y2mp9IFJ@Fw;t?KlE};(XRO1wq+j0B`|9bjs?pgfI#wUbhnw2X85BLBL6Qm z?7lG@vi5V$#p`+w^(c5!rf<~dtRafHcRQumwpkF@J+OY(9P6?)Cq;T$xZD2%#h8R7 z6r-7qtT0TlK6QjoG8VS@=xV=lExE9ctvOmJS2!EAX%bwGEZ*0E9a$4N(JcRup{3ZG z<0agsZt~EExPAY3V;%I3yP_`a2qmJ-?#cOV4KMRj=_%q{xyioaSSkHU!-F;35%)-v zeOmR|g=q+Hq#{=YZWF>|VIk>WfhHL-{IOtL1)uBbU+5fHk*<^yV~4A_>)LWzttlkP zIgk&O<#j{ZO54=oLiUu!L`BWi;kxyXr#9dwY*7==u)d<;6Rha>P$;U|(IHkBtojYq3jvE@#?zhv literal 0 HcmV?d00001 diff --git a/tests/ml_dsa/public_ml_dsa_87.der b/tests/ml_dsa/public_ml_dsa_87.der new file mode 100644 index 0000000000000000000000000000000000000000..49858bab6270b54edf8ca6bd0c5b3a775350e741 GIT binary patch literal 2614 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 Date: Tue, 18 Aug 2026 09:31:45 +0100 Subject: [PATCH 2/3] refactor unreachable code and ML-DSA rountrip tests --- src/decoding.rs | 3 +- src/jwk.rs | 2 +- tests/ml_dsa/mod.rs | 172 ++++++++++++++++++++++---------------------- 3 files changed, 90 insertions(+), 87 deletions(-) diff --git a/src/decoding.rs b/src/decoding.rs index f27757fe..cfe2f10b 100644 --- a/src/decoding.rs +++ b/src/decoding.rs @@ -1,4 +1,5 @@ use std::fmt::{Debug, Formatter}; +use std::unreachable; use base64::{Engine, engine::general_purpose::STANDARD}; use serde::de::DeserializeOwned; @@ -301,7 +302,7 @@ impl DecodingKey { Algorithm::MLDSA65 => ML_DSA_65_PUBLIC_KEY_LEN, Algorithm::MLDSA87 => ML_DSA_87_PUBLIC_KEY_LEN, // Unreachable: family check above guarantees an ML-DSA alg. - _ => return Err(new_error(ErrorKind::InvalidAlgorithm)), + _ => unreachable!(), }; if decoded.len() != expected_len { return Err(new_error(ErrorKind::InvalidKeyFormat)); diff --git a/src/jwk.rs b/src/jwk.rs index 48c02b78..83218ba7 100644 --- a/src/jwk.rs +++ b/src/jwk.rs @@ -788,7 +788,7 @@ impl Jwk { .common .key_algorithm .filter(|alg| *alg != KeyAlgorithm::UNKNOWN_ALGORITHM) - .map(|alg| serde_json::to_value(alg)) + .map(serde_json::to_value) .transpose()? .and_then(|value| value.as_str().map(str::to_owned)); diff --git a/tests/ml_dsa/mod.rs b/tests/ml_dsa/mod.rs index b779891d..52ae5daa 100644 --- a/tests/ml_dsa/mod.rs +++ b/tests/ml_dsa/mod.rs @@ -17,99 +17,102 @@ pub struct Claims { exp: i64, } -macro_rules! ml_dsa_der_round_trip { - ($name:ident, $alg:expr, $priv:literal, $pub_raw:literal) => { - #[test] - #[wasm_bindgen_test] - fn $name() { - let privkey = include_bytes!($priv); - let pubkey = include_bytes!($pub_raw); - - let signed = sign(b"hello world", &EncodingKey::from_mldsa_der(privkey), $alg).unwrap(); - let is_valid = - verify(&signed, b"hello world", &DecodingKey::from_mldsa_der(pubkey), $alg).unwrap(); - assert!(is_valid); - - // Wrong message must not verify. - let is_valid = - verify(&signed, b"goodbye world", &DecodingKey::from_mldsa_der(pubkey), $alg) - .unwrap(); - assert!(!is_valid); - } - }; +fn ml_dsa_der_round_trip(alg: Algorithm, privkey: &[u8], pubkey: &[u8]) { + let signed = sign(b"hello world", &EncodingKey::from_mldsa_der(privkey), alg).unwrap(); + + let is_valid = + verify(&signed, b"hello world", &DecodingKey::from_mldsa_der(pubkey), alg).unwrap(); + assert!(is_valid); + + // Wrong message must not verify. + let is_valid = + verify(&signed, b"goodbye world", &DecodingKey::from_mldsa_der(pubkey), alg).unwrap(); + assert!(!is_valid); } -ml_dsa_der_round_trip!( - round_trip_der_44, - Algorithm::MLDSA44, - "private_ml_dsa_44.der", - "public_ml_dsa_44.raw" -); -ml_dsa_der_round_trip!( - round_trip_der_65, - Algorithm::MLDSA65, - "private_ml_dsa_65.der", - "public_ml_dsa_65.raw" -); -ml_dsa_der_round_trip!( - round_trip_der_87, - Algorithm::MLDSA87, - "private_ml_dsa_87.der", - "public_ml_dsa_87.raw" -); +#[test] +#[wasm_bindgen_test] +fn round_trip_der_mldsa44() { + ml_dsa_der_round_trip( + Algorithm::MLDSA44, + include_bytes!("private_ml_dsa_44.der"), + include_bytes!("public_ml_dsa_44.raw"), + ); +} + +#[test] +#[wasm_bindgen_test] +fn round_trip_der_mldsa65() { + ml_dsa_der_round_trip( + Algorithm::MLDSA65, + include_bytes!("private_ml_dsa_65.der"), + include_bytes!("public_ml_dsa_65.raw"), + ); +} + +#[test] +#[wasm_bindgen_test] +fn round_trip_der_mldsa87() { + ml_dsa_der_round_trip( + Algorithm::MLDSA87, + include_bytes!("private_ml_dsa_87.der"), + include_bytes!("public_ml_dsa_87.raw"), + ); +} #[cfg(feature = "use_pem")] -macro_rules! ml_dsa_pem_round_trip_claim { - ($name:ident, $alg:expr, $priv:literal, $pub:literal) => { - #[cfg(feature = "use_pem")] - #[test] - #[wasm_bindgen_test] - fn $name() { - let privkey_pem = include_bytes!($priv); - let pubkey_pem = include_bytes!($pub); - let my_claims = Claims { - sub: "b@b.com".to_string(), - company: "ACME".to_string(), - exp: OffsetDateTime::now_utc().unix_timestamp() + 10000, - }; - let token = encode( - &Header::new($alg), - &my_claims, - &EncodingKey::from_mldsa_pem(privkey_pem).unwrap(), - ) - .unwrap(); - let token_data = decode::( - &token, - &DecodingKey::from_mldsa_pem(pubkey_pem).unwrap(), - &Validation::new($alg), - ) - .unwrap(); - assert_eq!(my_claims, token_data.claims); - } +fn ml_dsa_pem_round_trip_claim(alg: Algorithm, privkey_pem: &[u8], pubkey_pem: &[u8]) { + let my_claims = Claims { + sub: "b@b.com".to_string(), + company: "ACME".to_string(), + exp: OffsetDateTime::now_utc().unix_timestamp() + 10000, }; + let token = + encode(&Header::new(alg), &my_claims, &EncodingKey::from_mldsa_pem(privkey_pem).unwrap()) + .unwrap(); + + let token_data = decode::( + &token, + &DecodingKey::from_mldsa_pem(pubkey_pem).unwrap(), + &Validation::new(alg), + ) + .unwrap(); + + assert_eq!(my_claims, token_data.claims); } #[cfg(feature = "use_pem")] -ml_dsa_pem_round_trip_claim!( - round_trip_pem_claim_44, - Algorithm::MLDSA44, - "private_ml_dsa_44.pem", - "public_ml_dsa_44.pem" -); +#[test] +#[wasm_bindgen_test] +fn round_trip_pem_claim_mldsa44() { + ml_dsa_pem_round_trip_claim( + Algorithm::MLDSA44, + include_bytes!("private_ml_dsa_44.pem"), + include_bytes!("public_ml_dsa_44.pem"), + ); +} + #[cfg(feature = "use_pem")] -ml_dsa_pem_round_trip_claim!( - round_trip_pem_claim_65, - Algorithm::MLDSA65, - "private_ml_dsa_65.pem", - "public_ml_dsa_65.pem" -); +#[test] +#[wasm_bindgen_test] +fn round_trip_pem_claim_mldsa65() { + ml_dsa_pem_round_trip_claim( + Algorithm::MLDSA65, + include_bytes!("private_ml_dsa_65.pem"), + include_bytes!("public_ml_dsa_65.pem"), + ); +} + #[cfg(feature = "use_pem")] -ml_dsa_pem_round_trip_claim!( - round_trip_pem_claim_87, - Algorithm::MLDSA87, - "private_ml_dsa_87.pem", - "public_ml_dsa_87.pem" -); +#[test] +#[wasm_bindgen_test] +fn round_trip_pem_claim_mldsa87() { + ml_dsa_pem_round_trip_claim( + Algorithm::MLDSA87, + include_bytes!("private_ml_dsa_87.pem"), + include_bytes!("public_ml_dsa_87.pem"), + ); +} #[cfg(feature = "use_pem")] #[test] @@ -129,8 +132,7 @@ fn ml_dsa_jwk_round_trip() { company: "ACME".to_string(), exp: OffsetDateTime::now_utc().unix_timestamp() + 10000, }; - let token = - encode(&Header::new(Algorithm::MLDSA65), &my_claims, &encoding_key).unwrap(); + let token = encode(&Header::new(Algorithm::MLDSA65), &my_claims, &encoding_key).unwrap(); let token_data = decode::( &token, &DecodingKey::from_jwk(&jwk).unwrap(), From 9e86abffa567e49681987cf6d28e3bc42ca1ae1e Mon Sep 17 00:00:00 2001 From: Phil Schmieder Date: Tue, 18 Aug 2026 13:54:55 +0100 Subject: [PATCH 3/3] Fix wasm tests for ML-DSA signed JWTs --- Cargo.toml | 6 +++++- src/crypto/rust_crypto/ml_dsa.rs | 13 +++++-------- src/crypto/rust_crypto/mod.rs | 10 +++++----- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 88739565..6e454ea3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,11 @@ ml-dsa = { version = "0.1", optional = true, features = ["pkcs8", "rand_core", " [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" -getrandom = "0.2" + +# Two getrandom versions for wasm: 0.2 for ed25519-dalek, +# p256/p384, rsa and 0.4 for ml-dsa. Each needs its own wasm feature +getrandom = { version = "0.2", features = ["js"] } +getrandom_v04 = { package = "getrandom", version = "0.4", features = ["wasm_js"] } [dev-dependencies] wasm-bindgen-test = "0.3.1" diff --git a/src/crypto/rust_crypto/ml_dsa.rs b/src/crypto/rust_crypto/ml_dsa.rs index 3a9c0ab5..3218d1de 100644 --- a/src/crypto/rust_crypto/ml_dsa.rs +++ b/src/crypto/rust_crypto/ml_dsa.rs @@ -14,8 +14,7 @@ use crate::{Algorithm, DecodingKey, EncodingKey}; use ml_dsa::signature::{Signer as MlDsaSigner, Verifier as MlDsaVerifier}; use ml_dsa::{ EncodedSignature, EncodedVerifyingKey, MlDsa44, MlDsa65, MlDsa87, Signature, SigningKey, - VerifyingKey, - pkcs8::DecodePrivateKey, + VerifyingKey, pkcs8::DecodePrivateKey, }; use signature::{Error, Signer, Verifier}; @@ -64,10 +63,9 @@ macro_rules! define_ml_dsa_verifier { } // RFC 9964 carries the raw fixed-size public key encoding. - let encoded = EncodedVerifyingKey::<$params>::try_from( - decoding_key.try_get_as_bytes()?, - ) - .map_err(|_| ErrorKind::InvalidKeyFormat)?; + let encoded = + EncodedVerifyingKey::<$params>::try_from(decoding_key.try_get_as_bytes()?) + .map_err(|_| ErrorKind::InvalidKeyFormat)?; Ok(Self(VerifyingKey::<$params>::decode(&encoded))) } @@ -77,8 +75,7 @@ macro_rules! define_ml_dsa_verifier { fn verify(&self, msg: &[u8], signature: &Vec) -> std::result::Result<(), Error> { let encoded = EncodedSignature::<$params>::try_from(signature.as_slice()) .map_err(Error::from_source)?; - let signature = - Signature::<$params>::decode(&encoded).ok_or_else(Error::new)?; + let signature = Signature::<$params>::decode(&encoded).ok_or_else(Error::new)?; self.0.verify(msg, &signature).map_err(Error::from_source) } } diff --git a/src/crypto/rust_crypto/mod.rs b/src/crypto/rust_crypto/mod.rs index a406d33c..e7679127 100644 --- a/src/crypto/rust_crypto/mod.rs +++ b/src/crypto/rust_crypto/mod.rs @@ -1,14 +1,14 @@ +use ::ml_dsa::signature::Keypair as MlDsaKeypair; +use ::ml_dsa::{ + MlDsa44, MlDsa65, MlDsa87, SigningKey as MlDsaSigningKey, + pkcs8::DecodePrivateKey as MlDsaDecodePrivateKey, +}; use ::rsa::{ RsaPrivateKey, RsaPublicKey, pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey}, traits::PublicKeyParts, }; use ed25519_dalek::SigningKey as Ed25519SigningKey; -use ::ml_dsa::signature::Keypair as MlDsaKeypair; -use ::ml_dsa::{ - MlDsa44, MlDsa65, MlDsa87, SigningKey as MlDsaSigningKey, - pkcs8::DecodePrivateKey as MlDsaDecodePrivateKey, -}; use p256::{ecdsa::SigningKey as P256SigningKey, pkcs8::DecodePrivateKey}; use p384::ecdsa::SigningKey as P384SigningKey; use sha2::{Digest, Sha256, Sha384, Sha512};