From 3cdc217459c9a85f7d9da5c2a58cef520d3c65e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 05:03:52 +0000 Subject: [PATCH 1/5] 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 4e87d9b1b2f6..252bfaaaa73b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -34,6 +34,10 @@ Changelog registers an :class:`enum.Enum` subclass as an ASN.1 value set: members are encoded as their underlying value, and decoding fails if the decoded value does not match one of the declared members. +* 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 ae7b6c0d11884bc72695bc8181252aa5386378bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 05:33:58 +0000 Subject: [PATCH 2/5] 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 4c1edf89d65fdc1b78bc6fd53e491d3f49a2d65c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 15:05:55 +0000 Subject: [PATCH 3/5] 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 1757eb2075cf51e377cdac747a0e14e3bae57d85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 17:31:46 +0000 Subject: [PATCH 4/5] 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 1c64a891ba574a3b784894324706a43ff5a13563 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 21:13:35 +0000 Subject: [PATCH 5/5] 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(()) }