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

Design rules

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

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):

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:

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:

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:

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.