qanum-pqverify
qanum-pqverify, the second, verify-only ML-DSA (FIPS 204) and SLH-DSA (FIPS 205) implementation that checks every Qanum signature next to liboqs: independence, design rules, test vectors and mutation testing.
This page reproduces qanum-pqverify/README.md from qanum-core v0.9.2 (release tag). Links to files that are not published on this site are shown as plain text until the source repository is public.
Contents
Verify-only ML-DSA (FIPS 204) and SLH-DSA (FIPS 205) for Qanum’s second signature verifier.
docs/GOVERNANCE.md §3 asks for two independent, audited implementations of
every signature suite before mainnet. The node’s first verifier is liboqs.
This crate is the second one: on mainnet and regtest the node accepts a
signature only if both accept it, so that a defect in one implementation is not enough to
accept a forged signature.
Status
| Suite | Node id | State |
|---|---|---|
| ML-DSA-44 | 0x0101 |
implemented, tested |
| ML-DSA-87 | 0x0104 |
implemented, tested |
| SLH-DSA-SHAKE-128s | 0x0201 |
implemented, tested |
| SLH-DSA-SHAKE-256s | 0x0203 |
implemented, tested |
Wired into the node (src/crypto_dual.rs): testnet runs it in shadow
mode (liboqs decides; a disagreement is logged, kept as evidence and
reported by getblockchaininfo), mainnet and regtest in enforce mode (a
signature must pass both). Not audited yet; the external audit is a mainnet
condition.
Independence
- Written from the text of FIPS 204 and FIPS 205 only.
- The code of liboqs (mldsa-native, slhdsa-c), RustCrypto and other implementations was not used or consulted while writing it. Their test vectors are used; their code is not.
- The SHAKE128 / SHAKE256 sponge is written here from FIPS 202; only the
Keccak-p[1600, 24] permutation comes from the
keccakcrate (RustCrypto), which is not the Keccak liboqs uses. It is the only dependency, and it was already in the node’s lock.
Design rules
- Verification only. No secret data is handled, so constant-time code is not needed.
- No panics. Malformed input returns
false. Nounwrap,expectorpanic!in library code (enforced by lints); fixed-size buffers; every rejection-sampling loop has a bound far above the minimums of FIPS 204 Appendix C. no_std, no heap allocation,#![forbid(unsafe_code)]. The ML-DSA matrix  is generated element by element instead of stored.- Simple, checked arithmetic. Coefficients in
[0, q)at every function boundary. Additions and subtractions reduce with one conditional subtraction; products by the NTT constants use Montgomery multiplication (FIPS 204 Appendix A) and come out exact; sums of products are reduced once per coefficient. The few hot operations are done in types wide enough that they cannot overflow; everything else keeps the overflow checks of release builds. The NTTs are tested against Algorithms 41 and 42 written exactly as in the standard. - Strictly the standard. Any rule stricter than FIPS lives in the node,
not here, so both verifiers stay comparable against the official vectors.
For example, the wallet refuses weak ML-DSA public keys
(
crypto::mldsa_pubkey_is_weak), which FIPS 204 verification accepts.
Map to FIPS 204 (ML-DSA)
| Code | FIPS 204 |
|---|---|
mldsa44_verify*, mldsa87_verify* |
Algorithm 3, ML-DSA.Verify (pure; M’ = 0 ‖ |ctx| ‖ ctx ‖ M) |
mldsa::verify_internal |
Algorithm 8, ML-DSA.Verify_internal |
mldsa::encode::t1_unpack |
Algorithm 23, pkDecode (Algorithm 18) |
mldsa::encode::z_unpack |
Algorithm 27, sigDecode, z part (Algorithm 19) |
mldsa::encode::hint_bit_unpack |
Algorithm 21, HintBitUnpack |
mldsa::encode::simple_bit_pack |
Algorithms 16 and 28, SimpleBitPack / w1Encode |
mldsa::sample::rej_ntt_poly |
Algorithms 14, 30 and 32, CoeffFromThreeBytes / RejNTTPoly / ExpandA |
mldsa::sample::sample_in_ball |
Algorithm 29, SampleInBall (all of c̃) |
mldsa::rounding::decompose |
Algorithm 36, Decompose |
mldsa::rounding::use_hint |
Algorithm 40, UseHint |
mldsa::poly::ntt, ntt_inverse |
Algorithms 41 and 42; zetas computed from ζ = 1753 (Appendix B); Montgomery multiplication (Appendix A) |
Map to FIPS 205 (SLH-DSA, SHAKE)
| Code | FIPS 205 |
|---|---|
slhdsa_shake{128s,256s}_verify* |
Algorithm 24, slh_verify (pure; M’ = 0 ‖ |ctx| ‖ ctx ‖ M) |
slhdsa::verify_internal |
Algorithm 20, slh_verify_internal |
slhdsa::fors_pk_from_sig |
Algorithm 17, fors_pkFromSig |
slhdsa::ht_verify |
Algorithm 13, ht_verify |
slhdsa::xmss_pk_from_sig |
Algorithm 11, xmss_pkFromSig |
slhdsa::wots_pk_from_sig |
Algorithm 8, wots_pkFromSig |
slhdsa::chain |
Algorithm 5, chain |
slhdsa::base_2b, to_int |
Algorithms 4 and 2, base_2b / toInt |
slhdsa::f, h, t_l and H_msg |
Section 11.1, SHAKE instances of F, H, T_l, H_msg |
slhdsa::adrs |
Section 4.2 and Table 1, ADRS (32-byte form) |
Params |
Table 2, SLH-DSA-SHAKE-128s and -256s; derived sizes checked at compile time |
Tests
- NIST ACVP sigVer (external, pure, with contexts) for ML-DSA-44,
ML-DSA-87, SLH-DSA-SHAKE-128s and SLH-DSA-SHAKE-256s, from the node’s
tests/data/acvpsubsets. - The node’s start-up self-test signatures (made by liboqs): accepted, and rejected after one-bit changes to the signature, message or key.
- Unit tests of every building block: FIPS 202 known answers, the SHAKE
sponge against the
sha3crate for every input length up to three blocks plus one, NTT, the zetas table, the Montgomery constants and products, Decompose / UseHint over the whole range including the corner case, bit packing order, and every malformed hint encoding of Algorithm 21; for SLH-DSA, the ADRS layout, base_2b / toInt byte order, chain composition and the Table 2 sizes.
Run: cargo test --release --locked -p qanum-pqverify.
Full sets (too large for the repository; tests/full_vectors.rs, ignored
by default, each file pinned by SHA3-256 and commit):
- NIST ACVP-Server sigVer, all cases for the four node parameter sets, through the external (pure, with context) and internal interfaces. HashML-DSA / HashSLH-DSA and externally supplied μ are not implemented and are skipped.
- C2SP Wycheproof
mldsa_44_verify_testandmldsa_87_verify_test, all 421 cases (wrong key and signature lengths, malformed and repeated hints, norm bounds, contexts, zero keys). There are no Wycheproof SLH-DSA vectors.
Run: QANUM_PQV_VECTORS=<dir> cargo test --release --locked -p qanum-pqverify --test full_vectors -- --ignored.
Fuzzing: fuzz/fuzz_targets/pq_verify_diff.rs (liboqs against this crate,
any disagreement aborts) and pq_verify_nopanic.rs; see fuzz/README.md.
Mutation testing
mutation/curated.py plants 54 hand-written defects, one at a time, in
the security-relevant lines (bounds off by one, checks removed, hint
encoding rules, rounding corner cases, sampling, NTT, WOTS+ checksum,
FORS and XMSS index and order rules, ADRS fields, context length) and
runs the tests, including the full vector sets, against each one.
First run: 47 killed, 4 survived. The survivors showed gaps, now closed:
- signature or public key far too short (below the first split point): the length checks had no test of their own, because the decoders reject most wrong lengths anyway;
- a context of 256 + L bytes whose length byte would wrap to L, aliasing a valid (ctx, M) pair (tested with the ACVP cases, ML-DSA and SLH-DSA);
- the hypertree root comparison: PK.root also enters H_msg, so no
signature-level test can see a comparison that skips a byte; a unit test
now checks
ht_verifybyte by byte.
Current result: 54 of 54 killed. Run (from the workspace root):
QANUM_PQV_VECTORS=<dir> python3 qanum-pqverify/mutation/curated.py results.jsonl.
Systematic pass with cargo-mutants 27.1.0 over the whole crate (first version; release profile, full vector sets included; each job with its own target directory): 610 mutants, 527 caught, 4 timeouts (endless loops, also caught), 53 unviable (do not compile), 26 missed. That pass found:
- a latent panic: SampleInBall accepted any τ up to 256, but its sign bits are 64, so τ > 64 would shift past them. τ is a constant (39 or 60) and cannot come from input, but the guard is now τ ≤ 64, with a test;
from_i32(0)could return q instead of 0 without any test noticing (same value mod q); now tested.
The 26 missed mutants were each checked by hand; none is a test gap:
| Kind | Count | Example |
|---|---|---|
Same value: integer division, \| vs ^ on disjoint bits, reductions that cancel |
12 | (q - 1) / 88 vs (q + 1) / 88; (t << 8) \| b vs (t << 8) ^ b |
| Same value for both parameter sets | 5 | len2 = 3 and the checksum shift = 4 whatever the mutation |
| Reads one byte ahead of need, same output | 2 | while bits < b vs <= in base_2b and the ML-DSA unpacker |
Compile-time checks only (const assertion loops), no run-time effect |
4 | while i < checks.len() |
A buffer bound made larger (LEN_MAX) |
1 | 2 * N_MAX + 3 vs 2 * N_MAX * 3 |
| Draw caps of RejNTTPoly and SampleInBall, unreachable in practice | 2 | draws += 1 vs draws *= 1 |
The two draw caps are defensive bounds (far above the FIPS 204 Appendix C minimums); no input reaches them, so no test can.
xof.rs and mldsa/poly.rs were later rewritten for speed and passed
through cargo-mutants again on their own:
xof.rs: 68 mutants, 60 caught, 5 timeouts (endless loops), 3 unviable, none missed;mldsa/poly.rs: 129 mutants, 105 caught, 21 unviable, 3 missed, all with the same result: thepow_modreduction already counted above, one more Newton step for q^-1 (already exact), and one more inverse-NTT pass that finds no block to work on.
Performance
On the ARM test machine SLH-DSA verifies about as fast as with liboqs, and
ML-DSA takes about twice as long (liboqs uses vector assembly for ML-DSA;
this crate is portable Rust). Both verifiers together cost about 2 times
liboqs alone for SLH-DSA and about 3 times for ML-DSA; an invalid signature
in enforce mode costs what liboqs alone costs. Figures:
docs/benchmarks/2026-10-21_arm_second-verifier/.
On the x86-64 reference VPS liboqs also uses vector code for ML-DSA, and
there ML-DSA takes about 3 times as long with this crate; both verifiers
together cost about 4 times liboqs alone for ML-DSA and 2 times for
SLH-DSA. Figures: docs/benchmarks/2026-10-22_x86-64_second-verifier/.
License
MIT OR Apache-2.0, as the rest of Qanum.