diff --git a/Cargo.toml b/Cargo.toml index db809c05..6e454ea3 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,10 +46,15 @@ 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" -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" @@ -77,6 +82,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..3218d1de --- /dev/null +++ b/src/crypto/rust_crypto/ml_dsa.rs @@ -0,0 +1,168 @@ +//! 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..e7679127 100644 --- a/src/crypto/rust_crypto/mod.rs +++ b/src/crypto/rust_crypto/mod.rs @@ -1,3 +1,8 @@ +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}, @@ -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..cfe2f10b 100644 --- a/src/decoding.rs +++ b/src/decoding.rs @@ -1,10 +1,14 @@ use std::fmt::{Debug, Formatter}; +use std::unreachable; 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 +204,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 +265,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. + _ => unreachable!(), + }; + 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..83218ba7 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(serde_json::to_value) + .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..52ae5daa --- /dev/null +++ b/tests/ml_dsa/mod.rs @@ -0,0 +1,201 @@ +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, +} + +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); +} + +#[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")] +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")] +#[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")] +#[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")] +#[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] +#[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 00000000..d4708be3 Binary files /dev/null and b/tests/ml_dsa/private_ml_dsa_44.der differ 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 00000000..8fd7cbf0 Binary files /dev/null and b/tests/ml_dsa/private_ml_dsa_65.der differ 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 00000000..d0d2d920 Binary files /dev/null and b/tests/ml_dsa/private_ml_dsa_87.der differ diff --git a/tests/ml_dsa/private_ml_dsa_87.pem b/tests/ml_dsa/private_ml_dsa_87.pem new file mode 100644 index 00000000..baee91a2 --- /dev/null +++ b/tests/ml_dsa/private_ml_dsa_87.pem @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MDQCAQAwCwYJYIZIAWUDBAMTBCKAIBEoaNkM3A7mSi63Bu609aitY7giU2PoAR3z +PaPCSyjA +-----END PRIVATE KEY----- diff --git a/tests/ml_dsa/public_ml_dsa_44.der b/tests/ml_dsa/public_ml_dsa_44.der new file mode 100644 index 00000000..26bd77d9 Binary files /dev/null and b/tests/ml_dsa/public_ml_dsa_44.der differ 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 00000000..bfe833ea Binary files /dev/null and b/tests/ml_dsa/public_ml_dsa_44.raw differ diff --git a/tests/ml_dsa/public_ml_dsa_65.der b/tests/ml_dsa/public_ml_dsa_65.der new file mode 100644 index 00000000..cad023b8 Binary files /dev/null and b/tests/ml_dsa/public_ml_dsa_65.der differ 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----- +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 ++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 00000000..3725b020 Binary files /dev/null and b/tests/ml_dsa/public_ml_dsa_65.raw differ 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 00000000..49858bab Binary files /dev/null and b/tests/ml_dsa/public_ml_dsa_87.der differ diff --git a/tests/ml_dsa/public_ml_dsa_87.pem b/tests/ml_dsa/public_ml_dsa_87.pem new file mode 100644 index 00000000..0b91636b --- /dev/null +++ b/tests/ml_dsa/public_ml_dsa_87.pem @@ -0,0 +1,57 @@ +-----BEGIN PUBLIC KEY----- +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 +FEC+CQgNCkHVQZN+8eMmpR9yBjQIsQ== +-----END PUBLIC KEY----- diff --git a/tests/ml_dsa/public_ml_dsa_87.raw b/tests/ml_dsa/public_ml_dsa_87.raw new file mode 100644 index 00000000..2798eb59 Binary files /dev/null and b/tests/ml_dsa/public_ml_dsa_87.raw differ