Files
entropy/health.py
T
Radek 8ac9ab92eb Add FIPS 140-2 health checks on raw frames
Add health.py with monobit, runs, and long-run tests (FIPS 140-2 Annex C
thresholds for a 20000-bit sample). Wire into entropy.py so raw JPEG
bytes are tested before hashing; a frozen or degraded camera frame is
rejected and not used to seed the DRBG. Runs thresholds are doubled
because we count 0-runs and 1-runs together. Add --no-health to disable.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-08-31 09:42:12 +01:00

82 lines
2.8 KiB
Python

"""Statistical health checks for the raw entropy source.
These run on the frame hash (the input to the DRBG, not the DRBG output)
to detect a frozen or degraded camera before its entropy is mixed in.
A failing check means the frame is rejected and not used to reseed.
The thresholds are the standard FIPS 140-2 (Annex C) continuous-test
bounds for a 20000-bit sample. They are intentionally conservative: a
healthy camera frame hashed with SHA-256 passes easily; a frozen frame
(all identical bytes) fails hard.
"""
# FIPS 140-2 (Annex C) thresholds for 20000 bits, doubled because we
# count runs of 0s and runs of 1s together (the spec tracks them
# separately with these per-value bounds).
MONOBIT_MIN = 9725
MONOBIT_MAX = 10275
RUNS_MIN = {1: 4630, 2: 2228, 3: 1054, 4: 480, 5: 206, 6: 206}
RUNS_MAX = {1: 5370, 2: 2772, 3: 1446, 4: 768, 5: 418, 6: 418}
LONG_RUN_LIMIT = 26 # max run length of identical bits
class HealthCheckError(Exception):
"""Raised when a frame fails a statistical health check."""
def _bits(data):
"""Yield bits (as 0/1 ints) from a byte string, MSB first."""
for byte in data:
for shift in range(7, -1, -1):
yield (byte >> shift) & 1
def monobit(data):
"""Count of 1-bits. Must be within [MONOBIT_MIN, MONOBIT_MAX]."""
ones = sum(_bits(data))
if not (MONOBIT_MIN <= ones <= MONOBIT_MAX):
raise HealthCheckError(f"monobit: {ones} ones outside [{MONOBIT_MIN}, {MONOBIT_MAX}]")
return ones
def runs(data):
"""Distribution of runs (consecutive identical bits)."""
counts = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0}
prev = None
run_len = 0
long_run = 0
for bit in _bits(data):
if bit == prev:
run_len += 1
else:
if prev is not None:
bucket = min(run_len, 6)
counts[bucket] += 1
long_run = max(long_run, run_len)
prev = bit
run_len = 1
# final run
bucket = min(run_len, 6)
counts[bucket] += 1
long_run = max(long_run, run_len)
if long_run >= LONG_RUN_LIMIT:
raise HealthCheckError(f"runs: long run of {long_run} >= {LONG_RUN_LIMIT}")
for length, count in counts.items():
if not (RUNS_MIN[length] <= count <= RUNS_MAX[length]):
raise HealthCheckError(f"runs: length-{length} count {count} outside [{RUNS_MIN[length]}, {RUNS_MAX[length]}]")
return counts
def check(data):
"""Run all health checks. Raises HealthCheckError on failure.
Expects at least 2500 bytes (20000 bits) of input. Shorter inputs
are rejected because the FIPS thresholds assume a 20000-bit sample.
"""
if len(data) * 8 < 20000:
raise HealthCheckError(f"sample too short: {len(data)} bytes (< 2500)")
monobit(data)
runs(data)
return True