Add QTRNG tools: quantum dice, password generator, coin flip; build-test script
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cad9ef1b23
commit
4d302559e4
|
|
@ -9,3 +9,7 @@ scripts/node_modules/
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
random.bin
|
random.bin
|
||||||
|
|
||||||
|
# build-test runtime files
|
||||||
|
camera-qrng.log
|
||||||
|
.camera-qrng.pid
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,6 @@ dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"axum",
|
"axum",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
|
||||||
"hex",
|
"hex",
|
||||||
"nokhwa",
|
"nokhwa",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ hex = "0.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
futures-util = { version = "0.3", default-features = false, features = ["alloc"] }
|
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ The output is cryptographic quality - suitable for generating encryption keys, s
|
||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `GET /random?bytes=N` | Get N random bytes (max 1024) |
|
| `GET /random?bytes=N` | Get N random bytes (max 1MB) |
|
||||||
| `GET /random?bytes=N&hex=true` | Get N random bytes as hex string |
|
| `GET /random?bytes=N&hex=true` | Get N random bytes as hex string |
|
||||||
| `GET /stream` | Continuous stream of random bytes |
|
| `GET /stream` | Continuous stream of random bytes |
|
||||||
| `GET /health` | Health check |
|
| `GET /health` | Health check |
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ docker run -d \
|
||||||
Returns random bytes from camera thermal noise.
|
Returns random bytes from camera thermal noise.
|
||||||
|
|
||||||
**Query Parameters:**
|
**Query Parameters:**
|
||||||
- `bytes` - Number of bytes to return (default: 32, max: 1024)
|
- `bytes` - Number of bytes to return (default: 32, max: 1048576 (1MB))
|
||||||
- `hex` - Return as hex string instead of raw bytes (default: false)
|
- `hex` - Return as hex string instead of raw bytes (default: false)
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
@ -116,7 +116,7 @@ Returns `ok` if the server is running.
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
|
|
||||||
- Maximum 4 concurrent requests
|
- Maximum 4 concurrent requests
|
||||||
- Maximum 1024 bytes per request
|
- Maximum 1MB (1048576) bytes per request
|
||||||
- Returns 429 Too Many Requests when overloaded
|
- Returns 429 Too Many Requests when overloaded
|
||||||
|
|
||||||
## Cross-Platform Support
|
## Cross-Platform Support
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build, test, and relaunch camera-qrng locally.
|
||||||
|
#
|
||||||
|
# 1. Kills any running camera-qrng process
|
||||||
|
# 2. Runs cargo check and cargo test
|
||||||
|
# 3. Builds release for native + all installed cross targets
|
||||||
|
# 4. Verifies each built binary
|
||||||
|
# 5. Relaunches the native binary in the background
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/build-test.sh # full build-test cycle
|
||||||
|
# ./scripts/build-test.sh --no-launch # build + test only, don't relaunch
|
||||||
|
# ./scripts/build-test.sh --quick # native only, skip cross targets
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
LOG_FILE="$PROJECT_DIR/camera-qrng.log"
|
||||||
|
PID_FILE="$PROJECT_DIR/.camera-qrng.pid"
|
||||||
|
BINARY_NAME="camera-qrng"
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
err() { echo -e "${RED}[ERR]${NC} $1"; }
|
||||||
|
step() { echo -e "${CYAN}[STEP]${NC} $1"; }
|
||||||
|
|
||||||
|
# --- Parse flags ---
|
||||||
|
NO_LAUNCH=false
|
||||||
|
QUICK=false
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--no-launch) NO_LAUNCH=true ;;
|
||||||
|
--quick) QUICK=true ;;
|
||||||
|
-h|--help)
|
||||||
|
echo "Usage: $0 [--no-launch] [--quick]"
|
||||||
|
echo " --no-launch Build and test only, don't relaunch the service"
|
||||||
|
echo " --quick Native build only, skip cross-compilation targets"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) err "Unknown flag: $arg"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# 1. Kill previous instance
|
||||||
|
# ==================================================
|
||||||
|
step "Killing any running $BINARY_NAME..."
|
||||||
|
|
||||||
|
killed=false
|
||||||
|
if [[ -f "$PID_FILE" ]]; then
|
||||||
|
old_pid=$(cat "$PID_FILE")
|
||||||
|
if kill -0 "$old_pid" 2>/dev/null; then
|
||||||
|
kill "$old_pid" 2>/dev/null && killed=true
|
||||||
|
# Wait up to 5s for graceful shutdown
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
kill -0 "$old_pid" 2>/dev/null || break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
# Force kill if still alive
|
||||||
|
if kill -0 "$old_pid" 2>/dev/null; then
|
||||||
|
kill -9 "$old_pid" 2>/dev/null
|
||||||
|
sleep 0.5
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Also kill any stray processes not tracked by PID file
|
||||||
|
pgrep -f "target/release/$BINARY_NAME" 2>/dev/null | while read -r pid; do
|
||||||
|
if [[ "$pid" != "$$" ]]; then
|
||||||
|
kill "$pid" 2>/dev/null && killed=true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
if [[ "$killed" == true ]]; then
|
||||||
|
info "Previous instance stopped"
|
||||||
|
else
|
||||||
|
info "No previous instance running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# 2. Check and test
|
||||||
|
# ==================================================
|
||||||
|
step "Running cargo check..."
|
||||||
|
cargo check 2>&1 | tail -3
|
||||||
|
info "Check passed"
|
||||||
|
|
||||||
|
step "Running cargo test..."
|
||||||
|
cargo test 2>&1 | tail -5
|
||||||
|
info "Tests passed"
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# 3. Build targets
|
||||||
|
# ==================================================
|
||||||
|
|
||||||
|
# Detect native target triple
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
OS=$(uname -s)
|
||||||
|
case "${ARCH}-${OS}" in
|
||||||
|
arm64-Darwin) NATIVE_TARGET="aarch64-apple-darwin" ;;
|
||||||
|
x86_64-Darwin) NATIVE_TARGET="x86_64-apple-darwin" ;;
|
||||||
|
aarch64-Linux) NATIVE_TARGET="aarch64-unknown-linux-gnu" ;;
|
||||||
|
x86_64-Linux) NATIVE_TARGET="x86_64-unknown-linux-gnu" ;;
|
||||||
|
*) NATIVE_TARGET="native" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Build targets: always native, optionally cross
|
||||||
|
CROSS_TARGETS=()
|
||||||
|
if [[ "$QUICK" == false ]]; then
|
||||||
|
case "$OS" in
|
||||||
|
Darwin)
|
||||||
|
# Build both macOS architectures
|
||||||
|
if [[ "$ARCH" == "arm64" ]]; then
|
||||||
|
CROSS_TARGETS+=("x86_64-apple-darwin")
|
||||||
|
else
|
||||||
|
CROSS_TARGETS+=("aarch64-apple-darwin")
|
||||||
|
fi
|
||||||
|
# Linux targets if cross is installed
|
||||||
|
if command -v cross &>/dev/null; then
|
||||||
|
CROSS_TARGETS+=("aarch64-unknown-linux-gnu" "x86_64-unknown-linux-gnu")
|
||||||
|
else
|
||||||
|
warn "Skipping Linux targets (install 'cross': cargo install cross)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
if [[ "$ARCH" == "aarch64" ]]; then
|
||||||
|
CROSS_TARGETS+=("x86_64-unknown-linux-gnu")
|
||||||
|
else
|
||||||
|
CROSS_TARGETS+=("aarch64-unknown-linux-gnu")
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
success=0
|
||||||
|
failed=0
|
||||||
|
|
||||||
|
# --- Native build ---
|
||||||
|
step "Building native release ($NATIVE_TARGET)..."
|
||||||
|
cargo build --release 2>&1 | tail -3
|
||||||
|
NATIVE_BIN="target/release/$BINARY_NAME"
|
||||||
|
if [[ -f "$NATIVE_BIN" ]]; then
|
||||||
|
info "Native: $(file "$NATIVE_BIN" | sed "s|$PROJECT_DIR/||")"
|
||||||
|
success=$((success + 1))
|
||||||
|
else
|
||||||
|
err "Native binary not found at $NATIVE_BIN"
|
||||||
|
failed=$((failed + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Cross targets ---
|
||||||
|
for target in "${CROSS_TARGETS[@]}"; do
|
||||||
|
step "Building $target..."
|
||||||
|
|
||||||
|
use_cross=false
|
||||||
|
if [[ "$OS" == "Darwin" ]] && [[ "$target" == *"linux"* ]]; then
|
||||||
|
use_cross=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$use_cross" == true ]]; then
|
||||||
|
if cross build --release --target "$target" 2>&1 | tail -3; then
|
||||||
|
bin="target/$target/release/$BINARY_NAME"
|
||||||
|
if [[ -f "$bin" ]]; then
|
||||||
|
info "$target: $(file "$bin" | sed "s|$PROJECT_DIR/||")"
|
||||||
|
success=$((success + 1))
|
||||||
|
else
|
||||||
|
err "$target binary not found"
|
||||||
|
failed=$((failed + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
err "$target build failed"
|
||||||
|
failed=$((failed + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if cargo build --release --target "$target" 2>&1 | tail -3; then
|
||||||
|
bin="target/$target/release/$BINARY_NAME"
|
||||||
|
if [[ -f "$bin" ]]; then
|
||||||
|
info "$target: $(file "$bin" | sed "s|$PROJECT_DIR/||")"
|
||||||
|
success=$((success + 1))
|
||||||
|
else
|
||||||
|
err "$target binary not found"
|
||||||
|
failed=$((failed + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
err "$target build failed"
|
||||||
|
failed=$((failed + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Build results: $success succeeded, $failed failed"
|
||||||
|
|
||||||
|
if [[ $failed -gt 0 ]]; then
|
||||||
|
err "Some builds failed - check output above"
|
||||||
|
if [[ "$NO_LAUNCH" == true ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
warn "Continuing to launch native binary despite cross-build failures..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# 4. Verify native binary runs
|
||||||
|
# ==================================================
|
||||||
|
step "Verifying native binary..."
|
||||||
|
if [[ -x "$NATIVE_BIN" ]]; then
|
||||||
|
info "Binary OK: $(ls -lh "$NATIVE_BIN" | awk '{print $5}') $(file "$NATIVE_BIN" | grep -o 'Mach-O\|ELF\|PE32' || echo 'binary')"
|
||||||
|
else
|
||||||
|
err "Native binary not executable"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==================================================
|
||||||
|
# 5. Relaunch
|
||||||
|
# ==================================================
|
||||||
|
if [[ "$NO_LAUNCH" == true ]]; then
|
||||||
|
info "Skipping launch (--no-launch)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Launching $BINARY_NAME in background..."
|
||||||
|
nohup "$NATIVE_BIN" > "$LOG_FILE" 2>&1 &
|
||||||
|
NEW_PID=$!
|
||||||
|
echo "$NEW_PID" > "$PID_FILE"
|
||||||
|
|
||||||
|
# Give it a moment to start
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if kill -0 "$NEW_PID" 2>/dev/null; then
|
||||||
|
info "$BINARY_NAME running (PID $NEW_PID)"
|
||||||
|
info "Log: $LOG_FILE"
|
||||||
|
info "PID file: $PID_FILE"
|
||||||
|
echo ""
|
||||||
|
head -10 "$LOG_FILE" 2>/dev/null | while IFS= read -r line; do
|
||||||
|
echo " $line"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
info "Health check: http://localhost:${PORT:-8787}/health"
|
||||||
|
else
|
||||||
|
err "$BINARY_NAME failed to start. Log output:"
|
||||||
|
cat "$LOG_FILE" 2>/dev/null
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
@ -53,5 +53,5 @@ echo ""
|
||||||
echo " cargo run --release"
|
echo " cargo run --release"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To stop the virtual camera:"
|
echo "To stop the virtual camera:"
|
||||||
echo " ./start-obs-noise.sh stop"
|
echo " ./stop-obs-noise.sh"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
pub mod entropy;
|
pub mod entropy;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
pub mod tools;
|
||||||
|
|
||||||
pub use entropy::{
|
pub use entropy::{
|
||||||
extract_entropy, extract_entropy_camera, fill_entropy,
|
extract_entropy, extract_entropy_camera, fill_entropy,
|
||||||
|
|
@ -15,4 +16,7 @@ pub use entropy::{
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export the OpenSSL provider init for cdylib
|
// Re-export the OpenSSL provider init for cdylib
|
||||||
|
pub use tools::{roll_dice, generate_password, dice_bytes_needed, charset_from_flags, charset_alphanumeric, charset_full, charset_hex, DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT as DICE_MAX_COUNT, MAX_SIDES, MIN_SIDES, DEFAULT_LENGTH as PASSWORD_DEFAULT_LENGTH, MAX_LENGTH as PASSWORD_MAX_LENGTH};
|
||||||
|
|
||||||
|
|
||||||
pub use provider::OSSL_provider_init;
|
pub use provider::OSSL_provider_init;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ use axum::{
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use camera_trng::{extract_entropy, list_cameras, subscribe_entropy, unsubscribe_entropy, ensure_producer_running, test_camera, CameraConfig, CHUNK_SIZE};
|
use camera_trng::{extract_entropy, list_cameras, subscribe_entropy, unsubscribe_entropy, ensure_producer_running, test_camera, CameraConfig, CHUNK_SIZE};
|
||||||
|
mod qrng_handlers;
|
||||||
|
use qrng_handlers::{get_dice, get_password, get_coin};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
@ -69,6 +71,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
.route("/cameras", get(get_cameras))
|
.route("/cameras", get(get_cameras))
|
||||||
.route("/random", get(get_random))
|
.route("/random", get(get_random))
|
||||||
.route("/stream", get(get_stream))
|
.route("/stream", get(get_stream))
|
||||||
|
.route("/dice", get(get_dice))
|
||||||
|
.route("/password", get(get_password))
|
||||||
|
.route("/coin", get(get_coin))
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.route("/.well-known/mcp.json", get(mcp_wellknown))
|
.route("/.well-known/mcp.json", get(mcp_wellknown))
|
||||||
.route("/.well-known/skill.md", get(get_skill_md))
|
.route("/.well-known/skill.md", get(get_skill_md))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
//! HTTP handlers for QTRNG-backed tools: dice, passwords.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::Query,
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use camera_trng::{
|
||||||
|
extract_entropy,
|
||||||
|
roll_dice,
|
||||||
|
generate_password,
|
||||||
|
dice_bytes_needed,
|
||||||
|
charset_alphanumeric,
|
||||||
|
charset_full,
|
||||||
|
charset_hex,
|
||||||
|
charset_from_flags,
|
||||||
|
CameraConfig,
|
||||||
|
DEFAULT_SIDES,
|
||||||
|
DEFAULT_COUNT,
|
||||||
|
MIN_SIDES,
|
||||||
|
MAX_SIDES,
|
||||||
|
PASSWORD_DEFAULT_LENGTH,
|
||||||
|
PASSWORD_MAX_LENGTH,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
fn default_dice_sides() -> u32 {
|
||||||
|
DEFAULT_SIDES
|
||||||
|
}
|
||||||
|
fn default_dice_count() -> usize {
|
||||||
|
DEFAULT_COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DiceQuery {
|
||||||
|
#[serde(default = "default_dice_sides")]
|
||||||
|
pub d: u32,
|
||||||
|
#[serde(default = "default_dice_count")]
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PasswordQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub length: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub charset: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_dice(Query(params): Query<DiceQuery>) -> Response {
|
||||||
|
let sides = params.d;
|
||||||
|
let count = params.count;
|
||||||
|
|
||||||
|
if sides < MIN_SIDES || sides > MAX_SIDES {
|
||||||
|
return (StatusCode::BAD_REQUEST, format!("d must be between {} and {}", MIN_SIDES, MAX_SIDES)).into_response();
|
||||||
|
}
|
||||||
|
if count == 0 || count > camera_trng::DICE_MAX_COUNT {
|
||||||
|
return (StatusCode::BAD_REQUEST, format!("count must be 1..{}", camera_trng::DICE_MAX_COUNT)).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let need = dice_bytes_needed(sides, count);
|
||||||
|
let config = CameraConfig::from_env();
|
||||||
|
let result = tokio::task::spawn_blocking(move || extract_entropy(need, &config)).await;
|
||||||
|
|
||||||
|
let data = match result {
|
||||||
|
Ok(Ok(d)) => d,
|
||||||
|
Ok(Err(e)) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let rolls = match roll_dice(sides, count, &data) {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return (StatusCode::INTERNAL_SERVER_ERROR, "dice roll failed").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"sides": sides,
|
||||||
|
"count": count,
|
||||||
|
"rolls": rolls,
|
||||||
|
"sum": rolls.iter().sum::<u32>(),
|
||||||
|
});
|
||||||
|
Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(header::CACHE_CONTROL, "no-store")
|
||||||
|
.body(axum::body::Body::from(body.to_string()))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_password(Query(params): Query<PasswordQuery>) -> Response {
|
||||||
|
let length = params.length.unwrap_or(PASSWORD_DEFAULT_LENGTH).min(PASSWORD_MAX_LENGTH);
|
||||||
|
if length == 0 {
|
||||||
|
return (StatusCode::BAD_REQUEST, "length must be >= 1").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let charset: Vec<u8> = match params.charset.as_deref() {
|
||||||
|
Some("alphanumeric") | None => charset_alphanumeric(),
|
||||||
|
Some("full") => charset_full(),
|
||||||
|
Some("hex") => charset_hex(),
|
||||||
|
Some(flags) => {
|
||||||
|
let l = flags.to_lowercase();
|
||||||
|
charset_from_flags(
|
||||||
|
l.contains('l'),
|
||||||
|
l.contains('u'),
|
||||||
|
l.contains('d'),
|
||||||
|
l.contains('s'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let need = length * 4 + 64;
|
||||||
|
let config = CameraConfig::from_env();
|
||||||
|
let result = tokio::task::spawn_blocking(move || extract_entropy(need, &config)).await;
|
||||||
|
|
||||||
|
let data = match result {
|
||||||
|
Ok(Ok(d)) => d,
|
||||||
|
Ok(Err(e)) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let password = match generate_password(length, &charset, &data) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return (StatusCode::INTERNAL_SERVER_ERROR, "password generation failed").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"password": password,
|
||||||
|
"length": length,
|
||||||
|
});
|
||||||
|
Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(header::CACHE_CONTROL, "no-store")
|
||||||
|
.body(axum::body::Body::from(body.to_string()))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quantum coin flip: one random bit from QTRNG.
|
||||||
|
pub async fn get_coin() -> Response {
|
||||||
|
let config = CameraConfig::from_env();
|
||||||
|
let result = tokio::task::spawn_blocking(move || extract_entropy(1, &config)).await;
|
||||||
|
let data = match result {
|
||||||
|
Ok(Ok(d)) if !d.is_empty() => d[0],
|
||||||
|
_ => return (StatusCode::INTERNAL_SERVER_ERROR, "entropy failed").into_response(),
|
||||||
|
};
|
||||||
|
let side = if data & 1 == 0 { "heads" } else { "tails" };
|
||||||
|
let body = serde_json::json!({ "result": side });
|
||||||
|
Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(header::CACHE_CONTROL, "no-store")
|
||||||
|
.body(axum::body::Body::from(body.to_string()))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
//! Quantum dice rolling using QTRNG bytes.
|
||||||
|
//!
|
||||||
|
//! Configurable sides (d4, d6, d8, d12, d20, etc.) and count.
|
||||||
|
//! Uses rejection sampling for uniform distribution.
|
||||||
|
|
||||||
|
/// Default number of sides (d6).
|
||||||
|
pub const DEFAULT_SIDES: u32 = 6;
|
||||||
|
|
||||||
|
/// Default number of dice to roll.
|
||||||
|
pub const DEFAULT_COUNT: usize = 1;
|
||||||
|
|
||||||
|
/// Max dice per request to avoid excessive entropy use.
|
||||||
|
pub const MAX_COUNT: usize = 100;
|
||||||
|
|
||||||
|
/// Min/max sides (d2 = coin, up to d256).
|
||||||
|
pub const MIN_SIDES: u32 = 2;
|
||||||
|
pub const MAX_SIDES: u32 = 256;
|
||||||
|
|
||||||
|
/// Roll one die with given sides using bytes from a RNG.
|
||||||
|
/// Returns value in 1..=sides. Uses rejection sampling for uniformity.
|
||||||
|
#[inline]
|
||||||
|
fn roll_one(sides: u32, bytes: &[u8], byte_index: &mut usize) -> Option<u32> {
|
||||||
|
if sides < MIN_SIDES || sides > MAX_SIDES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let n = sides as u64;
|
||||||
|
let threshold = (u32::MAX as u64 / n) * n; // largest multiple of n that fits in u32
|
||||||
|
loop {
|
||||||
|
if *byte_index + 4 > bytes.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let word = u32::from_be_bytes([
|
||||||
|
bytes[*byte_index],
|
||||||
|
bytes[*byte_index + 1],
|
||||||
|
bytes[*byte_index + 2],
|
||||||
|
bytes[*byte_index + 3],
|
||||||
|
]);
|
||||||
|
*byte_index += 4;
|
||||||
|
if (word as u64) < threshold {
|
||||||
|
return Some((word as u32 % sides as u32) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roll `count` dice with `sides` each, using the provided random bytes.
|
||||||
|
/// Returns `None` if not enough bytes or invalid params; otherwise `Some(vec of rolls)`.
|
||||||
|
pub fn roll_dice(sides: u32, count: usize, bytes: &[u8]) -> Option<Vec<u32>> {
|
||||||
|
if count == 0 || count > MAX_COUNT {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if sides < MIN_SIDES || sides > MAX_SIDES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(count);
|
||||||
|
let mut idx = 0;
|
||||||
|
for _ in 0..count {
|
||||||
|
let v = roll_one(sides, bytes, &mut idx)?;
|
||||||
|
out.push(v);
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate bytes needed for `count` dice of `sides` (worst-case rejection).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn bytes_needed(sides: u32, count: usize) -> usize {
|
||||||
|
let n = sides as u64;
|
||||||
|
let threshold = (u32::MAX as u64 / n) * n;
|
||||||
|
let accept_prob = threshold as f64 / u32::MAX as f64;
|
||||||
|
let per_roll = 4.0_f64 / accept_prob;
|
||||||
|
(per_roll * count as f64).ceil() as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roll_d6_one() {
|
||||||
|
let bytes = [0u8; 64];
|
||||||
|
let r = roll_dice(6, 1, &bytes);
|
||||||
|
assert!(r.is_some());
|
||||||
|
let r = r.unwrap();
|
||||||
|
assert_eq!(r.len(), 1);
|
||||||
|
assert!(r[0] >= 1 && r[0] <= 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roll_d20_five() {
|
||||||
|
let bytes = [0u8; 128];
|
||||||
|
let r = roll_dice(20, 5, &bytes);
|
||||||
|
assert!(r.is_some());
|
||||||
|
let r = r.unwrap();
|
||||||
|
assert_eq!(r.len(), 5);
|
||||||
|
for &v in &r {
|
||||||
|
assert!(v >= 1 && v <= 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
//! QTRNG-backed tools: dice, passwords, etc.
|
||||||
|
|
||||||
|
mod dice;
|
||||||
|
mod password;
|
||||||
|
|
||||||
|
pub use dice::{roll_dice, bytes_needed as dice_bytes_needed, DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT, MAX_SIDES, MIN_SIDES};
|
||||||
|
pub use password::{
|
||||||
|
generate_password, charset_from_flags, charset_alphanumeric, charset_full, charset_hex,
|
||||||
|
DEFAULT_LENGTH, MAX_LENGTH,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
//! Quantum password/passphrase generation from QTRNG bytes.
|
||||||
|
//!
|
||||||
|
//! Configurable length and character set (lowercase, uppercase, digits, symbols).
|
||||||
|
|
||||||
|
/// Default password length.
|
||||||
|
pub const DEFAULT_LENGTH: usize = 16;
|
||||||
|
|
||||||
|
/// Max length per request.
|
||||||
|
pub const MAX_LENGTH: usize = 128;
|
||||||
|
|
||||||
|
/// Character set presets (no spaces; safe for passwords).
|
||||||
|
pub const CHARSET_LOWER: &str = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
pub const CHARSET_UPPER: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
pub const CHARSET_DIGIT: &str = "0123456789";
|
||||||
|
pub const CHARSET_SYMBOL: &str = "!@#$%^&*()-_=+[]{}|;:,.<>?";
|
||||||
|
|
||||||
|
/// Build charset from flags: lower, upper, digit, symbol.
|
||||||
|
pub fn charset_from_flags(lower: bool, upper: bool, digit: bool, symbol: bool) -> Vec<u8> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
if lower {
|
||||||
|
v.extend_from_slice(CHARSET_LOWER.as_bytes());
|
||||||
|
}
|
||||||
|
if upper {
|
||||||
|
v.extend_from_slice(CHARSET_UPPER.as_bytes());
|
||||||
|
}
|
||||||
|
if digit {
|
||||||
|
v.extend_from_slice(CHARSET_DIGIT.as_bytes());
|
||||||
|
}
|
||||||
|
if symbol {
|
||||||
|
v.extend_from_slice(CHARSET_SYMBOL.as_bytes());
|
||||||
|
}
|
||||||
|
if v.is_empty() {
|
||||||
|
v.extend_from_slice(CHARSET_LOWER.as_bytes());
|
||||||
|
v.extend_from_slice(CHARSET_DIGIT.as_bytes());
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "alphanumeric" = lower + upper + digit
|
||||||
|
pub fn charset_alphanumeric() -> Vec<u8> {
|
||||||
|
charset_from_flags(true, true, true, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "full" = lower + upper + digit + symbol
|
||||||
|
pub fn charset_full() -> Vec<u8> {
|
||||||
|
charset_from_flags(true, true, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "hex" = 0-9a-f
|
||||||
|
pub fn charset_hex() -> Vec<u8> {
|
||||||
|
b"0123456789abcdef".to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a password of `length` from random `bytes` using the given charset.
|
||||||
|
/// Charset must be non-empty. Returns `None` if not enough bytes or invalid params.
|
||||||
|
pub fn generate_password(length: usize, charset: &[u8], bytes: &[u8]) -> Option<String> {
|
||||||
|
if length == 0 || length > MAX_LENGTH || charset.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let n = charset.len() as u32;
|
||||||
|
let threshold = (u32::MAX / n) * n;
|
||||||
|
let mut out = String::with_capacity(length);
|
||||||
|
let mut idx = 0;
|
||||||
|
for _ in 0..length {
|
||||||
|
loop {
|
||||||
|
if idx + 4 > bytes.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let word = u32::from_be_bytes([
|
||||||
|
bytes[idx],
|
||||||
|
bytes[idx + 1],
|
||||||
|
bytes[idx + 2],
|
||||||
|
bytes[idx + 3],
|
||||||
|
]);
|
||||||
|
idx += 4;
|
||||||
|
if word < threshold {
|
||||||
|
let i = (word % n) as usize;
|
||||||
|
out.push(charset[i] as char);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate bytes needed for a password of given length with charset size.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn bytes_needed(length: usize, charset_len: usize) -> usize {
|
||||||
|
if charset_len == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let n = charset_len as u32;
|
||||||
|
let threshold = (u32::MAX / n) * n;
|
||||||
|
let accept_prob = threshold as f64 / u32::MAX as f64;
|
||||||
|
(4.0_f64 / accept_prob * length as f64).ceil() as usize
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue