mirror of
https://github.com/saymrwulf/fips205-source.git
synced 2026-09-04 20:03:45 +00:00
Compare commits
3 commits
797b4ef263
...
a3ce8e8644
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ce8e8644 | |||
| c945821bf9 | |||
| 3153988c4e |
4 changed files with 991 additions and 0 deletions
|
|
@ -354,6 +354,13 @@ mod tests {
|
||||||
use crate::slh_dsa_sha2_128s::{PublicKey, KG};
|
use crate::slh_dsa_sha2_128s::{PublicKey, KG};
|
||||||
use crate::traits::{KeyGen, SerDes, Signer, Verifier};
|
use crate::traits::{KeyGen, SerDes, Signer, Verifier};
|
||||||
use crate::types::{SlhDsaSig, SlhPublicKey};
|
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::rand_core::SeedableRng;
|
||||||
use rand_chacha::ChaCha8Rng;
|
use rand_chacha::ChaCha8Rng;
|
||||||
|
|
||||||
|
|
@ -412,4 +419,279 @@ mod tests {
|
||||||
assert!(!mono_wm, "wrong-message signature accepted");
|
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");
|
||||||
|
// Exact, not a floor: a NIST data update that quietly reduced non-empty
|
||||||
|
// contexts to one would otherwise pass silently (round-7 review).
|
||||||
|
assert_eq!(with_ctx, 9, "expected 9 executable non-empty-context KATs");
|
||||||
|
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");
|
||||||
|
// Exact counts. With 7 of 14 skipped for unimplemented prehash functions,
|
||||||
|
// a floor of `> 0` would let real coverage fall from 3 to 1 invisibly
|
||||||
|
// while the total still summed to 14 (round-7 review).
|
||||||
|
assert_eq!(checked, 3, "expected exactly 3 executable prehash KATs");
|
||||||
|
assert_eq!(wrong_len, 4, "expected exactly 4 wrong-length prehash cases");
|
||||||
|
assert_eq!(unsupported_alg, 7, "expected exactly 7 unimplemented-prehash skips");
|
||||||
|
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 over **72 distinct deterministic positions in the range
|
||||||
|
/// 11..=7779** of the 7856-byte signature (round-7 review measured the
|
||||||
|
/// schedule; "the whole signature" overstated it — bytes 0-10 and
|
||||||
|
/// 7780-7855 are never selected), 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_eq!(points, 108, "expected exactly 108 differential points, got {points}");
|
||||||
|
println!("randomized differential bridge: {points} assertion points");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
173
tests/nist_acvp_vectors/extract_sha2_128s.py
Normal file
173
tests/nist_acvp_vectors/extract_sha2_128s.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deterministically re-derive tests/nist_acvp_vectors/SLH-DSA-sigVer-FIPS205/
|
||||||
|
sha2_128s_extracted.json from the official NIST ACVP-Server vector set.
|
||||||
|
|
||||||
|
Why this file exists at all: the ACVP vector file vendored in this repository
|
||||||
|
contains no SLH-DSA-SHA2-128s sigVer group, so the parameter set the associated
|
||||||
|
verification work is about had no NIST known-answer verification coverage. The
|
||||||
|
three 128s groups are therefore taken from upstream.
|
||||||
|
|
||||||
|
Why this SCRIPT exists: external review (round 7) observed that a hand-made
|
||||||
|
extraction with a prose provenance note is not auditable — the first version's
|
||||||
|
note said "only `sk` dropped" while three further fields had in fact been
|
||||||
|
removed. The transformation is now executable, pinned, and fails closed:
|
||||||
|
|
||||||
|
* the upstream file's sha256 must match SOURCE_SHA256 exactly;
|
||||||
|
* exactly EXPECTED_GROUPS groups must match the parameter set, each with
|
||||||
|
EXPECTED_TESTS_PER_GROUP tests;
|
||||||
|
* exactly one field, `sk`, is removed, and it must be present to be removed
|
||||||
|
(so a schema change is caught, not silently transformed);
|
||||||
|
* every other key is carried through untouched — BY CONSTRUCTION, which a
|
||||||
|
reviewer verifies by reading `build()`, not by a self-check: no test inside a
|
||||||
|
transformer can detect a corrupted input, since the transformer is what
|
||||||
|
defines the output. The input is instead pinned by SOURCE_SHA256;
|
||||||
|
* verify mode re-derives and byte-compares the committed file, so a hand-edit
|
||||||
|
of the committed JSON IS caught;
|
||||||
|
* output is canonical (fixed indent, trailing newline).
|
||||||
|
|
||||||
|
NOT A GATE — re-runnable EVIDENCE. Round-8 review noted the distinction: this
|
||||||
|
script needs network access, so nothing invokes it automatically (it is outside
|
||||||
|
`cargo test` and outside verification/check.sh). The committed JSON is still
|
||||||
|
trusted at review time; what this script provides is that a reviewer can
|
||||||
|
CHECK that trust cheaply and mechanically instead of taking a prose note's word.
|
||||||
|
A CI job running it in verify mode would close the remaining gap.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 extract_sha2_128s.py # verify the committed file matches
|
||||||
|
python3 extract_sha2_128s.py --write # regenerate it
|
||||||
|
|
||||||
|
The upstream file is ~30 MB; it is fetched to a temporary path and not vendored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse, hashlib, json, os, sys, tempfile, urllib.request
|
||||||
|
|
||||||
|
SOURCE_URL = (
|
||||||
|
"https://raw.githubusercontent.com/usnistgov/ACVP-Server/master/"
|
||||||
|
"gen-val/json-files/SLH-DSA-sigVer-FIPS205/internalProjection.json"
|
||||||
|
)
|
||||||
|
SOURCE_SHA256 = "a013fc2104f4ed4799d96d51141f65b965969b2cf10646626a021b6d456ce792"
|
||||||
|
PARAMETER_SET = "SLH-DSA-SHA2-128s"
|
||||||
|
EXPECTED_GROUPS = 3
|
||||||
|
EXPECTED_TESTS_PER_GROUP = 14
|
||||||
|
DROP_FIELDS = frozenset({"sk"}) # the ONLY per-test removal
|
||||||
|
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"SLH-DSA-sigVer-FIPS205", "sha2_128s_extracted.json")
|
||||||
|
|
||||||
|
PROVENANCE = {
|
||||||
|
"what": f"{PARAMETER_SET} sigVer test groups, extracted verbatim from the "
|
||||||
|
"official NIST ACVP-Server vector set.",
|
||||||
|
"why": "The vector file vendored upstream in this directory contains NO "
|
||||||
|
f"{PARAMETER_SET} sigVer group (only 192s/256f/SHAKE variants), so the "
|
||||||
|
"one parameter set the verification work targets had zero NIST "
|
||||||
|
"known-answer verification coverage.",
|
||||||
|
"source_url": SOURCE_URL,
|
||||||
|
"source_sha256": SOURCE_SHA256,
|
||||||
|
"retrieved_utc": "2026-07-28",
|
||||||
|
"extraction": "Produced by tests/nist_acvp_vectors/extract_sha2_128s.py, which "
|
||||||
|
"verifies the upstream sha256, selects every testGroup whose "
|
||||||
|
f"parameterSet == {PARAMETER_SET}, and removes exactly ONE "
|
||||||
|
"per-test field: `sk` (the private key, not needed to verify a "
|
||||||
|
"signature). Every other per-test field and all group and "
|
||||||
|
"top-level metadata are carried through unchanged by construction. "
|
||||||
|
"The guarantees that can actually fail are: the pinned upstream "
|
||||||
|
"SOURCE_SHA256; the requirement that `sk` be present to be dropped; "
|
||||||
|
"the expected group and per-group test counts; and verify mode, which "
|
||||||
|
"re-derives and byte-compares this committed file.",
|
||||||
|
"note": "Test DATA only. Expected outcomes are NIST's `testPassed` field; "
|
||||||
|
"`reason` records why a negative case must be rejected.",
|
||||||
|
"regenerate": "python3 tests/nist_acvp_vectors/extract_sha2_128s.py --write",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch() -> dict:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as fh:
|
||||||
|
tmp = fh.name
|
||||||
|
try:
|
||||||
|
urllib.request.urlretrieve(SOURCE_URL, tmp)
|
||||||
|
raw = open(tmp, "rb").read()
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp)
|
||||||
|
got = hashlib.sha256(raw).hexdigest()
|
||||||
|
if got != SOURCE_SHA256:
|
||||||
|
sys.exit(f"FATAL: upstream sha256 {got} != pinned {SOURCE_SHA256}.\n"
|
||||||
|
"The NIST file changed. Review the delta and update the pin "
|
||||||
|
"deliberately; do not regenerate blindly.")
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def build(full: dict) -> dict:
|
||||||
|
groups = []
|
||||||
|
for g in full["testGroups"]:
|
||||||
|
if g.get("parameterSet") != PARAMETER_SET:
|
||||||
|
continue
|
||||||
|
tests = []
|
||||||
|
for t in g["tests"]:
|
||||||
|
for f in DROP_FIELDS:
|
||||||
|
if f not in t:
|
||||||
|
sys.exit(f"FATAL: tcId {t.get('tcId')} has no field {f!r} to drop")
|
||||||
|
# Fields are carried through BY CONSTRUCTION: `kept` is `t` minus
|
||||||
|
# DROP_FIELDS, so every retained key holds the identical object.
|
||||||
|
#
|
||||||
|
# Round-8 review found a tautological `assert` here — it compared
|
||||||
|
# `kept` against the comprehension that had just built it, so it could
|
||||||
|
# never fire. The first attempt to repair it (comparing kept[k] to
|
||||||
|
# t[k]) was tautological for the same reason, and that is the lesson
|
||||||
|
# worth recording: NO check inside this function can detect a
|
||||||
|
# corrupted input, because this function is what defines the output
|
||||||
|
# from that input. Faithfulness here is a property of the two lines
|
||||||
|
# below, which a reviewer reads; it is not something the script can
|
||||||
|
# test about itself.
|
||||||
|
#
|
||||||
|
# What actually protects the result, and can fail:
|
||||||
|
# * SOURCE_SHA256 — the input is pinned, so upstream cannot drift
|
||||||
|
# or be substituted without an explicit, reviewed pin change;
|
||||||
|
# * the `f not in t` presence check above — `sk` must exist to be
|
||||||
|
# dropped, so a schema change is caught rather than silently
|
||||||
|
# producing a different transformation;
|
||||||
|
# * EXPECTED_GROUPS / EXPECTED_TESTS_PER_GROUP below;
|
||||||
|
# * verify mode, which re-derives and byte-compares the committed
|
||||||
|
# file, so a hand-edit of the committed JSON is caught.
|
||||||
|
kept = {k: v for k, v in t.items() if k not in DROP_FIELDS}
|
||||||
|
tests.append(kept)
|
||||||
|
if len(tests) != EXPECTED_TESTS_PER_GROUP:
|
||||||
|
sys.exit(f"FATAL: group {g['tgId']} has {len(tests)} tests, "
|
||||||
|
f"expected {EXPECTED_TESTS_PER_GROUP}")
|
||||||
|
ng = {k: v for k, v in g.items() if k != "tests"}
|
||||||
|
ng["tests"] = tests
|
||||||
|
groups.append(ng)
|
||||||
|
if len(groups) != EXPECTED_GROUPS:
|
||||||
|
sys.exit(f"FATAL: found {len(groups)} {PARAMETER_SET} groups, expected {EXPECTED_GROUPS}")
|
||||||
|
out = {"_provenance": PROVENANCE}
|
||||||
|
for k, v in full.items(): # all top-level metadata, verbatim
|
||||||
|
if k != "testGroups":
|
||||||
|
out[k] = v
|
||||||
|
out["testGroups"] = groups
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def render(obj: dict) -> str:
|
||||||
|
return json.dumps(obj, indent=1) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--write", action="store_true", help="regenerate the committed file")
|
||||||
|
args = ap.parse_args()
|
||||||
|
text = render(build(fetch()))
|
||||||
|
if args.write:
|
||||||
|
open(OUT, "w").write(text)
|
||||||
|
print(f"wrote {OUT} ({len(text)} bytes)")
|
||||||
|
return 0
|
||||||
|
if not os.path.exists(OUT):
|
||||||
|
print(f"MISSING: {OUT}"); return 1
|
||||||
|
cur = open(OUT).read()
|
||||||
|
if cur == text:
|
||||||
|
print(f"OK: {OUT} matches a fresh extraction from the pinned upstream file")
|
||||||
|
return 0
|
||||||
|
print(f"MISMATCH: {OUT} differs from a fresh extraction — re-run with --write "
|
||||||
|
"and review the diff")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in a new issue