From 8ac9ab92ebe3d0e49ec8ca52f6e6137b3ff3773c Mon Sep 17 00:00:00 2001 From: Radek Date: Mon, 31 Aug 2026 09:42:12 +0100 Subject: [PATCH] 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 --- entropy.py | 14 +++++++++- health.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 health.py diff --git a/entropy.py b/entropy.py index 071b772..d4c4cdd 100644 --- a/entropy.py +++ b/entropy.py @@ -14,6 +14,8 @@ import time import cv2 +from health import check as health_check, HealthCheckError + sys.stdout.reconfigure(line_buffering=True) @@ -67,6 +69,12 @@ def frame_entropy(frame): 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): if out_path: 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("--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("--no-health", action="store_true", help="Disable FIPS 140-2 health checks on raw frames") args = parser.parse_args() if args.bit <= 0 or args.bit % 8 != 0: @@ -96,7 +105,10 @@ def main(): def produce(): 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) return drbg.generate(num_bytes) diff --git a/health.py b/health.py new file mode 100644 index 0000000..60e0d51 --- /dev/null +++ b/health.py @@ -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