Compare commits

...
7 Commits
Author SHA1 Message Date
Radek e07030d81e Rewrite README with change log and full requirements
Replace README.md with a single comprehensive doc. Add a "What Changed"
section mapping each improvement to its commit (DRBG fix, health checks,
cleanup, HTTP service, auth, tests, docs). Expand Requirements into
software, system libraries, hardware, kernel-RNG, and container
subsections. Keep the existing usage, HTTP service, container, remote
consumption, and tests sections.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-02 13:10:38 +01:00
Radek de17686a9b Update README and notes for DRBG, health checks, and HTTP service
Rewrite README to reflect the actual pipeline (frame -> health check
-> SHA-256 -> HMAC-DRBG -> emit), document all entropy.py flags, the
HTTP service endpoints and env vars, container build/run, Kubernetes
notes, and the remote-consumption rngd flow. Fix the notes.md typos
and align its commands with the new --out flag.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-02 11:52:24 +01:00
Radek d121b0f0f6 Add test suite and switch API to lifespan handler
test_entropy.py covers the HMAC-DRBG (determinism, reseed, length,
reseed limit), health checks (random passes, frozen/all-ones/short/
long-run rejected), a bit-balance smoke test on DRBG output, and the
API endpoints including auth gating. All 16 tests pass without a
camera (producer is stubbed).

Switch api.py from the deprecated @app.on_event('startup') to the
lifespan async context manager. Add pytest to requirements.txt.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-02 11:51:55 +01:00
Radek 1b99573b65 Add FastAPI entropy service with simple API-key auth and Dockerfile
api.py runs the camera producer in a background thread and serves the
latest entropy blob over HTTP. Endpoints: GET / (info), GET /healthz
(liveness, unauthenticated), GET /entropy (returns {hex, bits, ts}).
Auth is a single shared secret via X-API-Key header, controlled by the
ENTROPY_API_KEY env var; empty means no auth, so it is easy to start
open and lock down later. All config is via env vars for containers.

Dockerfile uses python:3.12-slim, installs OpenCV runtime libs, pins
deps from requirements.txt, exposes 8000, and has a healthcheck against
/healthz. Run with: docker build -t entropy-rng . && docker run -p 8000:8000 \
-e ENTROPY_CAMERA_URL=http://192.168.0.200/mjpg/video.mjpg entropy-rng

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-02 11:51:19 +01:00
Radek ab70195a07 Consolidate scripts, add requirements.txt, remove dead files
Remove entropy-rng-cam.py and entropy-rng-opencv.py, which were
superseded by entropy.py (args, loop mode, DRBG) and carried the same
unused-entropy bug. Remove the empty 0-byte `file`. Add requirements.txt
with pinned opencv and the FastAPI stack for the upcoming API stage.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-02 11:25:20 +01:00
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
Radek 239672ca63 Fix core RNG: seed HMAC-DRBG from camera frame hash
The previous version computed a SHA-256 of the frame into a variable
named `entropy` and then discarded it, drawing instead from the OS
CSPRNG via secrets.randbits(). The camera contributed nothing.

Replace with a NIST SP 800-90A HMAC-DRBG seeded from the frame hash so
the camera genuinely contributes entropy. Add --interval for loop
rate control, --out for writing raw bytes to a FIFO, mutual exclusion
of --loop/--single, and bit-multiple validation.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-08-31 09:41:12 +01:00
11 changed files with 668 additions and 155 deletions
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.12-slim
# OpenCV runtime libraries
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libglib2.0-0 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY entropy.py health.py api.py ./
ENV ENTROPY_BITS=256 \
ENTROPY_INTERVAL=1.0 \
ENTROPY_API_KEY="" \
UVICORN_HOST=0.0.0.0 \
UVICORN_PORT=8000
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
CMD python3 -c "import urllib.request,sys; urllib.request.urlopen('http://localhost:8000/healthz').read(); sys.exit(0)" || exit 1
CMD ["sh", "-c", "uvicorn api:app --host ${UVICORN_HOST} --port ${UVICORN_PORT}"]
+176 -54
View File
@@ -1,81 +1,203 @@
# Entropy-RNG: Camera-Based Random Number Generator
This project uses a live video feed from an **Axis M1013** network camera to generate cryptographically secure random numbers. The camera is pointed at a high-contrast scene (e.g., ceiling and lamps) to ensure ever-changing pixel data, which is used as a source of entropy.
This project uses a live video feed from a network camera (e.g. an **Axis M1013**) to generate cryptographically secure random numbers. The camera is pointed at a high-contrast, ever-changing scene (e.g. ceiling and lamps) so the pixel data carries genuine entropy. The output can be used locally (feeding the kernel RNG via `rngd`) or served over HTTP so remote systems can pull entropy to seed their own pools.
---
## What Changed
This section records the rewrite from the original scripts to the current pipeline. Each bullet maps to a commit in `git log`.
### Core RNG fix (the important one)
The original code computed a SHA-256 of the camera frame into a variable named `entropy` and then **discarded it**, drawing instead from the OS CSPRNG via `secrets.randbits()`. The camera contributed nothing — the output was just relabeled `/dev/urandom`.
- Replaced with a **NIST SP 800-90A HMAC-DRBG** (SHA-256) seeded from the frame hash. The camera now genuinely contributes entropy.
- The DRBG is reseeded every frame, so output changes even when camera noise is modest.
- Added `--interval` for loop rate control, `--out` for writing raw bytes to a FIFO, mutual exclusion of `--loop`/`--single`, and bit-multiple validation.
### Health checks
- Added `health.py` with **FIPS 140-2 continuous tests** (monobit, runs, long-run) on the raw JPEG bytes before hashing.
- A frozen, all-identical, or degraded frame is rejected and never seeds the DRBG.
- Runs thresholds are doubled because we count 0-runs and 1-runs together.
- `--no-health` disables the checks for debugging.
### Cleanup
- Removed `entropy-rng-cam.py` and `entropy-rng-opencv.py`, which were superseded by `entropy.py` and carried the same unused-entropy bug.
- Removed the empty 0-byte `file`.
- Added `requirements.txt` with pinned dependencies.
### HTTP service + container deployment
- Added `api.py` — a FastAPI app that runs the camera producer in a background thread and serves the latest entropy blob over HTTP.
- Added `Dockerfile` for container builds, with a healthcheck against `/healthz`.
- All config is via environment variables for containers.
### Auth (simple by design)
- Single shared secret via `X-API-Key` header, controlled by `ENTROPY_API_KEY`.
- Empty key means no auth, so you can start open and lock down by setting one env var.
- The API layer is where HMAC-signed blobs or mTLS plug in later — no architecture change needed.
### Tests
- Added `test_entropy.py` — 16 tests covering the DRBG, health checks, a bit-balance smoke test, and API auth gating. All pass without a camera (the producer is stubbed).
### Docs
- Rewrote this README and `notes.md` to match the new pipeline and commands.
---
## How It Works
1. **Capture Image Frames**
The script fetches a single frame from the camera's MJPEG stream using OpenCV or manual HTTP requests.
2. **Extract Entropy**
The pixel data from the frame is hashed using SHA-256 to generate a high-quality entropy pool.
3. **Generate Random Numbers**
The entropy is used to seed Python's `secrets` module, which generates cryptographically secure random numbers of specified bit lengths (e.g., 128-bit, 256-bit).
1. **Capture frame** — fetch a single MJPEG frame from the camera via OpenCV.
2. **Health check** — the raw JPEG bytes are run through FIPS 140-2 continuous tests (monobit, runs, long-run). A frozen or degraded frame is rejected before use.
3. **Extract entropy** — the frame is SHA-256 hashed to a 32-byte digest.
4. **DRBG** — the digest seeds a NIST SP 800-90A HMAC-DRBG (SHA-256), which generates the output bytes. The camera genuinely contributes entropy; the output is not just relabeled OS randomness.
5. **Emit** — hex to stdout, or raw bytes to a file/FIFO (`--out`).
---
## Requirements
- Python 3.x
- OpenCV (`opencv-python`)
- Pillow (`Pillow`)
- `requests` library
### Software
- Python 3.10+
- Python packages: `opencv-python`, `fastapi`, `uvicorn`, `pytest`
Install dependencies:
### System libraries (Debian/Ubuntu)
OpenCV needs runtime libraries that are not pip-installed:
```bash
pip install opencv-python Pillow requests
sudo apt-get install libgl1 libglib2.0-0
```
### Install Python dependencies
```bash
pip install -r requirements.txt
```
### Hardware
- An IP webcam serving an MJPEG stream over HTTP (e.g. Axis M1013 at `http://192.168.0.200/mjpg/video.mjpg`).
- The camera must be pointed at a dynamic, high-contrast scene. A static frame will fail health checks and be rejected.
### For the kernel-RNG flow
- Linux with `rngd` installed (`rng-tools` package).
- `sudo` privileges to run `rngd`.
### For container deployment
- Docker (or any OCI-compatible runtime) for local containers.
- Kubernetes for orchestrated deployment (optional).
---
## Local Usage
### Single number
```bash
python3 entropy.py --bit 256
```
### Loop, writing raw bytes to a FIFO for `rngd`
```bash
mkfifo /tmp/entropy.fifo
python3 entropy.py --loop --bit 512 --out /tmp/entropy.fifo &
sudo rngd -f -W 90% -x rdrand -x jitter -x pkcs11 -x rtlsdr \
-O namedpipe:path:/tmp/entropy.fifo -O namedpipe:timeout:2
```
### Flags
| Flag | Default | Description |
|---|---|---|
| `--url` | `http://192.168.0.200/mjpg/video.mjpg` | Camera MJPEG stream URL |
| `--bit` | `256` | Bit length of each random number (multiple of 8) |
| `--single N` | `1` | Generate N numbers and exit |
| `--loop` | off | Run forever |
| `--interval` | `1.0` | Seconds between iterations in `--loop` |
| `--out PATH` | stdout | Append raw bytes to a file/FIFO instead of printing hex |
| `--no-health` | off | Disable FIPS 140-2 health checks |
`--loop` and `--single` are mutually exclusive.
---
## HTTP Service (for remote systems)
`api.py` runs the camera producer in a background thread and serves the latest entropy blob over HTTP, so other systems can pull entropy to seed their own pools.
### Run locally
```bash
uvicorn api:app --host 0.0.0.0 --port 8000
```
### Endpoints
| Method | Path | Auth | Returns |
|---|---|---|---|
| GET | `/` | none | service info |
| GET | `/healthz` | none | `{status: ok}` (liveness probe) |
| GET | `/entropy` | `X-API-Key` if configured | `{hex, bits, ts}` |
### Configuration (env vars)
| Var | Default | Description |
|---|---|---|
| `ENTROPY_CAMERA_URL` | `http://192.168.0.200/mjpg/video.mjpg` | Camera MJPEG URL |
| `ENTROPY_BITS` | `256` | Bits per blob |
| `ENTROPY_INTERVAL` | `1.0` | Seconds between frames |
| `ENTROPY_NO_HEALTH` | `0` | `1` disables health checks |
| `ENTROPY_API_KEY` | empty | Shared secret for `/entropy`; empty = no auth |
---
## Container Deployment
### Build and run
```bash
docker build -t entropy-rng .
docker run -d -p 8000:8000 \
-e ENTROPY_CAMERA_URL=http://192.168.0.200/mjpg/video.mjpg \
-e ENTROPY_API_KEY=changeme \
entropy-rng
```
The container has a healthcheck against `/healthz`. The camera must be reachable from the container's network.
### Kubernetes notes
- **Replicas: 1.** Multiple pods hitting the same camera produce correlated output. Pin to a single replica.
- **Camera reachability** is the real constraint. Schedule the pod on a node that can reach the camera (use `nodeSelector` or a labeled node), or expose the camera via a `Service`.
- Store `ENTROPY_API_KEY` in a `Secret` and mount as an env var.
- Use an `Ingress` or `Service` for TLS termination in front of uvicorn.
---
## Consuming Remotely (seed another system's pool)
A remote Linux box can pull a blob and feed its kernel RNG via `rngd`:
```bash
curl -s -H "X-API-Key: changeme" https://entropy-svc.internal/entropy \
| python3 -c "import sys,json; print(json.load(sys.stdin)['hex'])" \
| xxd -r -p > /tmp/entropy.fifo
sudo rngd -f -W 90% -x rdrand -x jitter -x pkcs11 -x rtlsdr \
-O namedpipe:path:/tmp/entropy.fifo -O namedpipe:timeout:2
```
---
## Usage
## Tests
### 1. OpenCV Method (Recommended)
Run the OpenCV script to fetch a frame and generate random numbers:
```bash
python3 entropy-rng-opencv.py
pytest -q
```
**Example Output:**
```
128-bit random number: 0x67dbaf3c5eba5a0d41429d2173a21f2f
256-bit random number: 0xe3a707c10f70f975e074633ad22ea0eaaa8b482e87c6418e3ae2380b2e647a4
```
### 2. Manual HTTP Method
Run the manual script (alternative to OpenCV):
```bash
python3 entropy-rng-cam.py
```
**Example Output:**
```
128-bit random number: 0x494d97b11a4b6008e4597cfc29108354
256-bit random number: 0xd59f7f4d22ebf7507b702fb60e0f2a6ccbf2ad4b5e3ab969f9152222da8e9443
```
---
## Output Format
The generated random numbers are in **hexadecimal format** (e.g., `0x67dbaf3c5eba5a0d41429d2173a21f2f`).
- 128-bit numbers: 32 hex digits
- 256-bit numbers: 64 hex digits
---
## Why This Approach?
- **True Entropy Source**: The camera feed provides a dynamic, unpredictable source of entropy.
- **Cryptographically Secure**: Uses SHA-256 hashing and Python's `secrets` module.
- **No Additional Hardware**: Leverages existing network cameras.
Covers the HMAC-DRBG (determinism, reseed, length, reseed limit), health checks (random passes, frozen/all-ones/short/long-run rejected), a bit-balance smoke test, and the API endpoints including auth gating. Tests stub the camera, so they run without hardware.
---
## Notes
- Ensure the camera feed is **dynamic** (e.g., pointing at changing light patterns).
- For production use, consider adding error handling and logging.
- Point the camera at a dynamic scene. A static frame will fail health checks and be rejected.
- The DRBG is reseeded from each frame, so output changes even if the camera noise is modest.
- Auth is a single shared secret for now. When you need per-client keys, rotate to HMAC-signed blobs or mTLS — the API layer is where that plugs in.
- The 256-bits-per-frame entropy credit is conservative. If you want a defensible number for your specific camera, run `ent` or `rngtest` on a captured sample.
+124
View File
@@ -0,0 +1,124 @@
"""FastAPI entropy service.
Runs the camera entropy producer in a background thread and serves
the latest generated blob over HTTP. Designed for container deployment:
the producer and API share one process, so there is no FIFO to manage
and no cross-container IPC.
Endpoints:
GET /healthz -> liveness probe
GET /entropy -> { hex, bits, ts } (requires X-API-Key if configured)
GET / -> service info
"""
import os
import threading
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
import entropy
CAMERA_URL = os.environ.get("ENTROPY_CAMERA_URL", "http://192.168.0.200/mjpg/video.mjpg")
BITS = int(os.environ.get("ENTROPY_BITS", "256"))
INTERVAL = float(os.environ.get("ENTROPY_INTERVAL", "1.0"))
NO_HEALTH = os.environ.get("ENTROPY_NO_HEALTH", "") == "1"
# Simple shared-secret auth. Empty = no auth (for the first iteration).
API_KEY = os.environ.get("ENTROPY_API_KEY", "")
NUM_BYTES = BITS // 8
class LatestBlob:
"""Thread-safe holder for the most recent entropy blob."""
def __init__(self):
self._lock = threading.Lock()
self._blob = None
self._ts = None
self._ok = False
def set(self, blob):
with self._lock:
self._blob = blob
self._ts = time.time()
self._ok = True
def get(self):
with self._lock:
return self._blob, self._ts, self._ok
def mark_failed(self):
with self._lock:
self._ok = False
latest = LatestBlob()
def producer_loop():
"""Continuously generate entropy blobs and stash the latest one."""
while True:
try:
frame = entropy.fetch_frame_opencv(CAMERA_URL)
raw = entropy.frame_raw_bytes(frame)
if not NO_HEALTH:
from health import check as health_check
health_check(raw)
digest = entropy.HMACDRBG(raw).generate(NUM_BYTES)
latest.set(digest)
except Exception as e:
print(f"[producer] error: {e}", flush=True)
latest.mark_failed()
time.sleep(INTERVAL)
@asynccontextmanager
async def lifespan(app):
t = threading.Thread(target=producer_loop, daemon=True)
t.start()
yield
app = FastAPI(title="Entropy-RNG Service", version="1.0", lifespan=lifespan)
class EntropyResponse(BaseModel):
hex: str
bits: int
ts: str
@app.get("/")
def root():
blob, ts, ok = latest.get()
return {
"service": "entropy-rng",
"bits": BITS,
"healthy": ok,
"last_update": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() if ts else None,
}
@app.get("/healthz")
def healthz():
blob, ts, ok = latest.get()
if not ok or blob is None:
raise HTTPException(status_code=503, detail="no entropy available")
return {"status": "ok"}
@app.get("/entropy")
def get_entropy(x_api_key: str | None = Header(default=None)):
if API_KEY and x_api_key != API_KEY:
raise HTTPException(status_code=401, detail="invalid or missing API key")
blob, ts, ok = latest.get()
if blob is None:
raise HTTPException(status_code=503, detail="no entropy available yet")
return EntropyResponse(
hex=blob.hex(),
bits=BITS,
ts=datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
)
-47
View File
@@ -1,47 +0,0 @@
import hashlib
import requests
from io import BytesIO
from PIL import Image
import secrets
def fetch_single_frame(url):
response = requests.get(url, stream=True)
boundary = b'--myboundary'
data = b''
in_frame = False
frame_data = b''
for chunk in response.iter_content(chunk_size=1024):
data += chunk
if not in_frame and boundary in data:
in_frame = True
data = data.split(boundary, 1)[1]
if in_frame and b'Content-Type: image/jpeg' in data:
parts = data.split(b'\r\n\r\n', 1)
if len(parts) > 1:
frame_part = parts[1]
if b'\r\n--myboundary' in frame_part:
frame_data = frame_part.split(b'\r\n--myboundary', 1)[0]
try:
return Image.open(BytesIO(frame_data))
except Exception as e:
print(f"Error processing frame: {e}")
return None
raise ValueError("No valid frame found in the stream.")
def generate_random_numbers(frame, bit_lengths=[16, 32, 64, 128, 256]):
img_bytes = frame.resize((100, 100)).tobytes()
entropy = hashlib.sha256(img_bytes).digest()
# Use secrets.randbits directly
return {length: secrets.randbits(length) for length in bit_lengths}
url = "http://192.168.0.200/mjpg/video.mjpg"
try:
frame = fetch_single_frame(url)
if frame:
random_numbers = generate_random_numbers(frame)
for length, number in random_numbers.items():
print(f"{length}-bit random number: {hex(number)}")
else:
print("Failed to extract a valid frame.")
except Exception as e:
print(f"Error: {e}")
-27
View File
@@ -1,27 +0,0 @@
import cv2
import hashlib
import secrets
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 generate_random_numbers(frame, bit_lengths=[16, 32, 64, 128, 256]):
_, img_encoded = cv2.imencode('.jpg', frame)
img_bytes = img_encoded.tobytes()
entropy = hashlib.sha256(img_bytes).digest()
# Use secrets.randbits directly
return {length: secrets.randbits(length) for length in bit_lengths}
url = "http://192.168.0.200/mjpg/video.mjpg"
try:
frame = fetch_frame_opencv(url)
random_numbers = generate_random_numbers(frame)
for length, number in random_numbers.items():
print(f"{length}-bit random number: {hex(number)}")
except Exception as e:
print(f"Error: {e}")
+100 -21
View File
@@ -1,13 +1,59 @@
#!/usr/bin/env python3
import cv2
import hashlib
import secrets
import time
"""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()
@@ -16,11 +62,26 @@ def fetch_frame_opencv(url):
raise ValueError("Failed to fetch frame.")
return frame
def generate_random_numbers(frame, bit_lengths):
_, img_encoded = cv2.imencode('.jpg', frame)
img_bytes = img_encoded.tobytes()
entropy = hashlib.sha256(img_bytes).digest()
return {length: secrets.randbits(length) for length in bit_lengths}
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.")
@@ -28,31 +89,49 @@ def main():
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:
frame = fetch_frame_opencv(args.url)
random_numbers = generate_random_numbers(frame, [args.bit])
for number in random_numbers.values():
print(f"{number:x}")
blob = produce()
emit(blob, args.out)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
time.sleep(1) # Wait before retrying
time.sleep(args.interval)
else:
for i in range(args.single):
try:
frame = fetch_frame_opencv(args.url)
random_numbers = generate_random_numbers(frame, [args.bit])
for number in random_numbers.values():
print(f"{number:x}")
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(1) # Small delay between frames
time.sleep(args.interval)
return 0
if __name__ == "__main__":
import sys
main()
sys.exit(main())
View File
+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
+8 -6
View File
@@ -1,10 +1,12 @@
# In order to use this there needs to be a IP webcam with some stream availabgle to use as the randomes generating source.
# Sugest review code to see how it should be provided to the script.
# In order to use this there needs to be an IP webcam with a stream available to use as the randomness generating source.
# Suggest reviewing the code to see how it should be provided to the script.
#
# generate entropy via pie fifo file.
python3 entropy.py --loop --bit 1024 | xxd -r > /tmp/entropy.fifo
# generate entropy via a pipe file
mkfifo /tmp/entropy.fifo
python3 entropy.py --loop --bit 512 --out /tmp/entropy.fifo &
#
# Use the generated entropy
# Use the generated entropy
sudo rngd -f -W 90% -x rdrand -x jitter -x pkcs11 -x rtlsdr -O namedpipe:path:/tmp/entropy.fifo -O namedpipe:timeout:2
#
#
# Or serve it over HTTP for remote systems (see README -> HTTP Service)
uvicorn api:app --host 0.0.0.0 --port 8000
+7
View File
@@ -0,0 +1,7 @@
# Camera entropy source dependencies
opencv-python>=4.8,<5
# FastAPI stack for the entropy service (added in the API stage)
fastapi>=0.110
uvicorn[standard]>=0.29
# Test dependencies
pytest>=8.0
+146
View File
@@ -0,0 +1,146 @@
"""Tests for the entropy source: DRBG, health checks, and API.
Run with: pytest -q
These tests stub the camera so they run without hardware.
"""
import os
import pytest
from fastapi.testclient import TestClient
import entropy
import health
# --- HMAC-DRBG ---
def test_drbg_deterministic_same_seed():
a = entropy.HMACDRBG(b"seed-A").generate(32)
b = entropy.HMACDRBG(b"seed-A").generate(32)
assert a == b
def test_drbg_different_seed_different_output():
a = entropy.HMACDRBG(b"seed-A").generate(32)
b = entropy.HMACDRBG(b"seed-B").generate(32)
assert a != b
def test_drbg_reseed_changes_output():
d = entropy.HMACDRBG(b"seed")
first = d.generate(32)
d.reseed(b"new-entropy")
second = d.generate(32)
assert first != second
def test_drbg_output_length():
d = entropy.HMACDRBG(b"seed")
assert len(d.generate(1)) == 1
assert len(d.generate(64)) == 64
assert len(d.generate(1000)) == 1000
def test_drbg_requires_reseed_after_limit():
d = entropy.HMACDRBG(b"seed")
for _ in range(10001):
d.generate(1)
with pytest.raises(RuntimeError, match="reseed"):
d.generate(1)
# --- Health checks ---
def test_health_passes_random_data():
health.check(os.urandom(2500))
def test_health_rejects_frozen_frame():
with pytest.raises(health.HealthCheckError):
health.check(b"\x00" * 2500)
def test_health_rejects_all_ones():
with pytest.raises(health.HealthCheckError):
health.check(b"\xff" * 2500)
def test_health_rejects_short_sample():
with pytest.raises(health.HealthCheckError, match="too short"):
health.check(os.urandom(100))
def test_health_long_run_rejected():
# 2500 bytes with a single long run of zeros embedded
data = bytearray(os.urandom(2500))
data[1000:1000 + 30] = b"\x00" * 30 # 240-bit run of zeros
with pytest.raises(health.HealthCheckError, match="long run"):
health.check(bytes(data))
# --- Statistical smoke test on DRBG output ---
def test_drbg_output_is_balanced():
"""Generated bits should be roughly 50/50 over a large sample."""
d = entropy.HMACDRBG(b"seed")
out = d.generate(2500)
ones = sum(bin(byte).count("1") for byte in out)
total = len(out) * 8
# Expect ~50%; allow 45-55% to avoid flakiness
ratio = ones / total
assert 0.45 <= ratio <= 0.55, f"bit balance {ratio:.3f} outside [0.45, 0.55]"
# --- API ---
@pytest.fixture
def client(monkeypatch):
# Disable the real producer thread; we inject blobs manually
monkeypatch.setattr("api.producer_loop", lambda: None)
import api
api.latest.set(b"\x00" * 32)
return TestClient(api.app)
def test_api_healthz(client):
r = client.get("/healthz")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_api_root(client):
r = client.get("/")
assert r.status_code == 200
body = r.json()
assert body["service"] == "entropy-rng"
assert body["bits"] == 256
def test_api_entropy_returns_blob(client):
r = client.get("/entropy")
assert r.status_code == 200
body = r.json()
assert body["hex"] == "00" * 32
assert body["bits"] == 256
assert "ts" in body
def test_api_entropy_requires_key_when_set(client, monkeypatch):
import api
monkeypatch.setattr(api, "API_KEY", "secret")
# No key
r = client.get("/entropy")
assert r.status_code == 401
# Wrong key
r = client.get("/entropy", headers={"X-API-Key": "wrong"})
assert r.status_code == 401
# Right key
r = client.get("/entropy", headers={"X-API-Key": "secret"})
assert r.status_code == 200
def test_api_healthz_open_even_with_auth(client, monkeypatch):
import api
monkeypatch.setattr(api, "API_KEY", "secret")
r = client.get("/healthz")
assert r.status_code == 200