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.
315 lines
10 KiB
Python
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()
|