Polish: camera-only entropy, cleaner output, complete docs
- Remove CSPRNG fallback: pool now returns errors when camera entropy is insufficient instead of silently falling back to ChaCha20. Random data is only from camera input or not at all. - Fix throughput display: show Mbps/KB/s for smaller frame sizes, Gbps/MB/s for larger ones (actual frame size varies by pixel format). - Add RESEARCH.md note about pixel format impact on throughput. - Complete README API table with all endpoints (dice, password, coin, 8ball, cameras, docs, MCP). - Add docker-compose device mapping for Linux camera access with explanatory comments about macOS limitation. - Add macOS LaunchAgent scripts (install/uninstall/plist template). - Polish run-mac.sh with build step and clearer output. - Fix dead code warnings on library utility functions. - Add /logs/ to .gitignore for LaunchAgent log output. - Fix skill.md: remove stale CSPRNG fallback references. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6e9813ae99
commit
0314c598fb
|
|
@ -13,3 +13,6 @@ random.bin
|
|||
# build-test runtime files
|
||||
camera-qrng.log
|
||||
.camera-qrng.pid
|
||||
|
||||
# LaunchAgent logs
|
||||
/logs/
|
||||
|
|
|
|||
38
README.md
38
README.md
|
|
@ -17,23 +17,35 @@ cd camera-trng
|
|||
cargo build --release
|
||||
```
|
||||
|
||||
Or use Docker (build locally):
|
||||
```bash
|
||||
docker build -t camera-trng .
|
||||
```
|
||||
|
||||
### 2. Cover Your Camera Lens
|
||||
|
||||
Use tape, a lens cap, or put the camera in a dark box. The camera should see nothing but blackness.
|
||||
|
||||
### 3. Run
|
||||
|
||||
**macOS (native camera access via AVFoundation):**
|
||||
```bash
|
||||
./scripts/run-mac.sh
|
||||
```
|
||||
|
||||
**Linux (native):**
|
||||
```bash
|
||||
./target/release/camera-qrng
|
||||
```
|
||||
|
||||
**Linux (Docker with camera device):**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Server starts at `http://localhost:8787`
|
||||
|
||||
**Start with the OS (macOS LaunchAgent):**
|
||||
```bash
|
||||
./scripts/install-launchagent.sh
|
||||
```
|
||||
Runs the compiled binary at login and keeps it running. Logs go to `logs/`. To remove: `./scripts/uninstall-launchagent.sh`.
|
||||
|
||||
### 4. Get Random Bytes
|
||||
|
||||
```bash
|
||||
|
|
@ -65,17 +77,27 @@ The output is cryptographic quality - suitable for generating encryption keys, s
|
|||
| `GET /random?bytes=N` | Get N random bytes (max 1MB) |
|
||||
| `GET /random?bytes=N&hex=true` | Get N random bytes as hex string |
|
||||
| `GET /stream` | Continuous stream of random bytes |
|
||||
| `GET /health` | Health check |
|
||||
| `GET /stream?bytes=N` | Stream exactly N bytes then stop |
|
||||
| `GET /dice?d=6&count=3` | Roll quantum-random dice |
|
||||
| `GET /password?length=24` | Generate quantum-random password |
|
||||
| `GET /coin` | Quantum coin flip |
|
||||
| `GET /8ball?question=...` | Quantum Magic 8 Ball |
|
||||
| `GET /cameras` | List available camera devices |
|
||||
| `GET /health` | Health check with pool stats |
|
||||
| `GET /docs` | API documentation page |
|
||||
| `GET /.well-known/mcp.json` | MCP discovery endpoint |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**macOS "Camera in use" error:**
|
||||
```bash
|
||||
./scripts/release-camera.sh
|
||||
./target/release/camera-qrng
|
||||
./scripts/run-mac.sh
|
||||
```
|
||||
|
||||
**Docker (Linux):**
|
||||
**Docker on macOS:** Docker on Mac runs Linux containers and cannot access the Mac camera. Use `./scripts/run-mac.sh` instead.
|
||||
|
||||
**Docker on Linux (explicit device):**
|
||||
```bash
|
||||
docker run -d --device /dev/video0 -p 8787:8787 camera-trng
|
||||
```
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ As Steve Gibson noted on Security Now, camera sensors produce **Gbps of quantum
|
|||
|
||||
This is a **firehose of quantum-origin randomness**. A single 4K camera at 60fps provides more conditioned entropy than most dedicated QRNG hardware.
|
||||
|
||||
**Note:** Actual throughput depends on the camera's native pixel format. Many cameras deliver YUV or compressed frames that are smaller than the theoretical RGB size. The numbers above assume uncompressed RGB (3 bytes/pixel). The entropy per pixel remains the same regardless of format; conditioned output scales with the actual frame size reported at startup.
|
||||
|
||||
### Configuration
|
||||
|
||||
Set resolution via environment variables:
|
||||
|
|
@ -239,7 +241,7 @@ The SHA-256 conditioning ensures outputs are indistinguishable from ideal random
|
|||
- Cryptographic nonces and IVs
|
||||
- Salts for password hashing
|
||||
- UUID/ULID generation at scale
|
||||
- Seeding CSPRNGs
|
||||
- Seeding other RNGs
|
||||
- Key generation for symmetric encryption
|
||||
- Bulk key derivation
|
||||
- Applications requiring provable physical/quantum randomness
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
# Camera TRNG - Docker Compose
|
||||
# Linux only: Docker containers can access /dev/video0 via device mapping.
|
||||
# macOS: use ./scripts/run-mac.sh instead (Docker cannot access the Mac camera).
|
||||
services:
|
||||
camera-trng:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8787:8787"
|
||||
- "0.0.0.0:8787:8787"
|
||||
devices:
|
||||
- "/dev/video0:/dev/video0"
|
||||
environment:
|
||||
- PORT=8787
|
||||
- RUST_LOG=info
|
||||
restart: on-failure
|
||||
restart: always
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>camera-trng</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>BINARY_PATH</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>WORKING_DIR</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>WORKING_DIR/logs/camera-qrng.out.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>WORKING_DIR/logs/camera-qrng.err.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PORT</key>
|
||||
<string>8787</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install camera-qrng as a macOS LaunchAgent so it starts at login and stays running.
|
||||
# Usage: ./scripts/install-launchagent.sh
|
||||
|
||||
set -e
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BINARY="${ROOT}/target/release/camera-qrng"
|
||||
PLIST_SRC="${ROOT}/scripts/camera-qrng.plist.in"
|
||||
PLIST_DEST="${HOME}/Library/LaunchAgents/camera-trng.plist"
|
||||
|
||||
echo "Building release binary..."
|
||||
(cd "$ROOT" && cargo build --release)
|
||||
|
||||
mkdir -p "${ROOT}/logs"
|
||||
sed -e "s|BINARY_PATH|${BINARY}|g" -e "s|WORKING_DIR|${ROOT}|g" "$PLIST_SRC" > "$PLIST_DEST"
|
||||
echo "Installed plist to ${PLIST_DEST}"
|
||||
|
||||
launchctl unload "$PLIST_DEST" 2>/dev/null || true
|
||||
launchctl load "$PLIST_DEST"
|
||||
echo "Loaded. camera-qrng will start at login and restart if it exits."
|
||||
echo "Logs: ${ROOT}/logs/camera-qrng.out.log and .err.log"
|
||||
echo "To stop: launchctl unload ${PLIST_DEST}"
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run camera-trng natively on macOS (AVFoundation camera access).
|
||||
# Docker on Mac cannot access the host camera — use this script instead.
|
||||
# Usage: ./scripts/run-mac.sh
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
export PORT="${PORT:-8787}"
|
||||
|
||||
echo "=== Camera TRNG (macOS) ==="
|
||||
echo "Building release binary..."
|
||||
cargo build --release 2>&1
|
||||
|
||||
echo "Starting camera-qrng on http://0.0.0.0:${PORT}"
|
||||
echo "Press Ctrl-C to stop."
|
||||
exec ./target/release/camera-qrng
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
#!/usr/bin/env bash
|
||||
# Remove the camera-qrng LaunchAgent and stop the service.
|
||||
# Usage: ./scripts/uninstall-launchagent.sh
|
||||
|
||||
set -e
|
||||
PLIST="${HOME}/Library/LaunchAgents/camera-trng.plist"
|
||||
if [[ -f "$PLIST" ]]; then
|
||||
launchctl unload "$PLIST" 2>/dev/null || true
|
||||
rm -f "$PLIST"
|
||||
echo "Uninstalled camera-trng LaunchAgent."
|
||||
else
|
||||
echo "No plist found at ${PLIST}."
|
||||
fi
|
||||
4
skill.md
4
skill.md
|
|
@ -269,13 +269,11 @@ curl -s "http://localhost:8787/.well-known/mcp.json" | jq
|
|||
- Maximum 4 concurrent requests
|
||||
- Requires a camera device with covered lens
|
||||
- Performance depends on camera frame rate
|
||||
- CSPRNG fallback is used when the camera pool is empty (still cryptographically safe, but not quantum)
|
||||
|
||||
## Security Notes
|
||||
|
||||
- No authentication required (intended for local/trusted networks)
|
||||
- Random data is cryptographically secure from both the camera entropy pool and ChaCha20 CSPRNG fallback
|
||||
- The CSPRNG is periodically re-seeded with real camera entropy
|
||||
- Random data is only from camera input; otherwise requests fail
|
||||
- Consider rate limiting in production deployments
|
||||
|
||||
## See Also
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ pub fn requested_format(config: &CameraConfig) -> RequestedFormat<'static> {
|
|||
}
|
||||
}
|
||||
|
||||
fn camera_index_to_u32(idx: &CameraIndex, fallback: u32) -> u32 {
|
||||
fn camera_index_to_u32(idx: &CameraIndex, default_index: u32) -> u32 {
|
||||
match idx {
|
||||
CameraIndex::Index(i) => *i,
|
||||
CameraIndex::String(_) => fallback,
|
||||
CameraIndex::String(_) => default_index,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ pub const CHUNK_SIZE: usize = 256;
|
|||
|
||||
/// Extract conditioned entropy from a raw camera frame buffer.
|
||||
/// Returns Vec of SHA-256 hashes over LSB chunks.
|
||||
#[allow(dead_code)]
|
||||
pub fn condition_frame(raw: &[u8], frame_idx: u64) -> Vec<Vec<u8>> {
|
||||
let lsbs: Vec<u8> = raw.iter().map(|b| b & 0x03).collect();
|
||||
let mut hasher = Sha256::new();
|
||||
|
|
@ -25,6 +26,7 @@ pub fn condition_frame(raw: &[u8], frame_idx: u64) -> Vec<Vec<u8>> {
|
|||
}
|
||||
|
||||
/// Extract raw LSB bytes from a camera frame (no conditioning).
|
||||
#[allow(dead_code)]
|
||||
pub fn extract_lsbs(raw: &[u8]) -> Vec<u8> {
|
||||
raw.iter().map(|b| b & 0x03).collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
//! captures thermal/quantum noise from the CCD/CMOS dark current.
|
||||
//!
|
||||
//! Architecture: a background harvester feeds a ring-buffer pool.
|
||||
//! API requests pull from the pool (instant) with CSPRNG fallback.
|
||||
//! Random data is only from camera input; if the pool has insufficient bytes, requests fail.
|
||||
|
||||
mod config;
|
||||
mod pool;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
//! Resilient entropy pool: background camera harvesting + CSPRNG fallback.
|
||||
//! Entropy pool: random data from camera input only, or not at all.
|
||||
//!
|
||||
//! Architecture:
|
||||
//! - A background thread continuously captures camera frames and feeds conditioned
|
||||
//! entropy into a ring buffer pool.
|
||||
//! - API requests pull from the pool instantly (never touch the camera directly).
|
||||
//! - If the pool is empty (camera slow/hung), a ChaCha20Rng CSPRNG provides bytes.
|
||||
//! - The CSPRNG is periodically re-seeded from real camera entropy.
|
||||
//! - All camera operations have enforced timeouts so nothing ever blocks forever.
|
||||
//! A background thread captures camera frames and feeds conditioned entropy into
|
||||
//! a ring buffer. API requests pull from that buffer. The only source of random
|
||||
//! data is the camera; if the pool has insufficient bytes, requests fail.
|
||||
//!
|
||||
//! Resilience guarantees:
|
||||
//! - Poisoned mutexes are recovered (never panic on lock).
|
||||
//! - Harvester thread panics are caught and the thread auto-restarts.
|
||||
//! - Camera reconnection uses exponential backoff with jitter.
|
||||
//! - Frame capture has an enforced deadline.
|
||||
//! All camera operations have enforced timeouts. Poisoned mutexes are recovered;
|
||||
//! harvester panics are caught and the thread restarts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::panic;
|
||||
|
|
@ -37,12 +30,12 @@ const FRAME_TIMEOUT: Duration = Duration::from_secs(5);
|
|||
const RECONNECT_BASE: Duration = Duration::from_secs(2);
|
||||
/// Maximum reconnect backoff cap.
|
||||
const RECONNECT_MAX: Duration = Duration::from_secs(60);
|
||||
/// Minimum bytes of real entropy before first CSPRNG seed.
|
||||
/// Minimum camera bytes before marking pool as seeded (diagnostics only).
|
||||
const MIN_SEED_BYTES: usize = 64;
|
||||
/// How long to wait before restarting a panicked harvester thread.
|
||||
const PANIC_RESTART_DELAY: Duration = Duration::from_secs(3);
|
||||
|
||||
/// The global resilient pool, initialized once.
|
||||
/// Global pool; output is only from camera buffer.
|
||||
static RESILIENT_POOL: std::sync::OnceLock<ResilientPool> = std::sync::OnceLock::new();
|
||||
|
||||
// ── Poison-safe mutex helper ────────────────────────────────────────
|
||||
|
|
@ -66,9 +59,9 @@ struct PoolInner {
|
|||
|
||||
pub struct ResilientPool {
|
||||
inner: Mutex<PoolInner>,
|
||||
/// CSPRNG fallback, re-seeded from camera entropy.
|
||||
/// Internal; not used for any output. Output is only from camera buffer.
|
||||
rng: Mutex<ChaCha20Rng>,
|
||||
/// Set to true once the CSPRNG has been seeded with real camera entropy.
|
||||
/// True once pool has received enough camera bytes (diagnostics only).
|
||||
seeded: AtomicBool,
|
||||
/// Set to true while the harvester supervisor thread is alive.
|
||||
producer_running: AtomicBool,
|
||||
|
|
@ -85,8 +78,7 @@ struct SubscriberMap {
|
|||
|
||||
fn get_pool() -> &'static ResilientPool {
|
||||
RESILIENT_POOL.get_or_init(|| {
|
||||
let mut seed = [0u8; 32];
|
||||
rand::rng().fill(&mut seed);
|
||||
let seed = [0u8; 32]; // rng is not used for output; only camera buffer is.
|
||||
let rng = ChaCha20Rng::from_seed(seed);
|
||||
ResilientPool {
|
||||
inner: Mutex::new(PoolInner {
|
||||
|
|
@ -119,7 +111,7 @@ fn backoff_duration(attempt: u32) -> Duration {
|
|||
capped + Duration::from_millis(jitter_ms)
|
||||
}
|
||||
|
||||
/// Push conditioned entropy bytes into the pool and re-seed the CSPRNG.
|
||||
/// Push conditioned camera bytes into the pool.
|
||||
fn pool_push(data: &[u8]) {
|
||||
let pool = get_pool();
|
||||
let mut inner = lock_or_recover(&pool.inner);
|
||||
|
|
@ -137,7 +129,6 @@ fn pool_push(data: &[u8]) {
|
|||
let total = inner.total_harvested;
|
||||
drop(inner);
|
||||
|
||||
// Re-seed CSPRNG from camera entropy.
|
||||
if data.len() >= 32 && total >= MIN_SEED_BYTES as u64 {
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&data[..32]);
|
||||
|
|
@ -153,38 +144,33 @@ fn pool_push(data: &[u8]) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Pull bytes from the pool. Consumes real camera entropy first (FIFO),
|
||||
/// falls back to CSPRNG when pool is empty.
|
||||
/// **Never blocks. Always returns immediately.**
|
||||
pub fn pool_pull(num_bytes: usize) -> Vec<u8> {
|
||||
/// Pull bytes from the pool. Only from camera. Fails if fewer than `num_bytes` available.
|
||||
pub fn pool_pull(num_bytes: usize) -> Result<Vec<u8>, String> {
|
||||
let pool = get_pool();
|
||||
let mut out = Vec::with_capacity(num_bytes);
|
||||
|
||||
{
|
||||
let mut inner = lock_or_recover(&pool.inner);
|
||||
let take = num_bytes.min(inner.available);
|
||||
for _ in 0..take {
|
||||
if inner.available < num_bytes {
|
||||
return Err(format!(
|
||||
"insufficient camera entropy: need {} bytes, {} available (cover lens and wait for harvest)",
|
||||
num_bytes, inner.available
|
||||
));
|
||||
}
|
||||
for _ in 0..num_bytes {
|
||||
let pos = inner.read_pos;
|
||||
out.push(inner.buf[pos]);
|
||||
inner.read_pos = (pos + 1) % POOL_CAPACITY;
|
||||
}
|
||||
inner.available -= take;
|
||||
inner.available -= num_bytes;
|
||||
}
|
||||
|
||||
let remaining = num_bytes - out.len();
|
||||
if remaining > 0 {
|
||||
let mut rng_buf = vec![0u8; remaining];
|
||||
let mut rng = lock_or_recover(&pool.rng);
|
||||
rng.fill(&mut rng_buf[..]);
|
||||
out.extend_from_slice(&rng_buf);
|
||||
}
|
||||
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Extract entropy: always-available, non-blocking.
|
||||
/// Extract entropy: only from camera. Fails if pool has insufficient bytes.
|
||||
pub fn extract_entropy(num_bytes: usize, _config: &CameraConfig) -> Result<Vec<u8>, String> {
|
||||
Ok(pool_pull(num_bytes))
|
||||
pool_pull(num_bytes)
|
||||
}
|
||||
|
||||
/// Fill a buffer with entropy (used by OpenSSL provider).
|
||||
|
|
|
|||
21
src/main.rs
21
src/main.rs
|
|
@ -59,21 +59,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
Ok((actual_w, actual_h, frame_size)) => {
|
||||
let conditioned_per_frame = (frame_size / CHUNK_SIZE) * 32;
|
||||
let throughput_30fps = conditioned_per_frame * 30;
|
||||
let raw_gbps = (frame_size as f64 * 30.0 * 8.0) / 1_000_000_000.0;
|
||||
let raw_mbps = (frame_size as f64 * 30.0 * 8.0) / 1_000_000.0;
|
||||
let conditioned_kbs = throughput_30fps / 1_000;
|
||||
println!(
|
||||
"Camera OK at {}x{} - {} bytes/frame",
|
||||
actual_w, actual_h, frame_size
|
||||
);
|
||||
println!("Raw throughput: {:.1} Gbps at 30fps", raw_gbps);
|
||||
println!(
|
||||
"Conditioned output: ~{} MB/s at 30fps (8:1 ratio)",
|
||||
throughput_30fps / 1_000_000
|
||||
"Camera OK: {}x{}, {} KB/frame ({} bytes)",
|
||||
actual_w, actual_h, frame_size / 1024, frame_size
|
||||
);
|
||||
if raw_mbps >= 1000.0 {
|
||||
println!("Throughput: {:.1} Gbps raw, ~{} MB/s conditioned @ 30fps",
|
||||
raw_mbps / 1000.0, throughput_30fps / 1_000_000);
|
||||
} else {
|
||||
println!("Throughput: {:.0} Mbps raw, ~{} KB/s conditioned @ 30fps",
|
||||
raw_mbps, conditioned_kbs);
|
||||
}
|
||||
println!("Ensure lens is covered for optimal quantum noise capture");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Camera error: {}. Server will still start with CSPRNG fallback.",
|
||||
"Camera error: {}. Server will start; /random and stream return errors until camera entropy is available.",
|
||||
e
|
||||
);
|
||||
if e.contains("Lock Rejected") || e.contains("lock") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue