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>
138 lines
4.6 KiB
Python
138 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Camera-based entropy source.
|
|
|
|
Fetches MJPEG frames from a network camera, hashes them, and feeds the
|
|
hash into a NIST SP 800-90A HMAC-DRBG. Output is the DRBG's generated
|
|
bytes, so the camera genuinely contributes entropy (unlike the previous
|
|
version which computed a hash and then discarded it).
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import hmac
|
|
import sys
|
|
import time
|
|
|
|
import cv2
|
|
|
|
from health import check as health_check, HealthCheckError
|
|
|
|
sys.stdout.reconfigure(line_buffering=True)
|
|
|
|
|
|
class HMACDRBG:
|
|
"""NIST SP 800-90A HMAC-DRBG using SHA-256.
|
|
|
|
Seeded with camera frame entropy. Supports reseed so each frame
|
|
mixes fresh entropy into the internal state.
|
|
"""
|
|
|
|
def __init__(self, entropy, personalization=b""):
|
|
self.K = b"\x00" * 32
|
|
self.V = b"\x01" * 32
|
|
self.reseed_counter = 0
|
|
self._update(entropy + personalization)
|
|
|
|
def _update(self, provided_data):
|
|
self.K = hmac.new(self.K, self.V + b"\x00" + provided_data, hashlib.sha256).digest()
|
|
self.V = hmac.new(self.K, self.V, hashlib.sha256).digest()
|
|
self.K = hmac.new(self.K, self.V + b"\x01" + provided_data, hashlib.sha256).digest()
|
|
self.V = hmac.new(self.K, self.V, hashlib.sha256).digest()
|
|
|
|
def reseed(self, entropy):
|
|
self._update(entropy)
|
|
self.reseed_counter = 0
|
|
|
|
def generate(self, num_bytes):
|
|
if self.reseed_counter > 10000:
|
|
raise RuntimeError("DRBG requires reseed before generating more output.")
|
|
self.reseed_counter += 1
|
|
out = b""
|
|
while len(out) < num_bytes:
|
|
self.V = hmac.new(self.K, self.V, hashlib.sha256).digest()
|
|
out += self.V
|
|
self._update(b"")
|
|
return out[:num_bytes]
|
|
|
|
|
|
def fetch_frame_opencv(url):
|
|
cap = cv2.VideoCapture(url)
|
|
ret, frame = cap.read()
|
|
cap.release()
|
|
if not ret:
|
|
raise ValueError("Failed to fetch frame.")
|
|
return frame
|
|
|
|
|
|
def frame_entropy(frame):
|
|
"""Hash a captured frame to a 32-byte digest."""
|
|
_, img_encoded = cv2.imencode(".jpg", 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:
|
|
f.write(blob)
|
|
else:
|
|
sys.stdout.write(blob.hex() + "\n")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Generate random numbers from camera entropy.")
|
|
parser.add_argument("--url", default="http://192.168.0.200/mjpg/video.mjpg", help="Camera MJPEG stream URL")
|
|
parser.add_argument("--single", type=int, default=1, help="Generate N random numbers and exit (default: 1)")
|
|
parser.add_argument("--bit", type=int, default=256, help="Bit length of each random number (default: 256)")
|
|
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:
|
|
print("Error: --bit must be a positive multiple of 8", file=sys.stderr)
|
|
return 2
|
|
num_bytes = args.bit // 8
|
|
|
|
if args.loop and args.single != 1:
|
|
print("Error: --loop and --single are mutually exclusive", file=sys.stderr)
|
|
return 2
|
|
|
|
def produce():
|
|
frame = fetch_frame_opencv(args.url)
|
|
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)
|
|
|
|
if args.loop:
|
|
while True:
|
|
try:
|
|
blob = produce()
|
|
emit(blob, args.out)
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
time.sleep(args.interval)
|
|
else:
|
|
for i in range(args.single):
|
|
try:
|
|
blob = produce()
|
|
emit(blob, args.out)
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
return 1
|
|
if args.single > 1 and i < args.single - 1:
|
|
time.sleep(args.interval)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|