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>
This commit is contained in:
Radek
2026-08-31 09:42:12 +01:00
parent 239672ca63
commit 8ac9ab92eb
2 changed files with 94 additions and 1 deletions
+13 -1
View File
@@ -14,6 +14,8 @@ import time
import cv2 import cv2
from health import check as health_check, HealthCheckError
sys.stdout.reconfigure(line_buffering=True) sys.stdout.reconfigure(line_buffering=True)
@@ -67,6 +69,12 @@ def frame_entropy(frame):
return hashlib.sha256(img_encoded.tobytes()).digest() return hashlib.sha256(img_encoded.tobytes()).digest()
def frame_raw_bytes(frame):
"""Return the raw JPEG-encoded frame bytes (for health checks)."""
_, img_encoded = cv2.imencode(".jpg", frame)
return img_encoded.tobytes()
def emit(blob, out_path): def emit(blob, out_path):
if out_path: if out_path:
with open(out_path, "ab") as f: with open(out_path, "ab") as f:
@@ -83,6 +91,7 @@ def main():
parser.add_argument("--loop", action="store_true", help="Run forever, generating numbers every second") parser.add_argument("--loop", action="store_true", help="Run forever, generating numbers every second")
parser.add_argument("--interval", type=float, default=1.0, help="Seconds between iterations in --loop (default: 1.0)") parser.add_argument("--interval", type=float, default=1.0, help="Seconds between iterations in --loop (default: 1.0)")
parser.add_argument("--out", help="Append raw bytes to this path (e.g. a FIFO) instead of printing hex") parser.add_argument("--out", help="Append raw bytes to this path (e.g. a FIFO) instead of printing hex")
parser.add_argument("--no-health", action="store_true", help="Disable FIPS 140-2 health checks on raw frames")
args = parser.parse_args() args = parser.parse_args()
if args.bit <= 0 or args.bit % 8 != 0: if args.bit <= 0 or args.bit % 8 != 0:
@@ -96,7 +105,10 @@ def main():
def produce(): def produce():
frame = fetch_frame_opencv(args.url) frame = fetch_frame_opencv(args.url)
entropy = frame_entropy(frame) raw = frame_raw_bytes(frame)
if not args.no_health:
health_check(raw)
entropy = hashlib.sha256(raw).digest()
drbg = HMACDRBG(entropy) drbg = HMACDRBG(entropy)
return drbg.generate(num_bytes) return drbg.generate(num_bytes)
+81
View File
@@ -0,0 +1,81 @@
"""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