mirror of
https://github.com/saymrwulf/fips205-source.git
synced 2026-09-03 19:53:49 +00:00
vectors: make the extraction re-derivable and the provenance claim literally true
Round-7 review (both reviewers, independently) found that the ACVP provenance note said "the per-test private key `sk` dropped ... Nothing else altered" while the extraction had in fact also dropped `additionalRandomness` and `deferred` from all 42 tests, plus the top-level `isSample`. No field the tests consume was affected and no verdict changed — but the provenance block is the audit trail a third party diffs against, and as written it would have produced a false alarm or taught the next reviewer to wave differences through. Rather than reword the note, the transformation is now executable and pinned: tests/nist_acvp_vectors/extract_sha2_128s.py re-derives the file, verifies the upstream sha256 before doing anything, requires exactly 3 groups x 14 tests, removes exactly ONE field (`sk`) and asserts that invariant, carries every other per-test, group and top-level key through untouched, and writes canonical output. Run with no arguments it VERIFIES the committed file against a fresh extraction; --write regenerates. The file was regenerated with it, so "only `sk` removed" is now literally true and machine-checkable. Also from round 7, precision in the tests themselves: - exact assertions replace floors: `points == 108` (was >= 100), `with_ctx == 9` (was > 0), and prehash `3/4/7` (was `checked > 0`). With 7 of 14 prehash vectors skipped for unimplemented hash functions, a floor would have let real coverage fall from 3 to 1 while the total still summed to 14. - the randomized bridge's doc comment claimed corruption "across the WHOLE signature"; measured, the schedule hits 72 distinct positions in 11..=7779, never bytes 0-10 or 7780-7855. Corrected to the measured statement. Verified on stable Rust (rustc 1.95) as well as the pinned nightly: the bridge needs no nightly feature, so a third party can run all of it with cargo alone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3153988c4e
commit
c945821bf9
3 changed files with 354 additions and 120 deletions
|
|
@ -557,7 +557,9 @@ mod tests {
|
|||
}
|
||||
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");
|
||||
// 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"
|
||||
|
|
@ -610,7 +612,12 @@ mod tests {
|
|||
}
|
||||
}
|
||||
assert_eq!(checked + wrong_len + unsupported_alg, 14, "unaccounted NIST prehash tests");
|
||||
assert!(checked > 0, "no prehash KAT was executable");
|
||||
// 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)"
|
||||
|
|
@ -620,8 +627,11 @@ mod tests {
|
|||
/// 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
|
||||
/// 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]
|
||||
|
|
@ -681,7 +691,7 @@ mod tests {
|
|||
assert!(!mc, "round {round}: signature verified under the wrong context");
|
||||
points += 1;
|
||||
}
|
||||
assert!(points >= 100, "expected >=100 differential points, got {points}");
|
||||
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
138
tests/nist_acvp_vectors/extract_sha2_128s.py
Normal file
138
tests/nist_acvp_vectors/extract_sha2_128s.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
#!/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;
|
||||
* every other key present upstream is carried through untouched, and the
|
||||
script asserts that afterwards;
|
||||
* output is canonical (sorted keys off, fixed indent, trailing newline).
|
||||
|
||||
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; the script "
|
||||
"asserts this and fails if it is not so.",
|
||||
"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")
|
||||
kept = {k: v for k, v in t.items() if k not in DROP_FIELDS}
|
||||
# carried-through invariant, asserted rather than asserted-in-prose
|
||||
assert set(kept) == set(t) - DROP_FIELDS, "unexpected per-test key change"
|
||||
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