ML-DSA in Depth · 4 of 5L3algorithms
Pure, Pre-Hash or External Mu: Splitting an ML-DSA Signature Across a Hardware Boundary
FIPS 204 offers three ways to sign a large message with a key in hardware. They differ in what the hardware can still see, and the IETF has already chosen one.
Abstract
When the message is large or the private key lives in a hardware security module, ML-DSA can be used in three ways: pure signing with the whole message inside the module, the pre-hash variant HashML-DSA, or pure signing with the 64-byte message representative, called mu, computed outside the module. This piece reconstructs exactly what each path signs, using the formatted-message rules of FIPS 204, and traces why the IETF profiles for X.509, CMS and JOSE/COSE all exclude HashML-DSA while RFC 9881 describes external mu as the way to get pre-hashing without a new algorithm. It then follows NIST's 2025 guidance on validating split signing and identifies the consequence most designs miss: because mu is computed from public information alone, any caller allowed to submit mu to the module can obtain a signature on any message, so policy enforcement must move to whoever computes mu. The piece closes with an architecture for agent signing services and model-artifact signing that keeps that control where it belongs.
A model release pipeline has a multi-gigabyte weights file to sign and a private key that must never leave a hardware security module. An agent platform has thousands of small tool calls to sign per second with keys held in a managed key service. Both teams reach for the same idea: hash outside, sign inside. ML-DSA supports that idea in more than one way, and the ways are not interchangeable. They produce different signatures, are accepted by different standards, and move different kinds of control out of the hardware. Picking one by convenience, without seeing what the hardware stops being able to check, is how a careful key-protection design turns into a signing oracle.
Three paths to one signature
FIPS 204 defines two signature algorithms that share key generation. ML-DSA, often called pure ML-DSA, signs the message itself. HashML-DSA signs a digest of the message, computed with a named hash function or extendable-output function, together with domain-separation information. Section 5.4 explains the motivation for the second: hashing a large message inside the signing module may be too slow, for example when the platform has hardware support for some hash functions but not for SHAKE256. It then says, without qualification, that in general the pure version is preferred.
There is a third path that is not a separate algorithm. The pure signing algorithm begins by hashing the public-key hash and the formatted message into a 64-byte message representative, which FIPS 204 calls mu. The note on that line of Algorithm 7 says mu may optionally be computed in a different cryptographic module. A system can therefore compute mu wherever the message is, send only 64 bytes to the module that holds the key, and receive an ordinary pure ML-DSA signature that any standard verifier accepts. RFC 9881 calls this the external mu mode and describes it in an appendix as the way ML-DSA itself supports pre-hashing.
The question this article answers is which path to use and what each one costs. The answer turns on three things: what exactly is signed in each case, which standards accept the result, and what the hardware that holds the key can still verify about the request it is asked to sign.
What actually gets signed
In both algorithms the message is first wrapped into a formatted message. For pure ML-DSA, FIPS 204 Algorithm 2 prepends a single byte with value zero, a byte giving the length of the context string, and the context string itself, which may be up to 255 bytes and is empty by default. For HashML-DSA, Algorithm 4 prepends a byte with value one, the context length and context, the DER encoding of the object identifier of the pre-hash function, and then appends the digest of the message instead of the message. That leading byte is a domain separator: a pure signature can never be reinterpreted as a pre-hash signature or the reverse, because their formatted messages differ in their first byte.
The internal signing algorithm then computes mu by hashing, with SHAKE256, the 64-byte hash of the public key followed by the formatted message, and every later step depends only on mu, the private key and the randomness. Two consequences follow. First, mu already binds the signature to one public key, which is how ML-DSA obtains properties such as exclusive ownership. Second, everything needed to compute mu, namely the public key, the context and the message, is public. The secret enters only after mu exists.
Section 5.4 adds requirements for anyone who hashes the content before signing, whether by HashML-DSA or at the application level. The digest must come from an approved hash function or extendable-output function that offers at least the parameter set's security strength against both collisions and second preimages, which in practice means a digest at least twice that many bits long. The identifier of a HashML-DSA signature should name the pre-hash function. And although one key pair may produce both kinds of signature, NIST recommends that each key be used for only one.
| Field | Pure ML-DSA | HashML-DSA | Purpose |
|---|---|---|---|
| Domain byte | 0 | 1 | Pure and pre-hash signatures can never be confused |
| Context length and context | Up to 255 bytes, default empty | Up to 255 bytes, default empty | Bind a signature to one application purpose |
| Hash identifier | None | DER-encoded OID of the pre-hash | Name the digest function |
| Payload | The message | Digest of the message | What the signature covers |
| Then hashed with | Public-key hash, into mu | Public-key hash, into mu | Bind to one key |
Why the IETF profiles exclude HashML-DSA
RFC 9881, which defines ML-DSA identifiers for X.509 certificates and certificate revocation lists, states that HashML-DSA must not be used, and its Section 8.3 gives the reasoning. ML-DSA and HashML-DSA are incompatible algorithms with different verification routines, so a verifier must be told which one to run. Because the same identifiers name both the public key and the signature algorithm, a key would have to be committed to one mode when its certificate is issued, which the authors expect to cause operational problems for operators who do not know at key-generation time whether they will need pre-hashed signatures. External mu, the RFC observes, avoids all of these concerns.
The RFC also gives a minor security reason. Pure ML-DSA hashes the public-key hash in front of the message before anything else, so even if a collision attack on the SHA-3 family were ever found, an attacker would need a collision specific to one public key, finding two messages that collide after that key-dependent prefix, rather than a generic collision of the message alone. HashML-DSA signs a digest computed without that prefix, so a plain collision in the pre-hash would carry two messages to the same signature. The RFC judges the practical risk in X.509 to be immaterial, because certificates already include a hash of the issuer's key in a signed extension, but the structural difference applies to every other use.
The other IETF profiles follow the same line. RFC 9882, for ML-DSA in the Cryptographic Message Syntax, does not specify HashML-DSA and notes that CMS already hashes content into signed attributes, which are small. RFC 9964, for JOSE and COSE, does not register HashML-DSA algorithms and refers to RFC 9881 for the rationale, adding that supporting it later would need new algorithm registrations because the verification routine differs. The practical reading is that HashML-DSA is now a niche choice for closed systems whose protocol carries the mode explicitly, not a general integration path.
External mu: splitting signing at 64 bytes
External mu takes the pure algorithm and cuts it after the step that computes mu. RFC 9881 Appendix D writes the two halves explicitly. The first computes mu from the public key, the message and the context, exactly as pure signing would. The second takes the private key and a 64-byte mu, rejects any input of another length, draws the 32-byte randomness, and runs the rest of the internal signing algorithm with mu supplied directly. The result is bit-for-bit an ordinary ML-DSA signature, verified by the ordinary algorithm, with no new identifiers.
FIPS 204 constrains where each piece may run. Section 5.4 says that in the default hedged variant, the 32-byte random value shall be generated by the cryptographic module that runs the internal signing algorithm, and that all other steps of signing may be performed outside it. NIST's 2025 answer to a question about the validation programme adds the operational detail. Because computing mu in a different module is explicitly permitted, an implementation may expose a different interface from the one in the standard, for example a sign-mu function that takes a key reference and mu. The signing module must pass the standard's signature-generation tests through that modified interface, and the module that computes mu must at least have a validated SHAKE256 implementation, plus any hash functions it uses for application-level pre-hashing.
The NIST answer sketches three arrangements. In the first, one module builds the formatted message, computes the public-key hash and mu, and calls a second module that holds the key and generates the randomness. In the second, the first module hands mu to an application, which passes mu and a key reference to the signing module. In the third, the first module only provides hash functions, and an application outside both modules assembles mu by calling them. All three leave the randomness and the key inside the signing module, which is the invariant the standard insists on.
What the hardware can no longer see
Here is the consequence that most split-signing designs do not state. Mu is computed from the public key, the context and the message, all of which are public or chosen by the requester. Anyone who can call the module's sign-mu interface can therefore compute mu for any message they like and obtain a valid signature on it. The module cannot tell a model release from a forged release, or an approved tool call from an injected one, because all it receives is 64 bytes that look random. In pure signing inside the module, the hardware sees the message and can parse it, apply a policy, log it meaningfully and refuse. Under external mu, all of that capability moves to whoever computes mu.
This is not a weakness of ML-DSA; it is the same property any pre-hash design has, and it is accepted for good reasons. It does change what the hardware protects. The hardware still prevents key extraction and still guarantees fresh randomness, which matters for the fault and side-channel concerns that motivated hedged signing. What it no longer provides is authorization of content. A design review should therefore treat the sign-mu interface as equivalent to the ability to sign arbitrary messages with that key, and protect access to it accordingly.
Two small properties soften the edges. A mu computed with the wrong public key does not produce a usable signature: the module signs it, but verification under the intended key recomputes mu with that key's hash and fails, so key confusion fails safe rather than silently. And the 64-byte length check in RFC 9881's sign-mu routine stops a caller from smuggling a whole message or a truncated value into the interface. Neither property restores content authorization, which is a design responsibility of the system around the module.
Large artifacts, signed attributes and agents
Model artifacts are the large-message case in its purest form. The Cryptographic Message Syntax shows the pattern that most artifact-signing formats already follow: hash the content at the application level, place the digest in a set of signed attributes along with metadata such as the content type, and sign the attributes, which are small. RFC 9882 specifies pure ML-DSA over those attributes, requires SHA-512 support, recommends SHAKE256, and lists acceptable digest algorithms per parameter set so that the digest matches the signature's strength: the smallest parameter set accepts 256-bit digests and up, the middle one 384-bit and up, and the largest only 512-bit digests or SHAKE256. Seen this way, application-level hashing, HashML-DSA and external mu are three placements of the same idea, and the signed-attribute pattern has the advantage that the module can still see and check meaningful metadata.
Streaming is often cited as a reason for pre-hashing, and it deserves a precise answer. Pure ML-DSA hashes the public-key hash first and then the formatted message, so mu can be computed incrementally over a stream as long as the public key and context are known before the stream starts. Nothing in pure signing requires the whole message in memory; it requires knowing which key will sign before hashing begins. That ordering is a small design constraint, and it is the price of the key-specific collision resistance discussed above.
For agents the right architecture follows from that blind-signing property. An agent should never call a sign-mu interface directly. It should send the full canonical request, a tool call or an artifact manifest with its digest, to a policy service that checks the request against what that agent is allowed to do, applies the purpose-specific context string where the protocol uses one, computes mu, and only then calls the key module. The policy service is the only principal with sign-mu access, the key module enforces that, and both log the request identifier. The agent obtains signatures on exactly the things it is authorized to request, and a compromised agent cannot turn the key module into a general-purpose signer.
| Option | Module sees | Verifier runs | Standards acceptance | Key-specific collision resistance |
|---|---|---|---|---|
| Signed attributes (application digest) | Small attributes incl. digest and metadata | Pure ML-DSA over attributes | CMS (RFC 9882) | Yes, over the attributes |
| HashML-DSA | Digest and hash OID | HashML-DSA verify | Not in X.509, CMS or JOSE/COSE profiles | No, plain digest collisions suffice |
| External mu | 64 opaque bytes | Pure ML-DSA verify | Same signatures as pure everywhere | Yes |
Failure modes specific to split signing
Splitting signing at mu creates a small set of failure modes that do not exist when the module signs whole messages. They are this article's analysis of the design space rather than reported incidents, and each has a simple control. The first is randomness supplied from outside. An integration that lets the caller pass the 32-byte random value into the module, perhaps to make tests reproducible, violates the requirement that it be generated in the signing module. Worse, a caller who can choose that value and submit the same mu twice can force the same mask twice, recreating exactly the repeated-mask condition that hedged signing exists to remove. The control is that the sign-mu interface has no randomness parameter at all.
The second is a mismatched formatted message. If the hashing side builds a pre-hash formatted message, with its one-valued domain byte and hash identifier, but the system labels the result as pure ML-DSA, every verifier will reject the signature, because they rebuild the pure formatted message. The same happens if the context the hashing side used differs from the context the verifier supplies. These failures are safe in the sense that nothing is forged, but they surface as mysterious interoperability bugs. The control is a single shared function, validated against known answers, that computes mu for each message class, rather than hand-assembled byte strings in several services.
The third is replay into the module. Submitting the same mu twice yields two different valid signatures under hedged signing. That is harmless for authenticity, but it means the module cannot be used to enforce that something is signed only once; deduplication belongs in the policy service, keyed on the request's own identifier. The fourth is the confused deputy. A policy service that signs on behalf of many agents must bind which agent asked into what it signs, for example as a field in the canonical payload, or a request injected through one agent's inputs will be signed with authority that looks like the platform's. The policy service is now part of the trust boundary, and it should be reviewed, logged and hardened as such.
A worked design: signing a model release
Consider a release pipeline that must sign a model whose weights occupy many shards and tens of gigabytes, with an ML-DSA-87 key held in a hardware module. Signing each shard with HashML-DSA would produce signatures that the major IETF formats do not accept and would give up key-specific collision resistance. Streaming the whole model through the module would be slow and would give the module nothing it can meaningfully check. The design this article recommends instead builds a release manifest: the model name and version, the digest of every shard computed with SHA-512 or SHAKE256 so that each digest meets the strength the largest parameter set requires, the training-run and evaluation identifiers, and the approval record that authorizes the release.
The manifest is small and structured, and it is what gets signed. If the organization uses CMS, the manifest digest goes into signed attributes and pure ML-DSA signs those, exactly as RFC 9882 specifies. If it uses a custom format, the policy service canonicalizes the manifest, checks that the approval record matches the release policy, computes mu over the manifest with a context string fixed for model releases, and calls the module's sign-mu interface. Either way, the module signs a few hundred bytes of meaningful data or their 64-byte representative, the signature is an ordinary pure ML-DSA signature, and a verifier in a deployment environment recomputes each shard digest, checks it against the manifest, and verifies the manifest signature with standard code.
This design keeps every property the earlier sections identified as important. The key never leaves the module. The randomness is generated inside the module. The signature verifies with standard verification in any format that accepts pure ML-DSA. The content check, whether this release was approved, happens in one identifiable service with its own audit trail. And an agent that participates in the pipeline, for example one that runs evaluations and attaches results, can contribute to the manifest without ever being able to obtain a release signature on its own.
Context strings: where standards pin them and where you choose
The context string is ML-DSA's built-in tool for domain separation: two applications that use different contexts cannot have their signatures confused, even under the same key. FIPS 204 recommends that a non-empty context be indicated either by the signature's identifier or by the application. The IETF profiles make the choice for you: RFC 9881 leaves the context at its default empty string for certificates and revocation lists, RFC 9882 sets it to the empty string in CMS, and RFC 9964 requires it to be empty for the registered JOSE and COSE algorithms. In those formats, purpose separation has to come from the signed payload itself, for example a type field or an audience claim, and from using different keys for different purposes.
In custom protocols, which is where most agent-to-agent and tool-call signing lives today, the context string is available and useful. A platform can assign one short, versioned context per message class, for instance one for tool calls and another for model-release manifests, and fix that mapping in the protocol specification so verifiers know which context to supply. Under external mu the context is part of what the policy service puts into mu, which is one more reason the policy service, not the agent, should compute it. The rule that ties this together is to make purpose separation explicit somewhere the verifier checks, never implicit in the hope that two message formats will not collide.
Key takeaways
- FIPS 204 offers pure ML-DSA, HashML-DSA and, through a note on Algorithm 7, pure signing with an externally computed 64-byte mu; it says the pure version is generally preferred.
- Pure and pre-hash signatures are domain-separated by the first byte of the formatted message; HashML-DSA needs its own verification routine and should use a key dedicated to it.
- RFC 9881 forbids HashML-DSA in X.509 for interoperability and key-commitment reasons and loses nothing by it, because external mu gives pre-hashing with ordinary pure signatures; CMS and JOSE/COSE profiles also omit HashML-DSA.
- The 32-byte randomness must be generated in the module that runs the internal signing algorithm; NIST's 2025 guidance allows a sign-mu interface validated with modified tests.
- Mu is computed from public data, so access to a sign-mu interface is the ability to sign any message; content policy must move to the service that computes mu.
- For agents and model artifacts, route requests through a policy service that alone computes mu, applies purpose-specific contexts where allowed, and holds sign-mu access.
Practitioner Toolkit
Copy-paste, strictly defensive artifacts you can use today. Nothing here attacks a real system.
Decide between pure, signed attributes, HashML-DSA and external mu.
- Will the signature live in X.509, CMS, JOSE or COSE? Then do not use HashML-DSA.
- Can the key module see meaningful metadata (signed attributes) instead of opaque bytes? Prefer that.
- If using external mu, is the sign-mu interface reachable only by a policy service?
- Is the 32-byte randomness generated inside the module that holds the key?
- Is the public key fixed before streaming so mu can be computed incrementally?
- Do digests meet the parameter set's strength (256, 384 or 512 bits for 44, 65, 87)?
- Is each key used for only one of pure and pre-hash signing?
Sanitized structure for an agent signing gateway in front of a key module.
function handle_sign_request(agent_id, request):
canonical = canonicalize(request) # tool call or artifact manifest
if not policy.allows(agent_id, canonical.kind, canonical.target):
return DENIED # content policy lives here
ctx = CONTEXTS[canonical.kind] # e.g. empty for JOSE, fixed per class otherwise
pk = key_registry.public_key(canonical.kind)
mu = compute_mu(pk, canonical.bytes, ctx) # public computation, SHAKE256
sig = key_module.sign_mu(key_ref_for(canonical.kind), mu) # only this service holds sign-mu access
audit.log(agent_id, canonical.request_id, digest(canonical.bytes))
return sig # ordinary pure ML-DSA signatureIllustrative access rules that treat sign-mu as full signing authority.
key_module:
key: release-signing-ml-dsa-87
interfaces:
sign_message: disabled # messages are large; signing happens via mu
sign_mu:
allowed_principals: [policy-service] # never agents, never CI runners directly
input_length_bytes: 64
rnd_source: internal_rbg
key_usage: pure_ml_dsa_only # no HashML-DSA with this key
audit: every_call_with_caller_and_request_idGlossary
- Pure ML-DSA
- ML-DSA signing over the message itself, wrapped with a zero domain byte and an optional context string.
- HashML-DSA
- The FIPS 204 pre-hash variant that signs a digest of the message together with a one-valued domain byte and the hash function's identifier.
- Mu (message representative)
- The 64-byte SHAKE256 hash of the public-key hash and the formatted message that ML-DSA's internal signing algorithm actually signs.
- External mu
- Computing mu outside the module that holds the private key and passing only mu to it, yielding an ordinary pure ML-DSA signature.
- Formatted message
- The domain byte, context length, context string and payload that FIPS 204 hashes into mu.
- Context string
- An application-chosen byte string of up to 255 bytes, empty by default, that separates signatures made for different purposes.
- Signed attributes
- In CMS, a small set of attributes including the content digest that is signed in place of the content itself.
- Cryptographic module
- A hardware or software boundary that holds keys and implements validated cryptographic functions.
References
- NIST FIPS 204, Module-Lattice-Based Digital Signature Standard (2024), Sections 5.2-5.4 and Algorithms 2, 4 and 7
- NIST, FIPS 204 Section 6 FAQ: computing mu externally (2025)
- RFC 9881, Algorithm Identifiers for ML-DSA in the Internet X.509 PKI (2025), Section 8.3 and Appendix D
- RFC 9882, Use of the ML-DSA Signature Algorithm in the Cryptographic Message Syntax (2025)
- RFC 9964, ML-DSA for JSON Object Signing and Encryption and CBOR Object Signing and Encryption (2026)
- RFC 5652, Cryptographic Message Syntax (CMS)