From b4c0cf597f9746ff0a56f91d95c0cc4b37f24097 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 05:03:52 +0000 Subject: [PATCH 1/8] Add external mu (message representative) support to ML-DSA Adds sign_mu()/verify_mu() to the ML-DSA private/public key classes for signing and verifying a precomputed 64-byte external mu, as defined in FIPS 204. mu already incorporates the public key and any context string, so these methods take no context. Per-backend mechanism: - OpenSSL 3.5+: set the integer "mu" signature parameter (OSSL_SIGNATURE_PARAM_MU) and pass mu through EVP_DigestSign. - AWS-LC: EVP_PKEY_sign/EVP_PKEY_verify, which use the "ExternalMu" format for ML-DSA keys. - BoringSSL: the EVP layer has no external-mu support, so use the low-level MLDSA*_{sign,verify}_message_representative functions. --- CHANGELOG.rst | 4 + docs/hazmat/primitives/asymmetric/mldsa.rst | 102 +++++++++ .../hazmat/primitives/asymmetric/mldsa.py | 63 +++++ src/rust/cryptography-openssl/src/mldsa.rs | 216 +++++++++++++++++- src/rust/src/backend/mldsa.rs | 117 ++++++++++ tests/hazmat/primitives/test_mldsa.py | 61 +++++ 6 files changed, 559 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 36dfce4c5193..b88b38384b5c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -43,6 +43,10 @@ Changelog * Added :meth:`~cryptography.x509.Name.from_bytes` for parsing a :class:`~cryptography.x509.Name` from DER bytes, the inverse of :meth:`~cryptography.x509.Name.public_bytes`. +* Added external mu (message representative) support to + :doc:`/hazmat/primitives/asymmetric/mldsa` via the + ``sign_mu`` and ``verify_mu`` methods, which sign and verify a precomputed + 64-byte ``mu`` as defined in FIPS 204. .. _v48-0-0: diff --git a/docs/hazmat/primitives/asymmetric/mldsa.rst b/docs/hazmat/primitives/asymmetric/mldsa.rst index e298d29373da..7c63a9ab789c 100644 --- a/docs/hazmat/primitives/asymmetric/mldsa.rst +++ b/docs/hazmat/primitives/asymmetric/mldsa.rst @@ -95,6 +95,21 @@ Key interfaces :raises ValueError: If the context is longer than 255 bytes. + .. method:: sign_mu(mu) + + .. versionadded:: 49.0.0 + + Sign a precomputed ``mu`` (message representative) using ML-DSA-44, + the "external mu" variant from FIPS 204. ``mu`` already incorporates + the public key and any context string, so no context is accepted here. + + :param mu: The 64-byte message representative. + :type mu: :term:`bytes-like` + + :returns bytes: The signature (2420 bytes). + + :raises ValueError: If ``mu`` is not 64 bytes. + .. method:: private_bytes(encoding, format, encryption_algorithm) Allows serialization of the key to bytes. Encoding ( @@ -227,6 +242,25 @@ Key interfaces signature cannot be verified. :raises ValueError: If the context is longer than 255 bytes. + .. method:: verify_mu(signature, mu) + + .. versionadded:: 49.0.0 + + Verify a signature over a precomputed ``mu`` (message representative), + the "external mu" variant from FIPS 204. ``mu`` already incorporates + the public key and any context string. + + :param signature: The signature to verify. + :type signature: :term:`bytes-like` + + :param mu: The 64-byte message representative. + :type mu: :term:`bytes-like` + + :returns: None + :raises cryptography.exceptions.InvalidSignature: Raised when the + signature cannot be verified. + :raises ValueError: If ``mu`` is not 64 bytes. + .. class:: MLDSA65PrivateKey .. versionadded:: 47.0.0 @@ -281,6 +315,21 @@ Key interfaces :raises ValueError: If the context is longer than 255 bytes. + .. method:: sign_mu(mu) + + .. versionadded:: 49.0.0 + + Sign a precomputed ``mu`` (message representative) using ML-DSA-65, + the "external mu" variant from FIPS 204. ``mu`` already incorporates + the public key and any context string, so no context is accepted here. + + :param mu: The 64-byte message representative. + :type mu: :term:`bytes-like` + + :returns bytes: The signature (3309 bytes). + + :raises ValueError: If ``mu`` is not 64 bytes. + .. method:: private_bytes(encoding, format, encryption_algorithm) Allows serialization of the key to bytes. Encoding ( @@ -413,6 +462,25 @@ Key interfaces signature cannot be verified. :raises ValueError: If the context is longer than 255 bytes. + .. method:: verify_mu(signature, mu) + + .. versionadded:: 49.0.0 + + Verify a signature over a precomputed ``mu`` (message representative), + the "external mu" variant from FIPS 204. ``mu`` already incorporates + the public key and any context string. + + :param signature: The signature to verify. + :type signature: :term:`bytes-like` + + :param mu: The 64-byte message representative. + :type mu: :term:`bytes-like` + + :returns: None + :raises cryptography.exceptions.InvalidSignature: Raised when the + signature cannot be verified. + :raises ValueError: If ``mu`` is not 64 bytes. + .. class:: MLDSA87PrivateKey .. versionadded:: 47.0.0 @@ -467,6 +535,21 @@ Key interfaces :raises ValueError: If the context is longer than 255 bytes. + .. method:: sign_mu(mu) + + .. versionadded:: 49.0.0 + + Sign a precomputed ``mu`` (message representative) using ML-DSA-87, + the "external mu" variant from FIPS 204. ``mu`` already incorporates + the public key and any context string, so no context is accepted here. + + :param mu: The 64-byte message representative. + :type mu: :term:`bytes-like` + + :returns bytes: The signature (4627 bytes). + + :raises ValueError: If ``mu`` is not 64 bytes. + .. method:: private_bytes(encoding, format, encryption_algorithm) Allows serialization of the key to bytes. Encoding ( @@ -599,5 +682,24 @@ Key interfaces signature cannot be verified. :raises ValueError: If the context is longer than 255 bytes. + .. method:: verify_mu(signature, mu) + + .. versionadded:: 49.0.0 + + Verify a signature over a precomputed ``mu`` (message representative), + the "external mu" variant from FIPS 204. ``mu`` already incorporates + the public key and any context string. + + :param signature: The signature to verify. + :type signature: :term:`bytes-like` + + :param mu: The 64-byte message representative. + :type mu: :term:`bytes-like` + + :returns: None + :raises cryptography.exceptions.InvalidSignature: Raised when the + signature cannot be verified. + :raises ValueError: If ``mu`` is not 64 bytes. + .. _`FIPS 204`: https://csrc.nist.gov/pubs/fips/204/final diff --git a/src/cryptography/hazmat/primitives/asymmetric/mldsa.py b/src/cryptography/hazmat/primitives/asymmetric/mldsa.py index 0bd968457eb2..9c5294935107 100644 --- a/src/cryptography/hazmat/primitives/asymmetric/mldsa.py +++ b/src/cryptography/hazmat/primitives/asymmetric/mldsa.py @@ -55,6 +55,18 @@ def verify( Verify the signature. """ + @abc.abstractmethod + def verify_mu( + self, + signature: Buffer, + mu: Buffer, + ) -> None: + """ + Verify the signature over a precomputed mu (message representative). + + mu must be 64 bytes. + """ + @abc.abstractmethod def __eq__(self, other: object) -> bool: """ @@ -138,6 +150,15 @@ def sign(self, data: Buffer, context: Buffer | None = None) -> bytes: Signs the data. """ + @abc.abstractmethod + def sign_mu(self, mu: Buffer) -> bytes: + """ + Signs a precomputed mu (message representative). + + mu must be 64 bytes and already incorporates the context, so no + context is accepted here. + """ + @abc.abstractmethod def __copy__(self) -> MLDSA44PrivateKey: """ @@ -198,6 +219,18 @@ def verify( Verify the signature. """ + @abc.abstractmethod + def verify_mu( + self, + signature: Buffer, + mu: Buffer, + ) -> None: + """ + Verify the signature over a precomputed mu (message representative). + + mu must be 64 bytes. + """ + @abc.abstractmethod def __eq__(self, other: object) -> bool: """ @@ -281,6 +314,15 @@ def sign(self, data: Buffer, context: Buffer | None = None) -> bytes: Signs the data. """ + @abc.abstractmethod + def sign_mu(self, mu: Buffer) -> bytes: + """ + Signs a precomputed mu (message representative). + + mu must be 64 bytes and already incorporates the context, so no + context is accepted here. + """ + @abc.abstractmethod def __copy__(self) -> MLDSA65PrivateKey: """ @@ -341,6 +383,18 @@ def verify( Verify the signature. """ + @abc.abstractmethod + def verify_mu( + self, + signature: Buffer, + mu: Buffer, + ) -> None: + """ + Verify the signature over a precomputed mu (message representative). + + mu must be 64 bytes. + """ + @abc.abstractmethod def __eq__(self, other: object) -> bool: """ @@ -424,6 +478,15 @@ def sign(self, data: Buffer, context: Buffer | None = None) -> bytes: Signs the data. """ + @abc.abstractmethod + def sign_mu(self, mu: Buffer) -> bytes: + """ + Signs a precomputed mu (message representative). + + mu must be 64 bytes and already incorporates the context, so no + context is accepted here. + """ + @abc.abstractmethod def __copy__(self) -> MLDSA87PrivateKey: """ diff --git a/src/rust/cryptography-openssl/src/mldsa.rs b/src/rust/cryptography-openssl/src/mldsa.rs index d15b32d98f00..731f1e4431b6 100644 --- a/src/rust/cryptography-openssl/src/mldsa.rs +++ b/src/rust/cryptography-openssl/src/mldsa.rs @@ -4,16 +4,32 @@ #[cfg(any(CRYPTOGRAPHY_IS_BORINGSSL, CRYPTOGRAPHY_IS_AWSLC))] use foreign_types_shared::ForeignType; -#[cfg(any(CRYPTOGRAPHY_IS_BORINGSSL, CRYPTOGRAPHY_IS_AWSLC))] +#[cfg(any( + CRYPTOGRAPHY_IS_BORINGSSL, + CRYPTOGRAPHY_IS_AWSLC, + CRYPTOGRAPHY_OPENSSL_350_OR_GREATER +))] use foreign_types_shared::ForeignTypeRef; -#[cfg(any(CRYPTOGRAPHY_IS_BORINGSSL, CRYPTOGRAPHY_IS_AWSLC))] +#[cfg(any( + CRYPTOGRAPHY_IS_BORINGSSL, + CRYPTOGRAPHY_IS_AWSLC, + CRYPTOGRAPHY_OPENSSL_350_OR_GREATER +))] use openssl_sys as ffi; #[cfg(CRYPTOGRAPHY_IS_AWSLC)] use std::os::raw::c_int; -#[cfg(any(CRYPTOGRAPHY_IS_BORINGSSL, CRYPTOGRAPHY_IS_AWSLC))] +#[cfg(any( + CRYPTOGRAPHY_IS_BORINGSSL, + CRYPTOGRAPHY_IS_AWSLC, + CRYPTOGRAPHY_OPENSSL_350_OR_GREATER +))] use crate::cvt; -#[cfg(any(CRYPTOGRAPHY_IS_BORINGSSL, CRYPTOGRAPHY_IS_AWSLC))] +#[cfg(any( + CRYPTOGRAPHY_IS_BORINGSSL, + CRYPTOGRAPHY_IS_AWSLC, + CRYPTOGRAPHY_OPENSSL_350_OR_GREATER +))] use crate::cvt_p; use crate::OpenSSLResult; @@ -24,6 +40,10 @@ pub enum MlDsaVariant { MlDsa87, } +/// The length, in bytes, of an ML-DSA external mu (message representative) +/// value, as defined in FIPS 204. +pub const MLDSA_MU_BYTES: usize = 64; + #[cfg(CRYPTOGRAPHY_IS_AWSLC)] pub const PKEY_ID: openssl::pkey::Id = openssl::pkey::Id::from_raw(ffi::NID_PQDSA); @@ -264,6 +284,194 @@ pub fn verify( Ok(md_ctx.digest_verify(data, signature).unwrap_or(false)) } +/// Enable "external mu" mode on an OpenSSL signing/verification context by +/// setting the integer `mu` signature parameter (OSSL_SIGNATURE_PARAM_MU) to 1. +#[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] +fn set_mu(pkey_ctx: &mut openssl::pkey_ctx::PkeyCtxRef) -> OpenSSLResult<()> { + // SAFETY: We build a one-element OSSL_PARAM array holding the integer "mu" + // parameter set to 1 and apply it to the EVP_PKEY_CTX. Every pointer is + // valid for the duration of its use and freed before returning. + unsafe { + let bld = cvt_p(ffi::OSSL_PARAM_BLD_new())?; + if ffi::OSSL_PARAM_BLD_push_int(bld, c"mu".as_ptr(), 1) != 1 { + ffi::OSSL_PARAM_BLD_free(bld); + return Err(openssl::error::ErrorStack::get()); + } + let params = ffi::OSSL_PARAM_BLD_to_param(bld); + ffi::OSSL_PARAM_BLD_free(bld); + let params = cvt_p(params)?; + let res = ffi::EVP_PKEY_CTX_set_params(pkey_ctx.as_ptr(), params); + ffi::OSSL_PARAM_free(params); + cvt(res)?; + } + Ok(()) +} + +/// Sign a precomputed external mu (message representative). `mu` must be +/// [`MLDSA_MU_BYTES`] long, and already incorporates any context string, so no +/// context is accepted here. +pub fn sign_mu( + pkey: &openssl::pkey::PKeyRef, + variant: MlDsaVariant, + mu: &[u8], +) -> OpenSSLResult> { + cfg_if::cfg_if! { + if #[cfg(CRYPTOGRAPHY_IS_BORINGSSL)] { + // BoringSSL has no EVP-level external mu support, so we drop down to + // the low-level ML-DSA API, reconstructing the private key from its + // 32-byte seed. + let seed = mldsa_seed_raw(pkey)?; + // SAFETY: `seed` is a valid 32-byte seed and `mu` is a valid + // MLDSA_MU_BYTES buffer; both outlive the calls below. + unsafe { + match variant { + MlDsaVariant::MlDsa44 => { + let mut key = std::mem::MaybeUninit::::uninit(); + cvt(ffi::MLDSA44_private_key_from_seed( + key.as_mut_ptr(), + seed.as_ptr(), + seed.len(), + ))?; + let key = key.assume_init(); + let mut sig = vec![0u8; ffi::MLDSA44_SIGNATURE_BYTES as usize]; + cvt(ffi::MLDSA44_sign_message_representative( + sig.as_mut_ptr(), + &key, + mu.as_ptr(), + ))?; + Ok(sig) + } + MlDsaVariant::MlDsa65 => { + let mut key = std::mem::MaybeUninit::::uninit(); + cvt(ffi::MLDSA65_private_key_from_seed( + key.as_mut_ptr(), + seed.as_ptr(), + seed.len(), + ))?; + let key = key.assume_init(); + let mut sig = vec![0u8; ffi::MLDSA65_SIGNATURE_BYTES as usize]; + cvt(ffi::MLDSA65_sign_message_representative( + sig.as_mut_ptr(), + &key, + mu.as_ptr(), + ))?; + Ok(sig) + } + MlDsaVariant::MlDsa87 => { + let mut key = std::mem::MaybeUninit::::uninit(); + cvt(ffi::MLDSA87_private_key_from_seed( + key.as_mut_ptr(), + seed.as_ptr(), + seed.len(), + ))?; + let key = key.assume_init(); + let mut sig = vec![0u8; ffi::MLDSA87_SIGNATURE_BYTES as usize]; + cvt(ffi::MLDSA87_sign_message_representative( + sig.as_mut_ptr(), + &key, + mu.as_ptr(), + ))?; + Ok(sig) + } + } + } + } else if #[cfg(CRYPTOGRAPHY_IS_AWSLC)] { + // AWS-LC's EVP_PKEY_sign treats its input as an external mu (the + // "ExternalMu" format) for ML-DSA keys. + let _ = variant; + let mut ctx = openssl::pkey_ctx::PkeyCtx::new(pkey)?; + ctx.sign_init()?; + let mut sig = vec![]; + ctx.sign_to_vec(mu, &mut sig)?; + Ok(sig) + } else if #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] { + // OpenSSL signs an external mu by setting the "mu" parameter and + // passing the 64-byte mu in place of the message. + let _ = variant; + let mut md_ctx = openssl::md_ctx::MdCtx::new()?; + let pkey_ctx = md_ctx.digest_sign_init(None, pkey)?; + set_mu(pkey_ctx)?; + let mut sig = vec![]; + md_ctx.digest_sign_to_vec(mu, &mut sig)?; + Ok(sig) + } + } +} + +/// Verify a signature over a precomputed external mu (message representative). +/// `mu` must be [`MLDSA_MU_BYTES`] long. +pub fn verify_mu( + pkey: &openssl::pkey::PKeyRef, + variant: MlDsaVariant, + signature: &[u8], + mu: &[u8], +) -> OpenSSLResult { + cfg_if::cfg_if! { + if #[cfg(CRYPTOGRAPHY_IS_BORINGSSL)] { + let raw = pkey.raw_public_key()?; + // SAFETY: We parse the low-level public key from its encoded form + // and verify the signature over the MLDSA_MU_BYTES `mu`. + unsafe { + match variant { + MlDsaVariant::MlDsa44 => { + let mut key = std::mem::MaybeUninit::::uninit(); + let mut cbs = ffi::CBS { data: raw.as_ptr(), len: raw.len() }; + if cvt(ffi::MLDSA44_parse_public_key(key.as_mut_ptr(), &mut cbs)).is_err() { + return Ok(false); + } + let key = key.assume_init(); + Ok(ffi::MLDSA44_verify_message_representative( + &key, + signature.as_ptr(), + signature.len(), + mu.as_ptr(), + ) == 1) + } + MlDsaVariant::MlDsa65 => { + let mut key = std::mem::MaybeUninit::::uninit(); + let mut cbs = ffi::CBS { data: raw.as_ptr(), len: raw.len() }; + if cvt(ffi::MLDSA65_parse_public_key(key.as_mut_ptr(), &mut cbs)).is_err() { + return Ok(false); + } + let key = key.assume_init(); + Ok(ffi::MLDSA65_verify_message_representative( + &key, + signature.as_ptr(), + signature.len(), + mu.as_ptr(), + ) == 1) + } + MlDsaVariant::MlDsa87 => { + let mut key = std::mem::MaybeUninit::::uninit(); + let mut cbs = ffi::CBS { data: raw.as_ptr(), len: raw.len() }; + if cvt(ffi::MLDSA87_parse_public_key(key.as_mut_ptr(), &mut cbs)).is_err() { + return Ok(false); + } + let key = key.assume_init(); + Ok(ffi::MLDSA87_verify_message_representative( + &key, + signature.as_ptr(), + signature.len(), + mu.as_ptr(), + ) == 1) + } + } + } + } else if #[cfg(CRYPTOGRAPHY_IS_AWSLC)] { + let _ = variant; + let mut ctx = openssl::pkey_ctx::PkeyCtx::new(pkey)?; + ctx.verify_init()?; + Ok(ctx.verify(mu, signature).unwrap_or(false)) + } else if #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] { + let _ = variant; + let mut md_ctx = openssl::md_ctx::MdCtx::new()?; + let pkey_ctx = md_ctx.digest_verify_init(None, pkey)?; + set_mu(pkey_ctx)?; + Ok(md_ctx.digest_verify(mu, signature).unwrap_or(false)) + } + } +} + #[cfg(test)] mod tests { use super::MlDsaVariant; diff --git a/src/rust/src/backend/mldsa.rs b/src/rust/src/backend/mldsa.rs index ab7dce3df54f..b17e7d446568 100644 --- a/src/rust/src/backend/mldsa.rs +++ b/src/rust/src/backend/mldsa.rs @@ -95,6 +95,21 @@ impl MlDsa44PrivateKey { Ok(pyo3::types::PyBytes::new(py, &sig)) } + fn sign_mu<'p>( + &self, + py: pyo3::Python<'p>, + mu: CffiBuf<'_>, + ) -> CryptographyResult> { + if mu.as_bytes().len() != cryptography_openssl::mldsa::MLDSA_MU_BYTES { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), + )); + } + let sig = + cryptography_openssl::mldsa::sign_mu(&self.pkey, MlDsaVariant::MlDsa44, mu.as_bytes())?; + Ok(pyo3::types::PyBytes::new(py, &sig)) + } + fn public_key(&self) -> CryptographyResult { let raw_bytes = self.pkey.raw_public_key()?; Ok(MlDsa44PublicKey { @@ -152,6 +167,30 @@ impl MlDsa44PrivateKey { #[pyo3::pymethods] impl MlDsa44PublicKey { + #[pyo3(signature = (signature, mu))] + fn verify_mu(&self, signature: CffiBuf<'_>, mu: CffiBuf<'_>) -> CryptographyResult<()> { + if mu.as_bytes().len() != cryptography_openssl::mldsa::MLDSA_MU_BYTES { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), + )); + } + let valid = cryptography_openssl::mldsa::verify_mu( + &self.pkey, + MlDsaVariant::MlDsa44, + signature.as_bytes(), + mu.as_bytes(), + ) + .unwrap_or(false); + + if !valid { + return Err(CryptographyError::from( + exceptions::InvalidSignature::new_err(()), + )); + } + + Ok(()) + } + #[pyo3(signature = (signature, data, context=None))] fn verify( &self, @@ -298,6 +337,21 @@ impl MlDsa65PrivateKey { Ok(pyo3::types::PyBytes::new(py, &sig)) } + fn sign_mu<'p>( + &self, + py: pyo3::Python<'p>, + mu: CffiBuf<'_>, + ) -> CryptographyResult> { + if mu.as_bytes().len() != cryptography_openssl::mldsa::MLDSA_MU_BYTES { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), + )); + } + let sig = + cryptography_openssl::mldsa::sign_mu(&self.pkey, MlDsaVariant::MlDsa65, mu.as_bytes())?; + Ok(pyo3::types::PyBytes::new(py, &sig)) + } + fn public_key(&self) -> CryptographyResult { let raw_bytes = self.pkey.raw_public_key()?; Ok(MlDsa65PublicKey { @@ -358,6 +412,30 @@ impl MlDsa65PrivateKey { #[pyo3::pymethods] impl MlDsa65PublicKey { + #[pyo3(signature = (signature, mu))] + fn verify_mu(&self, signature: CffiBuf<'_>, mu: CffiBuf<'_>) -> CryptographyResult<()> { + if mu.as_bytes().len() != cryptography_openssl::mldsa::MLDSA_MU_BYTES { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), + )); + } + let valid = cryptography_openssl::mldsa::verify_mu( + &self.pkey, + MlDsaVariant::MlDsa65, + signature.as_bytes(), + mu.as_bytes(), + ) + .unwrap_or(false); + + if !valid { + return Err(CryptographyError::from( + exceptions::InvalidSignature::new_err(()), + )); + } + + Ok(()) + } + #[pyo3(signature = (signature, data, context=None))] fn verify( &self, @@ -504,6 +582,21 @@ impl MlDsa87PrivateKey { Ok(pyo3::types::PyBytes::new(py, &sig)) } + fn sign_mu<'p>( + &self, + py: pyo3::Python<'p>, + mu: CffiBuf<'_>, + ) -> CryptographyResult> { + if mu.as_bytes().len() != cryptography_openssl::mldsa::MLDSA_MU_BYTES { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), + )); + } + let sig = + cryptography_openssl::mldsa::sign_mu(&self.pkey, MlDsaVariant::MlDsa87, mu.as_bytes())?; + Ok(pyo3::types::PyBytes::new(py, &sig)) + } + fn public_key(&self) -> CryptographyResult { let raw_bytes = self.pkey.raw_public_key()?; Ok(MlDsa87PublicKey { @@ -561,6 +654,30 @@ impl MlDsa87PrivateKey { #[pyo3::pymethods] impl MlDsa87PublicKey { + #[pyo3(signature = (signature, mu))] + fn verify_mu(&self, signature: CffiBuf<'_>, mu: CffiBuf<'_>) -> CryptographyResult<()> { + if mu.as_bytes().len() != cryptography_openssl::mldsa::MLDSA_MU_BYTES { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), + )); + } + let valid = cryptography_openssl::mldsa::verify_mu( + &self.pkey, + MlDsaVariant::MlDsa87, + signature.as_bytes(), + mu.as_bytes(), + ) + .unwrap_or(false); + + if !valid { + return Err(CryptographyError::from( + exceptions::InvalidSignature::new_err(()), + )); + } + + Ok(()) + } + #[pyo3(signature = (signature, data, context=None))] fn verify( &self, diff --git a/tests/hazmat/primitives/test_mldsa.py b/tests/hazmat/primitives/test_mldsa.py index b2c11c00b71a..7c4c984d35ef 100644 --- a/tests/hazmat/primitives/test_mldsa.py +++ b/tests/hazmat/primitives/test_mldsa.py @@ -6,6 +6,7 @@ import binascii import copy import dataclasses +import hashlib import os import pytest @@ -163,6 +164,66 @@ def test_empty_context_equivalence(self, variant, backend): sig2 = key.sign(data, b"") pub.verify(sig2, data) + @staticmethod + def _compute_mu(pub_raw: bytes, data: bytes, ctx: bytes = b"") -> bytes: + # FIPS 204: mu = SHAKE256(SHAKE256(pk, 64) || M', 64) where for pure + # ML-DSA M' = 0x00 || len(ctx) || ctx || M. + tr = hashlib.shake_256(pub_raw).digest(64) + m_prime = b"\x00" + bytes([len(ctx)]) + ctx + data + return hashlib.shake_256(tr + m_prime).digest(64) + + @pytest.mark.parametrize("variant", ML_DSA_VARIANTS) + def test_sign_verify_mu(self, variant, backend): + key = variant.private_key_class.generate() + pub = key.public_key() + data = b"test data" + mu = self._compute_mu(pub.public_bytes_raw(), data) + + sig = key.sign_mu(mu) + # Round-trips through the external-mu API. + pub.verify_mu(sig, mu) + # An external-mu signature is an ordinary ML-DSA signature. + pub.verify(sig, data) + # An ordinary signature verifies through the external-mu API. + sig2 = key.sign(data) + pub.verify_mu(sig2, mu) + + @pytest.mark.parametrize("variant", ML_DSA_VARIANTS) + def test_sign_verify_mu_with_context(self, variant, backend): + key = variant.private_key_class.generate() + pub = key.public_key() + data = b"test data" + ctx = b"a context" + mu = self._compute_mu(pub.public_bytes_raw(), data, ctx) + + sig = key.sign_mu(mu) + # The context is folded into mu, so the ordinary verify must supply it. + pub.verify(sig, data, ctx) + pub.verify_mu(sig, mu) + + @pytest.mark.parametrize("variant", ML_DSA_VARIANTS) + def test_mu_wrong_length(self, variant, backend): + key = variant.private_key_class.generate() + pub = key.public_key() + with pytest.raises(ValueError): + key.sign_mu(b"0" * 63) + with pytest.raises(ValueError): + key.sign_mu(b"0" * 65) + sig = key.sign_mu(b"0" * 64) + with pytest.raises(ValueError): + pub.verify_mu(sig, b"0" * 63) + + @pytest.mark.parametrize("variant", ML_DSA_VARIANTS) + def test_verify_mu_invalid(self, variant, backend): + key = variant.private_key_class.generate() + pub = key.public_key() + mu = b"\x01" * 64 + sig = key.sign_mu(mu) + with pytest.raises(InvalidSignature): + pub.verify_mu(sig, b"\x02" * 64) + with pytest.raises(InvalidSignature): + pub.verify_mu(b"0" * variant.sig_size, mu) + def test_kat_vectors_44(self, backend, subtests): vectors = load_vectors_from_file( os.path.join("asymmetric", "MLDSA", "kat_MLDSA_44_det_pure.rsp"), From 4521f67fd298360306f86eba12decbd46848f235 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 05:33:58 +0000 Subject: [PATCH 2/8] Add external-mu known-answer tests for ML-DSA Derive mu from the existing deterministic pure KAT vectors (whose signatures come from an independent reference implementation) and check that verify_mu accepts each reference signature for its derived mu and rejects it for a tampered mu, across ML-DSA-44/65/87. https://claude.ai/code/session_01MmjphxZ6ookRpjUhQouKjf --- tests/hazmat/primitives/test_mldsa.py | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/hazmat/primitives/test_mldsa.py b/tests/hazmat/primitives/test_mldsa.py index 7c4c984d35ef..fd2f665ada22 100644 --- a/tests/hazmat/primitives/test_mldsa.py +++ b/tests/hazmat/primitives/test_mldsa.py @@ -32,6 +32,7 @@ @dataclasses.dataclass class MLDSAVariant: + name: str private_key_class: type public_key_class: type pub_key_size: int @@ -42,6 +43,7 @@ class MLDSAVariant: ML_DSA_VARIANTS = [ pytest.param( MLDSAVariant( + name="44", private_key_class=MLDSA44PrivateKey, public_key_class=MLDSA44PublicKey, pub_key_size=1312, @@ -52,6 +54,7 @@ class MLDSAVariant: ), pytest.param( MLDSAVariant( + name="65", private_key_class=MLDSA65PrivateKey, public_key_class=MLDSA65PublicKey, pub_key_size=1952, @@ -62,6 +65,7 @@ class MLDSAVariant: ), pytest.param( MLDSAVariant( + name="87", private_key_class=MLDSA87PrivateKey, public_key_class=MLDSA87PublicKey, pub_key_size=2592, @@ -287,6 +291,37 @@ def test_kat_vectors_87(self, backend, subtests): pub = MLDSA87PublicKey.from_public_bytes(pk) pub.verify(expected_sig, msg, ctx) + @pytest.mark.parametrize("variant", ML_DSA_VARIANTS) + def test_kat_vectors_external_mu(self, variant, backend, subtests): + # The deterministic pure KAT signatures come from an independent + # reference implementation. mu is fully determined by the public key, + # context, and message (FIPS 204 Algorithm 2), so deriving it and + # checking verify_mu accepts the reference signature exercises the + # external-mu path against known-answer data. + vectors = load_vectors_from_file( + os.path.join( + "asymmetric", "MLDSA", f"kat_MLDSA_{variant.name}_det_pure.rsp" + ), + load_nist_vectors, + ) + for vector in vectors: + with subtests.test(): + pk = binascii.unhexlify(vector["pk"]) + msg = binascii.unhexlify(vector["msg"]) + ctx = binascii.unhexlify(vector["ctx"]) + sm = binascii.unhexlify(vector["sm"]) + expected_sig = sm[: variant.sig_size] + mu = self._compute_mu(pk, msg, ctx) + + pub = variant.public_key_class.from_public_bytes(pk) + pub.verify_mu(expected_sig, mu) + + # A signature that is valid for this mu must be rejected for a + # different mu. + wrong_mu = bytes([mu[0] ^ 0x01]) + mu[1:] + with pytest.raises(InvalidSignature): + pub.verify_mu(expected_sig, wrong_mu) + @pytest.mark.parametrize("variant", ML_DSA_VARIANTS) def test_private_bytes_raw_round_trip(self, variant, backend): key = variant.private_key_class.generate() From e2df6786ae323bab51eea65d2082cee3eb5d549a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 15:05:55 +0000 Subject: [PATCH 3/8] Add ML-DSA external-mu Wycheproof tests The Wycheproof ML-DSA sign vectors carry a precomputed mu ('External Mu') for every case with a valid signature, including the mu-only 'Internal' cases NIST provides without an accompanying message or context. The signing tests skip those, so add external-mu tests that exercise verify_mu against the vector mu/signature pairs, confirm a perturbed mu fails, and (when msg/ctx are present) check the derived mu matches the vector. https://claude.ai/code/session_01MmjphxZ6ookRpjUhQouKjf --- tests/wycheproof/test_mldsa.py | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/wycheproof/test_mldsa.py b/tests/wycheproof/test_mldsa.py index 5f7f75c3d88e..87de2b875037 100644 --- a/tests/wycheproof/test_mldsa.py +++ b/tests/wycheproof/test_mldsa.py @@ -3,6 +3,7 @@ # for complete details. import binascii +import hashlib import pytest @@ -161,6 +162,71 @@ def test_mldsa65_sign_seed(backend, wycheproof): key.sign(msg, ctx) +def _compute_mu(pub_raw: bytes, msg: bytes, ctx: bytes) -> bytes: + # FIPS 204: mu = SHAKE256(SHAKE256(pk, 64) || M', 64) where for pure + # ML-DSA M' = 0x00 || len(ctx) || ctx || M. + tr = hashlib.shake_256(pub_raw).digest(64) + m_prime = b"\x00" + bytes([len(ctx)]) + ctx + msg + return hashlib.shake_256(tr + m_prime).digest(64) + + +def _external_mu_test(public_key_class, wycheproof): + # The sign vectors carry a precomputed mu ("External Mu") for every case + # that has a valid signature, including the "Internal" cases that NIST + # provides as bare mu values with no message or context. Those are + # skipped by the signing tests above (we don't expose Sign_internal) but + # exercise the precomputed-mu verification interface here. + if "mu" not in wycheproof.testcase or not wycheproof.valid: + return + + pub_raw = binascii.unhexlify(wycheproof.testgroup["publicKey"]) + pub = public_key_class.from_public_bytes(pub_raw) + mu = binascii.unhexlify(wycheproof.testcase["mu"]) + sig = binascii.unhexlify(wycheproof.testcase["sig"]) + + # The signature verifies through the precomputed-mu interface. + pub.verify_mu(sig, mu) + # And must not verify against a different mu. + with pytest.raises(InvalidSignature): + pub.verify_mu(bytes(sig), bytes([mu[0] ^ 0x01]) + mu[1:]) + + # When the message (and optional context) are also provided, the mu we + # derive must match the one in the vector, and the signature is an + # ordinary ML-DSA signature over that message. + if "msg" in wycheproof.testcase: + msg = binascii.unhexlify(wycheproof.testcase["msg"]) + ctx = binascii.unhexlify(wycheproof.testcase.get("ctx", "")) + assert _compute_mu(pub_raw, msg, ctx) == mu + pub.verify(sig, msg, ctx) + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA support", +) +@wycheproof_tests("mldsa_44_sign_seed_test.json") +def test_mldsa44_external_mu(backend, wycheproof): + _external_mu_test(MLDSA44PublicKey, wycheproof) + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA support", +) +@wycheproof_tests("mldsa_65_sign_seed_test.json") +def test_mldsa65_external_mu(backend, wycheproof): + _external_mu_test(MLDSA65PublicKey, wycheproof) + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa_supported(), + skip_message="Requires a backend with ML-DSA support", +) +@wycheproof_tests("mldsa_87_sign_seed_test.json") +def test_mldsa87_external_mu(backend, wycheproof): + _external_mu_test(MLDSA87PublicKey, wycheproof) + + @pytest.mark.supported( only_if=lambda backend: backend.mldsa_supported(), skip_message="Requires a backend with ML-DSA support", From b9be4a3c19ed662604f60464e76c8166a274e77c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 17:31:46 +0000 Subject: [PATCH 4/8] Track rust-openssl master for BoringSSL ML-DSA bindgen Point openssl and openssl-sys at the rust-openssl master branch via [patch.crates-io] so the BoringSSL build picks up the merged mldsa.h addition to the bindgen wrapper (sfackler/rust-openssl#2650), which the external-mu BoringSSL path needs for the MLDSA* low-level functions and the CBS public-key parser. Temporary until a release including it ships. https://claude.ai/code/session_01MmjphxZ6ookRpjUhQouKjf --- Cargo.lock | 9 +++------ Cargo.toml | 6 ++++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0470b391532..fa6965b28ad5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,8 +186,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openssl" version = "0.10.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +source = "git+https://github.com/sfackler/rust-openssl?branch=master#d5713d675e46976240ab7de2b3d2f29617a7825e" dependencies = [ "bitflags", "cfg-if", @@ -200,8 +199,7 @@ dependencies = [ [[package]] name = "openssl-macros" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +source = "git+https://github.com/sfackler/rust-openssl?branch=master#d5713d675e46976240ab7de2b3d2f29617a7825e" dependencies = [ "proc-macro2", "quote", @@ -211,8 +209,7 @@ dependencies = [ [[package]] name = "openssl-sys" version = "0.9.116" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +source = "git+https://github.com/sfackler/rust-openssl?branch=master#d5713d675e46976240ab7de2b3d2f29617a7825e" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index 157bb8cdb501..0cd84ebb1084 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,3 +36,9 @@ self_cell = "1" [profile.release] overflow-checks = true + +[patch.crates-io] +# Temporarily track rust-openssl master for the BoringSSL ML-DSA bindgen +# additions (mldsa.h) until a release including them is published. +openssl = { git = "https://github.com/sfackler/rust-openssl", branch = "master" } +openssl-sys = { git = "https://github.com/sfackler/rust-openssl", branch = "master" } From 2db008c3e6ae08452cd4a7b9d6169e26c8a16abb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 21:13:35 +0000 Subject: [PATCH 5/8] Build the ML-DSA mu OSSL_PARAM on the stack Replace the allocating OSSL_PARAM_BLD in set_mu with a fixed two-element OSSL_PARAM array constructed via OSSL_PARAM_construct_uint. The provider reads the 'mu' flag back with OSSL_PARAM_get_int, which accepts an unsigned integer param. This removes the builder's allocation-failure branch, which was unreachable in practice and left uncovered. https://claude.ai/code/session_01MmjphxZ6ookRpjUhQouKjf --- src/rust/cryptography-openssl/src/mldsa.rs | 36 ++++++++++------------ 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/rust/cryptography-openssl/src/mldsa.rs b/src/rust/cryptography-openssl/src/mldsa.rs index 731f1e4431b6..7204ed4fee36 100644 --- a/src/rust/cryptography-openssl/src/mldsa.rs +++ b/src/rust/cryptography-openssl/src/mldsa.rs @@ -25,11 +25,7 @@ use std::os::raw::c_int; CRYPTOGRAPHY_OPENSSL_350_OR_GREATER ))] use crate::cvt; -#[cfg(any( - CRYPTOGRAPHY_IS_BORINGSSL, - CRYPTOGRAPHY_IS_AWSLC, - CRYPTOGRAPHY_OPENSSL_350_OR_GREATER -))] +#[cfg(any(CRYPTOGRAPHY_IS_BORINGSSL, CRYPTOGRAPHY_IS_AWSLC))] use crate::cvt_p; use crate::OpenSSLResult; @@ -288,21 +284,23 @@ pub fn verify( /// setting the integer `mu` signature parameter (OSSL_SIGNATURE_PARAM_MU) to 1. #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] fn set_mu(pkey_ctx: &mut openssl::pkey_ctx::PkeyCtxRef) -> OpenSSLResult<()> { - // SAFETY: We build a one-element OSSL_PARAM array holding the integer "mu" - // parameter set to 1 and apply it to the EVP_PKEY_CTX. Every pointer is - // valid for the duration of its use and freed before returning. + // A fixed OSSL_PARAM array holding the integer "mu" parameter set to 1 + // enables external mu mode. The provider reads it back with + // OSSL_PARAM_get_int, which accepts an unsigned integer param, so we can + // build the array on the stack rather than via an allocating + // OSSL_PARAM_BLD. + let mut mu: std::os::raw::c_uint = 1; + // SAFETY: `params` and its backing `mu` value outlive the call into + // OpenSSL, and the array is terminated with OSSL_PARAM_construct_end(). unsafe { - let bld = cvt_p(ffi::OSSL_PARAM_BLD_new())?; - if ffi::OSSL_PARAM_BLD_push_int(bld, c"mu".as_ptr(), 1) != 1 { - ffi::OSSL_PARAM_BLD_free(bld); - return Err(openssl::error::ErrorStack::get()); - } - let params = ffi::OSSL_PARAM_BLD_to_param(bld); - ffi::OSSL_PARAM_BLD_free(bld); - let params = cvt_p(params)?; - let res = ffi::EVP_PKEY_CTX_set_params(pkey_ctx.as_ptr(), params); - ffi::OSSL_PARAM_free(params); - cvt(res)?; + let params = [ + ffi::OSSL_PARAM_construct_uint(c"mu".as_ptr(), &mut mu), + ffi::OSSL_PARAM_construct_end(), + ]; + cvt(ffi::EVP_PKEY_CTX_set_params( + pkey_ctx.as_ptr(), + params.as_ptr(), + ))?; } Ok(()) } From e368bc9d86b23a1792aa655d9de305a92e4e8bb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 23:25:11 +0000 Subject: [PATCH 6/8] Address ML-DSA external-mu review feedback - docs: describe how mu is computed and note there is no API to compute it yet, replacing the lower-value note about context. - backend: drop the redundant pyo3 signature attribute on verify_mu (no optional arguments, matching sign_mu). - openssl: note that the OpenSSL external-mu path must use digest-sign because ML-DSA does not implement EVP_PKEY_sign_init. - tests: clarify why the wycheproof external-mu helper filters on both the presence of mu and a valid result. https://claude.ai/code/session_01MmjphxZ6ookRpjUhQouKjf --- docs/hazmat/primitives/asymmetric/mldsa.rst | 42 +++++++++++++++------ src/rust/cryptography-openssl/src/mldsa.rs | 5 ++- src/rust/src/backend/mldsa.rs | 3 -- tests/wycheproof/test_mldsa.py | 12 +++--- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/docs/hazmat/primitives/asymmetric/mldsa.rst b/docs/hazmat/primitives/asymmetric/mldsa.rst index 7c63a9ab789c..8f0a508150d1 100644 --- a/docs/hazmat/primitives/asymmetric/mldsa.rst +++ b/docs/hazmat/primitives/asymmetric/mldsa.rst @@ -100,8 +100,12 @@ Key interfaces .. versionadded:: 49.0.0 Sign a precomputed ``mu`` (message representative) using ML-DSA-44, - the "external mu" variant from FIPS 204. ``mu`` already incorporates - the public key and any context string, so no context is accepted here. + the "external mu" variant from FIPS 204. ``mu`` is computed as + ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes + the context and message; because it already binds the public key and + context, no context is accepted here. ``cryptography`` does not + currently provide an API to compute ``mu``, so you must compute it + yourself. :param mu: The 64-byte message representative. :type mu: :term:`bytes-like` @@ -247,8 +251,10 @@ Key interfaces .. versionadded:: 49.0.0 Verify a signature over a precomputed ``mu`` (message representative), - the "external mu" variant from FIPS 204. ``mu`` already incorporates - the public key and any context string. + the "external mu" variant from FIPS 204. ``mu`` is computed as + ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes + the context and message. ``cryptography`` does not currently provide an + API to compute ``mu``, so you must compute it yourself. :param signature: The signature to verify. :type signature: :term:`bytes-like` @@ -320,8 +326,12 @@ Key interfaces .. versionadded:: 49.0.0 Sign a precomputed ``mu`` (message representative) using ML-DSA-65, - the "external mu" variant from FIPS 204. ``mu`` already incorporates - the public key and any context string, so no context is accepted here. + the "external mu" variant from FIPS 204. ``mu`` is computed as + ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes + the context and message; because it already binds the public key and + context, no context is accepted here. ``cryptography`` does not + currently provide an API to compute ``mu``, so you must compute it + yourself. :param mu: The 64-byte message representative. :type mu: :term:`bytes-like` @@ -467,8 +477,10 @@ Key interfaces .. versionadded:: 49.0.0 Verify a signature over a precomputed ``mu`` (message representative), - the "external mu" variant from FIPS 204. ``mu`` already incorporates - the public key and any context string. + the "external mu" variant from FIPS 204. ``mu`` is computed as + ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes + the context and message. ``cryptography`` does not currently provide an + API to compute ``mu``, so you must compute it yourself. :param signature: The signature to verify. :type signature: :term:`bytes-like` @@ -540,8 +552,12 @@ Key interfaces .. versionadded:: 49.0.0 Sign a precomputed ``mu`` (message representative) using ML-DSA-87, - the "external mu" variant from FIPS 204. ``mu`` already incorporates - the public key and any context string, so no context is accepted here. + the "external mu" variant from FIPS 204. ``mu`` is computed as + ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes + the context and message; because it already binds the public key and + context, no context is accepted here. ``cryptography`` does not + currently provide an API to compute ``mu``, so you must compute it + yourself. :param mu: The 64-byte message representative. :type mu: :term:`bytes-like` @@ -687,8 +703,10 @@ Key interfaces .. versionadded:: 49.0.0 Verify a signature over a precomputed ``mu`` (message representative), - the "external mu" variant from FIPS 204. ``mu`` already incorporates - the public key and any context string. + the "external mu" variant from FIPS 204. ``mu`` is computed as + ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes + the context and message. ``cryptography`` does not currently provide an + API to compute ``mu``, so you must compute it yourself. :param signature: The signature to verify. :type signature: :term:`bytes-like` diff --git a/src/rust/cryptography-openssl/src/mldsa.rs b/src/rust/cryptography-openssl/src/mldsa.rs index 7204ed4fee36..32c2ca6a0a57 100644 --- a/src/rust/cryptography-openssl/src/mldsa.rs +++ b/src/rust/cryptography-openssl/src/mldsa.rs @@ -384,7 +384,10 @@ pub fn sign_mu( Ok(sig) } else if #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] { // OpenSSL signs an external mu by setting the "mu" parameter and - // passing the 64-byte mu in place of the message. + // passing the 64-byte mu in place of the message. ML-DSA only + // supports the digest-sign flow (EVP_PKEY_sign_init is not + // implemented for it), so we cannot use the one-shot PkeyCtx path + // the AWS-LC branch uses. let _ = variant; let mut md_ctx = openssl::md_ctx::MdCtx::new()?; let pkey_ctx = md_ctx.digest_sign_init(None, pkey)?; diff --git a/src/rust/src/backend/mldsa.rs b/src/rust/src/backend/mldsa.rs index b17e7d446568..6a3255ea1b68 100644 --- a/src/rust/src/backend/mldsa.rs +++ b/src/rust/src/backend/mldsa.rs @@ -167,7 +167,6 @@ impl MlDsa44PrivateKey { #[pyo3::pymethods] impl MlDsa44PublicKey { - #[pyo3(signature = (signature, mu))] fn verify_mu(&self, signature: CffiBuf<'_>, mu: CffiBuf<'_>) -> CryptographyResult<()> { if mu.as_bytes().len() != cryptography_openssl::mldsa::MLDSA_MU_BYTES { return Err(CryptographyError::from( @@ -412,7 +411,6 @@ impl MlDsa65PrivateKey { #[pyo3::pymethods] impl MlDsa65PublicKey { - #[pyo3(signature = (signature, mu))] fn verify_mu(&self, signature: CffiBuf<'_>, mu: CffiBuf<'_>) -> CryptographyResult<()> { if mu.as_bytes().len() != cryptography_openssl::mldsa::MLDSA_MU_BYTES { return Err(CryptographyError::from( @@ -654,7 +652,6 @@ impl MlDsa87PrivateKey { #[pyo3::pymethods] impl MlDsa87PublicKey { - #[pyo3(signature = (signature, mu))] fn verify_mu(&self, signature: CffiBuf<'_>, mu: CffiBuf<'_>) -> CryptographyResult<()> { if mu.as_bytes().len() != cryptography_openssl::mldsa::MLDSA_MU_BYTES { return Err(CryptographyError::from( diff --git a/tests/wycheproof/test_mldsa.py b/tests/wycheproof/test_mldsa.py index 87de2b875037..76b3e5f4d106 100644 --- a/tests/wycheproof/test_mldsa.py +++ b/tests/wycheproof/test_mldsa.py @@ -171,11 +171,13 @@ def _compute_mu(pub_raw: bytes, msg: bytes, ctx: bytes) -> bytes: def _external_mu_test(public_key_class, wycheproof): - # The sign vectors carry a precomputed mu ("External Mu") for every case - # that has a valid signature, including the "Internal" cases that NIST - # provides as bare mu values with no message or context. Those are - # skipped by the signing tests above (we don't expose Sign_internal) but - # exercise the precomputed-mu verification interface here. + # Only some sign vectors carry a precomputed mu ("External Mu"), so we + # filter out the ones that don't. The ones that do include the "Internal" + # cases that NIST provides as bare mu values with no message or context; + # those are skipped by the signing tests above (we don't expose + # Sign_internal) but exercise the precomputed-mu verification interface + # here. We only test cases with a valid signature -- rejection of bad + # signatures is covered by the verify tests. if "mu" not in wycheproof.testcase or not wycheproof.valid: return From 401f6cd66f587e0cf980b23a40b8f05dc6f4c102 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 00:40:09 +0000 Subject: [PATCH 7/8] Infer ML-DSA variant from the key in sign_mu/verify_mu sign_mu and verify_mu took the ML-DSA variant as a caller-supplied argument, which is unsound: nothing tied it to the key. Infer it from the pkey via MlDsaVariant::from_pkey instead, matching how sign/verify work, and drop the argument from both functions and their callers. Only the BoringSSL low-level path actually needs the variant. Also trim the sign_mu/verify_mu docs back to a concise note that there is no API to compute mu. https://claude.ai/code/session_01MmjphxZ6ookRpjUhQouKjf --- docs/hazmat/primitives/asymmetric/mldsa.rst | 42 ++++++--------------- src/rust/cryptography-openssl/src/mldsa.rs | 10 +---- src/rust/src/backend/mldsa.rs | 39 ++++++------------- 3 files changed, 26 insertions(+), 65 deletions(-) diff --git a/docs/hazmat/primitives/asymmetric/mldsa.rst b/docs/hazmat/primitives/asymmetric/mldsa.rst index 8f0a508150d1..d4f4574803de 100644 --- a/docs/hazmat/primitives/asymmetric/mldsa.rst +++ b/docs/hazmat/primitives/asymmetric/mldsa.rst @@ -100,12 +100,8 @@ Key interfaces .. versionadded:: 49.0.0 Sign a precomputed ``mu`` (message representative) using ML-DSA-44, - the "external mu" variant from FIPS 204. ``mu`` is computed as - ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes - the context and message; because it already binds the public key and - context, no context is accepted here. ``cryptography`` does not - currently provide an API to compute ``mu``, so you must compute it - yourself. + the "external mu" variant from FIPS 204. There is currently no API to + compute ``mu``, so you must compute it yourself. :param mu: The 64-byte message representative. :type mu: :term:`bytes-like` @@ -251,10 +247,8 @@ Key interfaces .. versionadded:: 49.0.0 Verify a signature over a precomputed ``mu`` (message representative), - the "external mu" variant from FIPS 204. ``mu`` is computed as - ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes - the context and message. ``cryptography`` does not currently provide an - API to compute ``mu``, so you must compute it yourself. + the "external mu" variant from FIPS 204. There is currently no API to + compute ``mu``, so you must compute it yourself. :param signature: The signature to verify. :type signature: :term:`bytes-like` @@ -326,12 +320,8 @@ Key interfaces .. versionadded:: 49.0.0 Sign a precomputed ``mu`` (message representative) using ML-DSA-65, - the "external mu" variant from FIPS 204. ``mu`` is computed as - ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes - the context and message; because it already binds the public key and - context, no context is accepted here. ``cryptography`` does not - currently provide an API to compute ``mu``, so you must compute it - yourself. + the "external mu" variant from FIPS 204. There is currently no API to + compute ``mu``, so you must compute it yourself. :param mu: The 64-byte message representative. :type mu: :term:`bytes-like` @@ -477,10 +467,8 @@ Key interfaces .. versionadded:: 49.0.0 Verify a signature over a precomputed ``mu`` (message representative), - the "external mu" variant from FIPS 204. ``mu`` is computed as - ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes - the context and message. ``cryptography`` does not currently provide an - API to compute ``mu``, so you must compute it yourself. + the "external mu" variant from FIPS 204. There is currently no API to + compute ``mu``, so you must compute it yourself. :param signature: The signature to verify. :type signature: :term:`bytes-like` @@ -552,12 +540,8 @@ Key interfaces .. versionadded:: 49.0.0 Sign a precomputed ``mu`` (message representative) using ML-DSA-87, - the "external mu" variant from FIPS 204. ``mu`` is computed as - ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes - the context and message; because it already binds the public key and - context, no context is accepted here. ``cryptography`` does not - currently provide an API to compute ``mu``, so you must compute it - yourself. + the "external mu" variant from FIPS 204. There is currently no API to + compute ``mu``, so you must compute it yourself. :param mu: The 64-byte message representative. :type mu: :term:`bytes-like` @@ -703,10 +687,8 @@ Key interfaces .. versionadded:: 49.0.0 Verify a signature over a precomputed ``mu`` (message representative), - the "external mu" variant from FIPS 204. ``mu`` is computed as - ``SHAKE256(SHAKE256(public_key, 64) || M', 64)``, where ``M'`` encodes - the context and message. ``cryptography`` does not currently provide an - API to compute ``mu``, so you must compute it yourself. + the "external mu" variant from FIPS 204. There is currently no API to + compute ``mu``, so you must compute it yourself. :param signature: The signature to verify. :type signature: :term:`bytes-like` diff --git a/src/rust/cryptography-openssl/src/mldsa.rs b/src/rust/cryptography-openssl/src/mldsa.rs index 32c2ca6a0a57..f01630b84643 100644 --- a/src/rust/cryptography-openssl/src/mldsa.rs +++ b/src/rust/cryptography-openssl/src/mldsa.rs @@ -310,7 +310,6 @@ fn set_mu(pkey_ctx: &mut openssl::pkey_ctx::PkeyCtxRef) -> OpenSSLResult<( /// context is accepted here. pub fn sign_mu( pkey: &openssl::pkey::PKeyRef, - variant: MlDsaVariant, mu: &[u8], ) -> OpenSSLResult> { cfg_if::cfg_if! { @@ -322,7 +321,7 @@ pub fn sign_mu( // SAFETY: `seed` is a valid 32-byte seed and `mu` is a valid // MLDSA_MU_BYTES buffer; both outlive the calls below. unsafe { - match variant { + match MlDsaVariant::from_pkey(pkey) { MlDsaVariant::MlDsa44 => { let mut key = std::mem::MaybeUninit::::uninit(); cvt(ffi::MLDSA44_private_key_from_seed( @@ -376,7 +375,6 @@ pub fn sign_mu( } else if #[cfg(CRYPTOGRAPHY_IS_AWSLC)] { // AWS-LC's EVP_PKEY_sign treats its input as an external mu (the // "ExternalMu" format) for ML-DSA keys. - let _ = variant; let mut ctx = openssl::pkey_ctx::PkeyCtx::new(pkey)?; ctx.sign_init()?; let mut sig = vec![]; @@ -388,7 +386,6 @@ pub fn sign_mu( // supports the digest-sign flow (EVP_PKEY_sign_init is not // implemented for it), so we cannot use the one-shot PkeyCtx path // the AWS-LC branch uses. - let _ = variant; let mut md_ctx = openssl::md_ctx::MdCtx::new()?; let pkey_ctx = md_ctx.digest_sign_init(None, pkey)?; set_mu(pkey_ctx)?; @@ -403,7 +400,6 @@ pub fn sign_mu( /// `mu` must be [`MLDSA_MU_BYTES`] long. pub fn verify_mu( pkey: &openssl::pkey::PKeyRef, - variant: MlDsaVariant, signature: &[u8], mu: &[u8], ) -> OpenSSLResult { @@ -413,7 +409,7 @@ pub fn verify_mu( // SAFETY: We parse the low-level public key from its encoded form // and verify the signature over the MLDSA_MU_BYTES `mu`. unsafe { - match variant { + match MlDsaVariant::from_pkey(pkey) { MlDsaVariant::MlDsa44 => { let mut key = std::mem::MaybeUninit::::uninit(); let mut cbs = ffi::CBS { data: raw.as_ptr(), len: raw.len() }; @@ -459,12 +455,10 @@ pub fn verify_mu( } } } else if #[cfg(CRYPTOGRAPHY_IS_AWSLC)] { - let _ = variant; let mut ctx = openssl::pkey_ctx::PkeyCtx::new(pkey)?; ctx.verify_init()?; Ok(ctx.verify(mu, signature).unwrap_or(false)) } else if #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] { - let _ = variant; let mut md_ctx = openssl::md_ctx::MdCtx::new()?; let pkey_ctx = md_ctx.digest_verify_init(None, pkey)?; set_mu(pkey_ctx)?; diff --git a/src/rust/src/backend/mldsa.rs b/src/rust/src/backend/mldsa.rs index 6a3255ea1b68..94ee18161ef5 100644 --- a/src/rust/src/backend/mldsa.rs +++ b/src/rust/src/backend/mldsa.rs @@ -105,8 +105,7 @@ impl MlDsa44PrivateKey { pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), )); } - let sig = - cryptography_openssl::mldsa::sign_mu(&self.pkey, MlDsaVariant::MlDsa44, mu.as_bytes())?; + let sig = cryptography_openssl::mldsa::sign_mu(&self.pkey, mu.as_bytes())?; Ok(pyo3::types::PyBytes::new(py, &sig)) } @@ -173,13 +172,9 @@ impl MlDsa44PublicKey { pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), )); } - let valid = cryptography_openssl::mldsa::verify_mu( - &self.pkey, - MlDsaVariant::MlDsa44, - signature.as_bytes(), - mu.as_bytes(), - ) - .unwrap_or(false); + let valid = + cryptography_openssl::mldsa::verify_mu(&self.pkey, signature.as_bytes(), mu.as_bytes()) + .unwrap_or(false); if !valid { return Err(CryptographyError::from( @@ -346,8 +341,7 @@ impl MlDsa65PrivateKey { pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), )); } - let sig = - cryptography_openssl::mldsa::sign_mu(&self.pkey, MlDsaVariant::MlDsa65, mu.as_bytes())?; + let sig = cryptography_openssl::mldsa::sign_mu(&self.pkey, mu.as_bytes())?; Ok(pyo3::types::PyBytes::new(py, &sig)) } @@ -417,13 +411,9 @@ impl MlDsa65PublicKey { pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), )); } - let valid = cryptography_openssl::mldsa::verify_mu( - &self.pkey, - MlDsaVariant::MlDsa65, - signature.as_bytes(), - mu.as_bytes(), - ) - .unwrap_or(false); + let valid = + cryptography_openssl::mldsa::verify_mu(&self.pkey, signature.as_bytes(), mu.as_bytes()) + .unwrap_or(false); if !valid { return Err(CryptographyError::from( @@ -590,8 +580,7 @@ impl MlDsa87PrivateKey { pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), )); } - let sig = - cryptography_openssl::mldsa::sign_mu(&self.pkey, MlDsaVariant::MlDsa87, mu.as_bytes())?; + let sig = cryptography_openssl::mldsa::sign_mu(&self.pkey, mu.as_bytes())?; Ok(pyo3::types::PyBytes::new(py, &sig)) } @@ -658,13 +647,9 @@ impl MlDsa87PublicKey { pyo3::exceptions::PyValueError::new_err("mu must be 64 bytes"), )); } - let valid = cryptography_openssl::mldsa::verify_mu( - &self.pkey, - MlDsaVariant::MlDsa87, - signature.as_bytes(), - mu.as_bytes(), - ) - .unwrap_or(false); + let valid = + cryptography_openssl::mldsa::verify_mu(&self.pkey, signature.as_bytes(), mu.as_bytes()) + .unwrap_or(false); if !valid { return Err(CryptographyError::from( From 9519d01222e6bdf6123ed3570bc22322d6dcd3cc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 01:10:11 +0000 Subject: [PATCH 8/8] Propagate ML-DSA public key parse errors in verify_mu The BoringSSL verify_mu path re-parses the raw public key, which was already validated when the key was constructed, so the parse cannot fail in practice. Returning Ok(false) on that impossible failure left three unreachable lines uncovered. Propagate the error with ? instead (as sign_mu already does), which keeps coverage at 100%. https://claude.ai/code/session_01MmjphxZ6ookRpjUhQouKjf --- src/rust/cryptography-openssl/src/mldsa.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/rust/cryptography-openssl/src/mldsa.rs b/src/rust/cryptography-openssl/src/mldsa.rs index f01630b84643..b0afada61497 100644 --- a/src/rust/cryptography-openssl/src/mldsa.rs +++ b/src/rust/cryptography-openssl/src/mldsa.rs @@ -413,9 +413,7 @@ pub fn verify_mu( MlDsaVariant::MlDsa44 => { let mut key = std::mem::MaybeUninit::::uninit(); let mut cbs = ffi::CBS { data: raw.as_ptr(), len: raw.len() }; - if cvt(ffi::MLDSA44_parse_public_key(key.as_mut_ptr(), &mut cbs)).is_err() { - return Ok(false); - } + cvt(ffi::MLDSA44_parse_public_key(key.as_mut_ptr(), &mut cbs))?; let key = key.assume_init(); Ok(ffi::MLDSA44_verify_message_representative( &key, @@ -427,9 +425,7 @@ pub fn verify_mu( MlDsaVariant::MlDsa65 => { let mut key = std::mem::MaybeUninit::::uninit(); let mut cbs = ffi::CBS { data: raw.as_ptr(), len: raw.len() }; - if cvt(ffi::MLDSA65_parse_public_key(key.as_mut_ptr(), &mut cbs)).is_err() { - return Ok(false); - } + cvt(ffi::MLDSA65_parse_public_key(key.as_mut_ptr(), &mut cbs))?; let key = key.assume_init(); Ok(ffi::MLDSA65_verify_message_representative( &key, @@ -441,9 +437,7 @@ pub fn verify_mu( MlDsaVariant::MlDsa87 => { let mut key = std::mem::MaybeUninit::::uninit(); let mut cbs = ffi::CBS { data: raw.as_ptr(), len: raw.len() }; - if cvt(ffi::MLDSA87_parse_public_key(key.as_mut_ptr(), &mut cbs)).is_err() { - return Ok(false); - } + cvt(ffi::MLDSA87_parse_public_key(key.as_mut_ptr(), &mut cbs))?; let key = key.assume_init(); Ok(ffi::MLDSA87_verify_message_representative( &key,