diff --git a/.gitignore b/.gitignore
index d13f479..359ebea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,6 @@ random.bin
# build-test runtime files
camera-qrng.log
.camera-qrng.pid
+
+# LaunchAgent logs
+/logs/
diff --git a/README.md b/README.md
index 922a8d2..9e34756 100644
--- a/README.md
+++ b/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
```
diff --git a/RESEARCH.md b/RESEARCH.md
index 366c07a..bcb5fa0 100644
--- a/RESEARCH.md
+++ b/RESEARCH.md
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index f740b81..ba4479c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/scripts/camera-qrng.plist.in b/scripts/camera-qrng.plist.in
new file mode 100644
index 0000000..4a4f340
--- /dev/null
+++ b/scripts/camera-qrng.plist.in
@@ -0,0 +1,27 @@
+
+
+
+
+ Label
+ camera-trng
+ ProgramArguments
+
+ BINARY_PATH
+
+ WorkingDirectory
+ WORKING_DIR
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ WORKING_DIR/logs/camera-qrng.out.log
+ StandardErrorPath
+ WORKING_DIR/logs/camera-qrng.err.log
+ EnvironmentVariables
+
+ PORT
+ 8787
+
+
+
diff --git a/scripts/install-launchagent.sh b/scripts/install-launchagent.sh
new file mode 100755
index 0000000..c2429c8
--- /dev/null
+++ b/scripts/install-launchagent.sh
@@ -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}"
diff --git a/scripts/run-mac.sh b/scripts/run-mac.sh
new file mode 100755
index 0000000..96ee1a6
--- /dev/null
+++ b/scripts/run-mac.sh
@@ -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
diff --git a/scripts/uninstall-launchagent.sh b/scripts/uninstall-launchagent.sh
new file mode 100755
index 0000000..97567b1
--- /dev/null
+++ b/scripts/uninstall-launchagent.sh
@@ -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
diff --git a/skill.md b/skill.md
index bd6bd51..d0adca5 100644
--- a/skill.md
+++ b/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
diff --git a/src/entropy/camera.rs b/src/entropy/camera.rs
index c99ef49..3d43710 100644
--- a/src/entropy/camera.rs
+++ b/src/entropy/camera.rs
@@ -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,
}
}
diff --git a/src/entropy/extract.rs b/src/entropy/extract.rs
index 8ded9c5..c916812 100644
--- a/src/entropy/extract.rs
+++ b/src/entropy/extract.rs
@@ -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> {
let lsbs: Vec = 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> {
}
/// Extract raw LSB bytes from a camera frame (no conditioning).
+#[allow(dead_code)]
pub fn extract_lsbs(raw: &[u8]) -> Vec {
raw.iter().map(|b| b & 0x03).collect()
}
diff --git a/src/entropy/mod.rs b/src/entropy/mod.rs
index e72e828..d165b0b 100644
--- a/src/entropy/mod.rs
+++ b/src/entropy/mod.rs
@@ -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;
diff --git a/src/entropy/pool.rs b/src/entropy/pool.rs
index 0bca418..9174991 100644
--- a/src/entropy/pool.rs
+++ b/src/entropy/pool.rs
@@ -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 = std::sync::OnceLock::new();
// ── Poison-safe mutex helper ────────────────────────────────────────
@@ -66,9 +59,9 @@ struct PoolInner {
pub struct ResilientPool {
inner: Mutex,
- /// CSPRNG fallback, re-seeded from camera entropy.
+ /// Internal; not used for any output. Output is only from camera buffer.
rng: Mutex,
- /// 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 {
+/// Pull bytes from the pool. Only from camera. Fails if fewer than `num_bytes` available.
+pub fn pool_pull(num_bytes: usize) -> Result, 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, String> {
- Ok(pool_pull(num_bytes))
+ pool_pull(num_bytes)
}
/// Fill a buffer with entropy (used by OpenSSL provider).
diff --git a/src/main.rs b/src/main.rs
index a912c93..5efeda9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -59,21 +59,24 @@ async fn main() -> Result<(), Box> {
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") {