feat: add four 90-ring entropy generators with spatial and temporal XOR (FPGA v3)

This commit is contained in:
randogoth 2026-10-25 13:23:27 +02:00
parent 504b00752a
commit 585e755c66
9 changed files with 389 additions and 217 deletions

View file

@ -1,17 +1,22 @@
#!/usr/bin/env python3
"""Ring-oscillator experiments against the STRIX capture sketch (arduino/strix_capture).
"""Entropy generator experiments against the STRIX capture sketch (arduino/strix_capture).
Subcommands:
sweep capture sources at increasing decimation k and fit the accumulated jitter
freq measure every ring's frequency
xor capture the XOR of rings 0..n-1 for n = 1..16 and compare with the product law
pairs capture ring pairs simultaneously and test them for correlation
freq measure ring frequencies in every generator
rings estimate each ring's relative predictability at k=1 (sizing input)
sweep capture sources at increasing decimation k and fit the accumulated jitter
output check every generator's spatial XOR and processed output for bias and correlation
pairs capture the same ring index in two generators simultaneously and test for correlation
Entropy per bit is estimated from conditional block entropies and converted to a
predictability P; 2P-1 is the relative predictability, which multiplies when independent
sources are XORed. The jitter fit models a square wave sampled with Gaussian phase noise
whose standard deviation grows with sqrt(k). On synthetic captures it overestimates by a
factor of about 1.2-1.3, so divide by 1.3 when sizing the XOR combining conservatively.
sources are XORed. Statistics of XOR-combined streams overstate their randomness, because
short contexts cannot see the long-period structure of mixed rings, so size the XOR
combining from single-ring values (`rings`), not from `output`.
The jitter fit models a square wave sampled with Gaussian phase noise whose standard
deviation grows with sqrt(k). On synthetic captures it overestimates by a factor of about
1.2-1.3, so divide by 1.3 when sizing conservatively.
"""
import argparse
@ -24,14 +29,16 @@ from pathlib import Path
import numpy as np
import serial
RINGS = 16
CLOCK_HZ = 120e6
SOURCE_ENHANCED, SOURCE_TAP, SOURCE_XOR = 0x00, 0x10, 0x20
REG_FREQ_GATE = 0x05
# Capture sources for generator g
SOURCE_OUTPUT, SOURCE_SPATIAL, SOURCE_RING, SOURCE_TAP = 0x00, 0x04, 0x08, 0x0C
REG_FREQ_GATE, REG_GENERATORS, REG_RINGS = 0x05, 0x06, 0x07
REG_SOURCE_A, REG_DECIM_HIGH, REG_DECIM_LOW = 0x08, 0x09, 0x0A
REG_CONTROL, REG_STATUS, REG_SOURCE_B, REG_MODE = 0x0B, 0x0C, 0x0D, 0x0E
REG_FREQ_SELECT, REG_FREQ_COUNT3 = 0x11, 0x12
REG_RING_SELECT, REG_FREQ_COUNT3 = 0x11, 0x12
REG_GEN_ENABLE, REG_TEMPORAL, REG_FREQ_GEN = 0x16, 0x17, 0x18
CONTROL_START_FREQ = 0x04
STATUS_FREQ_VALID = 0x08
@ -40,27 +47,36 @@ def ring_length(ring: int) -> int:
return 9 + 3 * (ring % 3)
def source_name(code: int) -> str:
if code >= SOURCE_XOR:
return f"xor 0-{code - SOURCE_XOR}"
if code >= SOURCE_TAP:
return f"tap {code - SOURCE_TAP}"
return f"enhanced {code}"
def source_name(code: int, ring: int) -> str:
generator, kind = code & 0x03, code & 0x0C
names = {SOURCE_OUTPUT: "output", SOURCE_SPATIAL: "spatial",
SOURCE_RING: f"ring {ring}", SOURCE_TAP: f"tap {ring}"}
return f"g{generator} {names[kind]}"
# Board communication
class Board:
def __init__(self, port: str):
self.serial = serial.Serial(port, 115200, timeout=200)
# The sketch prints its identity once when the port opens; drain it so replies stay
# aligned with commands.
time.sleep(0.5)
self.serial.reset_input_buffer()
identity = self.command("id")
if not identity.startswith("STRX v2"):
sys.exit(f"unexpected board identity: {identity!r} (needs the v2 FPGA image)")
print(f"board: {identity}")
# The sketch also prints its identity when the port opens, possibly after our "id"
# reply if the board is still booting. Take the first identity line, then drain
# until the port is quiet so replies stay aligned with commands.
self.serial = serial.Serial(port, 115200, timeout=5)
self.serial.write(b"id\n")
identity = ""
while not identity.startswith("STRX"):
identity = self.serial.readline().decode(errors="replace").strip()
if not identity:
sys.exit("no answer from the capture sketch")
self.serial.timeout = 0.5
while self.serial.readline():
pass
self.serial.timeout = 200
if not identity.startswith("STRX v3"):
sys.exit(f"unexpected board identity: {identity!r} (needs the v3 FPGA image)")
self.generators = self.read(REG_GENERATORS)
self.rings = self.read(REG_RINGS)
print(f"board: {identity}, {self.generators} generators x {self.rings} rings")
def command(self, line: str) -> str:
self.serial.write(f"{line}\n".encode())
@ -90,8 +106,9 @@ class Board:
sys.exit(f"short capture: {len(data)} of {length} bytes")
return data
def frequency(self, ring: int) -> float:
self.write(REG_FREQ_SELECT, ring)
def frequency(self, generator: int, ring: int) -> float:
self.write(REG_FREQ_GEN, generator)
self.write(REG_RING_SELECT, ring)
self.write(REG_CONTROL, CONTROL_START_FREQ)
while not self.read(REG_STATUS) & STATUS_FREQ_VALID:
pass
@ -151,12 +168,18 @@ def analyze(bits: np.ndarray, order: int) -> dict:
}
def correlation(a: np.ndarray, b: np.ndarray) -> float:
a = a - a.mean()
b = b - b.mean()
return float(np.dot(a, b) / math.sqrt(np.dot(a, a) * np.dot(b, b)))
def print_header() -> None:
print(f"{'source':<12} {'k':>5} {'ones':>7} {'autocorr':>9} {'H/bit':>7} {'2P-1':>8}")
print(f"{'source':<14} {'k':>5} {'ones':>7} {'autocorr':>9} {'H/bit':>7} {'2P-1':>8}")
def print_row(name: str, k: int, r: dict) -> None:
print(f"{name:<12} {k:>5} {r['ones']:7.4f} {r['autocorr']:9.4f} {r['entropy']:7.4f} {r['relative']:8.5f}")
print(f"{name:<14} {k:>5} {r['ones']:7.4f} {r['autocorr']:9.4f} {r['entropy']:7.4f} {r['relative']:8.5f}")
# Jitter model: a square wave sampled at a nominal phase mu in the high half-cycle, with
@ -190,16 +213,53 @@ def fit_jitter(decimations: list[int], relative: list[float]) -> float:
# Subcommands
def run_freq(board: Board | None, args) -> None:
if board is None:
sys.exit("freq needs the board")
print(f"{'gen':>3} {'length':>6} {'rings':>5} {'min MHz':>8} {'mean MHz':>9} {'max MHz':>8}")
for g in range(board.generators):
by_length: dict[int, list[float]] = {}
for ring in range(board.rings):
by_length.setdefault(ring_length(ring), []).append(board.frequency(g, ring) / 1e6)
for length, freqs in sorted(by_length.items()):
print(f"{g:>3} {length:>6} {len(freqs):>5} {min(freqs):8.2f} {np.mean(freqs):9.2f} {max(freqs):8.2f}")
def run_rings(board: Board | None, args) -> None:
values = []
print(f"{'ring':>4} {'length':>6} {'ones':>7} {'2P-1':>8}")
for ring in args.rings:
path = args.out / f"ring_g{args.generator}_r{ring}_k1.bin"
if board:
board.write(REG_RING_SELECT, ring)
data = cached_capture(board, path, SOURCE_RING + args.generator, 1)
if data is None:
continue
r = analyze(to_bits(data), args.order)
values.append(r["relative"])
print(f"{ring:>4} {ring_length(ring):>6} {r['ones']:7.4f} {r['relative']:8.5f}")
if values:
# Independent sources multiply: the generator's spatial XOR has 2P-1 = product over rings.
log_spatial = float(np.sum(np.log(values)))
print(f"\n2P-1 per ring at k=1 (order {args.order}): mean {np.mean(values):.4f}, worst {max(values):.4f}")
print(f"spatial XOR of these {len(values)} rings: 2P-1 = {math.exp(log_spatial):.3e}")
for temporal in range(1, 9):
print(f" temporal XOR {temporal}: output 2P-1 = {math.exp(temporal * log_spatial):.3e}, "
f"{CLOCK_HZ / temporal / 1e6:5.1f} Mbps per generator")
def run_sweep(board: Board | None, args) -> None:
if board:
board.write(REG_RING_SELECT, args.ring)
results = {}
print_header()
for source in args.sources:
for k in args.decimations:
data = cached_capture(board, args.out / f"sweep_s{source:02x}_k{k}.bin", source, k)
data = cached_capture(board, args.out / f"sweep_s{source:02x}_r{args.ring}_k{k}.bin", source, k)
if data is None:
continue
results[(source, k)] = r = analyze(to_bits(data), args.order)
print_row(source_name(source), k, r)
print_row(source_name(source, args.ring), k, r)
print("\nfitted jitter per 120 MHz sample interval (ring cycles, rms):")
for source in args.sources:
@ -208,88 +268,80 @@ def run_sweep(board: Board | None, args) -> None:
if (source, k) in results and results[(source, k)]["relative"] > 0.1]
if len(points) >= 3:
ks, rel = zip(*points)
print(f" {source_name(source):<12} {fit_jitter(list(ks), list(rel)):.5f}")
print(f" {source_name(source, args.ring):<14} {fit_jitter(list(ks), list(rel)):.5f}")
def run_freq(board: Board | None, args) -> None:
if board is None:
sys.exit("freq needs the board")
print(f"{'ring':>4} {'length':>6} {'MHz':>9}")
for ring in range(RINGS):
print(f"{ring:>4} {ring_length(ring):>6} {board.frequency(ring) / 1e6:9.2f}")
def run_xor(board: Board | None, args) -> None:
single = {}
for ring in range(RINGS):
data = cached_capture(board, args.out / f"single_s{ring:02x}_k{args.k}.bin", SOURCE_ENHANCED + ring, args.k)
if data is not None:
single[ring] = analyze(to_bits(data), args.order)["relative"]
print(f"{'n':>3} {'ones':>7} {'H/bit':>7} {'2P-1':>8} {'product':>8}")
product = 1.0
for n in range(1, RINGS + 1):
product *= single.get(n - 1, float("nan"))
data = cached_capture(board, args.out / f"xor_n{n}_k{args.k}.bin", SOURCE_XOR + n - 1, args.k)
if data is None:
continue
r = analyze(to_bits(data), args.order)
print(f"{n:>3} {r['ones']:7.4f} {r['entropy']:7.4f} {r['relative']:8.5f} {product:8.5f}")
print("\n'product' is the prediction for independent rings: the product of the single rings' 2P-1.")
print("Measured values sit on the estimator's noise floor (about 0.03 for 256 Kbit) once it is reached.")
def correlation(a: np.ndarray, b: np.ndarray) -> float:
a = a - a.mean()
b = b - b.mean()
return float(np.dot(a, b) / math.sqrt(np.dot(a, a) * np.dot(b, b)))
def run_output(board: Board | None, args) -> None:
# Longer contexts are undersampled at 256 Kbit and read falsely low on random data.
order = min(args.order, 8)
temporal = board.read(REG_TEMPORAL) if board else args.temporal
generators = board.generators if board else 4
print(f"temporal XOR = {temporal}; output captured with decimation {temporal}")
print("ones should be within about +-0.003 of 0.5 (3 sigma for 256 Kbit)\n")
print_header()
for g in range(generators):
for source, k in [(SOURCE_SPATIAL + g, 1), (SOURCE_OUTPUT + g, temporal)]:
data = cached_capture(board, args.out / f"output_s{source:02x}_k{k}.bin", source, k)
if data is not None:
print_row(source_name(source, 0), k, analyze(to_bits(data), order))
def run_pairs(board: Board | None, args) -> None:
generators = board.generators if board else 4
rng = np.random.default_rng(0)
pairs = list(combinations(range(RINGS), 2))
print(f"{'pair':>7} {'corr':>8} {'null sd':>8} {'z':>7}")
flagged = 0
for i, j in pairs:
data = cached_capture(board, args.out / f"pair_{i}_{j}_k{args.k}.bin",
SOURCE_ENHANCED + i, args.k, SOURCE_ENHANCED + j)
if data is None:
continue
bits = to_bits(data).astype(float)
a, b = bits[0::2], bits[1::2]
# Null distribution: the same two streams misaligned by large random offsets.
null = [correlation(a, np.roll(b, s)) for s in rng.integers(len(b) // 4, 3 * len(b) // 4, 20)]
c, sd = correlation(a, b), float(np.std(null))
z = c / sd if sd > 0 else float("inf")
mark = " <-- dependent?" if abs(z) > 5 else ""
flagged += bool(mark)
print(f"{i:>3}-{j:<3} {c:8.4f} {sd:8.4f} {z:7.2f}{mark}")
print(f"\n{flagged} of {len(pairs)} pairs exceed |z| > 5")
print(f"{'ring':>4} {'gens':>5} {'corr':>8} {'null sd':>8} {'z':>7}")
flagged = total = 0
for ring in args.rings:
if board:
board.write(REG_RING_SELECT, ring)
for a, b in combinations(range(generators), 2):
data = cached_capture(board, args.out / f"pair_r{ring}_g{a}{b}_k{args.k}.bin",
SOURCE_RING + a, args.k, SOURCE_RING + b)
if data is None:
continue
bits = to_bits(data).astype(float)
x, y = bits[0::2], bits[1::2]
# Null distribution: the same two streams misaligned by large random offsets.
null = [correlation(x, np.roll(y, s)) for s in rng.integers(len(y) // 4, 3 * len(y) // 4, 20)]
c, sd = correlation(x, y), float(np.std(null))
z = c / sd if sd > 0 else float("inf")
mark = " <-- dependent?" if abs(z) > 5 else ""
flagged += bool(mark)
total += 1
print(f"{ring:>4} {a}-{b:<3} {c:8.4f} {sd:8.4f} {z:7.2f}{mark}")
print(f"\n{flagged} of {total} pairs exceed |z| > 5")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--port", default="/dev/ttyACM0")
parser.add_argument("--out", type=Path, default=Path("captures"))
parser.add_argument("--order", type=int, default=8, help="context bits for the entropy estimate")
parser.add_argument("--order", type=int, default=16, help="context bits for the entropy estimate")
parser.add_argument("--analyze-only", action="store_true", help="reuse captures in --out")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("freq")
rings = sub.add_parser("rings")
rings.add_argument("--generator", type=int, default=0)
rings.add_argument("--rings", type=int, nargs="+", default=list(range(90)))
sweep = sub.add_parser("sweep")
sweep.add_argument("--ring", type=int, default=0, help="ring for the ring/tap sources")
sweep.add_argument("--sources", type=lambda s: int(s, 0), nargs="+",
default=[SOURCE_TAP, SOURCE_ENHANCED, SOURCE_ENHANCED + 1, SOURCE_XOR + 1])
default=[SOURCE_TAP, SOURCE_RING, SOURCE_SPATIAL])
sweep.add_argument("--decimations", type=int, nargs="+",
default=[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096])
sub.add_parser("freq")
xor = sub.add_parser("xor")
xor.add_argument("-k", type=int, default=1, help="decimation")
output = sub.add_parser("output")
output.add_argument("--temporal", type=int, default=5, help="for --analyze-only")
pairs = sub.add_parser("pairs")
pairs.add_argument("--rings", type=int, nargs="+", default=list(range(0, 90, 9)))
pairs.add_argument("-k", type=int, default=64, help="decimation")
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
board = None if args.analyze_only else Board(args.port)
{"sweep": run_sweep, "freq": run_freq, "xor": run_xor, "pairs": run_pairs}[args.command](board, args)
handlers = {"freq": run_freq, "rings": run_rings, "sweep": run_sweep,
"output": run_output, "pairs": run_pairs}
handlers[args.command](board, args)
if __name__ == "__main__":