Skip to content

Composite ML DSA Signatures - #1690

Draft
johnpeck-us-ibm wants to merge 7 commits into
IBM:mainfrom
johnpeck-us-ibm:CompositeMLDSASigs
Draft

Composite ML DSA Signatures#1690
johnpeck-us-ibm wants to merge 7 commits into
IBM:mainfrom
johnpeck-us-ibm:CompositeMLDSASigs

Conversation

@johnpeck-us-ibm

Copy link
Copy Markdown
Member

This adds the Composite ML DSA Signatures which are a combination of ML DSA and traditional Signatures like RSA, EC-DSA, etc.

Signed-off-by: johnpeck-us-ibm <johnpeck@us.ibm.com>
Signed-off-by: johnpeck-us-ibm <johnpeck@us.ibm.com>
Signed-off-by: johnpeck-us-ibm <johnpeck@us.ibm.com>
Signed-off-by: johnpeck-us-ibm <johnpeck@us.ibm.com>
Signed-off-by: johnpeck-us-ibm <johnpeck@us.ibm.com>
@johnpeck-us-ibm johnpeck-us-ibm self-assigned this Jul 31, 2026
@johnpeck-us-ibm
johnpeck-us-ibm marked this pull request as draft July 31, 2026 18:00
Comment on lines +312 to +316
buf.write(DOMAIN_PREFIX);
buf.write(0x00); // separator
buf.write(oidDer);
buf.write(0x00); // len(ctx) = 0 (empty context)
buf.write(msg);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I have been implementing this draft, so I looked closely at the domain separation here. This M' does not match draft-ietf-lamps-pq-composite-sigs (currently -19, section 2.2), which specifies:

M' = Prefix || Label || len(ctx) || ctx || PH(M)

Three points on the construction in this method:

  1. Prefix: the draft fixes it to the ASCII string "CompositeAlgorithmSignatures2025" (32 bytes), but DOMAIN_PREFIX here is "CompositeAlgorithm" (18 bytes).

  2. The buf.write(0x00) marked "separator" is not in the draft: Prefix and Label (the DER OID) are concatenated directly. The next 0x00, standing for len(ctx) of an empty context, is correct.

  3. buf.write(msg) signs the raw message, but the draft signs PH(M), the per-algorithm pre-hash from section 6 (for example SHA-512 or SHAKE-256 depending on the pairing).

The effect is that these signatures are self-consistent, since sign and verify use the same construction, but they would not verify against another conforming implementation or against the draft's test vectors. The current tests are self round trips, so they pass without surfacing it. Adding the draft's Known Answer Test vectors as a test would catch all three and keep the construction pinned to the spec as it evolves.

I have a working construction and the vectors on my side, so I am glad to help wire up a KAT test or sanity-check the M' bytes if useful. (The verify side, for what it is worth, correctly requires both components.)

…this.

Signed-off-by: johnpeck-us-ibm <johnpeck@us.ibm.com>
@Arpan0995

Copy link
Copy Markdown

Thanks for pushing this along, and for wiring in the draft KAT vectors. M' now matches draft-19 §2.2. I traced why the KAT verifies still fail, checking each row against the reference generator (generate_test_vectors.py) and testvectors.json. It comes down to a few concrete points, grouped by how they fail.

Fails on every row

  1. Signature container. The reference s is a bare concatenation mldsaSig || tradSig (draft §4.3; serializeSignatureValue returns s1 + s2), split at the fixed ML-DSA length 2420/3309/4627. decodeCompositeSignature expects a SEQUENCE { BIT STRING, BIT STRING }, so it rejects the reference bytes at the tag check: the s for id-MLDSA44-RSA2048-PSS-SHA256 is 2676 bytes (2420 + 256) and starts with 0x2b, not 0x30. You already flagged this on the Ed25519 case. Fix: concatenate on sign; on verify split off the first 2420/3309/4627 bytes and take the remainder.

  2. ML-DSA context. The draft signs the ML-DSA component with the composite Label as its context, not empty: ML-DSA.Sign(sk, M', ctx = Label) (§3.2; reference self.mldsa.sign(Mprime, ctx=self.label.encode())). The composite currently passes no context, and PQCSignatureImpl does not accept one (its engineSetParameter rejects a non-null spec), so the ML-DSA engine needs a FIPS 204 context parameter wired through first. This is separate from the len(ctx) = 0 byte in M', which is correct.

Both are independent, so both are needed before any row verifies.

Test harness, not the provider

  1. The KAT MSG is {0x00..0x09}, but the vectors are over "The quick brown fox jumps over the lazy dog.". The test javadoc already names that sentence, so it looks like a leftover placeholder.

Per-row traditional parameters

These share one root cause: the traditional component's internal digest is taken from the composite name's trailing hash, but that token is the pre-hash PH, not the component's own digest, so it only bites where the two differ.

  • RSA-PSS: reference is SHA-256 for RSA-2048/3072 and SHA-384 for RSA-4096 (MGF1 matching, salt = digest length 32/48); the MLDSA65/87 rows are SHA512withRSASSA-PSS. The digest, MGF1, and salt move together.
  • RSA-PKCS#1 v1.5: the MLDSA65 RSA-3072/4096 rows are SHA512withRSA; reference is SHA-256 / SHA-384.
  • ECDSA: only MLDSA65-ECDSA-P256-SHA512 is off (SHA512withECDSA vs SHA-256); the rest match, and MLDSA87-ECDSA-P521-SHA512 correctly keeps SHA-512, so it is a one-row change.
  • Labels: the Ed25519 rows append -SHA512, brainpool uses BP256 / BP384, and Ed448 appends -SHAKE256. The Label sits in both M' and the ML-DSA context, so a wrong label breaks both components.
  • Ed448 pre-hash: the published row is id-MLDSA87-Ed448-SHAKE256 (PH = SHAKE256, 64 bytes); it is registered as SHA-512.

Already matching the draft: the M' skeleton, pure ML-DSA, ECDSA DER, EdDSA with no context, PSS trailerField = 1, and PH on the non-Ed448 rows. One aside outside the signature path: the OID arc is still draft-09 2.16.840.1.114027.80.9.1.x (draft-19 uses 1.3.6.1.5.5.7.6.x), which matters for key round-trip but not the KAT.

Happy to help with any of this. I have a conformant construction and the vectors on hand, so I can share a worked M' and signature byte breakdown for one row if that is useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants