feat: add four 90-ring entropy generators with spatial and temporal XOR (FPGA v3)
This commit is contained in:
parent
504b00752a
commit
585e755c66
9 changed files with 389 additions and 217 deletions
232
tools/jitter.py
232
tools/jitter.py
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue