From 176e34b515a168e253451d5de1d91ba98183268e Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 2 Mar 2026 08:35:27 -0500 Subject: [PATCH 01/23] MLDSA65 support for AWS-LC --- .../hazmat/backends/openssl/backend.py | 3 + .../bindings/_rust/openssl/__init__.pyi | 2 + .../hazmat/bindings/_rust/openssl/mldsa65.pyi | 13 + .../hazmat/primitives/asymmetric/mldsa65.py | 157 ++++++++ .../hazmat/primitives/asymmetric/types.py | 3 + .../cryptography-key-parsing/src/pkcs8.rs | 7 + src/rust/cryptography-key-parsing/src/spki.rs | 15 + src/rust/cryptography-openssl/src/lib.rs | 2 + src/rust/cryptography-openssl/src/mldsa.rs | 162 +++++++++ src/rust/cryptography-x509/src/common.rs | 3 + src/rust/cryptography-x509/src/oid.rs | 2 + src/rust/src/backend/keys.rs | 54 ++- src/rust/src/backend/mldsa65.rs | 255 +++++++++++++ src/rust/src/backend/mod.rs | 2 + src/rust/src/lib.rs | 3 + tests/hazmat/primitives/test_mldsa65.py | 338 ++++++++++++++++++ tests/wycheproof/test_mldsa.py | 104 ++++++ 17 files changed, 1115 insertions(+), 10 deletions(-) create mode 100644 src/cryptography/hazmat/bindings/_rust/openssl/mldsa65.pyi create mode 100644 src/cryptography/hazmat/primitives/asymmetric/mldsa65.py create mode 100644 src/rust/cryptography-openssl/src/mldsa.rs create mode 100644 src/rust/src/backend/mldsa65.rs create mode 100644 tests/hazmat/primitives/test_mldsa65.py create mode 100644 tests/wycheproof/test_mldsa.py diff --git a/src/cryptography/hazmat/backends/openssl/backend.py b/src/cryptography/hazmat/backends/openssl/backend.py index 1ac8335a653d..3e951356854d 100644 --- a/src/cryptography/hazmat/backends/openssl/backend.py +++ b/src/cryptography/hazmat/backends/openssl/backend.py @@ -272,6 +272,9 @@ def x448_supported(self) -> bool: and not rust_openssl.CRYPTOGRAPHY_IS_AWSLC ) + def mldsa_supported(self) -> bool: + return rust_openssl.CRYPTOGRAPHY_IS_AWSLC + def ed25519_supported(self) -> bool: return not self._fips_enabled diff --git a/src/cryptography/hazmat/bindings/_rust/openssl/__init__.pyi b/src/cryptography/hazmat/bindings/_rust/openssl/__init__.pyi index 1504f458ca32..5275739acb91 100644 --- a/src/cryptography/hazmat/bindings/_rust/openssl/__init__.pyi +++ b/src/cryptography/hazmat/bindings/_rust/openssl/__init__.pyi @@ -18,6 +18,7 @@ from cryptography.hazmat.bindings._rust.openssl import ( hpke, kdf, keys, + mldsa65, poly1305, rsa, x448, @@ -38,6 +39,7 @@ __all__ = [ "hpke", "kdf", "keys", + "mldsa65", "openssl_version", "openssl_version_text", "poly1305", diff --git a/src/cryptography/hazmat/bindings/_rust/openssl/mldsa65.pyi b/src/cryptography/hazmat/bindings/_rust/openssl/mldsa65.pyi new file mode 100644 index 000000000000..ae7edffc701a --- /dev/null +++ b/src/cryptography/hazmat/bindings/_rust/openssl/mldsa65.pyi @@ -0,0 +1,13 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from cryptography.hazmat.primitives.asymmetric import mldsa65 +from cryptography.utils import Buffer + +class MlDsa65PrivateKey: ... +class MlDsa65PublicKey: ... + +def generate_key() -> mldsa65.MlDsa65PrivateKey: ... +def from_public_bytes(data: bytes) -> mldsa65.MlDsa65PublicKey: ... +def from_seed_bytes(data: Buffer) -> mldsa65.MlDsa65PrivateKey: ... diff --git a/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py b/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py new file mode 100644 index 000000000000..c0811a2039a7 --- /dev/null +++ b/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py @@ -0,0 +1,157 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import annotations + +import abc + +from cryptography.exceptions import UnsupportedAlgorithm, _Reasons +from cryptography.hazmat.bindings._rust import openssl as rust_openssl +from cryptography.hazmat.primitives import _serialization +from cryptography.utils import Buffer + + +class MlDsa65PublicKey(metaclass=abc.ABCMeta): + @classmethod + def from_public_bytes(cls, data: bytes) -> MlDsa65PublicKey: + from cryptography.hazmat.backends.openssl.backend import backend + + if not backend.mldsa_supported(): + raise UnsupportedAlgorithm( + "ML-DSA-65 is not supported by this backend.", + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM, + ) + + return rust_openssl.mldsa65.from_public_bytes(data) + + @abc.abstractmethod + def public_bytes( + self, + encoding: _serialization.Encoding, + format: _serialization.PublicFormat, + ) -> bytes: + """ + The serialized bytes of the public key. + """ + + @abc.abstractmethod + def public_bytes_raw(self) -> bytes: + """ + The raw bytes of the public key. + Equivalent to public_bytes(Raw, Raw). + """ + + @abc.abstractmethod + def verify(self, signature: Buffer, data: Buffer) -> None: + """ + Verify the signature. + """ + + @abc.abstractmethod + def verify_with_context( + self, signature: Buffer, data: Buffer, context: Buffer + ) -> None: + """ + Verify the signature with a context string. + """ + + @abc.abstractmethod + def __eq__(self, other: object) -> bool: + """ + Checks equality. + """ + + @abc.abstractmethod + def __copy__(self) -> MlDsa65PublicKey: + """ + Returns a copy. + """ + + @abc.abstractmethod + def __deepcopy__(self, memo: dict) -> MlDsa65PublicKey: + """ + Returns a deep copy. + """ + + +if hasattr(rust_openssl, "mldsa65"): + MlDsa65PublicKey.register(rust_openssl.mldsa65.MlDsa65PublicKey) + + +class MlDsa65PrivateKey(metaclass=abc.ABCMeta): + @classmethod + def generate(cls) -> MlDsa65PrivateKey: + from cryptography.hazmat.backends.openssl.backend import backend + + if not backend.mldsa_supported(): + raise UnsupportedAlgorithm( + "ML-DSA-65 is not supported by this backend.", + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM, + ) + + return rust_openssl.mldsa65.generate_key() + + @classmethod + def from_seed_bytes(cls, data: Buffer) -> MlDsa65PrivateKey: + from cryptography.hazmat.backends.openssl.backend import backend + + if not backend.mldsa_supported(): + raise UnsupportedAlgorithm( + "ML-DSA-65 is not supported by this backend.", + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM, + ) + + return rust_openssl.mldsa65.from_seed_bytes(data) + + @abc.abstractmethod + def public_key(self) -> MlDsa65PublicKey: + """ + The MlDsa65PublicKey derived from the private key. + """ + + @abc.abstractmethod + def private_bytes( + self, + encoding: _serialization.Encoding, + format: _serialization.PrivateFormat, + encryption_algorithm: (_serialization.KeySerializationEncryption), + ) -> bytes: + """ + The serialized bytes of the private key. + """ + + @abc.abstractmethod + def private_bytes_raw(self) -> bytes: + """ + The raw bytes of the private key (32-byte seed). + Equivalent to private_bytes(Raw, Raw, NoEncryption()). + """ + + @abc.abstractmethod + def sign(self, data: Buffer) -> bytes: + """ + Signs the data. + """ + + @abc.abstractmethod + def sign_with_context(self, data: Buffer, context: Buffer) -> bytes: + """ + Signs the data with a context string. + """ + + @abc.abstractmethod + def __copy__(self) -> MlDsa65PrivateKey: + """ + Returns a copy. + """ + + @abc.abstractmethod + def __deepcopy__(self, memo: dict) -> MlDsa65PrivateKey: + """ + Returns a deep copy. + """ + + +if hasattr(rust_openssl, "mldsa65"): + MlDsa65PrivateKey.register(rust_openssl.mldsa65.MlDsa65PrivateKey) diff --git a/src/cryptography/hazmat/primitives/asymmetric/types.py b/src/cryptography/hazmat/primitives/asymmetric/types.py index 1fe4eaf51d85..942dd272e348 100644 --- a/src/cryptography/hazmat/primitives/asymmetric/types.py +++ b/src/cryptography/hazmat/primitives/asymmetric/types.py @@ -13,6 +13,7 @@ ec, ed448, ed25519, + mldsa65, rsa, x448, x25519, @@ -26,6 +27,7 @@ ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey, ed448.Ed448PublicKey, + mldsa65.MlDsa65PublicKey, x25519.X25519PublicKey, x448.X448PublicKey, ] @@ -42,6 +44,7 @@ dh.DHPrivateKey, ed25519.Ed25519PrivateKey, ed448.Ed448PrivateKey, + mldsa65.MlDsa65PrivateKey, rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, diff --git a/src/rust/cryptography-key-parsing/src/pkcs8.rs b/src/rust/cryptography-key-parsing/src/pkcs8.rs index 82078b38242e..9de150f7b9cf 100644 --- a/src/rust/cryptography-key-parsing/src/pkcs8.rs +++ b/src/rust/cryptography-key-parsing/src/pkcs8.rs @@ -108,6 +108,9 @@ pub fn parse_private_key( )?) } + #[cfg(CRYPTOGRAPHY_IS_AWSLC)] + AlgorithmParameters::MlDsa65 => Ok(openssl::pkey::PKey::private_key_from_der(data)?), + _ => Err(KeyParsingError::UnsupportedKeyType( k.algorithm.oid().clone(), )), @@ -440,6 +443,10 @@ pub fn serialize_private_key( (params, private_key_der) } + #[cfg(CRYPTOGRAPHY_IS_AWSLC)] + id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { + return Ok(pkey.private_key_to_pkcs8()?); + } _ => { unimplemented!("Unknown key type"); } diff --git a/src/rust/cryptography-key-parsing/src/spki.rs b/src/rust/cryptography-key-parsing/src/spki.rs index 7ce292b642d0..c59a07a7f18b 100644 --- a/src/rust/cryptography-key-parsing/src/spki.rs +++ b/src/rust/cryptography-key-parsing/src/spki.rs @@ -100,6 +100,12 @@ pub fn parse_public_key( Ok(openssl::pkey::PKey::from_dh(dh)?) } + #[cfg(CRYPTOGRAPHY_IS_AWSLC)] + AlgorithmParameters::MlDsa65 => Ok(cryptography_openssl::mldsa::new_raw_public_key( + k.subject_public_key.as_bytes(), + ) + .map_err(|_| KeyParsingError::InvalidKey)?), + _ => Err(KeyParsingError::UnsupportedKeyType( k.algorithm.oid().clone(), )), @@ -214,6 +220,15 @@ pub fn serialize_public_key( (params, pub_key_der) } + #[cfg(CRYPTOGRAPHY_IS_AWSLC)] + id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { + let raw_bytes = pkey.raw_public_key()?; + if raw_bytes.len() == cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES { + (AlgorithmParameters::MlDsa65, raw_bytes) + } else { + unimplemented!("Unsupported ML-DSA variant"); + } + } _ => { unimplemented!("Unknown key type"); } diff --git a/src/rust/cryptography-openssl/src/lib.rs b/src/rust/cryptography-openssl/src/lib.rs index 7dcf8599f0d5..1f90f08c5062 100644 --- a/src/rust/cryptography-openssl/src/lib.rs +++ b/src/rust/cryptography-openssl/src/lib.rs @@ -9,6 +9,8 @@ pub mod aead; pub mod cmac; pub mod fips; pub mod hmac; +#[cfg(CRYPTOGRAPHY_IS_AWSLC)] +pub mod mldsa; #[cfg(any( CRYPTOGRAPHY_IS_BORINGSSL, CRYPTOGRAPHY_IS_LIBRESSL, diff --git a/src/rust/cryptography-openssl/src/mldsa.rs b/src/rust/cryptography-openssl/src/mldsa.rs new file mode 100644 index 000000000000..2cbf4f7d8cba --- /dev/null +++ b/src/rust/cryptography-openssl/src/mldsa.rs @@ -0,0 +1,162 @@ +// This file is dual licensed under the terms of the Apache License, Version +// 2.0, and the BSD License. See the LICENSE file in the root of this repository +// for complete details. + +use foreign_types_shared::ForeignType; +use openssl_sys as ffi; +use std::os::raw::c_int; + +use crate::{cvt_p, OpenSSLResult}; + +pub const NID_ML_DSA_65: c_int = ffi::NID_MLDSA65; +pub const NID_PQDSA: c_int = ffi::NID_PQDSA; +const MLDSA65_SIGNATURE_BYTES: usize = 3309; +pub const MLDSA65_PUBLIC_KEY_BYTES: usize = 1952; +pub const MLDSA65_SEED_BYTES: usize = 32; + +extern "C" { + // We call ml_dsa_65_sign/verify directly instead of going through + // EVP_DigestSign/EVP_DigestVerify because the EVP PQDSA path hardcodes + // context to (NULL, 0), so we'd lose context string support. + fn ml_dsa_65_sign( + private_key: *const u8, + sig: *mut u8, + sig_len: *mut usize, + message: *const u8, + message_len: usize, + ctx_string: *const u8, + ctx_string_len: usize, + ) -> c_int; + + fn ml_dsa_65_verify( + public_key: *const u8, + sig: *const u8, + sig_len: usize, + message: *const u8, + message_len: usize, + ctx_string: *const u8, + ctx_string_len: usize, + ) -> c_int; +} + +/// Generate a random 32-byte ML-DSA-65 seed. +pub fn generate_seed() -> OpenSSLResult<[u8; MLDSA65_SEED_BYTES]> { + let mut seed = [0u8; MLDSA65_SEED_BYTES]; + openssl::rand::rand_bytes(&mut seed)?; + Ok(seed) +} + +pub fn new_raw_private_key( + data: &[u8], +) -> OpenSSLResult> { + // SAFETY: EVP_PKEY_pqdsa_new_raw_private_key creates a new EVP_PKEY from + // raw key bytes. For ML-DSA-65, a 32-byte seed expands into the full + // keypair. + unsafe { + let pkey = cvt_p(ffi::EVP_PKEY_pqdsa_new_raw_private_key( + NID_ML_DSA_65, + data.as_ptr(), + data.len(), + ))?; + Ok(openssl::pkey::PKey::from_ptr(pkey)) + } +} + +pub fn new_raw_public_key( + data: &[u8], +) -> OpenSSLResult> { + // SAFETY: EVP_PKEY_pqdsa_new_raw_public_key creates a new EVP_PKEY from + // raw public key bytes. + unsafe { + let pkey = cvt_p(ffi::EVP_PKEY_pqdsa_new_raw_public_key( + NID_ML_DSA_65, + data.as_ptr(), + data.len(), + ))?; + Ok(openssl::pkey::PKey::from_ptr(pkey)) + } +} + +pub fn sign( + pkey: &openssl::pkey::PKeyRef, + data: &[u8], + context: &[u8], +) -> OpenSSLResult> { + let raw_key = pkey.raw_private_key()?; + + let mut sig = vec![0u8; MLDSA65_SIGNATURE_BYTES]; + let mut sig_len: usize = 0; + + let msg_ptr = if data.is_empty() { + std::ptr::null() + } else { + data.as_ptr() + }; + let ctx_ptr = if context.is_empty() { + std::ptr::null() + } else { + context.as_ptr() + }; + + // SAFETY: ml_dsa_65_sign takes raw key bytes, message, and context. + let r = unsafe { + ml_dsa_65_sign( + raw_key.as_ptr(), + sig.as_mut_ptr(), + &mut sig_len, + msg_ptr, + data.len(), + ctx_ptr, + context.len(), + ) + }; + + if r != 1 { + return Err(openssl::error::ErrorStack::get()); + } + + sig.truncate(sig_len); + Ok(sig) +} + +pub fn verify( + pkey: &openssl::pkey::PKeyRef, + signature: &[u8], + data: &[u8], + context: &[u8], +) -> OpenSSLResult { + let raw_key = pkey.raw_public_key()?; + + let msg_ptr = if data.is_empty() { + std::ptr::null() + } else { + data.as_ptr() + }; + let ctx_ptr = if context.is_empty() { + std::ptr::null() + } else { + context.as_ptr() + }; + + // SAFETY: ml_dsa_65_verify takes raw key bytes, signature, message, + // and context. + let r = unsafe { + ml_dsa_65_verify( + raw_key.as_ptr(), + signature.as_ptr(), + signature.len(), + msg_ptr, + data.len(), + ctx_ptr, + context.len(), + ) + }; + + if r != 1 { + // Clear any errors from the OpenSSL error stack to prevent + // leaking errors into subsequent operations. + let _ = openssl::error::ErrorStack::get(); + } + + Ok(r == 1) +} diff --git a/src/rust/cryptography-x509/src/common.rs b/src/rust/cryptography-x509/src/common.rs index 6cec51bbfd05..7eb96aad701a 100644 --- a/src/rust/cryptography-x509/src/common.rs +++ b/src/rust/cryptography-x509/src/common.rs @@ -53,6 +53,9 @@ pub enum AlgorithmParameters<'a> { #[defined_by(oid::ED448_OID)] Ed448, + #[defined_by(oid::ML_DSA_65_OID)] + MlDsa65, + #[defined_by(oid::X25519_OID)] X25519, #[defined_by(oid::X448_OID)] diff --git a/src/rust/cryptography-x509/src/oid.rs b/src/rust/cryptography-x509/src/oid.rs index cda9e3621fd2..1861ee360380 100644 --- a/src/rust/cryptography-x509/src/oid.rs +++ b/src/rust/cryptography-x509/src/oid.rs @@ -108,6 +108,8 @@ pub const X448_OID: asn1::ObjectIdentifier = asn1::oid!(1, 3, 101, 111); pub const ED25519_OID: asn1::ObjectIdentifier = asn1::oid!(1, 3, 101, 112); pub const ED448_OID: asn1::ObjectIdentifier = asn1::oid!(1, 3, 101, 113); +pub const ML_DSA_65_OID: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 3, 18); + // Hashes pub const SHA1_OID: asn1::ObjectIdentifier = asn1::oid!(1, 3, 14, 3, 2, 26); pub const SHA224_OID: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 2, 4); diff --git a/src/rust/src/backend/keys.rs b/src/rust/src/backend/keys.rs index b8fc6f247781..60ca71c299b0 100644 --- a/src/rust/src/backend/keys.rs +++ b/src/rust/src/backend/keys.rs @@ -86,16 +86,24 @@ fn load_pem_private_key<'p>( let (data, mut password_used) = cryptography_key_parsing::pem::decrypt_pem(&p, password)?; let pkey = match p.tag() { - "PRIVATE KEY" => cryptography_key_parsing::pkcs8::parse_private_key(&data)?, - "RSA PRIVATE KEY" => cryptography_key_parsing::rsa::parse_pkcs1_private_key(&data).map_err(|e| { - CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") - })?, - "EC PRIVATE KEY" => cryptography_key_parsing::ec::parse_pkcs1_private_key(&data, None).map_err(|e| { - CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") - })?, - "DSA PRIVATE KEY" => cryptography_key_parsing::dsa::parse_pkcs1_private_key(&data).map_err(|e| { - CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") - })?, + "PRIVATE KEY" => { + cryptography_key_parsing::pkcs8::parse_private_key(&data)? + } + "RSA PRIVATE KEY" => { + cryptography_key_parsing::rsa::parse_pkcs1_private_key(&data).map_err(|e| { + CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") + })? + } + "EC PRIVATE KEY" => { + cryptography_key_parsing::ec::parse_pkcs1_private_key(&data, None).map_err(|e| { + CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") + })? + } + "DSA PRIVATE KEY" => { + cryptography_key_parsing::dsa::parse_pkcs1_private_key(&data).map_err(|e| { + CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") + })? + } _ => { assert_eq!(p.tag(), "ENCRYPTED PRIVATE KEY"); password_used = true; @@ -167,6 +175,19 @@ fn private_key_from_pkey<'p>( openssl::pkey::Id::DHX => Ok(crate::backend::dh::private_key_from_pkey(pkey) .into_pyobject(py)? .into_any()), + #[cfg(CRYPTOGRAPHY_IS_AWSLC)] + id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { + let pub_len = pkey.raw_public_key()?.len(); + if pub_len == cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES { + Ok(crate::backend::mldsa65::private_key_from_pkey(pkey) + .into_pyobject(py)? + .into_any()) + } else { + Err(CryptographyError::from( + exceptions::UnsupportedAlgorithm::new_err("Unsupported ML-DSA variant."), + )) + } + } _ => Err(CryptographyError::from( exceptions::UnsupportedAlgorithm::new_err("Unsupported key type."), )), @@ -294,6 +315,19 @@ fn public_key_from_pkey<'p>( .into_pyobject(py)? .into_any()), + #[cfg(CRYPTOGRAPHY_IS_AWSLC)] + id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { + let pub_len = pkey.raw_public_key()?.len(); + if pub_len == cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES { + Ok(crate::backend::mldsa65::public_key_from_pkey(pkey) + .into_pyobject(py)? + .into_any()) + } else { + Err(CryptographyError::from( + exceptions::UnsupportedAlgorithm::new_err("Unsupported ML-DSA variant."), + )) + } + } _ => Err(CryptographyError::from( exceptions::UnsupportedAlgorithm::new_err("Unsupported key type."), )), diff --git a/src/rust/src/backend/mldsa65.rs b/src/rust/src/backend/mldsa65.rs new file mode 100644 index 000000000000..fb9497914f4e --- /dev/null +++ b/src/rust/src/backend/mldsa65.rs @@ -0,0 +1,255 @@ +// This file is dual licensed under the terms of the Apache License, Version +// 2.0, and the BSD License. See the LICENSE file in the root of this repository +// for complete details. + +use pyo3::types::PyAnyMethods; + +use crate::backend::utils; +use crate::buf::CffiBuf; +use crate::error::{CryptographyError, CryptographyResult}; +use crate::exceptions; + +const MAX_CONTEXT_BYTES: usize = 255; + +#[pyo3::pyclass(frozen, module = "cryptography.hazmat.bindings._rust.openssl.mldsa65")] +pub(crate) struct MlDsa65PrivateKey { + pkey: openssl::pkey::PKey, +} + +#[pyo3::pyclass(frozen, module = "cryptography.hazmat.bindings._rust.openssl.mldsa65")] +pub(crate) struct MlDsa65PublicKey { + pkey: openssl::pkey::PKey, +} + +pub(crate) fn private_key_from_pkey( + pkey: &openssl::pkey::PKeyRef, +) -> MlDsa65PrivateKey { + MlDsa65PrivateKey { + pkey: pkey.to_owned(), + } +} + +pub(crate) fn public_key_from_pkey( + pkey: &openssl::pkey::PKeyRef, +) -> MlDsa65PublicKey { + MlDsa65PublicKey { + pkey: pkey.to_owned(), + } +} + +#[pyo3::pyfunction] +fn generate_key() -> CryptographyResult { + let seed = cryptography_openssl::mldsa::generate_seed()?; + let pkey = cryptography_openssl::mldsa::new_raw_private_key(&seed)?; + Ok(MlDsa65PrivateKey { pkey }) +} + +#[pyo3::pyfunction] +fn from_seed_bytes(data: CffiBuf<'_>) -> pyo3::PyResult { + let pkey = cryptography_openssl::mldsa::new_raw_private_key(data.as_bytes()) + .map_err(|_| pyo3::exceptions::PyValueError::new_err("Invalid ML-DSA-65 seed"))?; + Ok(MlDsa65PrivateKey { pkey }) +} + +#[pyo3::pyfunction] +fn from_public_bytes(data: &[u8]) -> pyo3::PyResult { + let pkey = cryptography_openssl::mldsa::new_raw_public_key(data) + .map_err(|_| pyo3::exceptions::PyValueError::new_err("Invalid ML-DSA-65 public key"))?; + Ok(MlDsa65PublicKey { pkey }) +} + +#[pyo3::pymethods] +impl MlDsa65PrivateKey { + fn sign<'p>( + &self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + ) -> CryptographyResult> { + let sig = cryptography_openssl::mldsa::sign(&self.pkey, data.as_bytes(), &[])?; + Ok(pyo3::types::PyBytes::new(py, &sig)) + } + + fn sign_with_context<'p>( + &self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + context: CffiBuf<'_>, + ) -> CryptographyResult> { + if context.as_bytes().len() > MAX_CONTEXT_BYTES { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err("Context must be at most 255 bytes"), + )); + } + let sig = + cryptography_openssl::mldsa::sign(&self.pkey, data.as_bytes(), context.as_bytes())?; + Ok(pyo3::types::PyBytes::new(py, &sig)) + } + + fn public_key(&self) -> CryptographyResult { + let raw_bytes = self.pkey.raw_public_key()?; + Ok(MlDsa65PublicKey { + pkey: cryptography_openssl::mldsa::new_raw_public_key(&raw_bytes)?, + }) + } + + fn private_bytes_raw<'p>( + &self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + // AWS-LC's raw_private_key() returns the expanded key, not the seed. + // Round-trip through PKCS#8 DER to extract the 32-byte seed. + // Note: private_key_to_pkcs8() (i2d_PKCS8PrivateKey_bio) must be used + // instead of private_key_to_der() (i2d_PrivateKey), because AWS-LC's + // i2d_PrivateKey doesn't support PQDSA keys. + let pkcs8_der = self.pkey.private_key_to_pkcs8()?; + let pki = + asn1::parse_single::>(&pkcs8_der) + .map_err(|_| { + pyo3::exceptions::PyValueError::new_err( + "Cannot extract seed from this ML-DSA-65 private key", + ) + })?; + // The privateKey content is [0x80, 0x20, <32 bytes of seed>] + // (context-specific tag 0, length 32) + if pki.private_key.len() == 2 + cryptography_openssl::mldsa::MLDSA65_SEED_BYTES + && pki.private_key[0] == 0x80 + && pki.private_key[1] == cryptography_openssl::mldsa::MLDSA65_SEED_BYTES as u8 + { + Ok(pyo3::types::PyBytes::new(py, &pki.private_key[2..])) + } else { + Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err( + "Cannot extract seed from this ML-DSA-65 private key", + ), + )) + } + } + + fn private_bytes<'p>( + slf: &pyo3::Bound<'p, Self>, + py: pyo3::Python<'p>, + encoding: crate::serialization::Encoding, + format: crate::serialization::PrivateFormat, + encryption_algorithm: &pyo3::Bound<'p, pyo3::PyAny>, + ) -> CryptographyResult> { + // Intercept Raw/Raw/NoEncryption so we return the seed. + // The generic pkey_private_bytes raw path calls raw_private_key() + // which returns the expanded key on AWS-LC, not the seed. + if encoding == crate::serialization::Encoding::Raw + && format == crate::serialization::PrivateFormat::Raw + && encryption_algorithm.is_instance(&crate::types::NO_ENCRYPTION.get(py)?)? + { + return slf.borrow().private_bytes_raw(py); + } + utils::pkey_private_bytes( + py, + slf, + &slf.borrow().pkey, + encoding, + format, + encryption_algorithm, + true, + false, + ) + } + + fn __copy__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyRef<'_, Self> { + slf + } + + fn __deepcopy__<'p>( + slf: pyo3::PyRef<'p, Self>, + _memo: &pyo3::Bound<'p, pyo3::PyAny>, + ) -> pyo3::PyRef<'p, Self> { + slf + } +} + +#[pyo3::pymethods] +impl MlDsa65PublicKey { + fn verify(&self, signature: CffiBuf<'_>, data: CffiBuf<'_>) -> CryptographyResult<()> { + let valid = cryptography_openssl::mldsa::verify( + &self.pkey, + signature.as_bytes(), + data.as_bytes(), + &[], + ) + .unwrap_or(false); + + if !valid { + return Err(CryptographyError::from( + exceptions::InvalidSignature::new_err(()), + )); + } + + Ok(()) + } + + fn verify_with_context( + &self, + signature: CffiBuf<'_>, + data: CffiBuf<'_>, + context: CffiBuf<'_>, + ) -> CryptographyResult<()> { + if context.as_bytes().len() > MAX_CONTEXT_BYTES { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err("Context must be at most 255 bytes"), + )); + } + let valid = cryptography_openssl::mldsa::verify( + &self.pkey, + signature.as_bytes(), + data.as_bytes(), + context.as_bytes(), + ) + .unwrap_or(false); + + if !valid { + return Err(CryptographyError::from( + exceptions::InvalidSignature::new_err(()), + )); + } + + Ok(()) + } + + fn public_bytes_raw<'p>( + &self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + let raw_bytes = self.pkey.raw_public_key()?; + Ok(pyo3::types::PyBytes::new(py, &raw_bytes)) + } + + fn public_bytes<'p>( + slf: &pyo3::Bound<'p, Self>, + py: pyo3::Python<'p>, + encoding: crate::serialization::Encoding, + format: crate::serialization::PublicFormat, + ) -> CryptographyResult> { + utils::pkey_public_bytes(py, slf, &slf.borrow().pkey, encoding, format, true, true) + } + + fn __eq__(&self, other: pyo3::PyRef<'_, Self>) -> bool { + self.pkey.public_eq(&other.pkey) + } + + fn __copy__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyRef<'_, Self> { + slf + } + + fn __deepcopy__<'p>( + slf: pyo3::PyRef<'p, Self>, + _memo: &pyo3::Bound<'p, pyo3::PyAny>, + ) -> pyo3::PyRef<'p, Self> { + slf + } +} + +#[pyo3::pymodule(gil_used = false)] +pub(crate) mod mldsa65 { + #[pymodule_export] + use super::{ + from_public_bytes, from_seed_bytes, generate_key, MlDsa65PrivateKey, MlDsa65PublicKey, + }; +} diff --git a/src/rust/src/backend/mod.rs b/src/rust/src/backend/mod.rs index a9133cafb8c8..a509ffcb2279 100644 --- a/src/rust/src/backend/mod.rs +++ b/src/rust/src/backend/mod.rs @@ -21,6 +21,8 @@ pub(crate) mod hmac; pub(crate) mod hpke; pub(crate) mod kdf; pub(crate) mod keys; +#[cfg(CRYPTOGRAPHY_IS_AWSLC)] +pub(crate) mod mldsa65; pub(crate) mod poly1305; pub(crate) mod rand; pub(crate) mod rsa; diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index 093e9ccf88ab..d71f11d1ae15 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -241,6 +241,9 @@ mod _rust { use crate::backend::kdf::kdf; #[pymodule_export] use crate::backend::keys::keys; + #[cfg(CRYPTOGRAPHY_IS_AWSLC)] + #[pymodule_export] + use crate::backend::mldsa65::mldsa65; #[pymodule_export] use crate::backend::poly1305::poly1305; #[pymodule_export] diff --git a/tests/hazmat/primitives/test_mldsa65.py b/tests/hazmat/primitives/test_mldsa65.py new file mode 100644 index 000000000000..86a77aa31df6 --- /dev/null +++ b/tests/hazmat/primitives/test_mldsa65.py @@ -0,0 +1,338 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + + +import binascii +import copy +import os + +import pytest + +from cryptography.exceptions import InvalidSignature, _Reasons +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.mldsa65 import ( + MlDsa65PrivateKey, + MlDsa65PublicKey, +) + +from ...doubles import DummyKeySerializationEncryption +from ...utils import ( + load_nist_vectors, + load_vectors_from_file, + raises_unsupported_algorithm, +) + + +@pytest.mark.supported( + only_if=lambda backend: not backend.mldsa_supported(), + skip_message="Requires a backend without ML-DSA-65 support", +) +def test_mldsa_unsupported(backend): + with raises_unsupported_algorithm( + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM + ): + MlDsa65PublicKey.from_public_bytes(b"0" * 1952) + + with raises_unsupported_algorithm( + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM + ): + MlDsa65PrivateKey.from_seed_bytes(b"0" * 32) + + with raises_unsupported_algorithm( + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM + ): + MlDsa65PrivateKey.generate() + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA-65 support", +) +class TestMlDsa65: + def test_sign_verify(self, backend): + key = MlDsa65PrivateKey.generate() + sig = key.sign(b"test data") + key.public_key().verify(sig, b"test data") + + @pytest.mark.parametrize( + "ctx", + [ + b"ctx", + b"a" * 255, + ], + ) + def test_sign_verify_with_context(self, backend, ctx): + key = MlDsa65PrivateKey.generate() + sig = key.sign_with_context(b"test data", ctx) + key.public_key().verify_with_context(sig, b"test data", ctx) + + def test_empty_context_equivalence(self, backend): + key = MlDsa65PrivateKey.generate() + pub = key.public_key() + data = b"test data" + sig = key.sign(data) + pub.verify_with_context(sig, data, b"") + sig2 = key.sign_with_context(data, b"") + pub.verify(sig2, data) + + def test_kat_vectors(self, backend, subtests): + vectors = load_vectors_from_file( + os.path.join("asymmetric", "MLDSA", "kat_MLDSA_65_det_pure.rsp"), + load_nist_vectors, + ) + for vector in vectors: + with subtests.test(): + xi = binascii.unhexlify(vector["xi"]) + pk = binascii.unhexlify(vector["pk"]) + msg = binascii.unhexlify(vector["msg"]) + ctx = binascii.unhexlify(vector["ctx"]) + sm = binascii.unhexlify(vector["sm"]) + expected_sig = sm[:3309] + + # Keygen: seed produces expected public key + key = MlDsa65PrivateKey.from_seed_bytes(xi) + assert key.public_key().public_bytes_raw() == pk + + # Sigver: known-good signature verifies + pub = MlDsa65PublicKey.from_public_bytes(pk) + if ctx: + pub.verify_with_context(expected_sig, msg, ctx) + else: + pub.verify(expected_sig, msg) + + def test_private_bytes_raw_round_trip(self, backend): + key = MlDsa65PrivateKey.generate() + seed = key.private_bytes_raw() + assert len(seed) == 32 + key2 = MlDsa65PrivateKey.from_seed_bytes(seed) + assert key2.private_bytes_raw() == seed + assert seed == key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + + pub = key.public_key() + raw_pub = pub.public_bytes_raw() + assert len(raw_pub) == 1952 + pub2 = MlDsa65PublicKey.from_public_bytes(raw_pub) + assert pub2.public_bytes_raw() == raw_pub + + @pytest.mark.parametrize( + ("encoding", "fmt", "encryption", "passwd", "load_func"), + [ + ( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + None, + serialization.load_pem_private_key, + ), + ( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + None, + serialization.load_der_private_key, + ), + ( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(b"password"), + b"password", + serialization.load_pem_private_key, + ), + ( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(b"password"), + b"password", + serialization.load_der_private_key, + ), + ], + ) + def test_round_trip_private_serialization( + self, encoding, fmt, encryption, passwd, load_func, backend + ): + key = MlDsa65PrivateKey.generate() + serialized = key.private_bytes(encoding, fmt, encryption) + loaded_key = load_func(serialized, passwd, backend) + assert isinstance(loaded_key, MlDsa65PrivateKey) + sig = loaded_key.sign(b"test data") + key.public_key().verify(sig, b"test data") + + @pytest.mark.parametrize( + ("encoding", "fmt", "load_func"), + [ + ( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + serialization.load_pem_public_key, + ), + ( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + serialization.load_der_public_key, + ), + ], + ) + def test_round_trip_public_serialization( + self, encoding, fmt, load_func, backend + ): + key = MlDsa65PrivateKey.generate() + pub = key.public_key() + serialized = pub.public_bytes(encoding, fmt) + loaded_pub = load_func(serialized, backend) + assert isinstance(loaded_pub, MlDsa65PublicKey) + assert loaded_pub == pub + + def test_invalid_signature(self, backend): + key = MlDsa65PrivateKey.generate() + sig = key.sign(b"test data") + with pytest.raises(InvalidSignature): + key.public_key().verify(sig, b"wrong data") + + with pytest.raises(InvalidSignature): + key.public_key().verify(b"0" * 3309, b"test data") + + def test_context_wrong_context(self, backend): + key = MlDsa65PrivateKey.generate() + sig = key.sign_with_context(b"test data", b"ctx-a") + with pytest.raises(InvalidSignature): + key.public_key().verify_with_context(sig, b"test data", b"ctx-b") + + def test_context_too_long(self, backend): + key = MlDsa65PrivateKey.generate() + with pytest.raises(ValueError): + key.sign_with_context(b"data", b"x" * 256) + with pytest.raises(ValueError): + key.public_key().verify_with_context(b"sig", b"data", b"x" * 256) + + def test_invalid_length_from_public_bytes(self, backend): + with pytest.raises(ValueError): + MlDsa65PublicKey.from_public_bytes(b"a" * 10) + + def test_invalid_length_from_seed_bytes(self, backend): + with pytest.raises(ValueError): + MlDsa65PrivateKey.from_seed_bytes(b"a" * 10) + + def test_invalid_type_public_bytes(self, backend): + with pytest.raises(TypeError): + MlDsa65PublicKey.from_public_bytes( + object() # type: ignore[arg-type] + ) + + def test_invalid_type_seed_bytes(self, backend): + with pytest.raises(TypeError): + MlDsa65PrivateKey.from_seed_bytes( + object() # type: ignore[arg-type] + ) + + def test_invalid_private_bytes(self, backend): + key = MlDsa65PrivateKey.generate() + with pytest.raises(TypeError): + key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + None, # type: ignore[arg-type] + ) + with pytest.raises(ValueError): + key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + DummyKeySerializationEncryption(), + ) + + with pytest.raises(ValueError): + key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.PKCS8, + DummyKeySerializationEncryption(), + ) + + with pytest.raises(ValueError): + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + + def test_invalid_public_bytes(self, backend): + key = MlDsa65PrivateKey.generate().public_key() + with pytest.raises(ValueError): + key.public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + with pytest.raises(ValueError): + key.public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.PKCS1, + ) + + with pytest.raises(ValueError): + key.public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.Raw, + ) + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA-65 support", +) +def test_public_key_equality(backend): + key = MlDsa65PrivateKey.generate() + pub1 = key.public_key() + pub2 = key.public_key() + pub3 = MlDsa65PrivateKey.generate().public_key() + assert pub1 == pub2 + assert pub1 != pub3 + assert pub1 != object() + + with pytest.raises(TypeError): + pub1 < pub2 # type: ignore[operator] + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA-65 support", +) +def test_public_key_copy(backend): + key = MlDsa65PrivateKey.generate() + pub1 = key.public_key() + pub2 = copy.copy(pub1) + assert pub1 == pub2 + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA-65 support", +) +def test_public_key_deepcopy(backend): + key = MlDsa65PrivateKey.generate() + pub1 = key.public_key() + pub2 = copy.deepcopy(pub1) + assert pub1 == pub2 + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA-65 support", +) +def test_private_key_copy(backend): + key1 = MlDsa65PrivateKey.generate() + key2 = copy.copy(key1) + assert key1.private_bytes_raw() == key2.private_bytes_raw() + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA-65 support", +) +def test_private_key_deepcopy(backend): + key1 = MlDsa65PrivateKey.generate() + key2 = copy.deepcopy(key1) + assert key1.private_bytes_raw() == key2.private_bytes_raw() diff --git a/tests/wycheproof/test_mldsa.py b/tests/wycheproof/test_mldsa.py new file mode 100644 index 000000000000..c8bb70f1b9d7 --- /dev/null +++ b/tests/wycheproof/test_mldsa.py @@ -0,0 +1,104 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +import binascii + +import pytest + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.mldsa65 import ( + MlDsa65PrivateKey, + MlDsa65PublicKey, +) + +from .utils import wycheproof_tests + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA-65 support", +) +@wycheproof_tests("mldsa_65_verify_test.json") +def test_mldsa65_verify(backend, wycheproof): + try: + pub = wycheproof.cache_value_to_group( + "cached_pub", + lambda: MlDsa65PublicKey.from_public_bytes( + binascii.unhexlify(wycheproof.testgroup["publicKey"]) + ), + ) + except ValueError: + assert wycheproof.invalid + assert wycheproof.has_flag("IncorrectPublicKeyLength") + return + + msg = binascii.unhexlify(wycheproof.testcase["msg"]) + sig = binascii.unhexlify(wycheproof.testcase["sig"]) + has_ctx = "ctx" in wycheproof.testcase + ctx = binascii.unhexlify(wycheproof.testcase["ctx"]) if has_ctx else None + + if wycheproof.valid: + if has_ctx: + pub.verify_with_context(sig, msg, ctx) + else: + pub.verify(sig, msg) + else: + with pytest.raises( + ( + ValueError, + InvalidSignature, + ) + ): + if has_ctx: + pub.verify_with_context(sig, msg, ctx) + else: + pub.verify(sig, msg) + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA-65 support", +) +@wycheproof_tests("mldsa_65_sign_seed_test.json") +def test_mldsa65_sign_seed(backend, wycheproof): + # Skip "Internal" tests + if wycheproof.has_flag("Internal"): + return + + seed = wycheproof.cache_value_to_group( + "cached_seed", + lambda: binascii.unhexlify(wycheproof.testgroup["privateSeed"]), + ) + key = wycheproof.cache_value_to_group( + "cached_key", + lambda: MlDsa65PrivateKey.from_seed_bytes(seed), + ) + pub = wycheproof.cache_value_to_group( + "cached_pub", + lambda: MlDsa65PublicKey.from_public_bytes( + binascii.unhexlify(wycheproof.testgroup["publicKey"]) + ), + ) + + assert key.public_key() == pub + + msg = binascii.unhexlify(wycheproof.testcase["msg"]) + has_ctx = "ctx" in wycheproof.testcase + ctx = binascii.unhexlify(wycheproof.testcase["ctx"]) if has_ctx else None + + if wycheproof.valid or wycheproof.acceptable: + # Sign and verify round-trip. We don't compare exact signature + # bytes because some backends use hedged (randomized) signing. + if has_ctx: + sig = key.sign_with_context(msg, ctx) + pub.verify_with_context(sig, msg, ctx) + else: + sig = key.sign(msg) + pub.verify(sig, msg) + else: + with pytest.raises(ValueError): + if has_ctx: + key.sign_with_context(msg, ctx) + else: + key.sign(msg) From 65ab9410ca8f4526533000b2b42c66766ef3bc33 Mon Sep 17 00:00:00 2001 From: Alexis Date: Tue, 3 Mar 2026 14:21:26 +0100 Subject: [PATCH 02/23] Improve coverage --- src/rust/cryptography-key-parsing/src/spki.rs | 11 ++++++ src/rust/cryptography-openssl/src/mldsa.rs | 17 +++++++++ src/rust/src/backend/keys.rs | 34 ++++++++++++++++++ src/rust/src/backend/mldsa65.rs | 15 +++++--- src/rust/test_data/mldsa44_priv.der | Bin 0 -> 54 bytes src/rust/test_data/mldsa44_pub.der | Bin 0 -> 1334 bytes tests/hazmat/primitives/test_mldsa65.py | 22 +++++++++--- tests/wycheproof/test_mldsa.py | 6 ++-- 8 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 src/rust/test_data/mldsa44_priv.der create mode 100644 src/rust/test_data/mldsa44_pub.der diff --git a/src/rust/cryptography-key-parsing/src/spki.rs b/src/rust/cryptography-key-parsing/src/spki.rs index c59a07a7f18b..04a726fe0f9c 100644 --- a/src/rust/cryptography-key-parsing/src/spki.rs +++ b/src/rust/cryptography-key-parsing/src/spki.rs @@ -269,4 +269,15 @@ mod tests { // Expected to panic _ = serialize_public_key(&pkey); } + + #[cfg(CRYPTOGRAPHY_IS_AWSLC)] + #[test] + #[should_panic(expected = "Unsupported ML-DSA variant")] + fn test_serialize_public_key_unsupported_mldsa_variant() { + // Load an ML-DSA-44 public key from a Wycheproof test vector DER. + let der = include_bytes!("../../test_data/mldsa44_pub.der"); + let pub_pkey = openssl::pkey::PKey::public_key_from_der(der).unwrap(); + // Expected to panic with "Unsupported ML-DSA variant" + _ = serialize_public_key(&pub_pkey); + } } diff --git a/src/rust/cryptography-openssl/src/mldsa.rs b/src/rust/cryptography-openssl/src/mldsa.rs index 2cbf4f7d8cba..1e4397ff9cbf 100644 --- a/src/rust/cryptography-openssl/src/mldsa.rs +++ b/src/rust/cryptography-openssl/src/mldsa.rs @@ -160,3 +160,20 @@ pub fn verify( Ok(r == 1) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sign_with_context_too_long_returns_error() { + // ML-DSA context strings are limited to 255 bytes. + // Passing a 256-byte context to ml_dsa_65_sign triggers + // an FFI error return. + let seed = generate_seed().unwrap(); + let pkey = new_raw_private_key(&seed).unwrap(); + let long_ctx = [0x41u8; 256]; + let result = sign(&pkey, b"test", &long_ctx); + assert!(result.is_err()); + } +} diff --git a/src/rust/src/backend/keys.rs b/src/rust/src/backend/keys.rs index 60ca71c299b0..acfeaf75b57b 100644 --- a/src/rust/src/backend/keys.rs +++ b/src/rust/src/backend/keys.rs @@ -372,4 +372,38 @@ mod tests { assert!(private_key_from_pkey(py, &pkey, false).is_err()); }); } + + #[test] + #[cfg(CRYPTOGRAPHY_IS_AWSLC)] + fn test_private_key_from_pkey_unsupported_mldsa_variant() { + use super::private_key_from_pkey; + + pyo3::Python::initialize(); + + pyo3::Python::attach(|py| { + // Load an ML-DSA-44 private key from a Wycheproof PKCS8 DER. + // ML-DSA-44 is a PQDSA key that we don't support; its public key + // length differs from ML-DSA-65, hitting the "Unsupported ML-DSA + // variant" error branch. + let der = include_bytes!("../../test_data/mldsa44_priv.der"); + let pkey = openssl::pkey::PKey::private_key_from_der(der).unwrap(); + assert!(private_key_from_pkey(py, &pkey, false).is_err()); + }); + } + + #[test] + #[cfg(CRYPTOGRAPHY_IS_AWSLC)] + fn test_public_key_from_pkey_unsupported_mldsa_variant() { + use super::public_key_from_pkey; + + pyo3::Python::initialize(); + + pyo3::Python::attach(|py| { + // Load an ML-DSA-44 public key from a Wycheproof SPKI DER. + let der = include_bytes!("../../test_data/mldsa44_pub.der"); + let pub_pkey = openssl::pkey::PKey::public_key_from_der(der).unwrap(); + let id = pub_pkey.id(); + assert!(public_key_from_pkey(py, &pub_pkey, id).is_err()); + }); + } } diff --git a/src/rust/src/backend/mldsa65.rs b/src/rust/src/backend/mldsa65.rs index fb9497914f4e..34c4e49fc5b4 100644 --- a/src/rust/src/backend/mldsa65.rs +++ b/src/rust/src/backend/mldsa65.rs @@ -58,7 +58,9 @@ fn from_public_bytes(data: &[u8]) -> pyo3::PyResult { Ok(MlDsa65PublicKey { pkey }) } +// NO-COVERAGE-START #[pyo3::pymethods] +// NO-COVERAGE-END impl MlDsa65PrivateKey { fn sign<'p>( &self, @@ -104,11 +106,7 @@ impl MlDsa65PrivateKey { let pkcs8_der = self.pkey.private_key_to_pkcs8()?; let pki = asn1::parse_single::>(&pkcs8_der) - .map_err(|_| { - pyo3::exceptions::PyValueError::new_err( - "Cannot extract seed from this ML-DSA-65 private key", - ) - })?; + .unwrap(); // The privateKey content is [0x80, 0x20, <32 bytes of seed>] // (context-specific tag 0, length 32) if pki.private_key.len() == 2 + cryptography_openssl::mldsa::MLDSA65_SEED_BYTES @@ -117,11 +115,16 @@ impl MlDsa65PrivateKey { { Ok(pyo3::types::PyBytes::new(py, &pki.private_key[2..])) } else { + // NO-COVERAGE-START + // All supported ML-DSA variants use 32-byte seeds with the + // [0x80, 0x20, ] encoding. This branch is purely + // defensive against future format changes. Err(CryptographyError::from( pyo3::exceptions::PyValueError::new_err( "Cannot extract seed from this ML-DSA-65 private key", ), )) + // NO-COVERAGE-END } } @@ -165,7 +168,9 @@ impl MlDsa65PrivateKey { } } +// NO-COVERAGE-START #[pyo3::pymethods] +// NO-COVERAGE-END impl MlDsa65PublicKey { fn verify(&self, signature: CffiBuf<'_>, data: CffiBuf<'_>) -> CryptographyResult<()> { let valid = cryptography_openssl::mldsa::verify( diff --git a/src/rust/test_data/mldsa44_priv.der b/src/rust/test_data/mldsa44_priv.der new file mode 100644 index 0000000000000000000000000000000000000000..244192344fdcd20657b0cbd3664dde621477f6e3 GIT binary patch literal 54 fcmXpoVPa%3;AZ1YX!Br9WoBU(WKn8R&>{c;-1P`& literal 0 HcmV?d00001 diff --git a/src/rust/test_data/mldsa44_pub.der b/src/rust/test_data/mldsa44_pub.der new file mode 100644 index 0000000000000000000000000000000000000000..2598b11b9404d679d0a043f27a2b1c06dbb24e9d GIT binary patch literal 1334 zcmV-61 z|FDLsro)-Bl%+2YL1f8q_@bplH{K*Q3WjT67O#;CZenoLqiUW?HkDp8MD$E!f`I#_#8yEGaqCj2##he zXv4P9Wz?G z#=tgkt@w9G-RS^Q_02{1(+@+W+&oYW>t&D5BU~2}K)T8sS`>3VcT1EO#X$8wxHN&U z$%90(Dnkk8A}a{lctZqdNpKJ`OmAz9uVpqIAz=nNQgx<%bpYGJAor1sZ;iLAWBQg` zN+QP%$d7V;c8ub`$60^Wa2QS;S*Eh#AWX=^&{D;e)ps5Y%#8aPA4da=90qdme6L4G zRxLetsex~?-tjpc@bF*FzbX#4!WzO$Cg)D%0~0=gr;`)iuq+>=hQ{3;LIIUDJ~|Le z|7~E1e{%%-o;$UhwO@h_*jp4u#ZHH z1DWplIyMV~L=L^!>TUtaQCZ`2TeLuh0LrI@%0Nkp9CIM^a5VC?5;{WG3v1o)08 zy3?|VMQD5C%##%J0R5$JIA-Eq? zFUt6G+4u#PWFnQ;Ze3sjAB;5y#uCfQ|Qkp;>9l%?=qCH=bYL=k6r(h zJfI;Oc%1Ca9lL)`UiFp3-j!+??fJ_NxlXER(utFfGu@``$89CCU&}a!D|iFX<}V1@`_OQe zd`@o@NpqG=oxlm0T!jJW*!M1e!iCi+pWO%cO=7I7mp8CE@29EJ(REjRYtNnC&uJ)k suQ=yYW4^v5Qx#k09|t4CbU2DrD!wV*a3kX<2wzN{+ChVxdox$7okVhl!2kdN literal 0 HcmV?d00001 diff --git a/tests/hazmat/primitives/test_mldsa65.py b/tests/hazmat/primitives/test_mldsa65.py index 86a77aa31df6..e6a570d0bf3d 100644 --- a/tests/hazmat/primitives/test_mldsa65.py +++ b/tests/hazmat/primitives/test_mldsa65.py @@ -55,6 +55,11 @@ def test_sign_verify(self, backend): sig = key.sign(b"test data") key.public_key().verify(sig, b"test data") + def test_sign_verify_empty_message(self, backend): + key = MlDsa65PrivateKey.generate() + sig = key.sign(b"") + key.public_key().verify(sig, b"") + @pytest.mark.parametrize( "ctx", [ @@ -96,10 +101,19 @@ def test_kat_vectors(self, backend, subtests): # Sigver: known-good signature verifies pub = MlDsa65PublicKey.from_public_bytes(pk) - if ctx: - pub.verify_with_context(expected_sig, msg, ctx) - else: - pub.verify(expected_sig, msg) + pub.verify_with_context(expected_sig, msg, ctx) + + def test_kat_verify_no_context(self, backend): + vectors = load_vectors_from_file( + os.path.join("asymmetric", "MLDSA", "kat_MLDSA_65_det_pure.rsp"), + load_nist_vectors, + ) + vector = vectors[0] + xi = binascii.unhexlify(vector["xi"]) + key = MlDsa65PrivateKey.from_seed_bytes(xi) + pub = key.public_key() + sig = key.sign(b"test") + pub.verify(sig, b"test") def test_private_bytes_raw_round_trip(self, backend): key = MlDsa65PrivateKey.generate() diff --git a/tests/wycheproof/test_mldsa.py b/tests/wycheproof/test_mldsa.py index c8bb70f1b9d7..9659fdcf72d2 100644 --- a/tests/wycheproof/test_mldsa.py +++ b/tests/wycheproof/test_mldsa.py @@ -98,7 +98,5 @@ def test_mldsa65_sign_seed(backend, wycheproof): pub.verify(sig, msg) else: with pytest.raises(ValueError): - if has_ctx: - key.sign_with_context(msg, ctx) - else: - key.sign(msg) + assert has_ctx + key.sign_with_context(msg, ctx) From 4fab32f7d95f8b6faa269c56e06d4fb1d8d0d5cd Mon Sep 17 00:00:00 2001 From: Alexis Date: Wed, 4 Mar 2026 04:50:33 -0500 Subject: [PATCH 03/23] Initial review --- src/rust/src/backend/mldsa65.rs | 10 ++++++---- tests/hazmat/primitives/test_mldsa65.py | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/rust/src/backend/mldsa65.rs b/src/rust/src/backend/mldsa65.rs index 34c4e49fc5b4..99290eb5fe52 100644 --- a/src/rust/src/backend/mldsa65.rs +++ b/src/rust/src/backend/mldsa65.rs @@ -46,15 +46,17 @@ fn generate_key() -> CryptographyResult { #[pyo3::pyfunction] fn from_seed_bytes(data: CffiBuf<'_>) -> pyo3::PyResult { - let pkey = cryptography_openssl::mldsa::new_raw_private_key(data.as_bytes()) - .map_err(|_| pyo3::exceptions::PyValueError::new_err("Invalid ML-DSA-65 seed"))?; + let pkey = cryptography_openssl::mldsa::new_raw_private_key(data.as_bytes()).map_err(|_| { + pyo3::exceptions::PyValueError::new_err("An ML-DSA-65 seed is 32 bytes long") + })?; Ok(MlDsa65PrivateKey { pkey }) } #[pyo3::pyfunction] fn from_public_bytes(data: &[u8]) -> pyo3::PyResult { - let pkey = cryptography_openssl::mldsa::new_raw_public_key(data) - .map_err(|_| pyo3::exceptions::PyValueError::new_err("Invalid ML-DSA-65 public key"))?; + let pkey = cryptography_openssl::mldsa::new_raw_public_key(data).map_err(|_| { + pyo3::exceptions::PyValueError::new_err("An ML-DSA-65 public key is 1952 bytes long") + })?; Ok(MlDsa65PublicKey { pkey }) } diff --git a/tests/hazmat/primitives/test_mldsa65.py b/tests/hazmat/primitives/test_mldsa65.py index e6a570d0bf3d..34a49867f4dc 100644 --- a/tests/hazmat/primitives/test_mldsa65.py +++ b/tests/hazmat/primitives/test_mldsa65.py @@ -97,6 +97,7 @@ def test_kat_vectors(self, backend, subtests): # Keygen: seed produces expected public key key = MlDsa65PrivateKey.from_seed_bytes(xi) + assert key.private_bytes_raw() == xi assert key.public_key().public_bytes_raw() == pk # Sigver: known-good signature verifies From 2450b4109be7e9dea3840dcceb80ce498225519f Mon Sep 17 00:00:00 2001 From: Alexis Date: Thu, 5 Mar 2026 05:44:42 -0500 Subject: [PATCH 04/23] First round of review --- .../hazmat/primitives/asymmetric/mldsa65.py | 25 ++++------ src/rust/cryptography-openssl/src/mldsa.rs | 38 +++----------- src/rust/src/backend/mldsa65.rs | 49 +++++-------------- tests/hazmat/primitives/test_mldsa65.py | 30 ++++-------- tests/wycheproof/test_mldsa.py | 20 ++------ 5 files changed, 42 insertions(+), 120 deletions(-) diff --git a/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py b/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py index c0811a2039a7..883abce1fcce 100644 --- a/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py +++ b/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py @@ -43,17 +43,14 @@ def public_bytes_raw(self) -> bytes: """ @abc.abstractmethod - def verify(self, signature: Buffer, data: Buffer) -> None: - """ - Verify the signature. - """ - - @abc.abstractmethod - def verify_with_context( - self, signature: Buffer, data: Buffer, context: Buffer + def verify( + self, + signature: Buffer, + data: Buffer, + context: Buffer | None = None, ) -> None: """ - Verify the signature with a context string. + Verify the signature. """ @abc.abstractmethod @@ -129,17 +126,13 @@ def private_bytes_raw(self) -> bytes: """ @abc.abstractmethod - def sign(self, data: Buffer) -> bytes: + def sign( + self, data: Buffer, context: Buffer | None = None + ) -> bytes: """ Signs the data. """ - @abc.abstractmethod - def sign_with_context(self, data: Buffer, context: Buffer) -> bytes: - """ - Signs the data with a context string. - """ - @abc.abstractmethod def __copy__(self) -> MlDsa65PrivateKey: """ diff --git a/src/rust/cryptography-openssl/src/mldsa.rs b/src/rust/cryptography-openssl/src/mldsa.rs index 1e4397ff9cbf..fe6c1be958c1 100644 --- a/src/rust/cryptography-openssl/src/mldsa.rs +++ b/src/rust/cryptography-openssl/src/mldsa.rs @@ -6,7 +6,7 @@ use foreign_types_shared::ForeignType; use openssl_sys as ffi; use std::os::raw::c_int; -use crate::{cvt_p, OpenSSLResult}; +use crate::{cvt, cvt_p, OpenSSLResult}; pub const NID_ML_DSA_65: c_int = ffi::NID_MLDSA65; pub const NID_PQDSA: c_int = ffi::NID_PQDSA; @@ -39,13 +39,6 @@ extern "C" { ) -> c_int; } -/// Generate a random 32-byte ML-DSA-65 seed. -pub fn generate_seed() -> OpenSSLResult<[u8; MLDSA65_SEED_BYTES]> { - let mut seed = [0u8; MLDSA65_SEED_BYTES]; - openssl::rand::rand_bytes(&mut seed)?; - Ok(seed) -} - pub fn new_raw_private_key( data: &[u8], ) -> OpenSSLResult> { @@ -99,8 +92,9 @@ pub fn sign( }; // SAFETY: ml_dsa_65_sign takes raw key bytes, message, and context. - let r = unsafe { - ml_dsa_65_sign( + // SAFETY: ml_dsa_65_sign takes raw key bytes, message, and context. + unsafe { + let r = ml_dsa_65_sign( raw_key.as_ptr(), sig.as_mut_ptr(), &mut sig_len, @@ -108,11 +102,8 @@ pub fn sign( data.len(), ctx_ptr, context.len(), - ) - }; - - if r != 1 { - return Err(openssl::error::ErrorStack::get()); + ); + cvt(r)?; } sig.truncate(sig_len); @@ -160,20 +151,3 @@ pub fn verify( Ok(r == 1) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sign_with_context_too_long_returns_error() { - // ML-DSA context strings are limited to 255 bytes. - // Passing a 256-byte context to ml_dsa_65_sign triggers - // an FFI error return. - let seed = generate_seed().unwrap(); - let pkey = new_raw_private_key(&seed).unwrap(); - let long_ctx = [0x41u8; 256]; - let result = sign(&pkey, b"test", &long_ctx); - assert!(result.is_err()); - } -} diff --git a/src/rust/src/backend/mldsa65.rs b/src/rust/src/backend/mldsa65.rs index 99290eb5fe52..5737b5edfc80 100644 --- a/src/rust/src/backend/mldsa65.rs +++ b/src/rust/src/backend/mldsa65.rs @@ -39,7 +39,8 @@ pub(crate) fn public_key_from_pkey( #[pyo3::pyfunction] fn generate_key() -> CryptographyResult { - let seed = cryptography_openssl::mldsa::generate_seed()?; + let mut seed = [0u8; cryptography_openssl::mldsa::MLDSA65_SEED_BYTES]; + openssl::rand::rand_bytes(&mut seed)?; let pkey = cryptography_openssl::mldsa::new_raw_private_key(&seed)?; Ok(MlDsa65PrivateKey { pkey }) } @@ -64,28 +65,20 @@ fn from_public_bytes(data: &[u8]) -> pyo3::PyResult { #[pyo3::pymethods] // NO-COVERAGE-END impl MlDsa65PrivateKey { + #[pyo3(signature = (data, context=None))] fn sign<'p>( &self, py: pyo3::Python<'p>, data: CffiBuf<'_>, + context: Option>, ) -> CryptographyResult> { - let sig = cryptography_openssl::mldsa::sign(&self.pkey, data.as_bytes(), &[])?; - Ok(pyo3::types::PyBytes::new(py, &sig)) - } - - fn sign_with_context<'p>( - &self, - py: pyo3::Python<'p>, - data: CffiBuf<'_>, - context: CffiBuf<'_>, - ) -> CryptographyResult> { - if context.as_bytes().len() > MAX_CONTEXT_BYTES { + let ctx_bytes = context.as_ref().map_or(&[][..], |c| c.as_bytes()); + if ctx_bytes.len() > MAX_CONTEXT_BYTES { return Err(CryptographyError::from( pyo3::exceptions::PyValueError::new_err("Context must be at most 255 bytes"), )); } - let sig = - cryptography_openssl::mldsa::sign(&self.pkey, data.as_bytes(), context.as_bytes())?; + let sig = cryptography_openssl::mldsa::sign(&self.pkey, data.as_bytes(), ctx_bytes)?; Ok(pyo3::types::PyBytes::new(py, &sig)) } @@ -174,31 +167,15 @@ impl MlDsa65PrivateKey { #[pyo3::pymethods] // NO-COVERAGE-END impl MlDsa65PublicKey { - fn verify(&self, signature: CffiBuf<'_>, data: CffiBuf<'_>) -> CryptographyResult<()> { - let valid = cryptography_openssl::mldsa::verify( - &self.pkey, - signature.as_bytes(), - data.as_bytes(), - &[], - ) - .unwrap_or(false); - - if !valid { - return Err(CryptographyError::from( - exceptions::InvalidSignature::new_err(()), - )); - } - - Ok(()) - } - - fn verify_with_context( + #[pyo3(signature = (signature, data, context=None))] + fn verify( &self, signature: CffiBuf<'_>, data: CffiBuf<'_>, - context: CffiBuf<'_>, + context: Option>, ) -> CryptographyResult<()> { - if context.as_bytes().len() > MAX_CONTEXT_BYTES { + let ctx_bytes = context.as_ref().map_or(&[][..], |c| c.as_bytes()); + if ctx_bytes.len() > MAX_CONTEXT_BYTES { return Err(CryptographyError::from( pyo3::exceptions::PyValueError::new_err("Context must be at most 255 bytes"), )); @@ -207,7 +184,7 @@ impl MlDsa65PublicKey { &self.pkey, signature.as_bytes(), data.as_bytes(), - context.as_bytes(), + ctx_bytes, ) .unwrap_or(false); diff --git a/tests/hazmat/primitives/test_mldsa65.py b/tests/hazmat/primitives/test_mldsa65.py index 34a49867f4dc..210ee34fcc5b 100644 --- a/tests/hazmat/primitives/test_mldsa65.py +++ b/tests/hazmat/primitives/test_mldsa65.py @@ -69,16 +69,16 @@ def test_sign_verify_empty_message(self, backend): ) def test_sign_verify_with_context(self, backend, ctx): key = MlDsa65PrivateKey.generate() - sig = key.sign_with_context(b"test data", ctx) - key.public_key().verify_with_context(sig, b"test data", ctx) + sig = key.sign(b"test data", ctx) + key.public_key().verify(sig, b"test data", ctx) def test_empty_context_equivalence(self, backend): key = MlDsa65PrivateKey.generate() pub = key.public_key() data = b"test data" sig = key.sign(data) - pub.verify_with_context(sig, data, b"") - sig2 = key.sign_with_context(data, b"") + pub.verify(sig, data, b"") + sig2 = key.sign(data, b"") pub.verify(sig2, data) def test_kat_vectors(self, backend, subtests): @@ -102,19 +102,7 @@ def test_kat_vectors(self, backend, subtests): # Sigver: known-good signature verifies pub = MlDsa65PublicKey.from_public_bytes(pk) - pub.verify_with_context(expected_sig, msg, ctx) - - def test_kat_verify_no_context(self, backend): - vectors = load_vectors_from_file( - os.path.join("asymmetric", "MLDSA", "kat_MLDSA_65_det_pure.rsp"), - load_nist_vectors, - ) - vector = vectors[0] - xi = binascii.unhexlify(vector["xi"]) - key = MlDsa65PrivateKey.from_seed_bytes(xi) - pub = key.public_key() - sig = key.sign(b"test") - pub.verify(sig, b"test") + pub.verify(expected_sig, msg, ctx) def test_private_bytes_raw_round_trip(self, backend): key = MlDsa65PrivateKey.generate() @@ -213,16 +201,16 @@ def test_invalid_signature(self, backend): def test_context_wrong_context(self, backend): key = MlDsa65PrivateKey.generate() - sig = key.sign_with_context(b"test data", b"ctx-a") + sig = key.sign(b"test data", b"ctx-a") with pytest.raises(InvalidSignature): - key.public_key().verify_with_context(sig, b"test data", b"ctx-b") + key.public_key().verify(sig, b"test data", b"ctx-b") def test_context_too_long(self, backend): key = MlDsa65PrivateKey.generate() with pytest.raises(ValueError): - key.sign_with_context(b"data", b"x" * 256) + key.sign(b"data", b"x" * 256) with pytest.raises(ValueError): - key.public_key().verify_with_context(b"sig", b"data", b"x" * 256) + key.public_key().verify(b"sig", b"data", b"x" * 256) def test_invalid_length_from_public_bytes(self, backend): with pytest.raises(ValueError): diff --git a/tests/wycheproof/test_mldsa.py b/tests/wycheproof/test_mldsa.py index 9659fdcf72d2..1ca17d87557e 100644 --- a/tests/wycheproof/test_mldsa.py +++ b/tests/wycheproof/test_mldsa.py @@ -39,10 +39,7 @@ def test_mldsa65_verify(backend, wycheproof): ctx = binascii.unhexlify(wycheproof.testcase["ctx"]) if has_ctx else None if wycheproof.valid: - if has_ctx: - pub.verify_with_context(sig, msg, ctx) - else: - pub.verify(sig, msg) + pub.verify(sig, msg, ctx) else: with pytest.raises( ( @@ -50,10 +47,7 @@ def test_mldsa65_verify(backend, wycheproof): InvalidSignature, ) ): - if has_ctx: - pub.verify_with_context(sig, msg, ctx) - else: - pub.verify(sig, msg) + pub.verify(sig, msg, ctx) @pytest.mark.supported( @@ -90,13 +84,9 @@ def test_mldsa65_sign_seed(backend, wycheproof): if wycheproof.valid or wycheproof.acceptable: # Sign and verify round-trip. We don't compare exact signature # bytes because some backends use hedged (randomized) signing. - if has_ctx: - sig = key.sign_with_context(msg, ctx) - pub.verify_with_context(sig, msg, ctx) - else: - sig = key.sign(msg) - pub.verify(sig, msg) + sig = key.sign(msg, ctx) + pub.verify(sig, msg, ctx) else: with pytest.raises(ValueError): assert has_ctx - key.sign_with_context(msg, ctx) + key.sign(msg, ctx) From ef4345fd618535dc00f4062cda7bcc03eb2a22af Mon Sep 17 00:00:00 2001 From: Alexis Date: Thu, 5 Mar 2026 11:42:28 -0500 Subject: [PATCH 05/23] Clean tests --- .../hazmat/primitives/asymmetric/mldsa65.py | 4 +- src/rust/cryptography-key-parsing/src/spki.rs | 4 +- src/rust/src/backend/keys.rs | 34 ----------------- tests/hazmat/primitives/test_mldsa65.py | 36 ++++++++++++++++++ .../asymmetric/MLDSA}/mldsa44_priv.der | Bin .../asymmetric/MLDSA}/mldsa44_pub.der | Bin 6 files changed, 40 insertions(+), 38 deletions(-) rename {src/rust/test_data => vectors/cryptography_vectors/asymmetric/MLDSA}/mldsa44_priv.der (100%) rename {src/rust/test_data => vectors/cryptography_vectors/asymmetric/MLDSA}/mldsa44_pub.der (100%) diff --git a/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py b/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py index 883abce1fcce..c761386c3d63 100644 --- a/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py +++ b/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py @@ -126,9 +126,7 @@ def private_bytes_raw(self) -> bytes: """ @abc.abstractmethod - def sign( - self, data: Buffer, context: Buffer | None = None - ) -> bytes: + def sign(self, data: Buffer, context: Buffer | None = None) -> bytes: """ Signs the data. """ diff --git a/src/rust/cryptography-key-parsing/src/spki.rs b/src/rust/cryptography-key-parsing/src/spki.rs index 04a726fe0f9c..56a2023c0724 100644 --- a/src/rust/cryptography-key-parsing/src/spki.rs +++ b/src/rust/cryptography-key-parsing/src/spki.rs @@ -275,7 +275,9 @@ mod tests { #[should_panic(expected = "Unsupported ML-DSA variant")] fn test_serialize_public_key_unsupported_mldsa_variant() { // Load an ML-DSA-44 public key from a Wycheproof test vector DER. - let der = include_bytes!("../../test_data/mldsa44_pub.der"); + let der = include_bytes!( + "../../../../vectors/cryptography_vectors/asymmetric/MLDSA/mldsa44_pub.der" + ); let pub_pkey = openssl::pkey::PKey::public_key_from_der(der).unwrap(); // Expected to panic with "Unsupported ML-DSA variant" _ = serialize_public_key(&pub_pkey); diff --git a/src/rust/src/backend/keys.rs b/src/rust/src/backend/keys.rs index acfeaf75b57b..60ca71c299b0 100644 --- a/src/rust/src/backend/keys.rs +++ b/src/rust/src/backend/keys.rs @@ -372,38 +372,4 @@ mod tests { assert!(private_key_from_pkey(py, &pkey, false).is_err()); }); } - - #[test] - #[cfg(CRYPTOGRAPHY_IS_AWSLC)] - fn test_private_key_from_pkey_unsupported_mldsa_variant() { - use super::private_key_from_pkey; - - pyo3::Python::initialize(); - - pyo3::Python::attach(|py| { - // Load an ML-DSA-44 private key from a Wycheproof PKCS8 DER. - // ML-DSA-44 is a PQDSA key that we don't support; its public key - // length differs from ML-DSA-65, hitting the "Unsupported ML-DSA - // variant" error branch. - let der = include_bytes!("../../test_data/mldsa44_priv.der"); - let pkey = openssl::pkey::PKey::private_key_from_der(der).unwrap(); - assert!(private_key_from_pkey(py, &pkey, false).is_err()); - }); - } - - #[test] - #[cfg(CRYPTOGRAPHY_IS_AWSLC)] - fn test_public_key_from_pkey_unsupported_mldsa_variant() { - use super::public_key_from_pkey; - - pyo3::Python::initialize(); - - pyo3::Python::attach(|py| { - // Load an ML-DSA-44 public key from a Wycheproof SPKI DER. - let der = include_bytes!("../../test_data/mldsa44_pub.der"); - let pub_pkey = openssl::pkey::PKey::public_key_from_der(der).unwrap(); - let id = pub_pkey.id(); - assert!(public_key_from_pkey(py, &pub_pkey, id).is_err()); - }); - } } diff --git a/tests/hazmat/primitives/test_mldsa65.py b/tests/hazmat/primitives/test_mldsa65.py index 210ee34fcc5b..b64db1be7e40 100644 --- a/tests/hazmat/primitives/test_mldsa65.py +++ b/tests/hazmat/primitives/test_mldsa65.py @@ -282,6 +282,42 @@ def test_invalid_public_bytes(self, backend): ) +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA-65 support", +) +def test_unsupported_mldsa_variant_private_key(backend): + # ML-DSA-44 is not supported; loading it must raise UnsupportedAlgorithm. + pkcs8_der = load_vectors_from_file( + os.path.join("asymmetric", "MLDSA", "mldsa44_priv.der"), + lambda derfile: derfile.read(), + mode="rb", + ) + with raises_unsupported_algorithm( + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM + ): + serialization.load_der_private_key( + pkcs8_der, password=None, backend=backend + ) + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA-65 support", +) +def test_unsupported_mldsa_variant_public_key(backend): + # ML-DSA-44 is not supported; loading it must raise UnsupportedAlgorithm. + spki_der = load_vectors_from_file( + os.path.join("asymmetric", "MLDSA", "mldsa44_pub.der"), + lambda derfile: derfile.read(), + mode="rb", + ) + with raises_unsupported_algorithm( + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM + ): + serialization.load_der_public_key(spki_der, backend=backend) + + @pytest.mark.supported( only_if=lambda backend: backend.mldsa_supported(), skip_message="Requires a backend with ML-DSA-65 support", diff --git a/src/rust/test_data/mldsa44_priv.der b/vectors/cryptography_vectors/asymmetric/MLDSA/mldsa44_priv.der similarity index 100% rename from src/rust/test_data/mldsa44_priv.der rename to vectors/cryptography_vectors/asymmetric/MLDSA/mldsa44_priv.der diff --git a/src/rust/test_data/mldsa44_pub.der b/vectors/cryptography_vectors/asymmetric/MLDSA/mldsa44_pub.der similarity index 100% rename from src/rust/test_data/mldsa44_pub.der rename to vectors/cryptography_vectors/asymmetric/MLDSA/mldsa44_pub.der From 2f6b31316d392ae5b60cf54c0be3e2c4f9b69d3b Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 6 Mar 2026 03:29:45 -0500 Subject: [PATCH 06/23] Revert spurious formatting --- src/rust/src/backend/keys.rs | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/rust/src/backend/keys.rs b/src/rust/src/backend/keys.rs index 60ca71c299b0..d34bd55637a3 100644 --- a/src/rust/src/backend/keys.rs +++ b/src/rust/src/backend/keys.rs @@ -86,24 +86,16 @@ fn load_pem_private_key<'p>( let (data, mut password_used) = cryptography_key_parsing::pem::decrypt_pem(&p, password)?; let pkey = match p.tag() { - "PRIVATE KEY" => { - cryptography_key_parsing::pkcs8::parse_private_key(&data)? - } - "RSA PRIVATE KEY" => { - cryptography_key_parsing::rsa::parse_pkcs1_private_key(&data).map_err(|e| { - CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") - })? - } - "EC PRIVATE KEY" => { - cryptography_key_parsing::ec::parse_pkcs1_private_key(&data, None).map_err(|e| { - CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") - })? - } - "DSA PRIVATE KEY" => { - cryptography_key_parsing::dsa::parse_pkcs1_private_key(&data).map_err(|e| { - CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") - })? - } + "PRIVATE KEY" => cryptography_key_parsing::pkcs8::parse_private_key(&data)?, + "RSA PRIVATE KEY" => cryptography_key_parsing::rsa::parse_pkcs1_private_key(&data).map_err(|e| { + CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") + })?, + "EC PRIVATE KEY" => cryptography_key_parsing::ec::parse_pkcs1_private_key(&data, None).map_err(|e| { + CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") + })?, + "DSA PRIVATE KEY" => cryptography_key_parsing::dsa::parse_pkcs1_private_key(&data).map_err(|e| { + CryptographyError::from(e).add_note(py, "If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters") + })?, _ => { assert_eq!(p.tag(), "ENCRYPTED PRIVATE KEY"); password_used = true; From 4fe06c7d0295ae01b9a91ad7c333b9b974debc01 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 6 Mar 2026 09:18:24 -0500 Subject: [PATCH 07/23] Incorporate review --- docs/development/custom-vectors/mldsa.rst | 27 +++++++++ .../custom-vectors/mldsa/generate_mldsa.py | 58 +++++++++++++++++++ docs/development/test-vectors.rst | 4 ++ .../hazmat/primitives/asymmetric/mldsa65.py | 9 ++- src/rust/src/backend/keys.rs | 4 ++ src/rust/src/backend/mldsa65.rs | 2 +- tests/wycheproof/test_mldsa.py | 17 ++---- 7 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 docs/development/custom-vectors/mldsa.rst create mode 100644 docs/development/custom-vectors/mldsa/generate_mldsa.py diff --git a/docs/development/custom-vectors/mldsa.rst b/docs/development/custom-vectors/mldsa.rst new file mode 100644 index 000000000000..470737d1f37c --- /dev/null +++ b/docs/development/custom-vectors/mldsa.rst @@ -0,0 +1,27 @@ +ML-DSA vector creation +====================== + +This page documents the code that was used to generate the ML-DSA-44 test +vector. This vector is used to verify that unsupported ML-DSA variants +(i.e. variants other than ML-DSA-65) are correctly rejected when loading +keys. + +Private key +----------- + +The following Python script was run to generate the vector file. + +.. literalinclude:: /development/custom-vectors/mldsa/generate_mldsa.py + +Download link: :download:`generate_mldsa.py +` + +Public key +---------- + +The public key was derived from the private key using the OpenSSL CLI +(requires OpenSSL 3.5+ or AWS-LC with ML-DSA-44 support): + +.. code-block:: console + + $ openssl pkey -in mldsa44_priv.der -inform DER -pubout -outform DER -out mldsa44_pub.der diff --git a/docs/development/custom-vectors/mldsa/generate_mldsa.py b/docs/development/custom-vectors/mldsa/generate_mldsa.py new file mode 100644 index 000000000000..21a67502d73d --- /dev/null +++ b/docs/development/custom-vectors/mldsa/generate_mldsa.py @@ -0,0 +1,58 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +import os + +from cryptography import x509 +from cryptography.hazmat import asn1 + + +@asn1.sequence +class AlgorithmIdentifier: + algorithm: x509.ObjectIdentifier + + +@asn1.sequence +class OneAsymmetricKey: + version: int + algorithm: AlgorithmIdentifier + private_key: bytes + + +def main(): + output_dir = os.path.join( + "vectors", "cryptography_vectors", "asymmetric", "MLDSA" + ) + + priv_path = os.path.join(output_dir, "mldsa44_priv.der") + + # ML-DSA-44 OID: 2.16.840.1.101.3.4.3.17 + # Construct a PKCS#8 OneAsymmetricKey with a fixed 32-byte seed. + # + # The privateKey content uses the seed-only CHOICE variant: + # ML-DSA-PrivateKey ::= CHOICE { + # seed [0] IMPLICIT OCTET STRING (SIZE (32)), + # expandedKey OCTET STRING, + # both SEQUENCE { seed, expandedKey } + # } + seed = b"\x2a" * 32 + # [0] IMPLICIT OCTET STRING: tag 0x80, length 0x20 + seed_only_privkey = b"\x80\x20" + seed + + obj = OneAsymmetricKey( + version=0, + algorithm=AlgorithmIdentifier( + algorithm=x509.ObjectIdentifier("2.16.840.1.101.3.4.3.17"), + ), + private_key=seed_only_privkey, + ) + + pkcs8_der = asn1.encode_der(obj) + + with open(priv_path, "wb") as f: + f.write(pkcs8_der) + + +if __name__ == "__main__": + main() diff --git a/docs/development/test-vectors.rst b/docs/development/test-vectors.rst index 0de474d4d009..f3b786235c65 100644 --- a/docs/development/test-vectors.rst +++ b/docs/development/test-vectors.rst @@ -65,6 +65,8 @@ Asymmetric ciphers * ``asymmetric/PKCS8/ed25519-scrypt.pem`` a PKCS8 encoded Ed25519 key from RustCrypto using scrypt as the KDF. The password is ``hunter42``. * FIPS 204 ML-DSA-{44,65,87} KAT vectors from `post-quantum-cryptography/KAT`_. +* ML-DSA-44 PKCS#8 and SPKI DER test vectors generated by this project. + See :doc:`/development/custom-vectors/mldsa` Custom asymmetric vectors ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -74,6 +76,7 @@ Custom asymmetric vectors custom-vectors/secp256k1 custom-vectors/rsa-oaep-sha2 + custom-vectors/mldsa * ``asymmetric/PEM_Serialization/ec_private_key.pem`` and ``asymmetric/DER_Serialization/ec_private_key.der`` - Contains an Elliptic @@ -1212,6 +1215,7 @@ Created Vectors custom-vectors/idea custom-vectors/seed custom-vectors/hkdf + custom-vectors/mldsa custom-vectors/rc2 diff --git a/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py b/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py index c761386c3d63..9a4f40a62e30 100644 --- a/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py +++ b/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py @@ -40,6 +40,8 @@ def public_bytes_raw(self) -> bytes: """ The raw bytes of the public key. Equivalent to public_bytes(Raw, Raw). + + The public key is 1,952 bytes for MLDSA-65. """ @abc.abstractmethod @@ -116,13 +118,18 @@ def private_bytes( ) -> bytes: """ The serialized bytes of the private key. + + This method only returns the serialization of the seed form of the + private key, never the expanded one. """ @abc.abstractmethod def private_bytes_raw(self) -> bytes: """ - The raw bytes of the private key (32-byte seed). + The raw bytes of the private key. Equivalent to private_bytes(Raw, Raw, NoEncryption()). + + This method only returns the seed form of the private key (32 bytes). """ @abc.abstractmethod diff --git a/src/rust/src/backend/keys.rs b/src/rust/src/backend/keys.rs index d34bd55637a3..1e25204e1a39 100644 --- a/src/rust/src/backend/keys.rs +++ b/src/rust/src/backend/keys.rs @@ -175,9 +175,11 @@ fn private_key_from_pkey<'p>( .into_pyobject(py)? .into_any()) } else { + // NO-COVERAGE-START Err(CryptographyError::from( exceptions::UnsupportedAlgorithm::new_err("Unsupported ML-DSA variant."), )) + // NO-COVERAGE-END } } _ => Err(CryptographyError::from( @@ -315,9 +317,11 @@ fn public_key_from_pkey<'p>( .into_pyobject(py)? .into_any()) } else { + // NO-COVERAGE-START Err(CryptographyError::from( exceptions::UnsupportedAlgorithm::new_err("Unsupported ML-DSA variant."), )) + // NO-COVERAGE-END } } _ => Err(CryptographyError::from( diff --git a/src/rust/src/backend/mldsa65.rs b/src/rust/src/backend/mldsa65.rs index 5737b5edfc80..d7019f2e3292 100644 --- a/src/rust/src/backend/mldsa65.rs +++ b/src/rust/src/backend/mldsa65.rs @@ -40,7 +40,7 @@ pub(crate) fn public_key_from_pkey( #[pyo3::pyfunction] fn generate_key() -> CryptographyResult { let mut seed = [0u8; cryptography_openssl::mldsa::MLDSA65_SEED_BYTES]; - openssl::rand::rand_bytes(&mut seed)?; + cryptography_openssl::rand::rand_bytes(&mut seed)?; let pkey = cryptography_openssl::mldsa::new_raw_private_key(&seed)?; Ok(MlDsa65PrivateKey { pkey }) } diff --git a/tests/wycheproof/test_mldsa.py b/tests/wycheproof/test_mldsa.py index 1ca17d87557e..5ba16031e771 100644 --- a/tests/wycheproof/test_mldsa.py +++ b/tests/wycheproof/test_mldsa.py @@ -60,19 +60,10 @@ def test_mldsa65_sign_seed(backend, wycheproof): if wycheproof.has_flag("Internal"): return - seed = wycheproof.cache_value_to_group( - "cached_seed", - lambda: binascii.unhexlify(wycheproof.testgroup["privateSeed"]), - ) - key = wycheproof.cache_value_to_group( - "cached_key", - lambda: MlDsa65PrivateKey.from_seed_bytes(seed), - ) - pub = wycheproof.cache_value_to_group( - "cached_pub", - lambda: MlDsa65PublicKey.from_public_bytes( - binascii.unhexlify(wycheproof.testgroup["publicKey"]) - ), + seed = binascii.unhexlify(wycheproof.testgroup["privateSeed"]) + key = MlDsa65PrivateKey.from_seed_bytes(seed) + pub = MlDsa65PublicKey.from_public_bytes( + binascii.unhexlify(wycheproof.testgroup["publicKey"]) ) assert key.public_key() == pub From ed6d9de0645478f7092a3707f383db31f92cdba1 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 6 Mar 2026 10:39:27 -0500 Subject: [PATCH 08/23] Rename from mldsa65 to mldsa --- docs/development/custom-vectors/mldsa.rst | 2 +- .../hazmat/bindings/_rust/openssl/__init__.pyi | 4 ++-- .../_rust/openssl/{mldsa65.pyi => mldsa.pyi} | 8 ++++---- .../primitives/asymmetric/{mldsa65.py => mldsa.py} | 14 +++++++------- .../hazmat/primitives/asymmetric/types.py | 6 +++--- src/rust/src/backend/keys.rs | 4 ++-- src/rust/src/backend/{mldsa65.rs => mldsa.rs} | 6 +++--- src/rust/src/backend/mod.rs | 2 +- src/rust/src/lib.rs | 2 +- .../primitives/{test_mldsa65.py => test_mldsa.py} | 2 +- tests/wycheproof/test_mldsa.py | 2 +- 11 files changed, 26 insertions(+), 26 deletions(-) rename src/cryptography/hazmat/bindings/_rust/openssl/{mldsa65.pyi => mldsa.pyi} (52%) rename src/cryptography/hazmat/primitives/asymmetric/{mldsa65.py => mldsa.py} (91%) rename src/rust/src/backend/{mldsa65.rs => mldsa.rs} (99%) rename tests/hazmat/primitives/{test_mldsa65.py => test_mldsa.py} (99%) diff --git a/docs/development/custom-vectors/mldsa.rst b/docs/development/custom-vectors/mldsa.rst index 470737d1f37c..a5f1c0bbacfd 100644 --- a/docs/development/custom-vectors/mldsa.rst +++ b/docs/development/custom-vectors/mldsa.rst @@ -20,7 +20,7 @@ Public key ---------- The public key was derived from the private key using the OpenSSL CLI -(requires OpenSSL 3.5+ or AWS-LC with ML-DSA-44 support): +(requires OpenSSL 3.5+): .. code-block:: console diff --git a/src/cryptography/hazmat/bindings/_rust/openssl/__init__.pyi b/src/cryptography/hazmat/bindings/_rust/openssl/__init__.pyi index 5275739acb91..16c8a2b80b2d 100644 --- a/src/cryptography/hazmat/bindings/_rust/openssl/__init__.pyi +++ b/src/cryptography/hazmat/bindings/_rust/openssl/__init__.pyi @@ -18,7 +18,7 @@ from cryptography.hazmat.bindings._rust.openssl import ( hpke, kdf, keys, - mldsa65, + mldsa, poly1305, rsa, x448, @@ -39,7 +39,7 @@ __all__ = [ "hpke", "kdf", "keys", - "mldsa65", + "mldsa", "openssl_version", "openssl_version_text", "poly1305", diff --git a/src/cryptography/hazmat/bindings/_rust/openssl/mldsa65.pyi b/src/cryptography/hazmat/bindings/_rust/openssl/mldsa.pyi similarity index 52% rename from src/cryptography/hazmat/bindings/_rust/openssl/mldsa65.pyi rename to src/cryptography/hazmat/bindings/_rust/openssl/mldsa.pyi index ae7edffc701a..83ef45f65dc0 100644 --- a/src/cryptography/hazmat/bindings/_rust/openssl/mldsa65.pyi +++ b/src/cryptography/hazmat/bindings/_rust/openssl/mldsa.pyi @@ -2,12 +2,12 @@ # 2.0, and the BSD License. See the LICENSE file in the root of this repository # for complete details. -from cryptography.hazmat.primitives.asymmetric import mldsa65 +from cryptography.hazmat.primitives.asymmetric import mldsa from cryptography.utils import Buffer class MlDsa65PrivateKey: ... class MlDsa65PublicKey: ... -def generate_key() -> mldsa65.MlDsa65PrivateKey: ... -def from_public_bytes(data: bytes) -> mldsa65.MlDsa65PublicKey: ... -def from_seed_bytes(data: Buffer) -> mldsa65.MlDsa65PrivateKey: ... +def generate_key() -> mldsa.MlDsa65PrivateKey: ... +def from_public_bytes(data: bytes) -> mldsa.MlDsa65PublicKey: ... +def from_seed_bytes(data: Buffer) -> mldsa.MlDsa65PrivateKey: ... diff --git a/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py b/src/cryptography/hazmat/primitives/asymmetric/mldsa.py similarity index 91% rename from src/cryptography/hazmat/primitives/asymmetric/mldsa65.py rename to src/cryptography/hazmat/primitives/asymmetric/mldsa.py index 9a4f40a62e30..18d3f856b54e 100644 --- a/src/cryptography/hazmat/primitives/asymmetric/mldsa65.py +++ b/src/cryptography/hazmat/primitives/asymmetric/mldsa.py @@ -23,7 +23,7 @@ def from_public_bytes(cls, data: bytes) -> MlDsa65PublicKey: _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM, ) - return rust_openssl.mldsa65.from_public_bytes(data) + return rust_openssl.mldsa.from_public_bytes(data) @abc.abstractmethod def public_bytes( @@ -74,8 +74,8 @@ def __deepcopy__(self, memo: dict) -> MlDsa65PublicKey: """ -if hasattr(rust_openssl, "mldsa65"): - MlDsa65PublicKey.register(rust_openssl.mldsa65.MlDsa65PublicKey) +if hasattr(rust_openssl, "mldsa"): + MlDsa65PublicKey.register(rust_openssl.mldsa.MlDsa65PublicKey) class MlDsa65PrivateKey(metaclass=abc.ABCMeta): @@ -89,7 +89,7 @@ def generate(cls) -> MlDsa65PrivateKey: _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM, ) - return rust_openssl.mldsa65.generate_key() + return rust_openssl.mldsa.generate_key() @classmethod def from_seed_bytes(cls, data: Buffer) -> MlDsa65PrivateKey: @@ -101,7 +101,7 @@ def from_seed_bytes(cls, data: Buffer) -> MlDsa65PrivateKey: _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM, ) - return rust_openssl.mldsa65.from_seed_bytes(data) + return rust_openssl.mldsa.from_seed_bytes(data) @abc.abstractmethod def public_key(self) -> MlDsa65PublicKey: @@ -151,5 +151,5 @@ def __deepcopy__(self, memo: dict) -> MlDsa65PrivateKey: """ -if hasattr(rust_openssl, "mldsa65"): - MlDsa65PrivateKey.register(rust_openssl.mldsa65.MlDsa65PrivateKey) +if hasattr(rust_openssl, "mldsa"): + MlDsa65PrivateKey.register(rust_openssl.mldsa.MlDsa65PrivateKey) diff --git a/src/cryptography/hazmat/primitives/asymmetric/types.py b/src/cryptography/hazmat/primitives/asymmetric/types.py index 942dd272e348..3854e2e234a9 100644 --- a/src/cryptography/hazmat/primitives/asymmetric/types.py +++ b/src/cryptography/hazmat/primitives/asymmetric/types.py @@ -13,7 +13,7 @@ ec, ed448, ed25519, - mldsa65, + mldsa, rsa, x448, x25519, @@ -27,7 +27,7 @@ ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey, ed448.Ed448PublicKey, - mldsa65.MlDsa65PublicKey, + mldsa.MlDsa65PublicKey, x25519.X25519PublicKey, x448.X448PublicKey, ] @@ -44,7 +44,7 @@ dh.DHPrivateKey, ed25519.Ed25519PrivateKey, ed448.Ed448PrivateKey, - mldsa65.MlDsa65PrivateKey, + mldsa.MlDsa65PrivateKey, rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, diff --git a/src/rust/src/backend/keys.rs b/src/rust/src/backend/keys.rs index 1e25204e1a39..2db2881cbbb6 100644 --- a/src/rust/src/backend/keys.rs +++ b/src/rust/src/backend/keys.rs @@ -171,7 +171,7 @@ fn private_key_from_pkey<'p>( id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { let pub_len = pkey.raw_public_key()?.len(); if pub_len == cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES { - Ok(crate::backend::mldsa65::private_key_from_pkey(pkey) + Ok(crate::backend::mldsa::private_key_from_pkey(pkey) .into_pyobject(py)? .into_any()) } else { @@ -313,7 +313,7 @@ fn public_key_from_pkey<'p>( id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { let pub_len = pkey.raw_public_key()?.len(); if pub_len == cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES { - Ok(crate::backend::mldsa65::public_key_from_pkey(pkey) + Ok(crate::backend::mldsa::public_key_from_pkey(pkey) .into_pyobject(py)? .into_any()) } else { diff --git a/src/rust/src/backend/mldsa65.rs b/src/rust/src/backend/mldsa.rs similarity index 99% rename from src/rust/src/backend/mldsa65.rs rename to src/rust/src/backend/mldsa.rs index d7019f2e3292..d51537547afb 100644 --- a/src/rust/src/backend/mldsa65.rs +++ b/src/rust/src/backend/mldsa.rs @@ -11,12 +11,12 @@ use crate::exceptions; const MAX_CONTEXT_BYTES: usize = 255; -#[pyo3::pyclass(frozen, module = "cryptography.hazmat.bindings._rust.openssl.mldsa65")] +#[pyo3::pyclass(frozen, module = "cryptography.hazmat.bindings._rust.openssl.mldsa")] pub(crate) struct MlDsa65PrivateKey { pkey: openssl::pkey::PKey, } -#[pyo3::pyclass(frozen, module = "cryptography.hazmat.bindings._rust.openssl.mldsa65")] +#[pyo3::pyclass(frozen, module = "cryptography.hazmat.bindings._rust.openssl.mldsa")] pub(crate) struct MlDsa65PublicKey { pkey: openssl::pkey::PKey, } @@ -231,7 +231,7 @@ impl MlDsa65PublicKey { } #[pyo3::pymodule(gil_used = false)] -pub(crate) mod mldsa65 { +pub(crate) mod mldsa { #[pymodule_export] use super::{ from_public_bytes, from_seed_bytes, generate_key, MlDsa65PrivateKey, MlDsa65PublicKey, diff --git a/src/rust/src/backend/mod.rs b/src/rust/src/backend/mod.rs index a509ffcb2279..a5e47e360357 100644 --- a/src/rust/src/backend/mod.rs +++ b/src/rust/src/backend/mod.rs @@ -22,7 +22,7 @@ pub(crate) mod hpke; pub(crate) mod kdf; pub(crate) mod keys; #[cfg(CRYPTOGRAPHY_IS_AWSLC)] -pub(crate) mod mldsa65; +pub(crate) mod mldsa; pub(crate) mod poly1305; pub(crate) mod rand; pub(crate) mod rsa; diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index d71f11d1ae15..fed6bd438371 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -243,7 +243,7 @@ mod _rust { use crate::backend::keys::keys; #[cfg(CRYPTOGRAPHY_IS_AWSLC)] #[pymodule_export] - use crate::backend::mldsa65::mldsa65; + use crate::backend::mldsa::mldsa; #[pymodule_export] use crate::backend::poly1305::poly1305; #[pymodule_export] diff --git a/tests/hazmat/primitives/test_mldsa65.py b/tests/hazmat/primitives/test_mldsa.py similarity index 99% rename from tests/hazmat/primitives/test_mldsa65.py rename to tests/hazmat/primitives/test_mldsa.py index b64db1be7e40..caddffb50cb1 100644 --- a/tests/hazmat/primitives/test_mldsa65.py +++ b/tests/hazmat/primitives/test_mldsa.py @@ -11,7 +11,7 @@ from cryptography.exceptions import InvalidSignature, _Reasons from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.mldsa65 import ( +from cryptography.hazmat.primitives.asymmetric.mldsa import ( MlDsa65PrivateKey, MlDsa65PublicKey, ) diff --git a/tests/wycheproof/test_mldsa.py b/tests/wycheproof/test_mldsa.py index 5ba16031e771..2327f0de7453 100644 --- a/tests/wycheproof/test_mldsa.py +++ b/tests/wycheproof/test_mldsa.py @@ -7,7 +7,7 @@ import pytest from cryptography.exceptions import InvalidSignature -from cryptography.hazmat.primitives.asymmetric.mldsa65 import ( +from cryptography.hazmat.primitives.asymmetric.mldsa import ( MlDsa65PrivateKey, MlDsa65PublicKey, ) From b60d88b0b6c4c3d245f799c752f8031b3249a03d Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 6 Mar 2026 12:29:33 -0500 Subject: [PATCH 09/23] Improve serialization/deserialization --- .../cryptography-key-parsing/src/pkcs8.rs | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/rust/cryptography-key-parsing/src/pkcs8.rs b/src/rust/cryptography-key-parsing/src/pkcs8.rs index 9de150f7b9cf..4d8d46101cb0 100644 --- a/src/rust/cryptography-key-parsing/src/pkcs8.rs +++ b/src/rust/cryptography-key-parsing/src/pkcs8.rs @@ -22,6 +22,18 @@ pub struct PrivateKeyInfo<'a> { pub attributes: Option>, } +// FIPS 204 Section 7.3 +// MLDSAPrivateKey ::= CHOICE { +// seed [0] IMPLICIT OCTET STRING (SIZE(32)), +// expandedKey OCTET STRING +// } +#[cfg(CRYPTOGRAPHY_IS_AWSLC)] +#[derive(asn1::Asn1Read, asn1::Asn1Write)] +struct MlDsaPrivateKey<'a> { + #[implicit(0)] + seed: Option<&'a [u8]>, +} + pub fn parse_private_key( data: &[u8], ) -> KeyParsingResult> { @@ -109,7 +121,7 @@ pub fn parse_private_key( } #[cfg(CRYPTOGRAPHY_IS_AWSLC)] - AlgorithmParameters::MlDsa65 => Ok(openssl::pkey::PKey::private_key_from_der(data)?), + AlgorithmParameters::MlDsa65 => parse_mldsa_private_key(k.private_key), _ => Err(KeyParsingError::UnsupportedKeyType( k.algorithm.oid().clone(), @@ -117,6 +129,17 @@ pub fn parse_private_key( } } +#[cfg(CRYPTOGRAPHY_IS_AWSLC)] +fn parse_mldsa_private_key( + private_key_data: &[u8], +) -> KeyParsingResult> { + let k = asn1::parse_single::>(private_key_data)?; + match k.seed { + Some(seed) => Ok(cryptography_openssl::mldsa::new_raw_private_key(seed)?), + None => Err(KeyParsingError::InvalidKey), + } +} + #[cfg(not(CRYPTOGRAPHY_IS_BORINGSSL))] fn parse_dh_private_key( private_key_data: &[u8], @@ -445,7 +468,11 @@ pub fn serialize_private_key( } #[cfg(CRYPTOGRAPHY_IS_AWSLC)] id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { - return Ok(pkey.private_key_to_pkcs8()?); + let seed = pkey.raw_private_key()?; + let private_key_der = asn1::write_single(&MlDsaPrivateKey { + seed: Some(seed.as_slice()), + })?; + (AlgorithmParameters::MlDsa65, private_key_der) } _ => { unimplemented!("Unknown key type"); From 39c9db3e7c028be8c0231e7bf28686c4c76dd207 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 9 Mar 2026 06:35:57 -0400 Subject: [PATCH 10/23] Fix coverage --- docs/development/custom-vectors/mldsa.rst | 17 +++--- .../custom-vectors/mldsa/generate_mldsa.py | 52 ++++++++++++------ tests/hazmat/primitives/test_mldsa.py | 16 ++++++ .../asymmetric/MLDSA/mldsa65_noseed_priv.der | Bin 0 -> 22 bytes 4 files changed, 58 insertions(+), 27 deletions(-) create mode 100644 vectors/cryptography_vectors/asymmetric/MLDSA/mldsa65_noseed_priv.der diff --git a/docs/development/custom-vectors/mldsa.rst b/docs/development/custom-vectors/mldsa.rst index a5f1c0bbacfd..3ab8e204b065 100644 --- a/docs/development/custom-vectors/mldsa.rst +++ b/docs/development/custom-vectors/mldsa.rst @@ -1,23 +1,22 @@ ML-DSA vector creation ====================== -This page documents the code that was used to generate the ML-DSA-44 test -vector. This vector is used to verify that unsupported ML-DSA variants -(i.e. variants other than ML-DSA-65) are correctly rejected when loading -keys. +This page documents the code that was used to generate the ML-DSA test +vectors. These vectors are used to verify: -Private key ------------ +* Unsupported ML-DSA variants (i.e. variants other than ML-DSA-65) are + correctly rejected when loading keys. +* ML-DSA-65 private keys without a seed are correctly rejected. -The following Python script was run to generate the vector file. +The following Python script was run to generate the vector files. .. literalinclude:: /development/custom-vectors/mldsa/generate_mldsa.py Download link: :download:`generate_mldsa.py ` -Public key ----------- +ML-DSA-44 public key +-------------------- The public key was derived from the private key using the OpenSSL CLI (requires OpenSSL 3.5+): diff --git a/docs/development/custom-vectors/mldsa/generate_mldsa.py b/docs/development/custom-vectors/mldsa/generate_mldsa.py index 21a67502d73d..68ff81d94a79 100644 --- a/docs/development/custom-vectors/mldsa/generate_mldsa.py +++ b/docs/development/custom-vectors/mldsa/generate_mldsa.py @@ -20,26 +20,20 @@ class OneAsymmetricKey: private_key: bytes -def main(): - output_dir = os.path.join( - "vectors", "cryptography_vectors", "asymmetric", "MLDSA" - ) +# ML-DSA-PrivateKey ::= CHOICE { +# seed [0] IMPLICIT OCTET STRING (SIZE (32)), +# expandedKey OCTET STRING, +# both SEQUENCE { seed, expandedKey } +# } +MLDSA_SEED_BYTES = 32 - priv_path = os.path.join(output_dir, "mldsa44_priv.der") - # ML-DSA-44 OID: 2.16.840.1.101.3.4.3.17 - # Construct a PKCS#8 OneAsymmetricKey with a fixed 32-byte seed. - # - # The privateKey content uses the seed-only CHOICE variant: - # ML-DSA-PrivateKey ::= CHOICE { - # seed [0] IMPLICIT OCTET STRING (SIZE (32)), - # expandedKey OCTET STRING, - # both SEQUENCE { seed, expandedKey } - # } - seed = b"\x2a" * 32 +def generate_mldsa44_unsupported_variant(output_dir: str) -> None: + seed = b"\x2a" * MLDSA_SEED_BYTES # [0] IMPLICIT OCTET STRING: tag 0x80, length 0x20 seed_only_privkey = b"\x80\x20" + seed + # ML-DSA-44 OID: 2.16.840.1.101.3.4.3.17 obj = OneAsymmetricKey( version=0, algorithm=AlgorithmIdentifier( @@ -47,11 +41,33 @@ def main(): ), private_key=seed_only_privkey, ) + with open(os.path.join(output_dir, "mldsa44_priv.der"), "wb") as f: + f.write(asn1.encode_der(obj)) - pkcs8_der = asn1.encode_der(obj) - with open(priv_path, "wb") as f: - f.write(pkcs8_der) +def generate_mldsa65_noseed(output_dir: str) -> None: + # ML-DSA-65 OID: 2.16.840.1.101.3.4.3.18 + # Generate an ML-DSA-65 PKCS#8 key whose inner privateKey is an + # empty SEQUENCE (0x30 0x00) — i.e. the "both" SEQUENCE form with + # no seed present. This exercises the InvalidKey error path in the + # Rust parser when seed is None. + obj = OneAsymmetricKey( + version=0, + algorithm=AlgorithmIdentifier( + algorithm=x509.ObjectIdentifier("2.16.840.1.101.3.4.3.18"), + ), + private_key=b"\x30\x00", + ) + with open(os.path.join(output_dir, "mldsa65_noseed_priv.der"), "wb") as f: + f.write(asn1.encode_der(obj)) + + +def main(): + output_dir = os.path.join( + "vectors", "cryptography_vectors", "asymmetric", "MLDSA" + ) + generate_mldsa44_unsupported_variant(output_dir) + generate_mldsa65_noseed(output_dir) if __name__ == "__main__": diff --git a/tests/hazmat/primitives/test_mldsa.py b/tests/hazmat/primitives/test_mldsa.py index caddffb50cb1..49c00ee8d012 100644 --- a/tests/hazmat/primitives/test_mldsa.py +++ b/tests/hazmat/primitives/test_mldsa.py @@ -301,6 +301,22 @@ def test_unsupported_mldsa_variant_private_key(backend): ) +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA-65 support", +) +def test_mldsa65_private_key_no_seed(backend): + pkcs8_der = load_vectors_from_file( + os.path.join("asymmetric", "MLDSA", "mldsa65_noseed_priv.der"), + lambda derfile: derfile.read(), + mode="rb", + ) + with pytest.raises(ValueError): + serialization.load_der_private_key( + pkcs8_der, password=None, backend=backend + ) + + @pytest.mark.supported( only_if=lambda backend: backend.mldsa_supported(), skip_message="Requires a backend with ML-DSA-65 support", diff --git a/vectors/cryptography_vectors/asymmetric/MLDSA/mldsa65_noseed_priv.der b/vectors/cryptography_vectors/asymmetric/MLDSA/mldsa65_noseed_priv.der new file mode 100644 index 0000000000000000000000000000000000000000..48ef3be4e83b21acf050e81143d14259ed01ffff GIT binary patch literal 22 dcmXpIVPa%3;AZ1YX!Br9WoBU(Vqr310014j0(bxb literal 0 HcmV?d00001 From db6d98d756e6ee635fd159b94c7d916302ae45b4 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 9 Mar 2026 10:48:40 -0400 Subject: [PATCH 11/23] Use ASN1 struct to parse the private key --- src/rust/src/backend/mldsa.rs | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/rust/src/backend/mldsa.rs b/src/rust/src/backend/mldsa.rs index d51537547afb..55d2d46467a0 100644 --- a/src/rust/src/backend/mldsa.rs +++ b/src/rust/src/backend/mldsa.rs @@ -102,25 +102,8 @@ impl MlDsa65PrivateKey { let pki = asn1::parse_single::>(&pkcs8_der) .unwrap(); - // The privateKey content is [0x80, 0x20, <32 bytes of seed>] - // (context-specific tag 0, length 32) - if pki.private_key.len() == 2 + cryptography_openssl::mldsa::MLDSA65_SEED_BYTES - && pki.private_key[0] == 0x80 - && pki.private_key[1] == cryptography_openssl::mldsa::MLDSA65_SEED_BYTES as u8 - { - Ok(pyo3::types::PyBytes::new(py, &pki.private_key[2..])) - } else { - // NO-COVERAGE-START - // All supported ML-DSA variants use 32-byte seeds with the - // [0x80, 0x20, ] encoding. This branch is purely - // defensive against future format changes. - Err(CryptographyError::from( - pyo3::exceptions::PyValueError::new_err( - "Cannot extract seed from this ML-DSA-65 private key", - ), - )) - // NO-COVERAGE-END - } + let seed = asn1::parse_single::>(pki.private_key).unwrap(); + Ok(pyo3::types::PyBytes::new(py, seed.into_inner())) } fn private_bytes<'p>( From 7bde7d502bca2c96feb9aaed94553ff346ed5420 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 13 Mar 2026 06:22:01 -0400 Subject: [PATCH 12/23] Use Enum instead of Struct --- .../cryptography-key-parsing/src/pkcs8.rs | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/rust/cryptography-key-parsing/src/pkcs8.rs b/src/rust/cryptography-key-parsing/src/pkcs8.rs index d58df6942161..450bf5b2bcd2 100644 --- a/src/rust/cryptography-key-parsing/src/pkcs8.rs +++ b/src/rust/cryptography-key-parsing/src/pkcs8.rs @@ -22,16 +22,12 @@ pub struct PrivateKeyInfo<'a> { pub attributes: Option>, } -// FIPS 204 Section 7.3 -// MLDSAPrivateKey ::= CHOICE { -// seed [0] IMPLICIT OCTET STRING (SIZE(32)), -// expandedKey OCTET STRING -// } +// RFC 9881 Section 6.5 #[cfg(CRYPTOGRAPHY_IS_AWSLC)] #[derive(asn1::Asn1Read, asn1::Asn1Write)] -struct MlDsaPrivateKey<'a> { +enum MlDsaPrivateKey<'a> { #[implicit(0)] - seed: Option<&'a [u8]>, + Seed(&'a [u8]), } pub fn parse_private_key( @@ -133,11 +129,8 @@ pub fn parse_private_key( fn parse_mldsa_private_key( private_key_data: &[u8], ) -> KeyParsingResult> { - let k = asn1::parse_single::>(private_key_data)?; - match k.seed { - Some(seed) => Ok(cryptography_openssl::mldsa::new_raw_private_key(seed)?), - None => Err(KeyParsingError::InvalidKey), - } + let MlDsaPrivateKey::Seed(seed) = asn1::parse_single::>(private_key_data)?; + Ok(cryptography_openssl::mldsa::new_raw_private_key(seed)?) } #[cfg(not(CRYPTOGRAPHY_IS_BORINGSSL))] @@ -472,9 +465,7 @@ pub fn serialize_private_key( #[cfg(CRYPTOGRAPHY_IS_AWSLC)] id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { let seed = pkey.raw_private_key()?; - let private_key_der = asn1::write_single(&MlDsaPrivateKey { - seed: Some(seed.as_slice()), - })?; + let private_key_der = asn1::write_single(&MlDsaPrivateKey::Seed(seed.as_slice()))?; (AlgorithmParameters::MlDsa65, private_key_der) } _ => { From a8083a41b16e211cfc6f9a21a5d44d4b92d30016 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 13 Mar 2026 06:45:45 -0400 Subject: [PATCH 13/23] Inline function --- src/rust/cryptography-key-parsing/src/pkcs8.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/rust/cryptography-key-parsing/src/pkcs8.rs b/src/rust/cryptography-key-parsing/src/pkcs8.rs index 450bf5b2bcd2..6471e5f2b42c 100644 --- a/src/rust/cryptography-key-parsing/src/pkcs8.rs +++ b/src/rust/cryptography-key-parsing/src/pkcs8.rs @@ -117,7 +117,11 @@ pub fn parse_private_key( } #[cfg(CRYPTOGRAPHY_IS_AWSLC)] - AlgorithmParameters::MlDsa65 => parse_mldsa_private_key(k.private_key), + AlgorithmParameters::MlDsa65 => { + let MlDsaPrivateKey::Seed(seed) = + asn1::parse_single::>(k.private_key)?; + Ok(cryptography_openssl::mldsa::new_raw_private_key(seed)?) + } _ => Err(KeyParsingError::UnsupportedKeyType( k.algorithm.oid().clone(), @@ -125,14 +129,6 @@ pub fn parse_private_key( } } -#[cfg(CRYPTOGRAPHY_IS_AWSLC)] -fn parse_mldsa_private_key( - private_key_data: &[u8], -) -> KeyParsingResult> { - let MlDsaPrivateKey::Seed(seed) = asn1::parse_single::>(private_key_data)?; - Ok(cryptography_openssl::mldsa::new_raw_private_key(seed)?) -} - #[cfg(not(CRYPTOGRAPHY_IS_BORINGSSL))] fn parse_dh_private_key( private_key_data: &[u8], From f849147612d02db250f9c4ea425b09aca017a57c Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 13 Mar 2026 07:01:19 -0400 Subject: [PATCH 14/23] Change match arm --- src/rust/cryptography-key-parsing/src/pkcs8.rs | 2 +- src/rust/cryptography-key-parsing/src/spki.rs | 2 +- src/rust/cryptography-openssl/src/mldsa.rs | 1 + src/rust/src/backend/keys.rs | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/rust/cryptography-key-parsing/src/pkcs8.rs b/src/rust/cryptography-key-parsing/src/pkcs8.rs index 6471e5f2b42c..5d9c1a0ad0ac 100644 --- a/src/rust/cryptography-key-parsing/src/pkcs8.rs +++ b/src/rust/cryptography-key-parsing/src/pkcs8.rs @@ -459,7 +459,7 @@ pub fn serialize_private_key( (params, private_key_der) } #[cfg(CRYPTOGRAPHY_IS_AWSLC)] - id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { + cryptography_openssl::mldsa::PKEY_ID => { let seed = pkey.raw_private_key()?; let private_key_der = asn1::write_single(&MlDsaPrivateKey::Seed(seed.as_slice()))?; (AlgorithmParameters::MlDsa65, private_key_der) diff --git a/src/rust/cryptography-key-parsing/src/spki.rs b/src/rust/cryptography-key-parsing/src/spki.rs index 56a2023c0724..fe9d71b6abce 100644 --- a/src/rust/cryptography-key-parsing/src/spki.rs +++ b/src/rust/cryptography-key-parsing/src/spki.rs @@ -221,7 +221,7 @@ pub fn serialize_public_key( (params, pub_key_der) } #[cfg(CRYPTOGRAPHY_IS_AWSLC)] - id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { + cryptography_openssl::mldsa::PKEY_ID => { let raw_bytes = pkey.raw_public_key()?; if raw_bytes.len() == cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES { (AlgorithmParameters::MlDsa65, raw_bytes) diff --git a/src/rust/cryptography-openssl/src/mldsa.rs b/src/rust/cryptography-openssl/src/mldsa.rs index fe6c1be958c1..eaf8de0815d7 100644 --- a/src/rust/cryptography-openssl/src/mldsa.rs +++ b/src/rust/cryptography-openssl/src/mldsa.rs @@ -10,6 +10,7 @@ use crate::{cvt, cvt_p, OpenSSLResult}; pub const NID_ML_DSA_65: c_int = ffi::NID_MLDSA65; pub const NID_PQDSA: c_int = ffi::NID_PQDSA; +pub const PKEY_ID: openssl::pkey::Id = openssl::pkey::Id::from_raw(NID_PQDSA); const MLDSA65_SIGNATURE_BYTES: usize = 3309; pub const MLDSA65_PUBLIC_KEY_BYTES: usize = 1952; pub const MLDSA65_SEED_BYTES: usize = 32; diff --git a/src/rust/src/backend/keys.rs b/src/rust/src/backend/keys.rs index 2db2881cbbb6..8b7d35ec69ed 100644 --- a/src/rust/src/backend/keys.rs +++ b/src/rust/src/backend/keys.rs @@ -168,7 +168,7 @@ fn private_key_from_pkey<'p>( .into_pyobject(py)? .into_any()), #[cfg(CRYPTOGRAPHY_IS_AWSLC)] - id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { + cryptography_openssl::mldsa::PKEY_ID => { let pub_len = pkey.raw_public_key()?.len(); if pub_len == cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES { Ok(crate::backend::mldsa::private_key_from_pkey(pkey) @@ -310,7 +310,7 @@ fn public_key_from_pkey<'p>( .into_any()), #[cfg(CRYPTOGRAPHY_IS_AWSLC)] - id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => { + cryptography_openssl::mldsa::PKEY_ID => { let pub_len = pkey.raw_public_key()?.len(); if pub_len == cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES { Ok(crate::backend::mldsa::public_key_from_pkey(pkey) From 7284fecd57e6ee599b3b8ee982cc040280f044b3 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 13 Mar 2026 07:01:55 -0400 Subject: [PATCH 15/23] Remove duplicate comment --- src/rust/cryptography-openssl/src/mldsa.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/rust/cryptography-openssl/src/mldsa.rs b/src/rust/cryptography-openssl/src/mldsa.rs index eaf8de0815d7..a491d035e8a2 100644 --- a/src/rust/cryptography-openssl/src/mldsa.rs +++ b/src/rust/cryptography-openssl/src/mldsa.rs @@ -92,7 +92,6 @@ pub fn sign( context.as_ptr() }; - // SAFETY: ml_dsa_65_sign takes raw key bytes, message, and context. // SAFETY: ml_dsa_65_sign takes raw key bytes, message, and context. unsafe { let r = ml_dsa_65_sign( From 16bd0d3e1983011017eaeb094394c8c9fb5cd88b Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 13 Mar 2026 09:07:40 -0400 Subject: [PATCH 16/23] Change unimplemented by assert --- src/rust/cryptography-key-parsing/src/spki.rs | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/rust/cryptography-key-parsing/src/spki.rs b/src/rust/cryptography-key-parsing/src/spki.rs index fe9d71b6abce..da18f6f6fdb8 100644 --- a/src/rust/cryptography-key-parsing/src/spki.rs +++ b/src/rust/cryptography-key-parsing/src/spki.rs @@ -223,11 +223,11 @@ pub fn serialize_public_key( #[cfg(CRYPTOGRAPHY_IS_AWSLC)] cryptography_openssl::mldsa::PKEY_ID => { let raw_bytes = pkey.raw_public_key()?; - if raw_bytes.len() == cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES { - (AlgorithmParameters::MlDsa65, raw_bytes) - } else { - unimplemented!("Unsupported ML-DSA variant"); - } + assert_eq!( + raw_bytes.len(), + cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES + ); + (AlgorithmParameters::MlDsa65, raw_bytes) } _ => { unimplemented!("Unknown key type"); @@ -269,17 +269,4 @@ mod tests { // Expected to panic _ = serialize_public_key(&pkey); } - - #[cfg(CRYPTOGRAPHY_IS_AWSLC)] - #[test] - #[should_panic(expected = "Unsupported ML-DSA variant")] - fn test_serialize_public_key_unsupported_mldsa_variant() { - // Load an ML-DSA-44 public key from a Wycheproof test vector DER. - let der = include_bytes!( - "../../../../vectors/cryptography_vectors/asymmetric/MLDSA/mldsa44_pub.der" - ); - let pub_pkey = openssl::pkey::PKey::public_key_from_der(der).unwrap(); - // Expected to panic with "Unsupported ML-DSA variant" - _ = serialize_public_key(&pub_pkey); - } } From b66fdca7b44d3893c4b8706a222bfaa6a6ae228a Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 13 Mar 2026 09:39:54 -0400 Subject: [PATCH 17/23] More review fix --- src/rust/src/backend/keys.rs | 36 +++++++++++---------------- tests/hazmat/primitives/test_mldsa.py | 2 -- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/src/rust/src/backend/keys.rs b/src/rust/src/backend/keys.rs index 8b7d35ec69ed..a638c305f096 100644 --- a/src/rust/src/backend/keys.rs +++ b/src/rust/src/backend/keys.rs @@ -170,17 +170,13 @@ fn private_key_from_pkey<'p>( #[cfg(CRYPTOGRAPHY_IS_AWSLC)] cryptography_openssl::mldsa::PKEY_ID => { let pub_len = pkey.raw_public_key()?.len(); - if pub_len == cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES { - Ok(crate::backend::mldsa::private_key_from_pkey(pkey) - .into_pyobject(py)? - .into_any()) - } else { - // NO-COVERAGE-START - Err(CryptographyError::from( - exceptions::UnsupportedAlgorithm::new_err("Unsupported ML-DSA variant."), - )) - // NO-COVERAGE-END - } + assert_eq!( + pub_len, + cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES + ); + Ok(crate::backend::mldsa::private_key_from_pkey(pkey) + .into_pyobject(py)? + .into_any()) } _ => Err(CryptographyError::from( exceptions::UnsupportedAlgorithm::new_err("Unsupported key type."), @@ -312,17 +308,13 @@ fn public_key_from_pkey<'p>( #[cfg(CRYPTOGRAPHY_IS_AWSLC)] cryptography_openssl::mldsa::PKEY_ID => { let pub_len = pkey.raw_public_key()?.len(); - if pub_len == cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES { - Ok(crate::backend::mldsa::public_key_from_pkey(pkey) - .into_pyobject(py)? - .into_any()) - } else { - // NO-COVERAGE-START - Err(CryptographyError::from( - exceptions::UnsupportedAlgorithm::new_err("Unsupported ML-DSA variant."), - )) - // NO-COVERAGE-END - } + assert_eq!( + pub_len, + cryptography_openssl::mldsa::MLDSA65_PUBLIC_KEY_BYTES + ); + Ok(crate::backend::mldsa::public_key_from_pkey(pkey) + .into_pyobject(py)? + .into_any()) } _ => Err(CryptographyError::from( exceptions::UnsupportedAlgorithm::new_err("Unsupported key type."), diff --git a/tests/hazmat/primitives/test_mldsa.py b/tests/hazmat/primitives/test_mldsa.py index 49c00ee8d012..45d7f254a9d2 100644 --- a/tests/hazmat/primitives/test_mldsa.py +++ b/tests/hazmat/primitives/test_mldsa.py @@ -95,12 +95,10 @@ def test_kat_vectors(self, backend, subtests): sm = binascii.unhexlify(vector["sm"]) expected_sig = sm[:3309] - # Keygen: seed produces expected public key key = MlDsa65PrivateKey.from_seed_bytes(xi) assert key.private_bytes_raw() == xi assert key.public_key().public_bytes_raw() == pk - # Sigver: known-good signature verifies pub = MlDsa65PublicKey.from_public_bytes(pk) pub.verify(expected_sig, msg, ctx) From e457a27567e5f5646bb6a6523cd58e9aecf83f23 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 23 Mar 2026 11:18:44 -0400 Subject: [PATCH 18/23] Fix Wycheproof vectors handling --- tests/wycheproof/test_mldsa.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/wycheproof/test_mldsa.py b/tests/wycheproof/test_mldsa.py index 2327f0de7453..2a6386c2de82 100644 --- a/tests/wycheproof/test_mldsa.py +++ b/tests/wycheproof/test_mldsa.py @@ -61,7 +61,12 @@ def test_mldsa65_sign_seed(backend, wycheproof): return seed = binascii.unhexlify(wycheproof.testgroup["privateSeed"]) - key = MlDsa65PrivateKey.from_seed_bytes(seed) + try: + key = MlDsa65PrivateKey.from_seed_bytes(seed) + except ValueError: + assert wycheproof.invalid + assert wycheproof.has_flag("IncorrectPrivateKeyLength") + return pub = MlDsa65PublicKey.from_public_bytes( binascii.unhexlify(wycheproof.testgroup["publicKey"]) ) From 8cb4c7e519f7ba9e93cfc1c39abf6fb4e7b821f7 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 23 Mar 2026 12:08:54 -0400 Subject: [PATCH 19/23] Add NO-COVERAGE marker --- src/rust/cryptography-key-parsing/src/pkcs8.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/rust/cryptography-key-parsing/src/pkcs8.rs b/src/rust/cryptography-key-parsing/src/pkcs8.rs index 5d9c1a0ad0ac..12462862e0ff 100644 --- a/src/rust/cryptography-key-parsing/src/pkcs8.rs +++ b/src/rust/cryptography-key-parsing/src/pkcs8.rs @@ -24,7 +24,9 @@ pub struct PrivateKeyInfo<'a> { // RFC 9881 Section 6.5 #[cfg(CRYPTOGRAPHY_IS_AWSLC)] +// NO-COVERAGE-START #[derive(asn1::Asn1Read, asn1::Asn1Write)] +// NO-COVERAGE-END enum MlDsaPrivateKey<'a> { #[implicit(0)] Seed(&'a [u8]), From c56210c69d69517f247dce3c769fda16cedc1b27 Mon Sep 17 00:00:00 2001 From: Alexis Date: Tue, 24 Mar 2026 04:41:27 -0400 Subject: [PATCH 20/23] Remove vectors --- docs/development/custom-vectors/mldsa.rst | 26 ------- .../custom-vectors/mldsa/generate_mldsa.py | 74 ------------------- docs/development/test-vectors.rst | 4 - 3 files changed, 104 deletions(-) delete mode 100644 docs/development/custom-vectors/mldsa.rst delete mode 100644 docs/development/custom-vectors/mldsa/generate_mldsa.py diff --git a/docs/development/custom-vectors/mldsa.rst b/docs/development/custom-vectors/mldsa.rst deleted file mode 100644 index 3ab8e204b065..000000000000 --- a/docs/development/custom-vectors/mldsa.rst +++ /dev/null @@ -1,26 +0,0 @@ -ML-DSA vector creation -====================== - -This page documents the code that was used to generate the ML-DSA test -vectors. These vectors are used to verify: - -* Unsupported ML-DSA variants (i.e. variants other than ML-DSA-65) are - correctly rejected when loading keys. -* ML-DSA-65 private keys without a seed are correctly rejected. - -The following Python script was run to generate the vector files. - -.. literalinclude:: /development/custom-vectors/mldsa/generate_mldsa.py - -Download link: :download:`generate_mldsa.py -` - -ML-DSA-44 public key --------------------- - -The public key was derived from the private key using the OpenSSL CLI -(requires OpenSSL 3.5+): - -.. code-block:: console - - $ openssl pkey -in mldsa44_priv.der -inform DER -pubout -outform DER -out mldsa44_pub.der diff --git a/docs/development/custom-vectors/mldsa/generate_mldsa.py b/docs/development/custom-vectors/mldsa/generate_mldsa.py deleted file mode 100644 index 68ff81d94a79..000000000000 --- a/docs/development/custom-vectors/mldsa/generate_mldsa.py +++ /dev/null @@ -1,74 +0,0 @@ -# This file is dual licensed under the terms of the Apache License, Version -# 2.0, and the BSD License. See the LICENSE file in the root of this repository -# for complete details. - -import os - -from cryptography import x509 -from cryptography.hazmat import asn1 - - -@asn1.sequence -class AlgorithmIdentifier: - algorithm: x509.ObjectIdentifier - - -@asn1.sequence -class OneAsymmetricKey: - version: int - algorithm: AlgorithmIdentifier - private_key: bytes - - -# ML-DSA-PrivateKey ::= CHOICE { -# seed [0] IMPLICIT OCTET STRING (SIZE (32)), -# expandedKey OCTET STRING, -# both SEQUENCE { seed, expandedKey } -# } -MLDSA_SEED_BYTES = 32 - - -def generate_mldsa44_unsupported_variant(output_dir: str) -> None: - seed = b"\x2a" * MLDSA_SEED_BYTES - # [0] IMPLICIT OCTET STRING: tag 0x80, length 0x20 - seed_only_privkey = b"\x80\x20" + seed - - # ML-DSA-44 OID: 2.16.840.1.101.3.4.3.17 - obj = OneAsymmetricKey( - version=0, - algorithm=AlgorithmIdentifier( - algorithm=x509.ObjectIdentifier("2.16.840.1.101.3.4.3.17"), - ), - private_key=seed_only_privkey, - ) - with open(os.path.join(output_dir, "mldsa44_priv.der"), "wb") as f: - f.write(asn1.encode_der(obj)) - - -def generate_mldsa65_noseed(output_dir: str) -> None: - # ML-DSA-65 OID: 2.16.840.1.101.3.4.3.18 - # Generate an ML-DSA-65 PKCS#8 key whose inner privateKey is an - # empty SEQUENCE (0x30 0x00) — i.e. the "both" SEQUENCE form with - # no seed present. This exercises the InvalidKey error path in the - # Rust parser when seed is None. - obj = OneAsymmetricKey( - version=0, - algorithm=AlgorithmIdentifier( - algorithm=x509.ObjectIdentifier("2.16.840.1.101.3.4.3.18"), - ), - private_key=b"\x30\x00", - ) - with open(os.path.join(output_dir, "mldsa65_noseed_priv.der"), "wb") as f: - f.write(asn1.encode_der(obj)) - - -def main(): - output_dir = os.path.join( - "vectors", "cryptography_vectors", "asymmetric", "MLDSA" - ) - generate_mldsa44_unsupported_variant(output_dir) - generate_mldsa65_noseed(output_dir) - - -if __name__ == "__main__": - main() diff --git a/docs/development/test-vectors.rst b/docs/development/test-vectors.rst index db7fbc3cc465..69a6d006a607 100644 --- a/docs/development/test-vectors.rst +++ b/docs/development/test-vectors.rst @@ -65,8 +65,6 @@ Asymmetric ciphers * ``asymmetric/PKCS8/ed25519-scrypt.pem`` a PKCS8 encoded Ed25519 key from RustCrypto using scrypt as the KDF. The password is ``hunter42``. * FIPS 204 ML-DSA-{44,65,87} KAT vectors from `post-quantum-cryptography/KAT`_. -* ML-DSA-44 PKCS#8 and SPKI DER test vectors generated by this project. - See :doc:`/development/custom-vectors/mldsa` Custom asymmetric vectors ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -76,7 +74,6 @@ Custom asymmetric vectors custom-vectors/secp256k1 custom-vectors/rsa-oaep-sha2 - custom-vectors/mldsa * ``asymmetric/PEM_Serialization/ec_private_key.pem`` and ``asymmetric/DER_Serialization/ec_private_key.der`` - Contains an Elliptic @@ -1223,7 +1220,6 @@ Created Vectors custom-vectors/idea custom-vectors/seed custom-vectors/hkdf - custom-vectors/mldsa custom-vectors/rc2 From f492ca383a043f223a665e24e905f7456190aa34 Mon Sep 17 00:00:00 2001 From: Alexis Date: Wed, 25 Mar 2026 05:35:14 -0400 Subject: [PATCH 21/23] Review comments --- src/cryptography/hazmat/primitives/asymmetric/mldsa.py | 2 +- src/rust/cryptography-key-parsing/src/pkcs8.rs | 2 +- src/rust/cryptography-openssl/src/mldsa.rs | 5 ++--- src/rust/src/backend/mldsa.rs | 8 ++++++-- tests/wycheproof/test_mldsa.py | 10 ++++------ 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/cryptography/hazmat/primitives/asymmetric/mldsa.py b/src/cryptography/hazmat/primitives/asymmetric/mldsa.py index 18d3f856b54e..b4ac1a08c757 100644 --- a/src/cryptography/hazmat/primitives/asymmetric/mldsa.py +++ b/src/cryptography/hazmat/primitives/asymmetric/mldsa.py @@ -114,7 +114,7 @@ def private_bytes( self, encoding: _serialization.Encoding, format: _serialization.PrivateFormat, - encryption_algorithm: (_serialization.KeySerializationEncryption), + encryption_algorithm: _serialization.KeySerializationEncryption, ) -> bytes: """ The serialized bytes of the private key. diff --git a/src/rust/cryptography-key-parsing/src/pkcs8.rs b/src/rust/cryptography-key-parsing/src/pkcs8.rs index 12462862e0ff..c2344cd2b5bb 100644 --- a/src/rust/cryptography-key-parsing/src/pkcs8.rs +++ b/src/rust/cryptography-key-parsing/src/pkcs8.rs @@ -27,7 +27,7 @@ pub struct PrivateKeyInfo<'a> { // NO-COVERAGE-START #[derive(asn1::Asn1Read, asn1::Asn1Write)] // NO-COVERAGE-END -enum MlDsaPrivateKey<'a> { +pub enum MlDsaPrivateKey<'a> { #[implicit(0)] Seed(&'a [u8]), } diff --git a/src/rust/cryptography-openssl/src/mldsa.rs b/src/rust/cryptography-openssl/src/mldsa.rs index a491d035e8a2..617d82e063a7 100644 --- a/src/rust/cryptography-openssl/src/mldsa.rs +++ b/src/rust/cryptography-openssl/src/mldsa.rs @@ -8,9 +8,8 @@ use std::os::raw::c_int; use crate::{cvt, cvt_p, OpenSSLResult}; -pub const NID_ML_DSA_65: c_int = ffi::NID_MLDSA65; -pub const NID_PQDSA: c_int = ffi::NID_PQDSA; -pub const PKEY_ID: openssl::pkey::Id = openssl::pkey::Id::from_raw(NID_PQDSA); +const NID_ML_DSA_65: c_int = ffi::NID_MLDSA65; +pub const PKEY_ID: openssl::pkey::Id = openssl::pkey::Id::from_raw(ffi::NID_PQDSA); const MLDSA65_SIGNATURE_BYTES: usize = 3309; pub const MLDSA65_PUBLIC_KEY_BYTES: usize = 1952; pub const MLDSA65_SEED_BYTES: usize = 32; diff --git a/src/rust/src/backend/mldsa.rs b/src/rust/src/backend/mldsa.rs index 55d2d46467a0..3571cf701f55 100644 --- a/src/rust/src/backend/mldsa.rs +++ b/src/rust/src/backend/mldsa.rs @@ -102,8 +102,12 @@ impl MlDsa65PrivateKey { let pki = asn1::parse_single::>(&pkcs8_der) .unwrap(); - let seed = asn1::parse_single::>(pki.private_key).unwrap(); - Ok(pyo3::types::PyBytes::new(py, seed.into_inner())) + let cryptography_key_parsing::pkcs8::MlDsaPrivateKey::Seed(seed) = + asn1::parse_single::>( + pki.private_key, + ) + .unwrap(); + Ok(pyo3::types::PyBytes::new(py, seed)) } fn private_bytes<'p>( diff --git a/tests/wycheproof/test_mldsa.py b/tests/wycheproof/test_mldsa.py index 2a6386c2de82..48615356efcc 100644 --- a/tests/wycheproof/test_mldsa.py +++ b/tests/wycheproof/test_mldsa.py @@ -22,11 +22,8 @@ @wycheproof_tests("mldsa_65_verify_test.json") def test_mldsa65_verify(backend, wycheproof): try: - pub = wycheproof.cache_value_to_group( - "cached_pub", - lambda: MlDsa65PublicKey.from_public_bytes( - binascii.unhexlify(wycheproof.testgroup["publicKey"]) - ), + pub = MlDsa65PublicKey.from_public_bytes( + binascii.unhexlify(wycheproof.testgroup["publicKey"]) ) except ValueError: assert wycheproof.invalid @@ -56,7 +53,8 @@ def test_mldsa65_verify(backend, wycheproof): ) @wycheproof_tests("mldsa_65_sign_seed_test.json") def test_mldsa65_sign_seed(backend, wycheproof): - # Skip "Internal" tests + # Skip "Internal" tests, they use the inner method `Sign_internal` + # instead of `Sign` which we do not expose. if wycheproof.has_flag("Internal"): return From d517b3537d7e4e4d9d6f2ab91908db49de8bd820 Mon Sep 17 00:00:00 2001 From: Alexis Date: Wed, 25 Mar 2026 05:58:48 -0400 Subject: [PATCH 22/23] Remove markers --- src/rust/src/backend/mldsa.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/rust/src/backend/mldsa.rs b/src/rust/src/backend/mldsa.rs index 3571cf701f55..b651d8db12e1 100644 --- a/src/rust/src/backend/mldsa.rs +++ b/src/rust/src/backend/mldsa.rs @@ -61,9 +61,7 @@ fn from_public_bytes(data: &[u8]) -> pyo3::PyResult { Ok(MlDsa65PublicKey { pkey }) } -// NO-COVERAGE-START #[pyo3::pymethods] -// NO-COVERAGE-END impl MlDsa65PrivateKey { #[pyo3(signature = (data, context=None))] fn sign<'p>( @@ -150,9 +148,7 @@ impl MlDsa65PrivateKey { } } -// NO-COVERAGE-START #[pyo3::pymethods] -// NO-COVERAGE-END impl MlDsa65PublicKey { #[pyo3(signature = (signature, data, context=None))] fn verify( From 1f1303a7bddf91607497bf68ed41bb215c68e071 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 27 Mar 2026 06:13:22 -0400 Subject: [PATCH 23/23] Additional round of review --- .../cryptography-key-parsing/src/pkcs8.rs | 28 +++++++++++++------ src/rust/src/backend/mldsa.rs | 16 ++--------- tests/hazmat/primitives/test_mldsa.py | 1 + 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/rust/cryptography-key-parsing/src/pkcs8.rs b/src/rust/cryptography-key-parsing/src/pkcs8.rs index c2344cd2b5bb..5c7354d25372 100644 --- a/src/rust/cryptography-key-parsing/src/pkcs8.rs +++ b/src/rust/cryptography-key-parsing/src/pkcs8.rs @@ -24,12 +24,24 @@ pub struct PrivateKeyInfo<'a> { // RFC 9881 Section 6.5 #[cfg(CRYPTOGRAPHY_IS_AWSLC)] -// NO-COVERAGE-START #[derive(asn1::Asn1Read, asn1::Asn1Write)] -// NO-COVERAGE-END -pub enum MlDsaPrivateKey<'a> { +pub enum MlDsaPrivateKey { #[implicit(0)] - Seed(&'a [u8]), + Seed([u8; 32]), +} + +/// Extract the 32-byte ML-DSA-65 seed from a private key. +/// +/// AWS-LC's `raw_private_key()` returns the expanded key, not the seed. +/// This function round-trips through the native PKCS#8 encoding to extract it. +/// https://github.com/aws/aws-lc/issues/3072 +#[cfg(CRYPTOGRAPHY_IS_AWSLC)] +pub fn mldsa_seed_from_pkey( + pkey: &openssl::pkey::PKeyRef, +) -> Result { + let pkcs8_der = pkey.private_key_to_pkcs8()?; + let pki = asn1::parse_single::>(&pkcs8_der).unwrap(); + Ok(asn1::parse_single::(pki.private_key).unwrap()) } pub fn parse_private_key( @@ -120,9 +132,8 @@ pub fn parse_private_key( #[cfg(CRYPTOGRAPHY_IS_AWSLC)] AlgorithmParameters::MlDsa65 => { - let MlDsaPrivateKey::Seed(seed) = - asn1::parse_single::>(k.private_key)?; - Ok(cryptography_openssl::mldsa::new_raw_private_key(seed)?) + let MlDsaPrivateKey::Seed(seed) = asn1::parse_single::(k.private_key)?; + Ok(cryptography_openssl::mldsa::new_raw_private_key(&seed)?) } _ => Err(KeyParsingError::UnsupportedKeyType( @@ -462,8 +473,7 @@ pub fn serialize_private_key( } #[cfg(CRYPTOGRAPHY_IS_AWSLC)] cryptography_openssl::mldsa::PKEY_ID => { - let seed = pkey.raw_private_key()?; - let private_key_der = asn1::write_single(&MlDsaPrivateKey::Seed(seed.as_slice()))?; + let private_key_der = asn1::write_single(&mldsa_seed_from_pkey(pkey)?)?; (AlgorithmParameters::MlDsa65, private_key_der) } _ => { diff --git a/src/rust/src/backend/mldsa.rs b/src/rust/src/backend/mldsa.rs index b651d8db12e1..87961630ff33 100644 --- a/src/rust/src/backend/mldsa.rs +++ b/src/rust/src/backend/mldsa.rs @@ -91,21 +91,9 @@ impl MlDsa65PrivateKey { &self, py: pyo3::Python<'p>, ) -> CryptographyResult> { - // AWS-LC's raw_private_key() returns the expanded key, not the seed. - // Round-trip through PKCS#8 DER to extract the 32-byte seed. - // Note: private_key_to_pkcs8() (i2d_PKCS8PrivateKey_bio) must be used - // instead of private_key_to_der() (i2d_PrivateKey), because AWS-LC's - // i2d_PrivateKey doesn't support PQDSA keys. - let pkcs8_der = self.pkey.private_key_to_pkcs8()?; - let pki = - asn1::parse_single::>(&pkcs8_der) - .unwrap(); let cryptography_key_parsing::pkcs8::MlDsaPrivateKey::Seed(seed) = - asn1::parse_single::>( - pki.private_key, - ) - .unwrap(); - Ok(pyo3::types::PyBytes::new(py, seed)) + cryptography_key_parsing::pkcs8::mldsa_seed_from_pkey(&self.pkey)?; + Ok(pyo3::types::PyBytes::new(py, &seed)) } fn private_bytes<'p>( diff --git a/tests/hazmat/primitives/test_mldsa.py b/tests/hazmat/primitives/test_mldsa.py index 45d7f254a9d2..1364ce73b7d0 100644 --- a/tests/hazmat/primitives/test_mldsa.py +++ b/tests/hazmat/primitives/test_mldsa.py @@ -160,6 +160,7 @@ def test_round_trip_private_serialization( serialized = key.private_bytes(encoding, fmt, encryption) loaded_key = load_func(serialized, passwd, backend) assert isinstance(loaded_key, MlDsa65PrivateKey) + assert loaded_key.private_bytes_raw() == key.private_bytes_raw() sig = loaded_key.sign(b"test data") key.public_key().verify(sig, b"test data")