feat: add per-generator NIST health tests and dead-ring monitor, onod3000 output test (FPGA v4)

This commit is contained in:
randogoth 2026-10-25 13:36:55 +02:00
parent 585e755c66
commit 67bd7eec58
9 changed files with 436 additions and 28 deletions

3
.gitignore vendored
View file

@ -25,3 +25,6 @@ fpga/**/simulation/
arduino/strix_capture/bitstream.h arduino/strix_capture/bitstream.h
arduino/strix_capture/signature.h arduino/strix_capture/signature.h
captures/ captures/
# Local builds of external tools (onod3000)
.build/

View file

@ -6,7 +6,9 @@
"python312@latest", "python312@latest",
"python312Packages.pyserial@latest", "python312Packages.pyserial@latest",
"python312Packages.numpy@latest", "python312Packages.numpy@latest",
"iverilog@latest" "iverilog@latest",
"rustc@latest",
"cargo@latest"
], ],
"shell": { "shell": {
"init_hook": [ "init_hook": [
@ -23,7 +25,7 @@
], ],
"sim": [ "sim": [
"mkdir -p /tmp/strix-sim", "mkdir -p /tmp/strix-sim",
"iverilog -g2005 -DSIM -o /tmp/strix-sim/tb_strix_core fpga/sim/tb_strix_core.v fpga/rtl/strix_core.v fpga/rtl/entropy_generator.v fpga/rtl/freq_counter.v fpga/rtl/ring_osc.v fpga/rtl/raw_capture.v fpga/rtl/spi_slave.v", "iverilog -g2005 -DSIM -o /tmp/strix-sim/tb_strix_core fpga/sim/tb_strix_core.v fpga/rtl/strix_core.v fpga/rtl/entropy_generator.v fpga/rtl/health_monitor.v fpga/rtl/freq_counter.v fpga/rtl/ring_osc.v fpga/rtl/raw_capture.v fpga/rtl/spi_slave.v",
"vvp -n /tmp/strix-sim/tb_strix_core" "vvp -n /tmp/strix-sim/tb_strix_core"
], ],
"fpga": [ "fpga": [
@ -38,6 +40,9 @@
"upload-capture": [ "upload-capture": [
"arduino-cli upload --fqbn arduino:samd:mkrvidor4000 -p ${PORT:-/dev/ttyACM0} arduino/strix_capture" "arduino-cli upload --fqbn arduino:samd:mkrvidor4000 -p ${PORT:-/dev/ttyACM0} arduino/strix_capture"
], ],
"onod-build": [
"cargo build --release --manifest-path ${ONOD3000_DIR:-$HOME/Projects/randonautica/code/onod3000}/Cargo.toml --target-dir .build/onod3000"
],
"jitter": [ "jitter": [
"python3 tools/jitter.py --port ${PORT:-/dev/ttyACM0} --out captures \"$@\"" "python3 tools/jitter.py --port ${PORT:-/dev/ttyACM0} --out captures \"$@\""
] ]

View file

@ -49,6 +49,44 @@
} }
} }
}, },
"cargo@latest": {
"last_modified": "2026-10-02T22:55:44Z",
"resolved": "github:NixOS/nixpkgs/55ba7f49ef2962b42cbd126522b7df5f95037679#cargo",
"source": "devbox-search",
"version": "1.98.1",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/z76zwygvlbbapfasr22i7xxrsi9w2wm6-cargo-1.98.1",
"default": true
}
],
"store_path": "/nix/store/z76zwygvlbbapfasr22i7xxrsi9w2wm6-cargo-1.98.1"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/4xg2np6a2m3ff0camgca44rb5h8g71nl-cargo-1.98.1",
"default": true
}
],
"store_path": "/nix/store/4xg2np6a2m3ff0camgca44rb5h8g71nl-cargo-1.98.1"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/gmgfpzc0w2pgby0lmq9r88m39albiyfw-cargo-1.98.1",
"default": true
}
],
"store_path": "/nix/store/gmgfpzc0w2pgby0lmq9r88m39albiyfw-cargo-1.98.1"
}
}
},
"github:NixOS/nixpkgs/nixpkgs-unstable": { "github:NixOS/nixpkgs/nixpkgs-unstable": {
"last_modified": "2026-09-29T03:29:29Z", "last_modified": "2026-09-29T03:29:29Z",
"resolved": "github:NixOS/nixpkgs/b6c8664de9b6cc07fe5666a29f91884ba81197c4?lastModified=1790652569&narHash=sha256-641r7xrlSOHYoktL9%2FaArPIYNgZJ9eM5URzSEOtPJDY%3D" "resolved": "github:NixOS/nixpkgs/b6c8664de9b6cc07fe5666a29f91884ba81197c4?lastModified=1790652569&narHash=sha256-641r7xrlSOHYoktL9%2FaArPIYNgZJ9eM5URzSEOtPJDY%3D"
@ -265,6 +303,72 @@
"store_path": "/nix/store/j90zi1mxv9dvqmsjqicnaafm22bp3qca-python3.12-pyserial-3.5" "store_path": "/nix/store/j90zi1mxv9dvqmsjqicnaafm22bp3qca-python3.12-pyserial-3.5"
} }
} }
},
"rustc@latest": {
"last_modified": "2026-10-02T22:55:44Z",
"plugin_version": "0.0.1",
"resolved": "github:NixOS/nixpkgs/55ba7f49ef2962b42cbd126522b7df5f95037679#rustc",
"source": "devbox-search",
"version": "1.98.1",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/yb6wa0sk67mynp3k90lw9jgcdjk9msrs-rustc-wrapper-1.98.1",
"default": true
},
{
"name": "man",
"path": "/nix/store/52a882rxhl50v46qm10820aa8n5n3xcv-rustc-wrapper-1.98.1-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/yhfspxrjqm5ysi79f2y6srsd6y4r1602-rustc-wrapper-1.98.1-doc"
}
],
"store_path": "/nix/store/yb6wa0sk67mynp3k90lw9jgcdjk9msrs-rustc-wrapper-1.98.1"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/8dpmmiq8hdj1z4fvig4ch1mfid0vzrxm-rustc-wrapper-1.98.1",
"default": true
},
{
"name": "man",
"path": "/nix/store/r8fchqs0dq0d6hw96jl3n37p0vpkp603-rustc-wrapper-1.98.1-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/i2iwz1pzzsy8wr9ijq4p3pclqfmzdvkf-rustc-wrapper-1.98.1-doc"
}
],
"store_path": "/nix/store/8dpmmiq8hdj1z4fvig4ch1mfid0vzrxm-rustc-wrapper-1.98.1"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/hr3npzrq9l029s5hf948by203m6qz3lw-rustc-wrapper-1.98.1",
"default": true
},
{
"name": "man",
"path": "/nix/store/0y1vday672aw7dp36ms72bcdpdsxdwri-rustc-wrapper-1.98.1-man",
"default": true
},
{
"name": "doc",
"path": "/nix/store/rf1fpdx7xg9wzvi7fkw9qwnk565n5yci-rustc-wrapper-1.98.1-doc"
}
],
"store_path": "/nix/store/hr3npzrq9l029s5hf948by203m6qz3lw-rustc-wrapper-1.98.1"
}
}
} }
} }
} }

View file

@ -16,6 +16,7 @@ module entropy_generator #(
input wire [7:0] ring_select, // ring exposed on the characterization outputs input wire [7:0] ring_select, // ring exposed on the characterization outputs
output reg out_bit, // processed output, held for `temporal` clocks output reg out_bit, // processed output, held for `temporal` clocks
output wire spatial_bit, // XOR of all rings, one per clock output wire spatial_bit, // XOR of all rings, one per clock
output wire [RINGS-1:0] ring_samples, // synchronized enhanced outputs, for health tests
output reg ring_enhanced, output reg ring_enhanced,
output reg ring_tap, output reg ring_tap,
output wire ring_raw_tap // unsynchronized, for frequency counting output wire ring_raw_tap // unsynchronized, for frequency counting
@ -62,7 +63,8 @@ module entropy_generator #(
level4 <= ^level3; level4 <= ^level3;
end end
assign spatial_bit = level4; assign spatial_bit = level4;
assign ring_samples = sample_sync;
// Temporal XOR over `temporal` consecutive spatial bits. // Temporal XOR over `temporal` consecutive spatial bits.
reg [3:0] phase = 4'd0; reg [3:0] phase = 4'd0;

141
fpga/rtl/health_monitor.v Normal file
View file

@ -0,0 +1,141 @@
// Continuous health tests for one entropy generator.
//
// - Repetition count test and adaptive proportion test (NIST SP 800-90B, section 4.4) on the
// generator's spatial XOR stream. The default cutoffs in strix_core assume 0.5 bits of
// min-entropy per bit and a false-alarm probability of 2^-40, which is far below the
// measured entropy, so they only catch real failures.
// - Dead-ring monitor: visits the rings one at a time and counts those whose sampled output
// does not change during a whole window.
//
// Failures are sticky until `clear`, which leaves the dead-ring sweep running. Monitoring
// pauses while the generator is disabled and for a short settling period after it is enabled.
module health_monitor #(
parameter RINGS = 90,
parameter DEAD_WINDOW_BITS = 16, // 2^16 clk cycles per ring (0.55 ms at 120 MHz)
parameter SETTLE_BITS = 10 // 1024 cycles ignored after enable
) (
input wire clk,
input wire active, // generator enabled and system out of reset
input wire clear, // clears failures and event counters
input wire sample_bit, // spatial XOR stream, one bit per clock
input wire [RINGS-1:0] ring_samples, // synchronized ring outputs
input wire [7:0] rct_cutoff,
input wire [10:0] apt_cutoff,
output reg rct_failed,
output reg apt_failed,
output reg [7:0] rct_events, // saturating
output reg [7:0] apt_events, // saturating
output reg [7:0] dead_rings, // result of the last complete sweep
output reg sweep_done // at least one sweep completed since activation
);
// Settling
reg [SETTLE_BITS-1:0] settle_count = {SETTLE_BITS{1'b0}};
wire settled = &settle_count;
always @(posedge clk) begin
if (!active) settle_count <= {SETTLE_BITS{1'b0}};
else if (!settled) settle_count <= settle_count + 1'b1;
end
wire testing = active && settled;
// Repetition count test: a run of rct_cutoff identical bits is a failure.
reg previous_bit;
reg [7:0] run_length;
always @(posedge clk) begin
previous_bit <= sample_bit;
if (!testing || sample_bit != previous_bit)
run_length <= 8'd1;
else if (run_length != 8'hFF)
run_length <= run_length + 8'd1;
end
wire rct_event = testing && sample_bit == previous_bit && run_length + 8'd1 == rct_cutoff;
// Adaptive proportion test: within each 1024-bit window, the first bit's value occurring
// apt_cutoff times or more is a failure.
reg [9:0] window_position;
reg reference_bit;
reg [10:0] matches;
reg window_flagged;
always @(posedge clk) begin
if (!testing) begin
window_position <= 10'd0;
end else begin
window_position <= window_position + 10'd1;
if (window_position == 10'd0) begin
reference_bit <= sample_bit;
matches <= 11'd1;
window_flagged <= 1'b0;
end else if (sample_bit == reference_bit) begin
matches <= matches + 11'd1;
if (matches + 11'd1 == apt_cutoff) window_flagged <= 1'b1;
end
end
end
wire apt_event = testing && window_position != 10'd0 && sample_bit == reference_bit
&& matches + 11'd1 == apt_cutoff && !window_flagged;
// Sticky results
always @(posedge clk) begin
if (clear) begin
rct_failed <= 1'b0;
apt_failed <= 1'b0;
rct_events <= 8'd0;
apt_events <= 8'd0;
end else begin
if (rct_event) begin
rct_failed <= 1'b1;
if (rct_events != 8'hFF) rct_events <= rct_events + 8'd1;
end
if (apt_event) begin
apt_failed <= 1'b1;
if (apt_events != 8'hFF) apt_events <= apt_events + 8'd1;
end
end
end
// Dead-ring monitor
reg [7:0] ring_index;
reg [DEAD_WINDOW_BITS-1:0] dead_window;
reg ring_bit, ring_bit_previous, ring_toggled;
reg [7:0] dead_so_far;
always @(posedge clk) begin
ring_bit <= ring_samples[ring_index];
ring_bit_previous <= ring_bit;
if (!testing) begin
ring_index <= 8'd0;
dead_window <= {DEAD_WINDOW_BITS{1'b0}};
ring_toggled <= 1'b0;
dead_so_far <= 8'd0;
sweep_done <= 1'b0;
end else begin
dead_window <= dead_window + 1'b1;
// The first two cycles of a window still carry the previous ring's bits.
if (dead_window > 2 && ring_bit != ring_bit_previous) ring_toggled <= 1'b1;
if (&dead_window) begin
ring_toggled <= 1'b0;
if (ring_index == RINGS - 1) begin
ring_index <= 8'd0;
dead_rings <= dead_so_far + !ring_toggled;
dead_so_far <= 8'd0;
sweep_done <= 1'b1;
end else begin
ring_index <= ring_index + 8'd1;
dead_so_far <= dead_so_far + !ring_toggled;
end
end
end
end
endmodule

View file

@ -1,5 +1,5 @@
// Board-independent STRIX logic: entropy generators, source selection, raw capture, // Board-independent STRIX logic: entropy generators with health monitors, source selection,
// frequency counter and the SPI register interface to the SAMD21. // raw capture, frequency counter and the SPI register interface to the SAMD21.
// //
// SPI protocol (mode 0, one transaction per ss_n low period): the first byte is a command, // SPI protocol (mode 0, one transaction per ss_n low period): the first byte is a command,
// bit 7 = read (1) or write (0), bits 6:0 = register address. A write stores the following // bit 7 = read (1) or write (0), bits 6:0 = register address. A write stores the following
@ -11,10 +11,15 @@
// 0x04+g spatial XOR of all rings, every clock // 0x04+g spatial XOR of all rings, every clock
// 0x08+g enhanced output of ring RING_SELECT // 0x08+g enhanced output of ring RING_SELECT
// 0x0C+g single tap of ring RING_SELECT // 0x0C+g single tap of ring RING_SELECT
//
// Health: a generator is healthy when it is enabled, its monitor has completed a dead-ring
// sweep, neither NIST test has failed since the last clear, and at most MAX_DEAD_RINGS rings
// are dead. Per-generator registers at 0x30 + 4g: FLAGS, RCT_EVENTS, APT_EVENTS, DEAD_RINGS.
module strix_core #( module strix_core #(
parameter CAPTURE_ADDR_WIDTH = 15, parameter CAPTURE_ADDR_WIDTH = 15,
parameter FREQ_GATE_BITS = 24, parameter FREQ_GATE_BITS = 24,
parameter RINGS_PER_GEN = 90 parameter RINGS_PER_GEN = 90,
parameter DEAD_WINDOW_BITS = 16
) ( ) (
input wire clk, input wire clk,
input wire reset, input wire reset,
@ -25,7 +30,7 @@ module strix_core #(
output wire capture_done output wire capture_done
); );
localparam [7:0] VERSION = 8'h03; localparam [7:0] VERSION = 8'h04;
localparam GENERATORS = 4; localparam GENERATORS = 4;
localparam [6:0] REG_ID0 = 7'h00, // "STRX" localparam [6:0] REG_ID0 = 7'h00, // "STRX"
@ -53,7 +58,20 @@ module strix_core #(
REG_FREQ_COUNT0 = 7'h15, REG_FREQ_COUNT0 = 7'h15,
REG_GEN_ENABLE = 7'h16, // bit g enables generator g REG_GEN_ENABLE = 7'h16, // bit g enables generator g
REG_TEMPORAL = 7'h17, // consecutive bits XORed per output bit, 1-8 REG_TEMPORAL = 7'h17, // consecutive bits XORed per output bit, 1-8
REG_FREQ_GEN = 7'h18; // generator for the frequency measurement REG_FREQ_GEN = 7'h18, // generator for the frequency measurement
REG_HEALTHY = 7'h20, // read-only: bit g = generator g healthy
REG_HEALTH_CTRL = 7'h21, // write: bit 0 clears failures and event counters
REG_RCT_CUTOFF = 7'h22, // repetition count test cutoff (run length)
REG_APT_HIGH = 7'h23, // adaptive proportion test cutoff (per 1024 bits)
REG_APT_LOW = 7'h24,
REG_MAX_DEAD = 7'h25; // dead rings tolerated per generator
// Per-generator health registers at 0x30 + 4g + index
localparam [1:0] GEN_FLAGS = 2'd0, // bit 0 RCT failed, 1 APT failed, 2 too many dead rings,
// 3 active, 4 sweep done, 5 healthy
GEN_RCT_EVENTS = 2'd1,
GEN_APT_EVENTS = 2'd2,
GEN_DEAD_RINGS = 2'd3;
// Generators // Generators
@ -62,15 +80,39 @@ module strix_core #(
reg [7:0] ring_select; reg [7:0] ring_select;
wire [GENERATORS-1:0] out_bit, spatial_bit, ring_enhanced, ring_tap, ring_raw_tap; wire [GENERATORS-1:0] out_bit, spatial_bit, ring_enhanced, ring_tap, ring_raw_tap;
reg health_clear;
reg [7:0] rct_cutoff, max_dead;
reg [10:0] apt_cutoff;
wire [GENERATORS-1:0] rct_failed, apt_failed, sweep_done, too_many_dead, healthy;
wire [7:0] rct_events [0:GENERATORS-1];
wire [7:0] apt_events [0:GENERATORS-1];
wire [7:0] dead_rings [0:GENERATORS-1];
genvar g; genvar g;
generate generate
for (g = 0; g < GENERATORS; g = g + 1) begin : gen for (g = 0; g < GENERATORS; g = g + 1) begin : gen
wire [RINGS_PER_GEN-1:0] ring_samples;
wire active = gen_enable[g] && !reset;
entropy_generator #(.RINGS(RINGS_PER_GEN)) generator ( entropy_generator #(.RINGS(RINGS_PER_GEN)) generator (
.clk(clk), .enable(gen_enable[g]), .temporal(temporal), .ring_select(ring_select), .clk(clk), .enable(gen_enable[g]), .temporal(temporal), .ring_select(ring_select),
.out_bit(out_bit[g]), .spatial_bit(spatial_bit[g]), .out_bit(out_bit[g]), .spatial_bit(spatial_bit[g]),
.ring_enhanced(ring_enhanced[g]), .ring_tap(ring_tap[g]), .ring_enhanced(ring_enhanced[g]), .ring_tap(ring_tap[g]),
.ring_raw_tap(ring_raw_tap[g]) .ring_raw_tap(ring_raw_tap[g]), .ring_samples(ring_samples)
); );
health_monitor #(.RINGS(RINGS_PER_GEN), .DEAD_WINDOW_BITS(DEAD_WINDOW_BITS)) monitor (
.clk(clk), .active(active), .clear(health_clear),
.sample_bit(spatial_bit[g]), .ring_samples(ring_samples),
.rct_cutoff(rct_cutoff), .apt_cutoff(apt_cutoff),
.rct_failed(rct_failed[g]), .apt_failed(apt_failed[g]),
.rct_events(rct_events[g]), .apt_events(apt_events[g]),
.dead_rings(dead_rings[g]), .sweep_done(sweep_done[g])
);
assign too_many_dead[g] = sweep_done[g] && dead_rings[g] > max_dead;
assign healthy[g] = active && sweep_done[g] && !rct_failed[g] && !apt_failed[g]
&& !too_many_dead[g];
end end
endgenerate endgenerate
@ -136,6 +178,22 @@ module strix_core #(
wire reading_data = is_read && reg_addr == REG_DATA; wire reading_data = is_read && reg_addr == REG_DATA;
function [7:0] gen_register(input [6:0] addr);
reg [1:0] n;
begin
n = addr[3:2];
if (addr[6:4] != 3'b011)
gen_register = 8'h00;
else case (addr[1:0])
GEN_FLAGS: gen_register = {2'd0, healthy[n], sweep_done[n], gen_enable[n],
too_many_dead[n], apt_failed[n], rct_failed[n]};
GEN_RCT_EVENTS: gen_register = rct_events[n];
GEN_APT_EVENTS: gen_register = apt_events[n];
default: gen_register = sweep_done[n] ? dead_rings[n] : 8'h00;
endcase
end
endfunction
always @(*) begin always @(*) begin
case (reg_addr) case (reg_addr)
REG_ID0: tx_byte = "S"; REG_ID0: tx_byte = "S";
@ -161,13 +219,19 @@ module strix_core #(
REG_GEN_ENABLE: tx_byte = {4'd0, gen_enable}; REG_GEN_ENABLE: tx_byte = {4'd0, gen_enable};
REG_TEMPORAL: tx_byte = {4'd0, temporal}; REG_TEMPORAL: tx_byte = {4'd0, temporal};
REG_FREQ_GEN: tx_byte = {6'd0, freq_gen}; REG_FREQ_GEN: tx_byte = {6'd0, freq_gen};
default: tx_byte = 8'h00; REG_HEALTHY: tx_byte = {4'd0, healthy};
REG_RCT_CUTOFF: tx_byte = rct_cutoff;
REG_APT_HIGH: tx_byte = {5'd0, apt_cutoff[10:8]};
REG_APT_LOW: tx_byte = apt_cutoff[7:0];
REG_MAX_DEAD: tx_byte = max_dead;
default: tx_byte = gen_register(reg_addr);
endcase endcase
end end
always @(posedge clk) begin always @(posedge clk) begin
capture_start <= 1'b0; capture_start <= 1'b0;
freq_start <= 1'b0; freq_start <= 1'b0;
health_clear <= 1'b0;
if (reset) begin if (reset) begin
gen_enable <= {GENERATORS{1'b1}}; gen_enable <= {GENERATORS{1'b1}};
temporal <= 4'd5; temporal <= 4'd5;
@ -177,6 +241,10 @@ module strix_core #(
pair_mode <= 1'b0; pair_mode <= 1'b0;
decimation <= 16'd5; decimation <= 16'd5;
freq_gen <= 2'd0; freq_gen <= 2'd0;
rct_cutoff <= 8'd81; // 0.5 bits/bit claimed, alpha 2^-40
apt_cutoff <= 11'd824; // same, 1024-bit window
max_dead <= 8'd8; // measured sizing tolerates about 17
health_clear <= 1'b1;
is_read <= 1'b0; is_read <= 1'b0;
reg_addr <= 7'd0; reg_addr <= 7'd0;
bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}}; bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}};
@ -204,6 +272,11 @@ module strix_core #(
REG_GEN_ENABLE: gen_enable <= rx_byte[GENERATORS-1:0]; REG_GEN_ENABLE: gen_enable <= rx_byte[GENERATORS-1:0];
REG_TEMPORAL: if (rx_byte >= 8'd1 && rx_byte <= 8'd8) temporal <= rx_byte[3:0]; REG_TEMPORAL: if (rx_byte >= 8'd1 && rx_byte <= 8'd8) temporal <= rx_byte[3:0];
REG_FREQ_GEN: freq_gen <= rx_byte[1:0]; REG_FREQ_GEN: freq_gen <= rx_byte[1:0];
REG_HEALTH_CTRL: health_clear <= rx_byte[0];
REG_RCT_CUTOFF: if (rx_byte >= 8'd2) rct_cutoff <= rx_byte;
REG_APT_HIGH: apt_cutoff[10:8] <= rx_byte[2:0];
REG_APT_LOW: apt_cutoff[7:0] <= rx_byte;
REG_MAX_DEAD: max_dead <= rx_byte;
REG_CONTROL: begin REG_CONTROL: begin
if (rx_byte[0]) capture_start <= 1'b1; if (rx_byte[0]) capture_start <= 1'b1;
if (rx_byte[0] || rx_byte[1]) bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}}; if (rx_byte[0] || rx_byte[1]) bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}};

View file

@ -1,6 +1,6 @@
// Testbench for strix_core: register access, generator XOR logic checked against the sampled // Testbench for strix_core: register access, generator XOR logic checked against the sampled
// ring outputs, captures read back across transactions, generator enable, and the frequency // ring outputs, captures read back across transactions, generator enable, the frequency
// counter. Rings are replaced by the SIM model in ring_osc.v. // counter, and the health monitors with injected faults. Rings are replaced by the SIM model in ring_osc.v.
`timescale 1ns / 1ps `timescale 1ns / 1ps
module tb_strix_core; module tb_strix_core;
@ -14,7 +14,14 @@ module tb_strix_core;
REG_GENERATORS = 7'h06, REG_RINGS = 7'h07, REG_SOURCE_A = 7'h08, REG_GENERATORS = 7'h06, REG_RINGS = 7'h07, REG_SOURCE_A = 7'h08,
REG_DECIM_LOW = 7'h0A, REG_CONTROL = 7'h0B, REG_STATUS = 7'h0C, REG_DECIM_LOW = 7'h0A, REG_CONTROL = 7'h0B, REG_STATUS = 7'h0C,
REG_DATA = 7'h10, REG_RING_SELECT = 7'h11, REG_FREQ_COUNT0 = 7'h15, REG_DATA = 7'h10, REG_RING_SELECT = 7'h11, REG_FREQ_COUNT0 = 7'h15,
REG_GEN_ENABLE = 7'h16, REG_TEMPORAL = 7'h17, REG_FREQ_GEN = 7'h18; REG_GEN_ENABLE = 7'h16, REG_TEMPORAL = 7'h17, REG_FREQ_GEN = 7'h18,
REG_HEALTHY = 7'h20, REG_HEALTH_CTRL = 7'h21;
// Per-generator health registers
function [6:0] gen_reg(input integer generator, input integer index);
gen_reg = 7'h30 + 4 * generator + index;
endfunction
localparam FLAGS = 0, RCT_EVENTS = 1, APT_EVENTS = 2, DEAD_RINGS = 3;
reg clk = 1'b0; reg clk = 1'b0;
always #4.1667 clk = ~clk; // 120 MHz always #4.1667 clk = ~clk; // 120 MHz
@ -23,7 +30,8 @@ module tb_strix_core;
reg sck = 1'b0, mosi = 1'b0, ss_n = 1'b1; reg sck = 1'b0, mosi = 1'b0, ss_n = 1'b1;
wire miso, done; wire miso, done;
strix_core #(.CAPTURE_ADDR_WIDTH(CAPTURE_ADDR_WIDTH), .FREQ_GATE_BITS(14), .RINGS_PER_GEN(RINGS)) dut ( strix_core #(.CAPTURE_ADDR_WIDTH(CAPTURE_ADDR_WIDTH), .FREQ_GATE_BITS(14), .RINGS_PER_GEN(RINGS),
.DEAD_WINDOW_BITS(6)) dut (
.clk(clk), .reset(reset), .clk(clk), .reset(reset),
.spi_sck(sck), .spi_mosi(mosi), .spi_ss_n(ss_n), .spi_miso(miso), .spi_sck(sck), .spi_mosi(mosi), .spi_ss_n(ss_n), .spi_miso(miso),
.capture_done(done) .capture_done(done)
@ -171,7 +179,7 @@ module tb_strix_core;
// Identification // Identification
read_reg(REG_ID0, v); expect(v, "S", "ID0"); read_reg(REG_ID0, v); expect(v, "S", "ID0");
read_reg(REG_ID3, v); expect(v, "X", "ID3"); read_reg(REG_ID3, v); expect(v, "X", "ID3");
read_reg(REG_VERSION, v); expect(v, 8'h03, "VERSION"); read_reg(REG_VERSION, v); expect(v, 8'h04, "VERSION");
read_reg(REG_GENERATORS, v); expect(v, 8'd4, "GENERATORS"); read_reg(REG_GENERATORS, v); expect(v, 8'd4, "GENERATORS");
read_reg(REG_RINGS, v); expect(v, RINGS, "RINGS"); read_reg(REG_RINGS, v); expect(v, RINGS, "RINGS");
read_reg(REG_TEMPORAL, v); expect(v, 8'd5, "TEMPORAL"); read_reg(REG_TEMPORAL, v); expect(v, 8'd5, "TEMPORAL");
@ -216,6 +224,41 @@ module tb_strix_core;
read_reg(REG_FREQ_COUNT0, v); read_reg(REG_FREQ_COUNT0, v);
if (v == 8'd0) begin $display("FAIL frequency count is zero"); errors = errors + 1; end if (v == 8'd0) begin $display("FAIL frequency count is zero"); errors = errors + 1; end
// Health: all generators healthy once a dead-ring sweep (90 x 64 cycles) has completed
#100000;
read_reg(REG_HEALTHY, v); expect(v, 8'h0F, "HEALTHY");
read_reg(gen_reg(0, FLAGS), v); expect(v, 8'h38, "FLAGS g0"); // healthy, swept, active
read_reg(gen_reg(0, DEAD_RINGS), v); expect(v, 8'd0, "DEAD g0");
// A dead ring is counted but tolerated
force dut.gen[1].generator.raw_enhanced[7] = 1'b0;
#120000;
read_reg(gen_reg(1, DEAD_RINGS), v); expect(v, 8'd1, "DEAD g1");
read_reg(REG_HEALTHY, v); expect(v, 8'h0F, "HEALTHY dead ring");
release dut.gen[1].generator.raw_enhanced[7];
// A stuck spatial stream fails both NIST tests
force dut.gen[2].generator.level4 = 1'b0;
#20000;
release dut.gen[2].generator.level4;
read_reg(REG_HEALTHY, v); expect(v, 8'h0B, "HEALTHY stuck");
read_reg(gen_reg(2, FLAGS), v); expect(v & 8'h03, 8'h03, "FLAGS g2 failed");
read_reg(gen_reg(2, RCT_EVENTS), v);
if (v == 8'd0) begin $display("FAIL no RCT events"); errors = errors + 1; end
read_reg(gen_reg(2, APT_EVENTS), v);
if (v == 8'd0) begin $display("FAIL no APT events"); errors = errors + 1; end
// Clear restores health without restarting the sweep
write_reg(REG_HEALTH_CTRL, 8'h01);
read_reg(REG_HEALTHY, v); expect(v, 8'h0F, "HEALTHY cleared");
read_reg(gen_reg(2, RCT_EVENTS), v); expect(v, 8'd0, "RCT events cleared");
// A disabled generator is not healthy
write_reg(REG_GEN_ENABLE, 8'h07);
read_reg(REG_HEALTHY, v); expect(v, 8'h07, "HEALTHY disabled");
read_reg(gen_reg(3, FLAGS), v); expect(v, 8'h00, "FLAGS g3 disabled");
write_reg(REG_GEN_ENABLE, 8'h0F);
if (xor_checks < 100) begin if (xor_checks < 100) begin
$display("FAIL only %0d temporal XOR checks ran", xor_checks); $display("FAIL only %0d temporal XOR checks ran", xor_checks);
errors = errors + 1; errors = errors + 1;

View file

@ -61,6 +61,7 @@ set_global_assignment -name VERILOG_FILE ../rtl/strix.v
set_global_assignment -name VERILOG_FILE ../rtl/sys_pll.v set_global_assignment -name VERILOG_FILE ../rtl/sys_pll.v
set_global_assignment -name VERILOG_FILE ../rtl/strix_core.v set_global_assignment -name VERILOG_FILE ../rtl/strix_core.v
set_global_assignment -name VERILOG_FILE ../rtl/entropy_generator.v set_global_assignment -name VERILOG_FILE ../rtl/entropy_generator.v
set_global_assignment -name VERILOG_FILE ../rtl/health_monitor.v
set_global_assignment -name VERILOG_FILE ../rtl/freq_counter.v set_global_assignment -name VERILOG_FILE ../rtl/freq_counter.v
set_global_assignment -name VERILOG_FILE ../rtl/ring_osc.v set_global_assignment -name VERILOG_FILE ../rtl/ring_osc.v
set_global_assignment -name VERILOG_FILE ../rtl/raw_capture.v set_global_assignment -name VERILOG_FILE ../rtl/raw_capture.v

View file

@ -5,7 +5,8 @@ Subcommands:
freq measure ring frequencies in every generator freq measure ring frequencies in every generator
rings estimate each ring's relative predictability at k=1 (sizing input) rings estimate each ring's relative predictability at k=1 (sizing input)
sweep capture sources at increasing decimation k and fit the accumulated jitter 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 output run the onod3000 test suite on every generator's processed output (256 KB each)
health show the health monitors (NIST repetition count / adaptive proportion, dead rings)
pairs capture the same ring index in two generators simultaneously and test for 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 Entropy per bit is estimated from conditional block entropies and converted to a
@ -21,6 +22,7 @@ deviation grows with sqrt(k). On synthetic captures it overestimates by a factor
import argparse import argparse
import math import math
import subprocess
import sys import sys
import time import time
from itertools import combinations from itertools import combinations
@ -39,6 +41,8 @@ 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_CONTROL, REG_STATUS, REG_SOURCE_B, REG_MODE = 0x0B, 0x0C, 0x0D, 0x0E
REG_RING_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 REG_GEN_ENABLE, REG_TEMPORAL, REG_FREQ_GEN = 0x16, 0x17, 0x18
REG_HEALTHY, REG_HEALTH_CTRL, REG_RCT_CUTOFF, REG_APT_HIGH, REG_APT_LOW, REG_MAX_DEAD = 0x20, 0x21, 0x22, 0x23, 0x24, 0x25
REG_GEN_HEALTH = 0x30 # + 4g: flags, RCT events, APT events, dead rings
CONTROL_START_FREQ = 0x04 CONTROL_START_FREQ = 0x04
STATUS_FREQ_VALID = 0x08 STATUS_FREQ_VALID = 0x08
@ -72,8 +76,8 @@ class Board:
while self.serial.readline(): while self.serial.readline():
pass pass
self.serial.timeout = 200 self.serial.timeout = 200
if not identity.startswith("STRX v3"): if not identity.startswith("STRX v4"):
sys.exit(f"unexpected board identity: {identity!r} (needs the v3 FPGA image)") sys.exit(f"unexpected board identity: {identity!r} (needs the v4 FPGA image)")
self.generators = self.read(REG_GENERATORS) self.generators = self.read(REG_GENERATORS)
self.rings = self.read(REG_RINGS) self.rings = self.read(REG_RINGS)
print(f"board: {identity}, {self.generators} generators x {self.rings} rings") print(f"board: {identity}, {self.generators} generators x {self.rings} rings")
@ -272,18 +276,45 @@ def run_sweep(board: Board | None, args) -> None:
def run_output(board: Board | None, args) -> None: 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 temporal = board.read(REG_TEMPORAL) if board else args.temporal
generators = board.generators if board else 4 generators = board.generators if board else 4
print(f"temporal XOR = {temporal}; output captured with decimation {temporal}") if not args.onod.exists():
print("ones should be within about +-0.003 of 0.5 (3 sigma for 256 Kbit)\n") sys.exit(f"{args.onod} not found; build it with `devbox run onod-build`")
print_header() print(f"temporal XOR = {temporal}; {args.bursts} bursts of 32 KB per generator\n")
for g in range(generators): for g in range(generators):
for source, k in [(SOURCE_SPATIAL + g, 1), (SOURCE_OUTPUT + g, temporal)]: source = SOURCE_OUTPUT + g
data = cached_capture(board, args.out / f"output_s{source:02x}_k{k}.bin", source, k) bursts = [cached_capture(board, args.out / f"output_g{g}_t{temporal}_b{b}.bin", source, temporal)
if data is not None: for b in range(args.bursts)]
print_row(source_name(source, 0), k, analyze(to_bits(data), order)) if any(b is None for b in bursts):
continue
path = args.out / f"output_g{g}_t{temporal}.bin"
path.write_bytes(b"".join(bursts))
report = subprocess.run([str(args.onod), "-f", str(path)], capture_output=True, text=True).stdout
lines = report.splitlines()
if args.verbose:
print(f"generator {g}:\n{report}")
else:
summary = next((l for l in lines if "tests passed" in l), "no summary")
failed = [l.split()[0] for l in lines if l.rstrip().endswith("❌")]
print(f"generator {g}: {summary}" + (f" failed: {', '.join(failed)}" if failed else ""))
def run_health(board: Board | None, args) -> None:
if board is None:
sys.exit("health needs the board")
if args.clear:
board.write(REG_HEALTH_CTRL, 1)
apt = board.read(REG_APT_HIGH) << 8 | board.read(REG_APT_LOW)
print(f"cutoffs: repetition {board.read(REG_RCT_CUTOFF)}, adaptive proportion {apt}/1024, "
f"dead rings tolerated {board.read(REG_MAX_DEAD)}")
healthy = board.read(REG_HEALTHY)
print(f"{'gen':>3} {'healthy':>7} {'active':>6} {'swept':>5} {'RCT':>4} {'APT':>4} {'dead':>4}")
for g in range(board.generators):
flags = board.read(REG_GEN_HEALTH + 4 * g)
rct, apt_events, dead = (board.read(REG_GEN_HEALTH + 4 * g + i) for i in (1, 2, 3))
yes = lambda bit: "yes" if flags >> bit & 1 else "no"
print(f"{g:>3} {('yes' if healthy >> g & 1 else 'NO'):>7} {yes(3):>6} {yes(4):>5} "
f"{rct:>4} {apt_events:>4} {dead:>4}")
def run_pairs(board: Board | None, args) -> None: def run_pairs(board: Board | None, args) -> None:
@ -332,6 +363,11 @@ def main() -> None:
default=[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096]) default=[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096])
output = sub.add_parser("output") output = sub.add_parser("output")
output.add_argument("--temporal", type=int, default=5, help="for --analyze-only") output.add_argument("--temporal", type=int, default=5, help="for --analyze-only")
output.add_argument("--bursts", type=int, default=8, help="32 KB captures per generator")
output.add_argument("--onod", type=Path, default=Path(".build/onod3000/release/onod3000"))
output.add_argument("--verbose", action="store_true", help="print the full onod3000 report")
health = sub.add_parser("health")
health.add_argument("--clear", action="store_true", help="clear failures and event counters first")
pairs = sub.add_parser("pairs") pairs = sub.add_parser("pairs")
pairs.add_argument("--rings", type=int, nargs="+", default=list(range(0, 90, 9))) pairs.add_argument("--rings", type=int, nargs="+", default=list(range(0, 90, 9)))
pairs.add_argument("-k", type=int, default=64, help="decimation") pairs.add_argument("-k", type=int, default=64, help="decimation")
@ -340,7 +376,7 @@ def main() -> None:
args.out.mkdir(parents=True, exist_ok=True) args.out.mkdir(parents=True, exist_ok=True)
board = None if args.analyze_only else Board(args.port) board = None if args.analyze_only else Board(args.port)
handlers = {"freq": run_freq, "rings": run_rings, "sweep": run_sweep, handlers = {"freq": run_freq, "rings": run_rings, "sweep": run_sweep,
"output": run_output, "pairs": run_pairs} "output": run_output, "health": run_health, "pairs": run_pairs}
handlers[args.command](board, args) handlers[args.command](board, args)