#!/usr/bin/env python3 # xcheck_mlkemr.py - Cross-validate the hardware ML-KEM KeyGen KAT vectors # against the ml-kem-r Rust reference's NIST .rsp files. # # WHY THIS SCRIPT EXISTS (byte-order convention): # The hardware feeds G(reverse(d) || K), i.e. it byte-reverses the d/z seeds # before hashing. Consequently the hardware vector files # sync_rtl/top/TB/vectors/kat_k{K}_c{C}_{d,z}.hex # store d/z in the REVERSE byte order of the NIST .rsp `d`/`z` fields. Both # conventions hash to the SAME pk/sk (reverse(reverse(d)) == d), so both the # hardware KAT and ml-kem-r's own KAT pass -- they just disagree on how d/z # are written down. A naive byte-for-byte compare of d/z therefore "fails" # even though the implementations are equivalent. This script compares with # the reversal applied, and compares ek/dk (== pk/sk) directly. # # See also: the TB d/z dump in tb_mlkem_kg_katK_xsim.v and the keygen G-hash # reversal in sync_rtl/top/mlkem_top.v. # # Usage: # python3 sync_rtl/top/TB/xcheck_mlkemr.py # ML_KEM_R=/path/to/ml-kem-r python3 sync_rtl/top/TB/xcheck_mlkemr.py # # Exit code 0 = all consistent, 1 = discrepancy / missing files. import os import sys # Hardware vector dir (relative to repo root, where this script's ../../.. is). SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) HW_DIR = SCRIPT_DIR # vectors live under SCRIPT_DIR/vectors # ml-kem-r location: env override, else common sibling path. ML_KEM_R = os.environ.get("ML_KEM_R", os.path.expanduser("~/Dev/ml-kem-r")) RSP = { 2: "test_data/kat_MLKEM_512.rsp", 3: "test_data/kat_MLKEM_768.rsp", 4: "test_data/kat_MLKEM_1024.rsp", } # Cases present in the hardware vector set per K (K=2 has 0..4, K=3/4 have 0..2). CASES = {2: range(5), 3: range(3), 4: range(3)} def die(msg): print(f"ERROR: {msg}", file=sys.stderr) sys.exit(1) def rsp_cases(path): """Parse a NIST .rsp into a list of dicts keyed by count.""" if not os.path.isfile(path): die(f"ml-kem-r .rsp not found: {path}\n" f" set ML_KEM_R=/path/to/ml-kem-r (current: {ML_KEM_R})") cur, out = None, [] with open(path) as f: for ln in f: ln = ln.strip() if ln.startswith("count = "): if cur is not None: out.append(cur) cur = {"count": int(ln[8:])} elif " = " in ln and cur is not None: k, v = ln.split(" = ", 1) cur[k] = v.strip().lower() if cur is not None: out.append(cur) return out def hwcat(k, c, which): """Concatenate a hardware .hex vector file into one lowercase hex string.""" path = os.path.join(HW_DIR, "vectors", f"kat_k{k}_c{c}_{which}.hex") if not os.path.isfile(path): die(f"hardware vector not found: {path}") with open(path) as f: return "".join(f.read().split()).lower() def revhex(h): """Byte-reverse a hex string.""" return bytes.fromhex(h)[::-1].hex() def main(): print(f"ml-kem-r: {ML_KEM_R}") print(f"hardware: {os.path.join(HW_DIR, 'vectors')}") print("convention: hardware d/z == reverse(NIST d/z); ek==pk, dk==sk\n") all_ok = True total = 0 for k in (2, 3, 4): cases = rsp_cases(os.path.join(ML_KEM_R, RSP[k])) bycount = {c["count"]: c for c in cases} for c in CASES[k]: total += 1 hd, hz = hwcat(k, c, "d"), hwcat(k, c, "z") hek, hdk = hwcat(k, c, "ek"), hwcat(k, c, "dk") rc = bycount.get(c) if rc is None: die(f"ml-kem-r .rsp for K={k} has no count={c}") d_ok = revhex(hd) == rc["d"] z_ok = revhex(hz) == rc["z"] ek_ok = hek == rc["pk"] dk_ok = hdk == rc["sk"] case_ok = d_ok and z_ok and ek_ok and dk_ok all_ok = all_ok and case_ok print(f"K={k} c{c}: d(rev)={'Y' if d_ok else 'N'} " f"z(rev)={'Y' if z_ok else 'N'} " f"ek={'Y' if ek_ok else 'N'} " f"dk={'Y' if dk_ok else 'N'} -> " f"{'OK' if case_ok else 'MISMATCH'}") print(f"\n{total} cases checked: " f"{'ALL CONSISTENT' if all_ok else 'DISCREPANCY FOUND'}") sys.exit(0 if all_ok else 1) if __name__ == "__main__": main()