Skip to main content

WAI Extension: Hybrid Signatures (Ed25519 with ML-DSA-65)

Mirrored from the canonical text at commit 260cbd09 ().

Status: Draft. Sub-spec of WAI. Gives WAI’s signed records a second, post-quantum signature beside their Ed25519 one, so a record stays verifiable while either algorithm holds: one suite, ML-DSA-65 (FIPS 204) beside Ed25519 (RFC 8032), combined as the composite id-MLDSA65-Ed25519-SHA512 of draft-ietf-lamps-pq-composite-sigs-19. It covers the key logs of jwp-receipts §7 (an entry’s post-quantum key, a hybrid root, an anchor that requires hybrid), group receipts (the third version of the labelled payload of jwp-receipts §2.3), claims (jwp-receipts §2.4.3), and every JSON record an extension signs as wai:<domain>\x01 ‖ JCS(record) (§3.4), through one shared check. It adds a verifier policy that refuses a record signed classically only, and a confirmation step that refuses a classical-only record signed by a key its origin lists as hybrid. Nothing changes for a publisher that signs classically: its records keep their bytes and verify as before. No new transport. The reference implementation is the pq_sig module of wai-rs (feature pq_signatures), with the readers of jwp_delivery, claim_receipt, key_log and synthesis; the corpus is pq-conformance/, and its second implementation is pq-conformance/pq_verify.py. Keywords MUST, MUST NOT, SHOULD, MAY are RFC 2119/8174.

Texts read for this revision, each pinned to the version named: FIPS 204, Module-Lattice-Based Digital Signature Standard (August 2024), whose PDF’s SHA-256 as fetched on 22 October 2026 was 57239b9f84c03227eda3ca0991204dc7764c79af9ce2e6824eda774918d46b6b; draft-ietf-lamps-pq-composite-sigs-19 (the revision of 20 October 2026), whose text’s SHA-256 as fetched that day was b4ee04416efc26e7d8de7c740dc3848ffe8b8cc9dcd035ad9d4e33e18844ec83; RFC 8032; and the ACVP test vectors for FIPS 204’s ML-DSA at the upstream commit 975de31eb83d87039ec88934fdc47d8c312b892d (§10.1). A section number of one of those texts in this extension is that version’s.


1. Scope

Every signature WAI defines is Ed25519 (SPEC §7 Signatures). A quantum computer large enough to compute discrete logarithms on Ed25519’s curve recovers a private key from its public key, and its holder then signs anything under it: on the day such a computer exists, a record signed with Ed25519 alone no longer shows who signed it. Media provenance and delivery receipts are archival — a receipt signed today is asked to prove something years later — so the signature they carry has to outlast that day.

A hybrid signature is two signatures over the same bytes, one classical and one post-quantum, and a record verifies only if both do. While Ed25519 holds, the record is as strong as it was; once it does not, the record stands on the post-quantum half, provided that half’s key is bound to the signer by something a forger cannot rewrite either (§6.1, §7).

In scope:

Not in scope, in this revision:


2. The suite

One suite:

membervalue
pq_suiteid-MLDSA65-Ed25519-SHA512 (draft-ietf-lamps-pq-composite-sigs-19 §6, OID 1.3.6.1.5.5.7.6.48)
post-quantum halfML-DSA-65 (FIPS 204 §4): a 1952-byte public key (pkEncode, Algorithm 22), a 3309-byte signature (sigEncode, Algorithm 26)
classical halfEd25519 (RFC 8032), the key a record’s signer_pubkey already carries, verified by SPEC §7 Signatures
labelCOMPSIG-MLDSA65-Ed25519-SHA512, in ASCII (draft §6)
prefixCompositeAlgorithmSignatures2025, in ASCII (draft §2.2)
pre-hashSHA-512 (FIPS 180-4)

Why ML-DSA-65. The post-quantum half is the half that must still hold when the classical one does not, so its parameters are chosen for the life of the record, not for the life of the key. FIPS 204 §4 claims security category 3 for ML-DSA-65 and category 2 for ML-DSA-44; for records asked to prove something for decades, the margin of category 3 against advances in the cryptanalysis of module lattices is worth the 889 bytes per signature and 640 bytes per key it costs (FIPS 204 §4, Table 2; §9 measures what that is per record). ML-DSA-87 (category 5) adds 1318 bytes per signature more. The draft’s §10.4 recommends this composite where the signature must be strongly unforgeable, and WAI’s link hashes cover signature bytes (jwp-receipts §3 step 3, §2.4.3), so a second valid signature of the same bytes would be a second receipt (§7).

Why the composite. The draft’s construction is one WAI can adopt as it stands: it prefixes and pre-hashes the message once for both halves, binds both to the suite by the label, and has a published analysis of what it gives when one half fails (draft §9.2). A WAI hybrid record carries the composite’s two component signatures in two members, sig and pq_sig; pq_sig ‖ sig is the draft’s composite signature value and pq_key ‖ signer_pubkey its composite public key (draft §4), so a composite verifier can check a WAI record given the bytes it signs. A simpler form — two signatures over the same domain-separated bytes — would also bind both halves where the record’s own members include the post-quantum key, as every JSON record’s do (§3.2); the composite adds that the classical half is over bytes no classical record of any family signs (§4.3), which holds for the binary group receipt too, whose members are not a JSON object.

Keys. A hybrid key is an Ed25519 key and an ML-DSA-65 key generated apart, each from its own 32-byte seed (RFC 8032 §5.1.5; FIPS 204 Algorithm 6, ML-DSA.KeyGen_internal(ξ)), and used only together (draft §3.1, §9.3): its Ed25519 key never signs an item with a classical signature only, and its ML-DSA key never signs outside this suite. §6.1 makes the first of these checkable.


3. The hybrid members

3.1 In a JSON record

A hybrid JSON record carries three members beside its signer_pubkey and sig:

membertypemeaning
pq_suitestringThe suite (§2): id-MLDSA65-Ed25519-SHA512.
pq_key1952 B hexThe signer’s ML-DSA-65 public key. In a key-log revision, whose signer is its root, the member is root_pq_key (§6.1).
pq_sig3309 B hexThe ML-DSA-65 signature (§4).

Byte strings are lowercase hex, as every byte string of jwp-receipts §2.4.3. A record carries all three or none: with none it is classical and is read and verified as its family defines; with all three it is hybrid. A reader checks them in this order, and refuses the record at its family’s member step (§6) with the reason given:

  1. one or two of the three present: pq_malformed;
  2. pq_suite not a string: pq_malformed; a string naming no suite of §2: pq_suite_unknown;
  3. pq_key, then pq_sig, not lowercase hex of exactly the suite’s length: pq_malformed (FIPS 204 §3.6.2: a key or signature of another length is refused, never read).

A reader built without this extension does not know the members, and refuses a hybrid record as its family refuses any member it does not define (malformed, unknown_member, key_log_malformed, record_malformed), so a hybrid record is never read as a classical one.

3.2 What is signed

The message M of a hybrid record is the bytes its family’s rule signs, computed with pq_suite and the key member among the record’s members and with sig and pq_sig left out: for a claim, "moq-jwp:claim\x01" ‖ JCS(claim without "sig" and "pq_sig"); for a key-log revision, "wai:key-set\x01" ‖ JCS(revision without "sig" and "pq_sig"); for a record of the pattern of §3.4, domain ‖ JCS(record without "sig" and "pq_sig"). JCS is jwp-receipts §2.4.3’s canonical form. A record’s link hash and identity are unchanged in definition: they are over the whole record, both signatures and all three hybrid members included.

3.3 In a binary record

The group receipt’s binary form carries the post-quantum key inside its signing payload and the post-quantum signature after sig, under the payload’s third version, whose signing domain names the suite (§6.2). It has no pq_suite member: moq-jwp:group-sign\x03 is this suite, and a later suite takes a later version.

3.4 Records of the pattern wai:<domain>\x01 ‖ JCS

Many extensions sign their JSON records the same way: a record carries signer_pubkey and sig, and sig is Ed25519 over domain ‖ JCS(record without "sig"), domain being the extension’s own wai:<name>\x01 (synthesis §2 is one). Any such record MAY carry the three members of §3.1 beside sig, and then is checked by one shared check, the same for every such extension:

  1. the hybrid members, as §3.1 reads them;
  2. signer_pubkey and sig present as 32 and 64 bytes of lowercase hex, or the record is the extension’s own malformed record;
  3. §4.2 over M = domain ‖ JCS(record without "sig" and "pq_sig"), under the verifier’s policy (§5): a key that fails is the extension’s own bad-key reason, an Ed25519 half that fails its own bad-signature reason, and otherwise this extension’s reasons (§8).

The extension’s own member tables then apply to the record without its hybrid members, and its own checks to what it states. A classical record’s message is the bytes its extension signs today, so a classical record reads exactly as before. An extension’s verifier implements this section by calling the shared check where it checks a signature; the reference does so for synthesis records. A verifier of an extension that does not implement it refuses a hybrid record as malformed (§3.1).


4. Signing and verifying

4.1 Signing

Given M (§3.2), the signer computes the composite’s message representative with an empty application context (draft §2.2, §3.2 step 2):

M'     = "CompositeAlgorithmSignatures2025" ‖ "COMPSIG-MLDSA65-Ed25519-SHA512" ‖ 0x00 ‖ SHA-512(M)
sig    = Ed25519(ed_private_key, M')                                   RFC 8032, 64 bytes
pq_sig = ML-DSA.Sign(ml_dsa_private_key, M', ctx = "COMPSIG-MLDSA65-Ed25519-SHA512")
                                                                       FIPS 204 Algorithm 2, 3309 bytes

The ML-DSA half takes the label as its context string (draft §3.2 step 4), so FIPS 204’s own domain separation (Algorithm 2 line 10) binds it to the suite. A signer MAY sign in FIPS 204’s hedged or deterministic variant (§3.4); a verifier cannot tell them apart. The corpus signs deterministically, so it regenerates byte for byte; a signer on a platform exposed to fault or side-channel attacks SHOULD sign hedged (FIPS 204 §3.4, §3.6.1).

4.2 Verifying

A verifier checks an item’s signature in this order and reports the first that fails:

  1. signer_pubkey is an Ed25519 key SPEC §7 Signatures steps 1 and 2 accept: otherwise the family’s bad-key reason (bad_signer_key, bad_key, key_log_signature_invalid);
  2. under the policy that requires hybrid (§5), an item with no post-quantum half: hybrid_required, before its signature is examined;
  3. the Ed25519 half under SPEC §7 Signatures steps 3 to 5, over M' for a hybrid item and over M for a classical one: otherwise the family’s signature reason (signature_verify_failed, bad_signature, key_log_signature_invalid);
  4. the post-quantum half: the key and the signature of the suite’s lengths (pq_malformed), and ML-DSA.Verify(pq_key, M', pq_sig, ctx = label) (FIPS 204 Algorithm 3) true: pq_signature_invalid otherwise, a signature whose hint does not decode or whose response is out of range (Algorithm 27, Algorithm 8 line 13) among them.

The classical half is verified by WAI’s strict rule, which refuses some signatures RFC 8032’s permissive reading accepts and no honest signer makes; the composite’s verification (draft §3.3) requires each component to verify under its own specification, which this is.

4.3 What stripping does

The Ed25519 half of a hybrid record signs M', never M. Remove the post-quantum half from a hybrid record, and its sig verifies over no classical record’s bytes: M' begins with the composite’s prefix and holds a digest, not a family’s domain and members (draft §9.2.3, weak non-separability). Remove only the members of §3.1 from a JSON record, and its M changes too, since pq_suite and the key member are signed. Either way the stripped record is refused at its signature step; neither way can a party that cannot forge Ed25519 turn a hybrid record into a classical one.

A party that can forge Ed25519 does not need to strip anything: it makes a classical record outright. Against it, what holds is policy, not construction: §5 for a relying party that requires hybrid of every record, and §6.1 for any record signed by a key its origin lists as hybrid.


5. The policy

A verifier that implements this extension MUST offer the policy require hybrid: an item with a classical signature only is refused (hybrid_required) at its family’s signature step, after its key is checked and before its signature is examined. A relying party that relies on records for longer than it trusts Ed25519 SHOULD apply it to them. Without it, a verifier accepts classical and hybrid items alike, and verifies both halves of a hybrid one.

The policy is the relying party’s, and is not carried in any record. Its counterpart in a key log is an anchor that requires hybrid (§6.1).


6. Where hybrid signatures go

6.1 Key logs (jwp-receipts §7)

A hybrid entry. A key entry of jwp-receipts §7.2 MAY carry pq_suite and pq_key (§3.1), together: the entry is then hybrid, and the origin states that every item its key signs is hybrid with that ML-DSA key. Rule 1 adds that no two entries share a pq_key, none is the revision’s root_pq_key, and no entry’s pair is the one next_root commits to (below). Rule 6 adds that an entry’s form never changes: an entry listed hybrid keeps its pq_suite and pq_key, and one listed classical never gains them (key_log_rewrites_history). An origin that moves a key to hybrid lists a successor, as for any change of authority (jwp-receipts §7.6).

A hybrid root. A revision MAY carry pq_suite, root_pq_key and pq_sig (§3.1): its root is then hybrid, and both halves sign it, §4 over the M of §3.2. Rule 3 requires both to verify (key_log_signature_invalid). A hybrid root’s ML-DSA key is part of the root: rule 5’s “its root and next_root are its parent’s” holds only if root_pq_key is the parent’s too, present or absent alike.

The hybrid commitment. next_root commits to a classical root as jwp-receipts §7.2 defines, or to a hybrid one as

commitment_hybrid(K, P) = BLAKE3("wai:key-set-next\x02" ‖ K ‖ P)

over its Ed25519 key K and its ML-DSA key P. Rule 1 adds that next_root is not the commitment to the revision’s own root in either form. A rotation (rule 5) is a child whose root is not its parent’s and whose commitment, in the child’s form, is the parent’s next_root: a root becomes hybrid only by a rotation its parent committed to in the hybrid form, and a hybrid root’s non-rotating children are signed by both its halves.

A classical root is no stronger than Ed25519: whoever can forge it signs revisions that add keys, hybrid ones among them, and a hybrid entry under such a root binds its ML-DSA key to the origin only as firmly as Ed25519 does. Hence:

An anchor that requires hybrid. A relying party that pins an origin (jwp-receipts §7.3) MAY record that it requires hybrid of it. Then:

Confirmation step 4a. After jwp-receipts §7.4 step 4 has found the entry and matched its key, in order:

A key status and a module grant (jwp-receipts §7.9) have no hybrid form in this revision: a status is signed by the root’s Ed25519 key alone, and a grant by a key that §6.1 refuses if its entry is hybrid. An origin lists classical keys for grants until §7.9 gives them a hybrid form; §7.9’s freshness bound limits how long a status matters.

6.2 Group receipts (jwp-receipts §2.3, §2.4.1, §2.7)

The payload’s third version. A hybrid group is signed over

signing_payload_v3 = "moq-jwp:group-sign\x03"
                  || the labelled payload's fields after its domain, in its order   // §2.3
                  || pq_key[1952]                                                   // the publisher's ML-DSA-65 key

by both halves (§4, M = the payload). A hybrid group is a labelled group: its class, coverage and parent link are the labelled payload’s, under every rule of jwp-receipts §2.4.1. Its JSON form is the labelled group’s members with sign_version 3 and two more, pq_key and pq_sig. Read at jwp-receipts §3 step 1: sign_version 3 with neither of them is a version jwp-receipts does not define (group_label_invalid); with one of them, or either of them in a group whose sign_version is not 3, or either not lowercase hex of the suite’s length, pq_malformed. Verified at step 2 by §4.2.

Canonical bytes and link hash. canonical_bytes(G) is the payload, then sig, then pq_sig, so a hybrid group’s link hash (jwp-receipts §3 step 3) covers both signatures. The binary form (jwp-receipts §2.7.1) of a hybrid group is its canonical bytes, read in that order: the fields of the labelled form, the 1952-byte key, the 64-byte sig, the 3309-byte pq_sig. A reader of jwp-receipts §2.7.4 that predates this extension finds a signing domain that is neither of §2.3’s and refuses the record (malformed).

Objects. Object receipts are unchanged: they are bound to a hybrid group through its root as to any other (§2.2, §3 step 5), so the post-quantum cost is paid once per group, not per object.

Confirmation. A hybrid group is an item whose ML-DSA key is its pq_key (step 4a above).

6.3 Claims (jwp-receipts §2.4.3, §3.1)

A claim MAY carry the three members of §3.1; a claim has no other optional members. At §3.1 step 1, after the subject type is admitted, a verifier reads them as §3.1 says (pq_malformed, pq_suite_unknown), and the envelope’s member check is of the claim without them. At step 3 it verifies by §4.2: bad_signer_key, hybrid_required, signature_verify_failed, pq_signature_invalid. The claim’s link_hash is over the whole claim. A claim chain (§2.4.3) is per signer_pubkey, hybrid or not.

6.4 Records of the pattern

As §3.4 says. The reference wires the shared check into synthesis’s records; the other extensions that sign as wai:<domain>\x01 ‖ JCS adopt it by the same call.


7. Security considerations


8. Reasons

The codes this extension adds. A family reports them at the step §6 names, beside its own codes, which keep their meaning: a hybrid item whose Ed25519 key or half fails is refused with the family’s own reason; a key-log revision that breaks §6.1’s additions to rule 1 is key_log_malformed, to rule 3 key_log_signature_invalid, to rule 5 key_log_root_not_committed, to rule 6 key_log_rewrites_history.

codewhereraised when
pq_malformedthe family’s member stepone or two of the hybrid members present; pq_suite not a string; a key or signature not lowercase hex of the suite’s length; a group’s hybrid members outside its third version, or one of them missing in it
pq_suite_unknownthe family’s member steppq_suite names no suite of §2
pq_signature_invalidthe family’s signature stepML-DSA.Verify over M' with the label as context is false (§4.2 step 4)
hybrid_requiredthe family’s signature step; confirmation step 4aunder the policy of §5, an item with a classical signature only; under an anchor that requires hybrid, the same item at confirmation (unconfirmed)
hybrid_downgradeconfirmation step 4athe entry is hybrid and the item carries a classical signature only (refused)
pq_key_mismatchconfirmation step 4athe item’s ML-DSA key is not the entry’s, or the entry is classical (refused)

9. Sizes and costs

Sizes are the corpus’s own items (pq-conformance/vectors.json, sizes), the same on every machine. Times are wai_pq_vectors bench, built with --release, on the reference’s build host: a 32-core arm64 workstation that other jobs shared during the runs (its load average between 15 and 24). Each time is the median of seven rounds over 64 distinct 512-byte messages, about a claim’s signing input — ML-DSA’s signing loop repeats a number of times that depends on the message (FIPS 204 §4, Table 1: 5.1 on average for ML-DSA-65), so one message would time one draw of it. The range is that median across three runs.

operationtime per operation
Ed25519 sign10.6–11.5 µs
Ed25519 verify, SPEC §7 strict34.0–34.9 µs
ML-DSA-65 sign, deterministic349–378 µs
ML-DSA-65 verify75–88 µs
hybrid sign, both halves323–366 µs
hybrid verify, both halves114–128 µs

A hybrid record costs about thirty times an Ed25519 signature to sign and between three and four times to verify: some eight thousand hybrid verifications a second on one core.

itemclassicalhybriddifference
group receipt, binary (jwp-receipts §2.7.1)291 B5552 B+5261 B: the key, 1952, and the signature, 3309
a group’s receipts with 3 objects (WJR1, §2.7.3)722 B5983 B+5261 B
object receipt, binary141 B141 Bnone
claim, JSON579 B11164 B+10585 B: the key and signature in hex, and pq_suite
first key-log revision, JSON: classical root and one classical entry; hybrid root, one hybrid and one classical entry684 B15443 B+14759 B: the root’s key and signature and the hybrid entry’s key, in hex, and one entry more
origin claim of synthesis, JSON557 B11142 B+10585 B

A receipts track (moq-streaming-format §4.1) carries one group’s receipts per media group. A hybrid publisher’s track grows by 5261 bytes a group, whatever the group holds: at one group a second, by 42 kbit/s. Beside a group of one object (376 bytes classical) that is fifteen times the track; of 30 objects (7409 bytes), 1.71 times; of 60 (16439 bytes), 1.32 times. A room’s receipts are its tracks’ receipts tracks, each a publisher’s: the cost is per publishing track and group, never per subscriber, and a subscriber pays it only for the tracks it subscribes to. The second implementation, in pure Python, verifies an ML-DSA-65 signature in about 4 ms on an arm64 laptop (median of 45, Python 3.15).


10. Conformance

10.1 The vendored vectors

pq-conformance/acvp/ holds FIPS 204’s ACVP test vectors for ML-DSA-65, derived by extract.py from the internal projections at the upstream commit named above, each upstream file and each derived file pinned by SHA-256 (pq-conformance/README.md): key generation (25 vectors), the deterministic signature generation of the external interface with a context, of the internal interface, and of the internal interface over an external μ (15 each), and signature verification over the same three interfaces (15 each, four of every five modified). The reference reproduces every key and every signature byte for byte and gives every verification its verdict; the second implementation verifies every signature both files state. draft-composite-sigs-19.json is the draft’s Appendix E vector for this suite, which both verify, with no context and with the draft’s.

10.2 The corpus

pq-conformance/vectors.json, written by wai_pq_vectors gen from seeds that are one byte repeated 32 times (jwp-receipts §7.8: every key in it is published and attests nothing). Each case is a group receipt (JSON or a group’s receipts in the binary form, its objects verified by inclusion), a claim, a record of §3.4’s pattern, or a key log anchored by pinning; a group or claim may carry a confirmation against a pinned log, with or without the hybrid requirement. The negative controls include: the post-quantum half stripped, made over other bytes, and with a bit flipped; a classical item under the policy of §5, under a hybrid entry, and under an anchor that requires hybrid; a classical revision under such an anchor; a hybrid entry later listed without its key; a child signed by a hybrid root’s Ed25519 key alone and with another ML-DSA key; another parameter set, named (id-MLDSA44-Ed25519-SHA512, validly signed) and by length. Every code of §8 is given.

A conforming verifier gives every case its stated verdict. wai_pq_vectors verify regenerates the file byte for byte and checks every verdict; pq_verify.py, written from this text and sharing no code with the reference, checks the vendored vectors, every case, the sizes of §9 and this section’s table of codes, and then requires every mutated case to be refused and every one of its own checks, dropped, to be caught by the corpus.


11. References