Compare commits
7
Commits
a2dedb502e
...
e07030d81e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e07030d81e | ||
|
|
de17686a9b | ||
|
|
d121b0f0f6 | ||
|
|
1b99573b65 | ||
|
|
ab70195a07 | ||
|
|
8ac9ab92eb | ||
|
|
239672ca63 |
+26
@@ -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}"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user