aere-research/kat/mlkem768_reference.py
Aere Network 4a0b48588c Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:30 +03:00

315 lines
10 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]
a_hat = [[_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[j][i], 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()