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:
+13
-1
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user