# pq-conformance — hybrid signatures
The corpus of [`extensions/pq-signatures.md`](../extensions/pq-signatures.md) §10: records signed
with Ed25519 and ML-DSA-65 together, the controls each rule refuses, and the published vectors the
ML-DSA implementations are held to before any of it.
**Provenance.** `vectors.json` is written by `wai_pq_vectors gen` (in `wai/wai-rs`, feature
`pq_signatures`) from published seeds, each one byte repeated 32 times, for the Ed25519 keys and
for ML-DSA's ξ, with deterministic signing. `acvp/` holds ML-DSA-65's ACVP vectors as
`acvp/extract.py` derives them from the ACVP server repository's internal projections at a pinned
commit, under the notice their source publishes, and `draft-composite-sigs-19.json` is the case of
`draft-ietf-lamps-pq-composite-sigs-19` Appendix E for this suite (*Provenance of the vendored
vectors*, below).
## Files
- `vectors.json`, written by `wai_pq_vectors gen` (`wai-rs`, feature `pq_signatures`):
- `suite`: the suite of §2, its label, prefix and lengths;
- `codes`: the codes of §8, in its order;
- `origin` and `logs`: the key logs the cases anchor and hold, each a key-log document
(jwp-receipts §7.2), by name: `hybrid` (a hybrid root from its first revision, a revision
that adds a hybrid key, a rotation to the committed hybrid root), `hybrid-r0` (its first
revision), `classical` (a classical root committed to a hybrid successor, and a classical
revision), `classical-r0`, `rotated` (the classical root rotated to that successor), and the
controls `downgraded`, `bad-pq-half`, `next-root-own`, `pq-key-twice`, `entry-suite-44`,
`root-pq-swapped` and `classical-child`;
- `cases`: each with its `name`, what it is (`about`), its `family` — `group` (a group receipt's
JSON members in `group`, or a group's receipts in the binary form of jwp-receipts §2.7.3 in
`receipts_bin`, hexadecimal), `claim` (a claim's text), `record` (a record of §3.4's pattern:
its signing `domain` and its `text`) or `key_log` (a log of `logs`, anchored by pinning) — the
verifier's `policy` (`accept-classical` or `require-hybrid`, §5), for a group or a claim an
optional `confirm` (the log the relying party pins, `pin`; whether its anchor requires hybrid,
`require_hybrid`; the logs whose revisions it also holds, `held`), and the verdict it has,
`expect`;
- `sizes`: the sizes §9 states, read from the corpus's own items.
- `acvp/`: FIPS 204's ACVP test vectors for ML-DSA-65 (below), and `extract.py`, which derives
them from the upstream files.
- `draft-composite-sigs-19.json`: the test vector `draft-ietf-lamps-pq-composite-sigs-19`
Appendix E gives for `id-MLDSA65-Ed25519-SHA512` (below).
- `pq_verify.py` and `_mldsa.py`: the second implementation (below).
## The verdicts
A case's `expect` is one line. An item's verdict first:
- a group: `accept link=`, with ` objects=` when its objects were given and each was
verified by inclusion (jwp-receipts §3, *Verified by inclusion*); or `reject step=
reason=` (jwp-receipts §3, §3.3);
- a claim: `accept link=`, or `reject step= reason=` (jwp-receipts §3.1);
- a record: `accept form=hybrid` or `accept form=classical`, or `reject reason=` (§3.4: the
extension's own `bad_key` and `bad_signature`, or a code of §8);
- a key log: `anchored head=` or `refused reason=` (jwp-receipts §7.3).
Then, for a case with `confirm`, ` | ` and the confirmation (jwp-receipts §7.4 with step 4a):
`confirmed key_id= head=`, `refused step= reason=` or `unconfirmed
step= reason=`. The anchor is the latest revision of the log pinned (§7.3); the held
revisions are those of `held` that pass rules 1 to 3.
## Rules of the corpus
- Every key is made from a seed that is one byte repeated 32 times — an Ed25519 seed (RFC 8032
§5.1.5), or an ML-DSA seed ξ (FIPS 204 Algorithm 6) — so every key here is published and
attests nothing (jwp-receipts §7.8). The reference's `pq_sig::is_published_test_pq_key` names
the ML-DSA ones, and `key_log::lists_published_test_key` reports a log that lists one.
- Ed25519 is deterministic, and every ML-DSA signature here is made in FIPS 204's deterministic
variant (§3.4), so `wai_pq_vectors gen` writes the same bytes on every machine.
- Each case states the verdict it was written to have, and the generator refuses to write a
corpus in which the reference does not reach every one, or which leaves a code of §8 ungiven.
- The directory holds nothing but the generated file, this README, the vendored vectors and
their derivation, and the second implementation; `wai_pq_vectors verify` checks that.
## Provenance of the vendored vectors
**ACVP.** `acvp/ml-dsa-65-keygen.json`, `acvp/ml-dsa-65-siggen.json` and
`acvp/ml-dsa-65-sigver.json` are derived from the internal projections of the ACVP test vectors
for FIPS 204's ML-DSA, as published in the ACVP server's repository at commit
`975de31eb83d87039ec88934fdc47d8c312b892d`, directory `gen-val/json-files/`:
| upstream file | SHA-256 |
|---|---|
| `ML-DSA-keyGen-FIPS204/internalProjection.json` | `e67ee6540d40e11506c3c4e3b1f79fc1cefcd49820db99fc61f87cc8ba463baf` |
| `ML-DSA-sigGen-FIPS204/internalProjection.json` | `72dcaf5f69853ca267ccd16af9cb40949786aca0fcfbf05d1ebeba132b93af22` |
| `ML-DSA-sigVer-FIPS204/internalProjection.json` | `47cdd6314c7f746d02421ffcba89d4dbc7bb875ac49e07a029fdfc26fba55437` |
`python3 -I acvp/extract.py acvp` checks each upstream file against that
digest and keeps its ML-DSA-65 groups that pq-signatures uses: every key generation group; the
deterministic signature generation groups of the pure external interface (with a context string),
of the internal interface, and of the internal interface over an external μ; and the signature
verification groups of the same three interfaces. The pre-hash groups (HashML-DSA, FIPS 204 §5.4)
are left out. Each kept test is as upstream wrote it. The derived files, pinned by the reference's
tests (`pq_sig::acvp`):
| file | SHA-256 | tests |
|---|---|---|
| `acvp/ml-dsa-65-keygen.json` | `06b637b695150bfc752748e8636858f2e2da9c77547ab9856ddd115af5754a0f` | 25 |
| `acvp/ml-dsa-65-siggen.json` | `8c47e6fa05e32abcaa9ae32a6a361771ca5bd8275847db9eac4b5a959eb9c327` | 45 |
| `acvp/ml-dsa-65-sigver.json` | `cd48b587b423ec42b62971afcbfc1395491b994101192b0a10ce681a42e78a81` | 45, of which 36 modified |
These files were derived on 22 October 2026 from the upstream files above by keeping the groups
named and nothing else. The upstream files carry this notice, kept intact here as it asks:
> NIST-developed software is provided by NIST as a public service. You may use, copy, and distribute copies of the software in any medium, provided that you keep intact this entire notice. You may improve, modify, and create derivative works of the software or any portion of the software, and you may copy and distribute such modifications or works. Modified works should carry a notice stating that you changed the software and should note the date and nature of any such change. Please explicitly acknowledge the National Institute of Standards and Technology as the source of the software.
>
> NIST-developed software is expressly provided "AS IS." NIST MAKES NO WARRANTY OF ANY KIND, EXPRESS, IMPLIED, IN FACT, OR ARISING BY OPERATION OF LAW, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND DATA ACCURACY. NIST NEITHER REPRESENTS NOR WARRANTS THAT THE OPERATION OF THE SOFTWARE WILL BE UNINTERRUPTED OR ERROR-FREE, OR THAT ANY DEFECTS WILL BE CORRECTED. NIST DOES NOT WARRANT OR MAKE ANY REPRESENTATIONS REGARDING THE USE OF THE SOFTWARE OR THE RESULTS THEREOF, INCLUDING BUT NOT LIMITED TO THE CORRECTNESS, ACCURACY, RELIABILITY, OR USEFULNESS OF THE SOFTWARE.
>
> You are solely responsible for determining the appropriateness of using and distributing the software and you assume all risks associated with its use, including but not limited to the risks and costs of program errors, compliance with applicable laws, damage to or loss of data, programs or equipment, and the unavailability or interruption of operation. This software is not intended to be used in any situation where a failure could cause risk of injury or damage to property. The software developed by NIST employees is not subject to copyright protection within the United States.
**The composite's vector.** `draft-composite-sigs-19.json` is the case `id-MLDSA65-Ed25519-SHA512`
of `draft-ietf-lamps-pq-composite-sigs-19` Appendix E, with the appendix's message `m` and context
`ctx`, read from the draft's text (whose SHA-256 is pq-signatures' header's) by removing the
line breaks the text wraps its values with, and written in hexadecimal: `pk` (the ML-DSA key and
the Ed25519 key, draft §4.1), `sk` (the ML-DSA seed and the Ed25519 seed, §4.2), `s` (over `m`
with no context) and `sWithContext` (over `m` with `ctx`), each `mldsaSig ‖ tradSig` (§4.3). Its
SHA-256, pinned by the reference's tests, is
`d880a8fbb305e520620bff771b1e11bb21a83c97fbb9e286210d7c9e64898f20`.
## Equivalence
A conforming verifier gives every case its verdict. The reference reproduces every ACVP key and
signature byte for byte and gives every verification its verdict (`cargo test
--no-default-features --features pq_signatures --lib pq_sig::`), regenerates `vectors.json` byte for byte and reaches every verdict
(`wai_pq_vectors verify pq-conformance`).
## The second implementation
`pq_verify.py`, written from pq-signatures' text and the documents it cites, shares no code with
`wai-rs`. ML-DSA-65 verification is `_mldsa.py`, written from FIPS 204 with Python's integers and
the standard library's SHAKE; it signs nothing. It takes from `../claim-conformance/
claim_verify.py` what pq-signatures reuses from jwp-receipts §3.1 (the JSON reading, JCS, the body
checks and class rules of a claim, strict Ed25519, BLAKE3), and from
`../signer-conformance/signer_verify.py` §7's classical rule 1, the `signer_id` grammar and a
claim's authorities. It checks every ACVP verification vector and every ACVP signature, the
composite's vector, every case, the sizes and §8's table; then it requires every case that
accepts, mutated (a flipped byte of either signature, the post-quantum half stripped), to be
refused, and the corpus to catch the verifier with any one of its own checks dropped.
```
python3 -I pq_verify.py . everything, about ten seconds
python3 -I pq_verify.py verdicts vectors.json nameverdict for every case
```
`.github/workflows/wai-conformance.yml` runs the reference's tests and both verifiers, and
requires the two to give every case the same verdict.