Skip to content

Add external mu (message representative) support for ML-DSA#14979

Draft
reaperhulk wants to merge 8 commits into
mainfrom
claude/compassionate-einstein-0qHpD
Draft

Add external mu (message representative) support for ML-DSA#14979
reaperhulk wants to merge 8 commits into
mainfrom
claude/compassionate-einstein-0qHpD

Conversation

@reaperhulk

Copy link
Copy Markdown
Member

This PR adds support for the "external mu" variant of ML-DSA signing and verification as defined in FIPS 204. This allows applications to precompute the message representative (mu) and sign/verify it directly, which is useful for protocols that need to work with the intermediate mu value.

Key Changes

  • New constant: MLDSA_MU_BYTES (64 bytes) defines the length of an ML-DSA external mu value
  • New signing method: sign_mu(mu) on all three private key classes (MlDsa44, MlDsa65, MlDsa87) to sign a precomputed 64-byte mu
  • New verification method: verify_mu(signature, mu) on all three public key classes to verify a signature over a precomputed mu
  • OpenSSL 3.5.0+ support: Added CRYPTOGRAPHY_OPENSSL_350_OR_GREATER to the conditional compilation flags for the new functions
  • Backend implementations:
    • BoringSSL: Uses low-level ML-DSA API with private key reconstruction from seed
    • AWS-LC: Uses EVP_PKEY_sign/verify with "ExternalMu" format
    • OpenSSL 3.5.0+: Uses EVP with the "mu" signature parameter
  • Input validation: Both methods validate that mu is exactly 64 bytes, raising ValueError if not
  • Comprehensive tests: Added unit tests and Wycheproof test vector validation for the external mu interface

Implementation Details

The external mu interface is implemented with conditional compilation to support three different backends:

  • For BoringSSL, the implementation reconstructs the private key from its 32-byte seed and calls the low-level MLDSA*_sign_message_representative functions
  • For AWS-LC, the EVP interface natively supports external mu through the "ExternalMu" format
  • For OpenSSL 3.5.0+, a helper function set_mu() configures the signature context with the "mu" parameter before signing/verifying

The external mu signatures are fully compatible with ordinary ML-DSA signatures—a signature produced via sign_mu() can be verified with the standard verify() method if the message and context are provided, and vice versa.

https://claude.ai/code/session_01MmjphxZ6ookRpjUhQouKjf

@reaperhulk reaperhulk marked this pull request as draft June 7, 2026 17:35
@alex alex mentioned this pull request Jun 8, 2026
@alex alex linked an issue Jun 8, 2026 that may be closed by this pull request

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this second sentence is low value, and instead there should be something about how you compuete mu (even if just to say we don't have an API for it yet)


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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same

) -> OpenSSLResult<Vec<u8>> {
cfg_if::cfg_if! {
if #[cfg(CRYPTOGRAPHY_IS_BORINGSSL)] {
// BoringSSL has no EVP-level external mu support, so we drop down to

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if humanly possible, I'd like to wait on that, since it makes this 100x shorter

// 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)?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work if you do a pkeyctx with sign_init on openssl? (with or without set_mu)? that'd nicely let us consolidate with the aws-lc path

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried this against OpenSSL 4.0 and it doesn't work: EVP_PKEY_sign_init (and verify_init) fail for ML-DSA with provider signature not supported (crypto/evp/signature.c). The ML-DSA provider only registers the digest-sign / sign-message flow (OSSL_FUNC_SIGNATURE_DIGEST_SIGN*, plus a one-shot SIGN with no SIGN_INIT), so we can't drive it through a PkeyCtx like the AWS-LC path — it has to go through digest_sign_init + the mu param. I left a comment to that effect.


Generated by Claude Code

/// context is accepted here.
pub fn sign_mu(
pkey: &openssl::pkey::PKeyRef<openssl::pkey::Private>,
variant: MlDsaVariant,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should not take variant as an argument, it should be inferred from pkey, as sign does. otherwise this is unsound

/// `mu` must be [`MLDSA_MU_BYTES`] long.
pub fn verify_mu(
pkey: &openssl::pkey::PKeyRef<openssl::pkey::Public>,
variant: MlDsaVariant,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Comment thread src/rust/src/backend/mldsa.rs Outdated

#[pyo3::pymethods]
impl MlDsa87PublicKey {
#[pyo3(signature = (signature, mu))]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does this one have a signature = block?

Comment thread tests/wycheproof/test_mldsa.py Outdated
Comment on lines +174 to +180
# 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment makes no sense to me, if mu exists in all of them, what's the first clause doing?

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now way too verbose.

@alex

alex commented Jun 9, 2026

Copy link
Copy Markdown
Member

Claude you've got merge conflicts, can you rebase?

claude added 7 commits June 9, 2026 00:57
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.
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
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
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 (rust-openssl/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
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
- 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
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
@reaperhulk reaperhulk force-pushed the claude/compassionate-einstein-0qHpD branch from 971d6b8 to 401f6cd Compare June 9, 2026 00:59
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Provide HashML-DSA

3 participants