#!/usr/bin/env python3 """gen_vectors.py - Test vector generator for sample_cbd_sync module. Generates random seed+nonce+eta test vectors, computes expected CBD coefficients using Python hashlib (stdlib only), and writes input hex and expected output hex files. Algorithm (matching RTL sample_cbd_sync.v): PRF(sigma, N) = SHAKE-256(sigma || N) → squeeze eta*64 bytes For each of 256 coefficients: eta=2: read 4 bits, coeff = (b0+b1) - (b2+b3) eta=3: read 6 bits, coeff = (b0+b1+b2) - (b3+b4+b5) Each coefficient in range [-eta, eta], stored as 12-bit signed. Bit ordering (FIPS 202 / RTL match): The RTL feeds seed_i[0] as the first bit into SHA3. Python hashlib expects bytes[0] LSB as the first bit. $readmemh stores hex with MSB first → seed_i[255:0] MSB-first. So: Python input = reverse(bytes.fromhex(seed_hex)) + bytes([nonce]). Usage: python3 gen_vectors.py # Generate vectors python3 gen_vectors.py --verify # Verify results against expected """ import hashlib import os import random import sys N_COEFFS = 256 Q = 3329 # not used for CBD, but for reference def random_hex(bits): """Generate a random hex string (MSB-first) of the given bit length.""" val = random.getrandbits(bits) num_nibbles = (bits + 3) // 4 return f"{val:0{num_nibbles}X}" def bits_from_bytes(data): """Convert bytes to LSB-first bit string. data[0] LSB = bits[0], data[0] bit 1 = bits[1], ... """ bits = [] for b in data: for bit in range(8): bits.append('1' if (b >> bit) & 1 else '0') return ''.join(bits) def shake256_prf(seed_hex, nonce, eta): """Compute SHAKE-256(seed || nonce) matching RTL bit ordering. Args: seed_hex: 64-char hex string (MSB-first, as stored by $readmemh). nonce: 8-bit integer. eta: 2 or 3. Returns: bytes: eta * 64 bytes of SHAKE-256 squeeze output. """ # seed_hex is MSB-first: seed_i[255:0] = 256'h # RTL feeds seed_i[0] first into SHA3. # Python: reverse bytes so that byte[0] = seed_i[7:0], # then append nonce byte. seed_bytes = bytes.fromhex(seed_hex) # MSB-first bytes shake_input = bytes(reversed(seed_bytes)) + bytes([nonce & 0xFF]) shake = hashlib.shake_256(shake_input) return shake.digest(eta * 64) def cbd_sample(prf_bytes, eta): """Apply Centered Binomial Distribution to PRF output bytes. Args: prf_bytes: bytes from SHAKE-256 squeeze (eta * 64 bytes). eta: 2 or 3. Returns: list of 256 signed integers in range [-eta, eta]. """ bits = bits_from_bytes(prf_bytes) step = eta * 2 # 4 for eta=2, 6 for eta=3 half = eta # 2 for eta=2, 3 for eta=3 coeffs = [] for i in range(N_COEFFS): pos_sum = sum(1 for j in range(half) if bits[step * i + j] == '1') neg_sum = sum(1 for j in range(half) if bits[step * i + half + j] == '1') coeffs.append(pos_sum - neg_sum) return coeffs def coeff_to_hex_12signed(val): """Convert signed 12-bit value to 3-char hex string (two's complement).""" masked = val & 0xFFF # 12-bit unsigned return f"{masked:03X}" def generate_one(eta): """Generate a single test vector. Returns: dict with 'seed_hex', 'nonce', 'eta', 'coeffs' keys. """ seed_hex = random_hex(256) nonce = random.randint(0, 255) prf_bytes = shake256_prf(seed_hex, nonce, eta) coeffs = cbd_sample(prf_bytes, eta) return { "seed_hex": seed_hex, "nonce": nonce, "eta": eta, "coeffs": coeffs, } def write_input_hex(vectors, filepath): """Write input vectors as packed hex for $readmemh. Each line: {eta[1:0], nonce[7:0], seed[255:0]} = 266 bits. Written as 67 hex chars (268 bits, top 2 bits zero). """ os.makedirs(os.path.dirname(filepath), exist_ok=True) with open(filepath, "w") as f: for v in vectors: eta = v["eta"] nonce = v["nonce"] seed_hex = v["seed_hex"] # Pack: {2'b00, eta[1:0], nonce[7:0], seed[255:0]} = 268 bits = 67 hex chars # The first hex char carries eta in its lower 2 bits: # hex value = 0x0 | eta → nibble = 0b00XX where XX = eta # TB reads vec_eta = vector_mem[idx][265:264] packed = f"{eta & 0x3:01X}{nonce:02X}{seed_hex}" # 1+2+64 = 67 chars f.write(packed + "\n") def write_expected_hex(vectors, filepath): """Write expected coefficients: one 12-bit hex value per line.""" os.makedirs(os.path.dirname(filepath), exist_ok=True) with open(filepath, "w") as f: for i, v in enumerate(vectors): f.write(f"# VECTOR_{i} eta={v['eta']} nonce=0x{v['nonce']:02X} seed={v['seed_hex']}\n") for c in v["coeffs"]: f.write(coeff_to_hex_12signed(c) + "\n") def verify_results(result_file, vectors): """Verify RTL output against expected values. Args: result_file: Path to RTL result file (coeff hex per line). vectors: List of vector dicts with 'coeffs'. Returns: bool: True if all vectors match. """ with open(result_file, "r") as f: lines = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")] # lines may contain trailing comments separated by " #" cleaned = [] for line in lines: comment_idx = line.find(" #") if comment_idx >= 0: line = line[:comment_idx].strip() cleaned.append(line) expected_all = [] for v in vectors: for c in v["coeffs"]: expected_all.append(coeff_to_hex_12signed(c)) if len(cleaned) != len(expected_all): print(f" COUNT MISMATCH: got {len(cleaned)}, expected {len(expected_all)}") return False mismatches = 0 for i, (g, e) in enumerate(zip(cleaned, expected_all)): if g.upper() != e.upper(): if mismatches < 10: print(f" MISMATCH[{i}]: got={g.upper()}, expected={e.upper()}") mismatches += 1 if mismatches > 0: print(f" Total mismatches: {mismatches}") return False return True def main(): vector_count = 4 # 2 vectors with eta=2, 2 with eta=3 base_dir = os.path.dirname(os.path.abspath(__file__)) vectors_dir = os.path.join(base_dir, "vectors") input_file = os.path.join(vectors_dir, "sample_cbd_input.hex") expected_file = os.path.join(vectors_dir, "sample_cbd_expected.hex") result_file = os.path.join(vectors_dir, "sample_cbd_result.hex") verify_mode = "--verify" in sys.argv if verify_mode: if not os.path.exists(result_file): print(f"ERROR: Result file not found: {result_file}") print(" Run simulation first to generate results.") sys.exit(1) if not os.path.exists(input_file): print(f"ERROR: Input file not found: {input_file}") sys.exit(1) print(f"Verifying results from {result_file}...") # Recompute expected from input file with open(input_file, "r") as f: input_lines = [l.strip() for l in f if l.strip()] vectors = [] for line in input_lines: if len(line) != 67: print(f"WARNING: Skipping line with unexpected length {len(line)}: {line[:20]}...") continue # Parse: first char = eta_nibble, next 2 = nonce, next 64 = seed eta_nibble = int(line[0], 16) eta = eta_nibble >> 2 # bits[3:2] nonce = int(line[1:3], 16) seed_hex = line[3:] prf_bytes = shake256_prf(seed_hex, nonce, eta) coeffs = cbd_sample(prf_bytes, eta) vectors.append({"seed_hex": seed_hex, "nonce": nonce, "eta": eta, "coeffs": coeffs}) ok = verify_results(result_file, vectors) if ok: print("ALL VECTORS PASSED") else: print("VERIFICATION FAILED") sys.exit(1) else: # Generate mode print(f"Generating {vector_count} test vectors...") vectors = [] # eta=2 vectors for i in range(vector_count // 2): v = generate_one(eta=2) vectors.append(v) print(f" Vector {len(vectors)-1}: eta=2, nonce=0x{v['nonce']:02X}, " f"seed={v['seed_hex'][:8]}..., coeffs[0]={v['coeffs'][0]}") # eta=3 vectors for i in range(vector_count // 2): v = generate_one(eta=3) vectors.append(v) print(f" Vector {len(vectors)-1}: eta=3, nonce=0x{v['nonce']:02X}, " f"seed={v['seed_hex'][:8]}..., coeffs[0]={v['coeffs'][0]}") write_input_hex(vectors, input_file) print(f"Wrote {len(vectors)} vectors to {input_file}") write_expected_hex(vectors, expected_file) print(f"Wrote expected coefficients to {expected_file}") # Sanity checks for v in vectors: for c in v["coeffs"]: assert -v["eta"] <= c <= v["eta"], \ f"Coefficient {c} out of range [-{v['eta']}, {v['eta']}]" print("All sanity checks passed.") if __name__ == "__main__": main()