The public history carried kat/__pycache__/mlkem768_reference.cpython-314.pyc, a compiled Python artifact embedding the operator's absolute local path. Text secret scanners do not read compiled binaries, which is exactly how it slipped through, and removing it from the tip would have left it reachable through the old root commits. So this repository is republished from a single clean root. This root also carries, from the previously unpublished line of work: - corrected LICENSE year, LICENSING.md, VERIFY-POLICY.md, and CITATIONS-UNRESOLVED.md remeasured 2026-08-11 (101 paths, README aligned) - O-018: run_consensus_verification.py ran 19 of 29 models and reported PASS; it now runs all 29, and computemarket_smt.py gains resolveByTimeout / reclaimUnsettled cases plus a negative control - O-006: the word 'audited' removed from next to Bouncy Castle, twice, after a concurrent edit resurrected it - O-014: prior art named and dated - Algorand's native falcon_verify shipped about ten months before AERE's precompiles; the primacy claim is withdrawn where it was implied - bench/ scripts parametrized so they actually run for an outsider (the earlier textual sanitization left $STAGING unexpanded inside Python strings) - AIP-2/AIP-3 errata with measured figures, spec remeasurements at 2026-08-01, and the spec-zk-stack retractions (owner is an operational key, not the Foundation; 'maximally sound' withdrawn; aggregator V1 deprecated) The redacted bench-host environment files from the sanitized line are kept exactly as published; the unredacted local variants are not carried.
321 lines
11 KiB
Python
321 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# ---------------------------------------------------------------------------
|
|
# mlkem768_reference.py
|
|
#
|
|
# Independent, dependency-free ML-KEM-768 (FIPS 203) ENCAPSULATION oracle,
|
|
# written directly from FIPS 203 and using only Python's standard-library
|
|
# hashlib (SHA3-256, SHA3-512, SHAKE128, SHAKE256). It shares no code with
|
|
# Bouncy Castle, which is what the 0x0AE6 precompile wraps, so agreement
|
|
# between the two is real cross-implementation evidence rather than a tautology.
|
|
#
|
|
# Purpose: the ML-KEM-768 ACVP vectors in ../vectors/mlkem768_acvp.txt were
|
|
# previously only checkable by running MlKemAcvpKat.java against a patched Besu
|
|
# build. This script makes them checkable from a clean checkout with no build
|
|
# step, so an outside researcher can reproduce the claim.
|
|
#
|
|
# python mlkem768_reference.py ../vectors/mlkem768_acvp.txt [out.json]
|
|
#
|
|
# Vector line format: ek_hex|m_hex|c_hex|k_hex
|
|
# ML-KEM.Encaps_internal(ek, m) must reproduce BOTH the ciphertext c and the
|
|
# shared secret k. Checking c alone would not catch a wrong shared secret.
|
|
#
|
|
# NOTE ON SCOPE: 0x0AE6 is TESTNET-ONLY. It is NOT live on Aere mainnet 2800.
|
|
# ---------------------------------------------------------------------------
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
|
|
N = 256
|
|
Q = 3329
|
|
K = 3 # ML-KEM-768
|
|
ETA1 = 2
|
|
ETA2 = 2
|
|
DU = 10
|
|
DV = 4
|
|
|
|
# zeta^BitRev7(i) mod q, the standard FIPS 203 NTT constant table.
|
|
ZETAS = [
|
|
1, 1729, 2580, 3289, 2642, 630, 1897, 848, 1062, 1919, 193, 797, 2786,
|
|
3260, 569, 1746, 296, 2447, 1339, 1476, 3046, 56, 2240, 1333, 1426, 2094,
|
|
535, 2882, 2393, 2879, 1974, 821, 289, 331, 3253, 1756, 1197, 2304, 2277,
|
|
2055, 650, 1977, 2513, 632, 2865, 33, 1320, 1915, 2319, 1435, 807, 452,
|
|
1438, 2868, 1534, 2402, 2647, 2617, 1481, 648, 2474, 3110, 1227, 910, 17,
|
|
2761, 583, 2649, 1637, 723, 2288, 1100, 1409, 2662, 3281, 233, 756, 2156,
|
|
3015, 3050, 1703, 1651, 2789, 1789, 1847, 952, 1461, 2687, 939, 2308, 2437,
|
|
2388, 733, 2337, 268, 641, 1584, 2298, 2037, 3220, 375, 2549, 2090, 1645,
|
|
1063, 319, 2773, 757, 2099, 561, 2466, 2594, 2804, 1092, 403, 1026, 1143,
|
|
2150, 2775, 886, 1722, 1212, 1874, 1029, 2110, 2935, 885, 2154,
|
|
]
|
|
|
|
# zeta^(2*BitRev7(i)+1) for the degree-2 base multiplications.
|
|
GAMMAS = [
|
|
17, 3312, 2761, 568, 583, 2746, 2649, 680, 1637, 1692, 723, 2606, 2288,
|
|
1041, 1100, 2229, 1409, 1920, 2662, 667, 3281, 48, 233, 3096, 756, 2573,
|
|
2156, 1173, 3015, 314, 3050, 279, 1703, 1626, 1651, 1678, 2789, 540, 1789,
|
|
1540, 1847, 1482, 952, 2377, 1461, 1868, 2687, 642, 939, 2390, 2308, 1021,
|
|
2437, 892, 2388, 941, 733, 2596, 2337, 992, 268, 3061, 641, 2688, 1584,
|
|
1745, 2298, 1031, 2037, 1292, 3220, 109, 375, 2954, 2549, 780, 2090, 1239,
|
|
1645, 1684, 1063, 2266, 319, 3010, 2773, 556, 757, 2572, 2099, 1230, 561,
|
|
2768, 2466, 863, 2594, 735, 2804, 525, 1092, 2237, 403, 2926, 1026, 2303,
|
|
1143, 2186, 2150, 1179, 2775, 554, 886, 2443, 1722, 1607, 1212, 2117, 1874,
|
|
1455, 1029, 2300, 2110, 1219, 2935, 394, 885, 2444, 2154, 1175,
|
|
]
|
|
|
|
|
|
# --- hashes (FIPS 203 section 4.1) -----------------------------------------
|
|
def _H(b):
|
|
return hashlib.sha3_256(b).digest()
|
|
|
|
|
|
def _G(b):
|
|
d = hashlib.sha3_512(b).digest()
|
|
return d[:32], d[32:]
|
|
|
|
|
|
class _Squeezer:
|
|
"""Incremental reader over a SHAKE128 stream.
|
|
|
|
hashlib's SHAKE objects have no incremental read, so squeeze a block and
|
|
grow it if the rejection sampler exhausts it. digest(n) is a prefix of
|
|
digest(n+m) for a fixed absorbed input, so growing is safe.
|
|
"""
|
|
|
|
def __init__(self, xof):
|
|
self._xof = xof
|
|
self._buf = xof.digest(1024)
|
|
self._pos = 0
|
|
|
|
def read(self, n):
|
|
if self._pos + n > len(self._buf):
|
|
self._buf = self._xof.digest(len(self._buf) * 2)
|
|
out = self._buf[self._pos:self._pos + n]
|
|
self._pos += n
|
|
return out
|
|
|
|
|
|
def _XOF(rho, i, j):
|
|
return _Squeezer(hashlib.shake_128(rho + bytes([i, j])))
|
|
|
|
|
|
def _PRF(eta, s, b):
|
|
return hashlib.shake_256(s + bytes([b])).digest(64 * eta)
|
|
|
|
|
|
# --- bit / byte conversion (Algorithms 3-6) --------------------------------
|
|
def _bytes_to_bits(bs):
|
|
out = []
|
|
for byte in bs:
|
|
for i in range(8):
|
|
out.append((byte >> i) & 1)
|
|
return out
|
|
|
|
|
|
def _byte_encode(f, d):
|
|
bits = []
|
|
for x in f:
|
|
for i in range(d):
|
|
bits.append((x >> i) & 1)
|
|
out = bytearray(len(bits) // 8)
|
|
for i, bit in enumerate(bits):
|
|
out[i // 8] |= bit << (i % 8)
|
|
return bytes(out)
|
|
|
|
|
|
def _byte_decode(bs, d):
|
|
bits = _bytes_to_bits(bs)
|
|
m = Q if d == 12 else (1 << d)
|
|
return [
|
|
sum(bits[i * d + j] << j for j in range(d)) % m
|
|
for i in range(len(bits) // d)
|
|
]
|
|
|
|
|
|
# --- sampling (Algorithms 7-8) ---------------------------------------------
|
|
def _sample_ntt(xof):
|
|
a = []
|
|
while len(a) < N:
|
|
c = xof.read(3)
|
|
d1 = c[0] + 256 * (c[1] % 16)
|
|
d2 = (c[1] // 16) + 16 * c[2]
|
|
if d1 < Q:
|
|
a.append(d1)
|
|
if d2 < Q and len(a) < N:
|
|
a.append(d2)
|
|
return a
|
|
|
|
|
|
def _sample_poly_cbd(bs, eta):
|
|
bits = _bytes_to_bits(bs)
|
|
f = []
|
|
for i in range(N):
|
|
x = sum(bits[2 * i * eta + j] for j in range(eta))
|
|
y = sum(bits[2 * i * eta + eta + j] for j in range(eta))
|
|
f.append((x - y) % Q)
|
|
return f
|
|
|
|
|
|
# --- NTT (Algorithms 9-11) --------------------------------------------------
|
|
def _ntt(f):
|
|
f = list(f)
|
|
i = 1
|
|
length = 128
|
|
while length >= 2:
|
|
start = 0
|
|
while start < N:
|
|
z = ZETAS[i]
|
|
i += 1
|
|
for j in range(start, start + length):
|
|
t = (z * f[j + length]) % Q
|
|
f[j + length] = (f[j] - t) % Q
|
|
f[j] = (f[j] + t) % Q
|
|
start += 2 * length
|
|
length //= 2
|
|
return f
|
|
|
|
|
|
def _ntt_inv(f):
|
|
f = list(f)
|
|
i = 127
|
|
length = 2
|
|
while length <= 128:
|
|
start = 0
|
|
while start < N:
|
|
z = ZETAS[i]
|
|
i -= 1
|
|
for j in range(start, start + length):
|
|
t = f[j]
|
|
f[j] = (t + f[j + length]) % Q
|
|
f[j + length] = (z * (f[j + length] - t)) % Q
|
|
start += 2 * length
|
|
length *= 2
|
|
return [(x * 3303) % Q for x in f]
|
|
|
|
|
|
def _multiply_ntts(f, g):
|
|
h = [0] * N
|
|
for i in range(N // 2):
|
|
a0, a1 = f[2 * i], f[2 * i + 1]
|
|
b0, b1 = g[2 * i], g[2 * i + 1]
|
|
gm = GAMMAS[i]
|
|
h[2 * i] = (a0 * b0 + a1 * b1 % Q * gm) % Q
|
|
h[2 * i + 1] = (a0 * b1 + a1 * b0) % Q
|
|
return h
|
|
|
|
|
|
def _add(f, g):
|
|
return [(a + b) % Q for a, b in zip(f, g)]
|
|
|
|
|
|
# --- compression (section 4.2.1) -------------------------------------------
|
|
def _compress(f, d):
|
|
return [(((x << d) + Q // 2) // Q) % (1 << d) for x in f]
|
|
|
|
|
|
def _decompress(f, d):
|
|
return [((x * Q) + (1 << (d - 1))) >> d for x in f]
|
|
|
|
|
|
# --- K-PKE.Encrypt (Algorithm 14) ------------------------------------------
|
|
def _kpke_encrypt(ek, m, r):
|
|
t_hat = [_byte_decode(ek[384 * i:384 * (i + 1)], 12) for i in range(K)]
|
|
rho = ek[384 * K:384 * K + 32]
|
|
|
|
# FIPS 203 absorbs rho || j || i to produce A_hat[i][j], so the array built
|
|
# here from _XOF(rho, i, j) is already A_hat TRANSPOSED. Encrypt wants
|
|
# A_hat^T . y, so it is indexed [i][j] directly below with no second
|
|
# transpose. Getting this backwards still yields a correct shared secret K
|
|
# and a correct v, and corrupts only c1, which is why the shared-secret
|
|
# check alone is not sufficient evidence.
|
|
a_hat_t = [[_sample_ntt(_XOF(rho, i, j)) for j in range(K)] for i in range(K)]
|
|
|
|
y = [_ntt(_sample_poly_cbd(_PRF(ETA1, r, i), ETA1)) for i in range(K)]
|
|
e1 = [_sample_poly_cbd(_PRF(ETA2, r, K + i), ETA2) for i in range(K)]
|
|
e2 = _sample_poly_cbd(_PRF(ETA2, r, 2 * K), ETA2)
|
|
|
|
# u = NTT^-1(A^T . y) + e1
|
|
u = []
|
|
for i in range(K):
|
|
acc = [0] * N
|
|
for j in range(K):
|
|
acc = _add(acc, _multiply_ntts(a_hat_t[i][j], y[j]))
|
|
u.append(_add(_ntt_inv(acc), e1[i]))
|
|
|
|
mu = _decompress(_byte_decode(m, 1), 1)
|
|
acc = [0] * N
|
|
for i in range(K):
|
|
acc = _add(acc, _multiply_ntts(t_hat[i], y[i]))
|
|
v = _add(_add(_ntt_inv(acc), e2), mu)
|
|
|
|
c1 = b"".join(_byte_encode(_compress(u[i], DU), DU) for i in range(K))
|
|
c2 = _byte_encode(_compress(v, DV), DV)
|
|
return c1 + c2
|
|
|
|
|
|
def encaps_internal(ek, m):
|
|
"""ML-KEM.Encaps_internal (Algorithm 17). Returns (shared_secret_K, ciphertext_c)."""
|
|
k_bar, r = _G(m + _H(ek))
|
|
c = _kpke_encrypt(ek, m, r)
|
|
return k_bar, c
|
|
|
|
|
|
def main():
|
|
vectors = sys.argv[1] if len(sys.argv) > 1 else "../vectors/mlkem768_acvp.txt"
|
|
outpath = sys.argv[2] if len(sys.argv) > 2 else "kat-results-mlkem-reference.json"
|
|
|
|
passed = failed = total = 0
|
|
results = []
|
|
with open(vectors) as f:
|
|
for raw in f:
|
|
line = raw.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
p = line.split("|")
|
|
if len(p) != 5:
|
|
continue
|
|
total += 1
|
|
tc, ek_hex, m_hex, c_hex, k_hex = p
|
|
ek = bytes.fromhex(ek_hex)
|
|
m = bytes.fromhex(m_hex)
|
|
c_want = bytes.fromhex(c_hex)
|
|
k_want = bytes.fromhex(k_hex)
|
|
k_got, c_got = encaps_internal(ek, m)
|
|
ok = (k_got == k_want) and (c_got == c_want)
|
|
if ok:
|
|
passed += 1
|
|
else:
|
|
failed += 1
|
|
results.append({
|
|
"tcId": tc,
|
|
"ekLen": len(ek),
|
|
"ctLen": len(c_got),
|
|
"ciphertextMatch": c_got == c_want,
|
|
"sharedSecretMatch": k_got == k_want,
|
|
"status": "PASS" if ok else "FAIL",
|
|
})
|
|
print(f"ML-KEM-768 tc{tc} ekLen={len(ek)} ctLen={len(c_got)} "
|
|
f"c={'OK' if c_got == c_want else 'MISMATCH'} "
|
|
f"k={'OK' if k_got == k_want else 'MISMATCH'} -> "
|
|
f"{'PASS' if ok else 'FAIL'}")
|
|
|
|
report = {
|
|
"precompile": "ML-KEM-768 (FIPS 203) deterministic encapsulate",
|
|
"address": "0x0000000000000000000000000000000000000ae6",
|
|
"liveOnMainnet2800": False,
|
|
"note": "TESTNET-ONLY precompile. Verified here against an independent "
|
|
"pure-Python FIPS 203 oracle (stdlib hashlib only), not Bouncy Castle.",
|
|
"oracle": "mlkem768_reference.py (this file)",
|
|
"passed": passed, "failed": failed, "total": total, "results": results,
|
|
}
|
|
with open(outpath, "w") as f:
|
|
json.dump(report, f, indent=2)
|
|
|
|
print(f"ML-KEM-768 KAT: passed={passed} failed={failed} total={total}")
|
|
if failed != 0 or total == 0:
|
|
print("MLKEM_KAT_FAILED")
|
|
sys.exit(3)
|
|
print("MLKEM_KAT_OK")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|