Initial commit: Camera TRNG project

Rust project for true random number generation using camera input.
This commit is contained in:
Leopere 2026-01-22 11:53:10 -05:00
commit 1b7e21a8a0
No known key found for this signature in database
GPG Key ID: EA43219BE7B419F1
5 changed files with 1911 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1668
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "camera-trng"
version = "0.1.0"
edition = "2021"
description = "True random number generator using camera sensor noise"
[dependencies]
# Camera capture - cross platform
nokhwa = { version = "0.10", features = ["input-native"] }
# Lightweight HTTP server
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
# For extracting entropy
sha2 = "0.10"
hex = "0.4"
serde = { version = "1", features = ["derive"] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"

72
README.md Normal file
View File

@ -0,0 +1,72 @@
# Camera TRNG
A minimal cross-platform true random number generator that extracts entropy from camera sensor noise.
## How It Works
Camera sensors exhibit thermal noise and shot noise at the pixel level. This noise is most concentrated in the least significant bits (LSBs) of each pixel value. This service:
1. Captures frames from the default camera at the lowest resolution
2. Extracts the 2 LSBs from each pixel (where thermal/shot noise dominates)
3. Hashes the LSBs with SHA-256 to whiten the data and remove any bias
4. Mixes in timing entropy for additional randomness
## Build
```bash
cargo build --release
```
## Run
```bash
./target/release/camera-trng
# Or set a custom port
PORT=9000 ./target/release/camera-trng
```
## API
### GET /random
Returns random bytes from camera noise.
**Query Parameters:**
- `bytes` - Number of bytes to return (default: 32, max: 1024)
- `hex` - Return as hex string instead of raw bytes (default: false)
**Examples:**
```bash
# Get 32 random bytes as hex
curl "http://localhost:8787/random?hex=true"
# Get 64 raw random bytes
curl "http://localhost:8787/random?bytes=64" -o random.bin
# Get 256 bytes as hex
curl "http://localhost:8787/random?bytes=256&hex=true"
```
### GET /health
Returns `ok` if the server is running.
## Rate Limiting
- Maximum 4 concurrent requests
- Maximum 1024 bytes per request
- Returns 429 Too Many Requests when overloaded
## Cross-Platform Support
Uses `nokhwa` for camera access, supporting:
- macOS (AVFoundation)
- Windows (Media Foundation)
- Linux (V4L2)
## Security Notes
This is intended for hobby/experimental use. For cryptographic applications:
- Consider mixing with system entropy (`/dev/urandom`)
- The quality of randomness depends on camera sensor characteristics
- Environmental factors (lighting, temperature) affect noise levels

145
src/main.rs Normal file
View File

@ -0,0 +1,145 @@
use axum::{
body::Body,
extract::Query,
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
use nokhwa::{
pixel_format::RgbFormat,
utils::{CameraIndex, RequestedFormat, RequestedFormatType},
Camera,
};
use sha2::{Digest, Sha256};
use std::sync::atomic::{AtomicUsize, Ordering};
const MAX_BYTES_PER_REQUEST: usize = 1024;
const MAX_CONCURRENT: usize = 4;
const DEFAULT_PORT: u16 = 8787;
static ACTIVE_REQUESTS: AtomicUsize = AtomicUsize::new(0);
#[derive(serde::Deserialize)]
struct RandomQuery {
#[serde(default = "default_bytes")]
bytes: usize,
#[serde(default)]
hex: bool,
}
fn default_bytes() -> usize { 32 }
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let port = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT);
// Test camera access at startup
println!("Testing camera access...");
if let Err(e) = test_camera() {
eprintln!("Camera error: {}. Server will still start.", e);
} else {
println!("Camera OK");
}
let app = Router::new()
.route("/random", get(get_random))
.route("/health", get(health));
let addr = format!("0.0.0.0:{}", port);
println!("Camera TRNG on http://{}", addr);
println!(" GET /random?bytes=N&hex=bool (max {}B)", MAX_BYTES_PER_REQUEST);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn test_camera() -> Result<(), String> {
let index = CameraIndex::Index(0);
let format = RequestedFormat::new::<RgbFormat>(RequestedFormatType::None);
Camera::new(index, format).map_err(|e| e.to_string())?;
Ok(())
}
async fn health() -> &'static str { "ok" }
async fn get_random(Query(params): Query<RandomQuery>) -> Response {
// Simple rate limiting
let current = ACTIVE_REQUESTS.fetch_add(1, Ordering::SeqCst);
if current >= MAX_CONCURRENT {
ACTIVE_REQUESTS.fetch_sub(1, Ordering::SeqCst);
return (StatusCode::TOO_MANY_REQUESTS, "Too many requests").into_response();
}
let bytes = params.bytes.min(MAX_BYTES_PER_REQUEST);
if bytes == 0 {
ACTIVE_REQUESTS.fetch_sub(1, Ordering::SeqCst);
return (StatusCode::BAD_REQUEST, "bytes must be > 0").into_response();
}
// Run camera ops in blocking task
let result = tokio::task::spawn_blocking(move || extract_entropy(bytes)).await;
ACTIVE_REQUESTS.fetch_sub(1, Ordering::SeqCst);
match result {
Ok(Ok(data)) => {
if params.hex {
Response::builder()
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(hex::encode(&data)))
.unwrap()
} else {
Response::builder()
.header(header::CONTENT_TYPE, "application/octet-stream")
.body(Body::from(data))
.unwrap()
}
}
Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
fn extract_entropy(num_bytes: usize) -> Result<Vec<u8>, String> {
let index = CameraIndex::Index(0);
let format = RequestedFormat::new::<RgbFormat>(RequestedFormatType::None);
let mut camera = Camera::new(index, format).map_err(|e| e.to_string())?;
camera.open_stream().map_err(|e| e.to_string())?;
let mut entropy = Vec::with_capacity(num_bytes);
let mut hasher = Sha256::new();
let frames_needed = (num_bytes / 32) + 1;
for _ in 0..frames_needed {
let frame = camera.frame().map_err(|e| e.to_string())?;
let raw = frame.buffer();
// Extract LSBs - thermal/shot noise lives here
let lsbs: Vec<u8> = raw.iter().map(|b| b & 0x03).collect();
// Hash to whiten and remove bias
hasher.update(&lsbs);
hasher.update(&nanos_now().to_le_bytes());
let hash = hasher.finalize_reset();
entropy.extend_from_slice(&hash);
if entropy.len() >= num_bytes { break; }
}
camera.stop_stream().ok();
entropy.truncate(num_bytes);
Ok(entropy)
}
fn nanos_now() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
}