tests: NIST ACVP SHA2-128s verification KATs + a real differential bridge

External review flagged across rounds 4-6 that the empirical evidence tying the
proved model to the deployed code had not moved by a single data point in six
rounds: the sole bridge was nine assertion points (3 rounds, one fixed seed,
corruption always at byte 100), and the ACVP vectors vendored here contain NO
SLH-DSA-SHA2-128s sigVer group at all — the one parameter set this verification
campaign is about had zero NIST known-answer verification coverage.

VECTORS. tests/nist_acvp_vectors/SLH-DSA-sigVer-FIPS205/sha2_128s_extracted.json
carries the three SHA2-128s sigVer groups extracted verbatim from the official
NIST ACVP-Server vector set (source URL, upstream file sha256 and extraction
method recorded in the file's own _provenance block; per-test private keys
dropped as unnecessary to verify). 42 tests: 2 valid and 12 negative per group,
the negatives spread over structurally distinct corruption sites — modified R,
modified SIGFORS, modified SIGHT, modified message, too-small and too-large
signatures.

TESTS (all in src/verify_mono.rs, so they exercise the monomorphic path the Lean
certificates are about):

- mono_matches_nist_acvp_128s_internal — NIST's `internal` group carries M'
  directly, which is exactly what slh_verify_128s consumes, so these are true
  known-answer tests OF THE PROVED PATH: 10 executed, 4 attributed to
  deserialization (wrong-length signatures, rejected above the extraction root).
  Accounting is exact — all 14 are accounted for, nothing silently skipped.
- mono_matches_nist_acvp_128s_external_pure — builds M' the way lib.rs does and
  requires mono, the deployed verifier and NIST to agree: 10 executed, 9 of them
  with a NON-EMPTY context. This is the first empirical check of the
  domain-separator byte and context-length prefix that TRUSTED-BASE item 10
  declares outside every proof.
- deployed_matches_nist_acvp_128s_prehash — validates the deployed prehash path
  for 128s: 3 executed, 4 wrong-length, and 7 skipped because NIST exercises
  prehash functions (SHA3-*, truncated SHA2) this crate's `Ph` enum does not
  implement. Counted and reported rather than hidden.
- mono_matches_deployed_randomized — replaces the fixed-seed/fixed-byte bridge:
  12 rounds, varying message lengths including empty, corruption spread across
  the WHOLE 7856-byte signature, plus wrong-public-key and wrong-context cases
  that were never exercised before. 108 assertion points, each requiring mono
  and deployed to agree.

Bridge coverage: 9 assertion points -> 131, of which 20 are NIST known-answer
tests on the proved path where there were previously none.

No change to any verify-path function: this commit touches test code and test
data only, so the Charon/Aeneas extraction is unaffected (verified separately by
re-running extract.sh and diffing the generated model).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mrwulf 2026-07-28 09:42:56 +02:00
parent 797b4ef263
commit 3153988c4e
2 changed files with 722 additions and 0 deletions

View file

@ -354,6 +354,13 @@ mod tests {
use crate::slh_dsa_sha2_128s::{PublicKey, KG};
use crate::traits::{KeyGen, SerDes, Signer, Verifier};
use crate::types::{SlhDsaSig, SlhPublicKey};
// This crate is no_std; the test binary links std, so pull in the pieces the
// NIST-vector tests need (heap vectors for variable-length messages/contexts,
// and serde_json for the ACVP file).
extern crate std;
use std::vec::Vec;
use std::{println, vec};
use rand_chacha::rand_core::SeedableRng;
use rand_chacha::ChaCha8Rng;
@ -412,4 +419,269 @@ mod tests {
assert!(!mono_wm, "wrong-message signature accepted");
}
}
// ───────────────────────────────────────────────────────────────────────
// NIST ACVP known-answer coverage for SLH-DSA-SHA2-128s.
//
// Why this exists: the ACVP vector file vendored upstream contains NO
// SHA2-128s sigVer group (only 192s/256f/SHAKE variants), so the single
// parameter set this verification campaign is about had ZERO NIST
// known-answer verification coverage — flagged by external review across
// three rounds as the largest non-gate gap. The three 128s sigVer groups
// were extracted verbatim from the official NIST ACVP-Server vector set
// into tests/nist_acvp_vectors/SLH-DSA-sigVer-FIPS205/sha2_128s_extracted.json
// (provenance, source URL and upstream sha256 recorded inside that file).
//
// NIST supplies 14 tests per group: 2 valid, and 12 negative spread over
// structurally distinct corruption sites — modified R, modified SIGFORS,
// modified SIGHT, modified message, and signatures that are too small or
// too large. That is materially stronger than flipping one fixed byte.
const ACVP_128S: &str = include_str!(
"../tests/nist_acvp_vectors/SLH-DSA-sigVer-FIPS205/sha2_128s_extracted.json"
);
fn hexb(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
fn acvp_groups() -> Vec<serde_json::Value> {
let v: serde_json::Value = serde_json::from_str(ACVP_128S).unwrap();
v["testGroups"].as_array().unwrap().clone()
}
fn pk_parts(pk_hex: &str) -> SlhPublicKey<16> {
let b = hexb(pk_hex);
assert_eq!(b.len(), 32, "128s public key must be 32 bytes");
let mut pk_seed = [0u8; 16];
let mut pk_root = [0u8; 16];
pk_seed.copy_from_slice(&b[0..16]);
pk_root.copy_from_slice(&b[16..32]);
SlhPublicKey { pk_seed, pk_root }
}
/// THE PROVED PATH AGAINST NIST. The `internal` group carries M' directly
/// (no context wrapping, no domain separator), which is exactly the input
/// `verify_mono::slh_verify_128s` consumes — so these are true
/// known-answer tests of the function the eleven Lean certificates are
/// about, not of a wrapper above it.
#[test]
fn mono_matches_nist_acvp_128s_internal() {
let mut checked = 0usize;
let mut deserialization_rejects = 0usize;
for g in acvp_groups() {
if g["signatureInterface"] != "internal" { continue; }
for t in g["tests"].as_array().unwrap() {
let expected = t["testPassed"].as_bool().unwrap();
let sig_v = hexb(t["signature"].as_str().unwrap());
let msg = hexb(t["message"].as_str().unwrap());
let ipk = pk_parts(t["pk"].as_str().unwrap());
// Wrong-length signatures are rejected by deserialization, which
// sits ABOVE the extraction root (TRUSTED-BASE item 10) — the
// proved path is never reached. Record, do not silently skip.
if sig_v.len() != 7856 {
assert!(!expected, "NIST expects a wrong-length signature to fail");
deserialization_rejects += 1;
continue;
}
let mut sig_bytes = [0u8; 7856];
sig_bytes.copy_from_slice(&sig_v);
let sig = SlhDsaSig::<12, 7, 9, 14, 35, 16>::deserialize(&sig_bytes);
let got = slh_verify_128s(&msg, &sig, &ipk);
assert_eq!(
got, expected,
"tcId {} ({}): mono verdict {} != NIST {}",
t["tcId"], t["reason"].as_str().unwrap_or(""), got, expected
);
checked += 1;
}
}
// Exact accounting: every NIST test is either executed against the proved
// path or explicitly attributed to deserialization. Nothing is silently
// skipped, and if NIST's file changes shape this fails loudly.
assert_eq!(checked + deserialization_rejects, 14, "unaccounted NIST internal tests");
assert_eq!(checked, 10, "expected 10 executable internal KATs");
assert_eq!(deserialization_rejects, 4, "expected 4 wrong-length (too small/large) cases");
println!(
"mono vs NIST ACVP 128s (internal): {checked} executed against the PROVED path, \
{deserialization_rejects} rejected at deserialization (above the extraction root)"
);
}
/// THE M' ASSEMBLY, PINNED EMPIRICALLY. The `external pure` group carries a
/// real (often NON-EMPTY) context, so building M' the way lib.rs does and
/// feeding it to the mono path checks the domain-separator byte and the
/// context-length prefix that TRUSTED-BASE item 10 declares OUTSIDE every
/// proof. Mono, the deployed verifier, and NIST must all agree.
#[test]
fn mono_matches_nist_acvp_128s_external_pure() {
use crate::slh_dsa_sha2_128s::PublicKey as PK128s;
let mut checked = 0usize;
let mut wrong_len = 0usize;
let mut with_ctx = 0usize;
for g in acvp_groups() {
if g["signatureInterface"] != "external" || g["preHash"] != "pure" { continue; }
for t in g["tests"].as_array().unwrap() {
let expected = t["testPassed"].as_bool().unwrap();
let sig_v = hexb(t["signature"].as_str().unwrap());
if sig_v.len() != 7856 { assert!(!expected); wrong_len += 1; continue; }
let msg = hexb(t["message"].as_str().unwrap());
let ctx = hexb(t["context"].as_str().unwrap_or(""));
assert!(ctx.len() <= 255, "ACVP context longer than FIPS 205 allows");
if !ctx.is_empty() { with_ctx += 1; }
let pk_b = hexb(t["pk"].as_str().unwrap());
let mut pk_arr = [0u8; 32];
pk_arr.copy_from_slice(&pk_b);
let mut sig_bytes = [0u8; 7856];
sig_bytes.copy_from_slice(&sig_v);
// M' = toByte(0,1) ‖ toByte(|ctx|,1) ‖ ctx ‖ M (pure variant)
let mut mprime = Vec::with_capacity(2 + ctx.len() + msg.len());
mprime.push(0u8);
mprime.push(ctx.len() as u8);
mprime.extend_from_slice(&ctx);
mprime.extend_from_slice(&msg);
let ipk = pk_parts(t["pk"].as_str().unwrap());
let sig = SlhDsaSig::<12, 7, 9, 14, 35, 16>::deserialize(&sig_bytes);
let mono = slh_verify_128s(&mprime, &sig, &ipk);
let deployed = PK128s::try_from_bytes(&pk_arr).unwrap().verify(&msg, &sig_bytes, &ctx);
assert_eq!(mono, deployed, "tcId {}: mono != deployed", t["tcId"]);
assert_eq!(
mono, expected,
"tcId {} ({}): verdict {} != NIST {}",
t["tcId"], t["reason"].as_str().unwrap_or(""), mono, expected
);
checked += 1;
}
}
assert_eq!(checked + wrong_len, 14, "unaccounted NIST external-pure tests");
assert_eq!(checked, 10, "expected 10 executable external-pure KATs");
assert!(with_ctx > 0, "no NON-EMPTY context exercised — the separator/ctx prefix is untested");
println!(
"mono+deployed vs NIST ACVP 128s (external pure): {checked} executed \
({with_ctx} with a NON-EMPTY context), {wrong_len} rejected at deserialization"
);
}
/// The prehash group validates the DEPLOYED verifier against NIST for 128s.
/// The mono path is deliberately NOT driven here: prehash M' assembly adds
/// an OID and a message digest, and reconstructing it in the test would be
/// re-implementing the very wrapper code that is out of scope — the honest
/// statement is that this group covers the deployed path only.
#[test]
fn deployed_matches_nist_acvp_128s_prehash() {
use crate::slh_dsa_sha2_128s::PublicKey as PK128s;
use crate::types::Ph;
let mut checked = 0usize;
let mut unsupported_alg = 0usize;
let mut wrong_len = 0usize;
for g in acvp_groups() {
if g["preHash"] != "preHash" { continue; }
for t in g["tests"].as_array().unwrap() {
let expected = t["testPassed"].as_bool().unwrap();
let sig_v = hexb(t["signature"].as_str().unwrap());
if sig_v.len() != 7856 { assert!(!expected); wrong_len += 1; continue; }
// FIPS 205 permits more prehash functions than this crate's `Ph`
// enum implements (NIST exercises SHA3-* and the truncated SHA2
// variants too). Those are unreachable through the public API, so
// they are counted and skipped rather than failing the test.
let ph = match t["hashAlg"].as_str().unwrap_or("") {
"SHA2-256" => Ph::SHA256,
"SHA2-512" => Ph::SHA512,
"SHAKE-128" => Ph::SHAKE128,
"SHAKE-256" => Ph::SHAKE256,
_ => { unsupported_alg += 1; continue; }
};
let msg = hexb(t["message"].as_str().unwrap());
let ctx = hexb(t["context"].as_str().unwrap_or(""));
let pk_b = hexb(t["pk"].as_str().unwrap());
let mut pk_arr = [0u8; 32];
pk_arr.copy_from_slice(&pk_b);
let mut sig_bytes = [0u8; 7856];
sig_bytes.copy_from_slice(&sig_v);
let got = PK128s::try_from_bytes(&pk_arr).unwrap().hash_verify(&msg, &sig_bytes, &ctx, &ph);
assert_eq!(
got, expected,
"tcId {} ({}): deployed prehash verdict {} != NIST {}",
t["tcId"], t["reason"].as_str().unwrap_or(""), got, expected
);
checked += 1;
}
}
assert_eq!(checked + wrong_len + unsupported_alg, 14, "unaccounted NIST prehash tests");
assert!(checked > 0, "no prehash KAT was executable");
println!(
"deployed vs NIST ACVP 128s (prehash): {checked} executed, {wrong_len} wrong-length, \
{unsupported_alg} skipped (hash function not implemented by this crate)"
);
}
/// RANDOMIZED DIFFERENTIAL BRIDGE. The original bridge was nine assertion
/// points: three rounds from one fixed seed, corrupting one fixed byte
/// (index 100) of a 7856-byte signature. This walks many seeds and spreads
/// corruption across the WHOLE signature, and adds wrong-key and
/// wrong-context cases the original never exercised. Every point asserts
/// mono and deployed agree — that is the bridge — and that forgeries are
/// rejected.
#[test]
fn mono_matches_deployed_randomized() {
let mut rng = ChaCha8Rng::seed_from_u64(0x5EED_0F15u64);
let mut points = 0usize;
for round in 0u32..12 {
let (pk, sk) = KG::try_keygen_with_rng(&mut rng).unwrap();
let (pk_other, _) = KG::try_keygen_with_rng(&mut rng).unwrap();
// vary message length, including empty
let mlen = (round as usize * 7) % 23;
let msg: Vec<u8> = (0..mlen).map(|i| (i as u8).wrapping_mul(31).wrapping_add(round as u8)).collect();
let sig_bytes = sk.try_sign_with_rng(&mut rng, &msg, &[], false).unwrap();
let mut mprime = vec![0u8, 0u8];
mprime.extend_from_slice(&msg);
let (ipk, sig) = internal_inputs(&pk, &sig_bytes);
// valid
let d = pk.verify(&msg, &sig_bytes, &[]);
let m = slh_verify_128s(&mprime, &sig, &ipk);
assert!(d, "deployed rejected a fresh valid signature");
assert_eq!(m, d, "round {round}: mono != deployed on a valid signature");
points += 1;
// corruption spread across the entire signature, not one fixed byte
for k in 0..6 {
let mut bad = sig_bytes;
let idx = ((round as usize * 1237 + k * 1301) * 7 + 11) % bad.len();
let bit = 1u8 << ((round as usize + k) % 8);
bad[idx] ^= bit;
let (_, bad_sig) = internal_inputs(&pk, &bad);
let db = pk.verify(&msg, &bad, &[]);
let mb = slh_verify_128s(&mprime, &bad_sig, &ipk);
assert_eq!(mb, db, "round {round}: mono != deployed on corruption at byte {idx}");
assert!(!mb, "round {round}: corrupted signature accepted (byte {idx})");
points += 1;
}
// wrong public key — never exercised before
let (ipk_other, _) = internal_inputs(&pk_other, &sig_bytes);
let dk = pk_other.verify(&msg, &sig_bytes, &[]);
let mk = slh_verify_128s(&mprime, &sig, &ipk_other);
assert_eq!(mk, dk, "round {round}: mono != deployed under a wrong public key");
assert!(!mk, "round {round}: signature verified under the wrong public key");
points += 1;
// wrong context: deployed is given a non-empty ctx while the
// signature was made over the empty one; M' changes accordingly.
let ctx = [round as u8, 0xAA];
let mut mprime_ctx = vec![0u8, ctx.len() as u8];
mprime_ctx.extend_from_slice(&ctx);
mprime_ctx.extend_from_slice(&msg);
let dc = pk.verify(&msg, &sig_bytes, &ctx);
let mc = slh_verify_128s(&mprime_ctx, &sig, &ipk);
assert_eq!(mc, dc, "round {round}: mono != deployed under a wrong context");
assert!(!mc, "round {round}: signature verified under the wrong context");
points += 1;
}
assert!(points >= 100, "expected >=100 differential points, got {points}");
println!("randomized differential bridge: {points} assertion points");
}
}

File diff suppressed because one or more lines are too long