- TAP: 21
- Title: ML-DSA signing scheme for TUF metadata
- Version: 1
- Last-Modified: 2026-09-30
- Author: Fredrik Skogman
- Type: Standardization
- Status: Accepted
- Content-Type: text/markdown
- Created: 2026-04-29
This TAP proposes an application-level pre-hashing scheme to use with ML-DSA that minimizes the data sent to the signing device. Instead of passing the full canonicalized metadata, the application computes a cryptographic hash over the metadata and computes a pre-signing byte string (including domain separator and protocol version). The pre-signing byte string is what is sent to the signing device. This approach keeps the HSM interface simple and bounded while preserving the security properties required by FIPS 204.
TUF metadata can be large, particularly for targets metadata in repositories with many artifacts. ML-DSA pure mode signing requires the entire message to be available to the signing device. When private keys are held in hardware security modules (HSMs), the HSM must receive the full message to produce a pure mode signature. Transmitting large metadata payloads to an HSM introduces practical limitations on message size, and potential interface constraints that make pure mode ML-DSA unsuitable for some TUF deployments.
Conventions used:
0x__: a raw byte value, specified as a hexadecimal number||: byte concatenation
The raw TUF metadata is NEVER signed, instead a pre-signing byte string is created with the following format, offering domain separation and versioning at the protocol level:
0x74 || 0x75 || 0x66 || <version> || H(MSG)
The domain separators are the ASCII codes for tuf.
Version must be a single byte specifying the version. 0x01 is
currently the only defined version.
The hash function and the canonicalization scheme for the message are specified by the version.
Pure ML-DSA MUST be used with an empty context.
keytype:ml-dsascheme: (ml-dsa-<parameter set>/<version>where version is encoded as a decimal number without leading zeros)ml-dsa-44/<version>ml-dsa-65/<version>ml-dsa-87/<version>
keyval.public: PEM encoding of DER-encodedSubjectPublicKeyInfostructures as defined for ML-DSA in RFC 9881signature.sig: Hex-encoded signature byte string as per FIPS 204 §7.2
Note
As of this publication only version 1 (0x01) is specified. Any
other version must be rejected during signing or verification.
Why not use the scheme to specify the hash algorithm and instead use
the version? The version specifies the entire set of choices and
cryptographically binds those choices to the signature which would
reduce risks of misuse. This allows for easier updates of the
versioning scheme as it's an all-or-nothing approach. By providing
some details via the scheme and others via some versioning opens up
for possible confusion.
Why not use HashML-DSA? With Ed25519 the ecosystem support has been much better for the pure version, and it's likely it will be the same for ML-DSA. Based on this an application specific protocol is better suited for wider adoption. Pre-hash algorithms are really not needed either, and they can add more complexity, see HashML-DSA considered harmful.
Certain implementations expose an API where μ is exposed directly to the sign interface like OpenSSL 4.0, however APIs like this are not guaranteed to be available for every ecosystem, nor can we trust that each cryptographic provider separates the μ computation to a different cryptographic module to avoid large payloads to be transmitted to the signing device.
To allow for future updates on hash algorithm selection to mitigate
any collision or preimage attack the selection of hash algorithm is
specified via a protocol version. This provides a layer of indirection
where certain details can change over time without encoding too much
information into the scheme parameter.
- Version byte:
0x01 - Hash algorithm: SHA-512
- Implementations MUST support
ML-DSA-44(scheme: ml-dsa-44/1)ML-DSA-65(scheme: ml-dsa-65/1)ML-DSA-87(scheme: ml-dsa-87/1)
- Metadata canonicalization scheme: encoded as "canonical JSON" as described in the TUF Specification.
- Load the public key from TUF metadata
- Parse the version from the public key's
schemeand prepare the hash functionH - Compute the canonicalized metadata representation
MSG - Create the pre-signing byte string:
0x74 || 0x75 || 0x66 || version || H(MSG) - Sign the pre-signing byte string using an empty context
- Compute canonical metadata representation
- Load up the public key for verification
- Parse
schemeinto parameter set and version- Reject if the protocol version is not supported
- Implementations MUST NOT infer or select an ML-DSA parameter set or version from the signature bytes alone -- underlying crypto implementations should reject mismatched signature/public key combinations
- Verifier must reconstruct the exact signed bytes itself
- It should not accept a caller-supplied digest/prefix blindly
- The
versionMUST be taken from the trusted TUF metadata'sschemeparameter - The version binds hash function to use
- It should compute:
digest = H(MSG) - Then verify over
0x74 || 0x75 || 0x66 || version || digestusing an empty context
- Reject unknown or mismatched versions
- Do not fall back
- Do not try multiple interpretations
- Do not accept the same signature under HashML-DSA or another scheme
- SHA-512 length extension: Not a concern. The signature is made over
domain || version || digest. Length extension is more a concern when the digest is computed to be used as a MAC - SHA-512 vs ML-DSA-87 margin. SHA-512 has zero margin, but is valid. Future versions can increase the margin if deemed necessary
- Signature confusion/replay: With the domain separation a valid signature over the raw digest from another domain would not be valid in the TUF metadata domain (collisions on the domain and version bytes are very unlikely)
This TAP is backwards compatible for repositories that do not use the
ml-dsa keytype. Existing clients that do not support this TAP can
continue to validate metadata signed with currently supported keytypes.
Repositories that use ml-dsa signatures require clients that support
this TAP. Clients that do not support the ml-dsa keytype will not be
able to validate those signatures.
The following implementations and tests support this TAP:
- go-tuf PR #780 implements ML-DSA signing and verification.
- securesystemslib 1.5.0 implements ML-DSA signing and verification.
- tuf-conformance includes ML-DSA keytype and scheme tests.
Note
All sizes are as TUF encodes them (hex for signatures and single line
PEM encoding for public keys).
Key sizes can differ by a few bytes between keys for some key types,
and also if the last \n is present or not in the JSON encoding of
PEM encoded keys.
Metadata sizes can also differ due to whitespace being stripped or not,
so take them as an indicator of the growth.
Looking at an example repository that relies on five shared root/targets keys and a shared key for snapshot and timestamp. Only the public key and signatures are accounted for in the size table.
| Role | Keys | Sigs | ECDSA | ML-DSA-44 | ML-DSA-65 | ML-DSA-87 |
|---|---|---|---|---|---|---|
| root | 6 | 5 | 1812 (1.0x) | 35540 (19.6x) | 49710 (27.4x) | 68172 (37.6x) |
| targets | 0 | 5 | 720 | 24300 (33.6x) | 33090 (46.0x) | 46260 (64.3x) |
| snapshot | 0 | 1 | 144 | 4840 (33.6x) | 6618 (46.0x) | 9254 (64.3x) |
| timestamp | 0 | 1 | 144 | 4840 (33.6x) | 6618 (46.0x) | 9254 (64.3x) |
Or looking at it for a single key:
| Type | Public key bytes | Signature bytes |
|---|---|---|
| ECDSA | 182 (1.0x) | 144 (1.0x) |
| ML-DSA-44 | 1890 (10.4x) | 4840 (33.6x) |
| ML-DSA-65 | 2770 (15.2x) | 6618 (46.0x) |
| ML-DSA-87 | 3652 (20.1x) | 9254 (64.3x) |
The above tables only include the raw key and signature byte sizes, so the real world effect on a repository can differ, in particular for the targets file. Looking at an example repository we can get a better feel:
| File | Naked | ECDSA | ML-DSA-44 | ML-DSA-65 | ML-DSA-87 |
|---|---|---|---|---|---|
| root | 3829 | 5641 (1.0x) | 39369 (7.0x) | 53539 (9.5x) | 72001 (12.8x) |
| targets | 3418 | 4138 (1.0x) | 27718 (6.7x) | 36508 (8.8x) | 49678 (12.0x) |
| snapshot | 1619 | 1763 (1.0x) | 6459 (3.7x) | 8237 (4.7x) | 10873 (6.2x) |
| timestamp | 308 | 452 (1.0x) | 5148 (11.4x) | 6926 (15.3x) | 9562 (21.2x) |
Note that the example repo linked above includes one delegation, which was removed for this example computation to keep it simpler.
From FIPS 204 on application level hashing (§5.4):
In order to maintain the same level of security strength when the content is hashed at the application level or using HashML-DSA, the digest that is signed needs to be generated using an approved hash function or XOF (e.g., from FIPS 180 [8] or FIPS 202 [7]) that provides at least 𝜆 bits of classical security strength against both collision and second preimage attacks [7, Table 4]6.
The verification of a signature that is created in this way will require the verify function to generate a digest from the message in the same way to be used as input for the verification function.
- Obtaining at least 𝜆 bits of classical security strength against collision attacks requires that the digest to be signed be at least 2𝜆 bits in length.
This document has been placed in the public domain.