From 82b51c048529732f3bba74e0a23ddf6d83f41d0e Mon Sep 17 00:00:00 2001 From: "Michael Peters Jr." Date: Wed, 2 Sep 2026 17:06:24 -0700 Subject: [PATCH] Restore USB sample timing guard, add usage homepage, bump advisories The C reference driver discards any 512-byte sample whose USB write+read round trip exceeds MAX_MICROSEC_FOR_SAMPLES (5 ms), because a stalled bit-bang clock lets the INM's analog loop settle and lowers the entropy of the following bits. The infnoise-rs port dropped that check. Restore it in the reader loop for both warm-up and steady state, configurable via NAAS_MAX_SAMPLE_MICROS (default 5000), with rejections counted in a new per-device naas_timing_rejections_total counter. Serve a plain HTML usage page at / on the API listener. No stylesheet; the request limit, version and multiplier are baked in from the live config at startup so the page always matches what the server enforces. Bump dependencies flagged by cargo deny: h2 0.4.14 -> 0.4.19 RUSTSEC-2026-0258 anyhow 1.0.102 -> 1.0.104 RUSTSEC-2026-0190 crossbeam-epoch 0.9.18 -> 0.9.20 RUSTSEC-2026-0204 metrics 0.24.5 -> 0.24.6 yanked release Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NqAaMnufxqQd4ggw9duAMF --- Cargo.lock | 16 ++-- INTEGRATION.md | 4 + crates/naas-api/src/config.rs | 11 +++ crates/naas-api/src/handlers.rs | 141 +++++++++++++++++++++++++++++++- crates/naas-api/src/main.rs | 3 +- crates/naas-api/src/reader.rs | 51 ++++++++++-- 6 files changed, 208 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b447b0b..f1a1a42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "async-channel" @@ -237,9 +237,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -410,9 +410,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -647,9 +647,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "metrics" -version = "0.24.5" +version = "0.24.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff56c2e7dce6bd462e3b8919986a617027481b1dcc703175b58cf9dd98a2f071" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" dependencies = [ "portable-atomic", "rapidhash", diff --git a/INTEGRATION.md b/INTEGRATION.md index 572a43e..0601ebd 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -67,7 +67,9 @@ USB latency: ~16 ms per 512-byte buffer at the configured baud. Producer-side qu **One reader thread per USB device.** Sync I/O (libftdi has no real async story we want to bother with). Each reader runs the same loop the `infnoise` binary does: ```rust +let start = Instant::now(); device.read_buffer(&mut in_buf)?; +if start.elapsed() > max_sample { continue; } // MAX_MICROSEC_FOR_SAMPLES guard from the C let entropy = extract_bytes(&mut bytes, &in_buf, &mut hc)?; if hc.ok_to_use_data() && hc.entropy_on_target(entropy, BUFLEN) { let entropy_clamped = clamp_entropy(entropy, hc.expected_entropy_per_bit()); @@ -92,6 +94,7 @@ if hc.ok_to_use_data() && hc.entropy_on_target(entropy, BUFLEN) { | Method | Path | Behavior | |---|---|---| +| `GET` | `/` | Plain HTML usage page with the live limits baked in. | | `GET` | `/api/v1/random/` | Return exactly `n` bytes as `application/octet-stream`. 400 if `n` > 1 MiB. | | `GET` | `/api/v1/random/stream` | `Transfer-Encoding: chunked` octet-stream. Streams from the pool until the client disconnects. For sustained consumers. | | `GET` | `/api/v1/random/hex/` | Same bytes, hex-encoded `text/plain; charset=us-ascii`. 1 MiB cap on the underlying byte count. | @@ -117,6 +120,7 @@ All settings via env vars with matching CLI flags (clap derive + `env` feature). | `NAAS_MULTIPLIER` | `--multiplier` | `100` | INM whitener multiplier. Supported: 1, 10, 100, 1000. | | `NAAS_POOL_BYTES` | `--pool-bytes` | `1048576` | Bounded pool capacity. | | `NAAS_MAX_REQUEST_BYTES` | `--max-request-bytes` | `1048576` | Per-request cap on the bounded `random` endpoints. | +| `NAAS_MAX_SAMPLE_MICROS` | `--max-sample-micros` | `5000` | Discard a USB sample if its write+read round trip took longer than this. Mirrors `MAX_MICROSEC_FOR_SAMPLES` in the C driver. Rejections count in `naas_timing_rejections_total`. | | `NAAS_SERIAL_ALLOWLIST` | `--serial-allowlist` | *(unset)* | Comma-separated FTDI serials. Unset = scan and use all detected devices. | | `NAAS_LOG_LEVEL` | `--log-level` | `info` | `tracing` env-filter syntax (e.g. `info,naas_api=debug`). | diff --git a/crates/naas-api/src/config.rs b/crates/naas-api/src/config.rs index fcacb12..91d1079 100644 --- a/crates/naas-api/src/config.rs +++ b/crates/naas-api/src/config.rs @@ -1,4 +1,5 @@ use std::net::SocketAddr; +use std::time::Duration; use clap::Parser; @@ -30,6 +31,12 @@ pub struct Config { #[arg(long, env = "NAAS_MAX_REQUEST_BYTES", default_value_t = 1_048_576)] pub max_request_bytes: usize, + /// Discard any USB sample whose write+read round trip took longer than + /// this. Long clock stalls let the INM's analog loop settle, which + /// reduces entropy. Mirrors `MAX_MICROSEC_FOR_SAMPLES` in the C driver. + #[arg(long, env = "NAAS_MAX_SAMPLE_MICROS", default_value_t = 5_000)] + pub max_sample_micros: u64, + #[arg(long, env = "NAAS_SERIAL_ALLOWLIST", value_delimiter = ',')] pub serial_allowlist: Vec, @@ -43,6 +50,10 @@ impl Config { (self.pool_bytes / MAX_CHUNK_BYTES).max(64) } + pub fn max_sample_duration(&self) -> Duration { + Duration::from_micros(self.max_sample_micros) + } + pub fn process_mode(&self) -> infnoise_core::ProcessMode { match self.multiplier { 0 => infnoise_core::ProcessMode::AllEntropy, diff --git a/crates/naas-api/src/handlers.rs b/crates/naas-api/src/handlers.rs index 47899b4..ee3c551 100644 --- a/crates/naas-api/src/handlers.rs +++ b/crates/naas-api/src/handlers.rs @@ -2,7 +2,7 @@ use axum::Router; use axum::body::Body; use axum::extract::{Path, State}; use axum::http::{HeaderValue, StatusCode, header}; -use axum::response::{IntoResponse, Response}; +use axum::response::{Html, IntoResponse, Response}; use axum::routing::get; use base64::Engine; use bytes::Bytes; @@ -10,6 +10,7 @@ use futures::stream; use metrics::Counter; use tower_http::trace::TraceLayer; +use crate::config::Config; use crate::error::ServiceError; use crate::pool::Pool; @@ -17,6 +18,7 @@ use crate::pool::Pool; struct ApiState { pool: Pool, max_bytes: usize, + index_html: Bytes, metrics: ResponseMetrics, } @@ -39,13 +41,15 @@ impl ResponseMetrics { } } -pub fn router(pool: Pool, max_bytes: usize) -> Router { +pub fn router(pool: Pool, cfg: &Config) -> Router { let state = ApiState { pool, - max_bytes, + max_bytes: cfg.max_request_bytes, + index_html: Bytes::from(index_html(cfg)), metrics: ResponseMetrics::new(), }; Router::new() + .route("/", get(index)) .route("/api/v1/random/stream", get(random_stream)) .route("/api/v1/random/{n}", get(random_bytes)) .route("/api/v1/random/hex/{n}", get(random_hex)) @@ -54,6 +58,137 @@ pub fn router(pool: Pool, max_bytes: usize) -> Router { .with_state(state) } +async fn index(State(state): State) -> Html { + Html(state.index_html.clone()) +} + +/// Render the plain-HTML usage page once at startup with the live config +/// baked in. No stylesheet on purpose: white background, black text, +/// whatever font the browser defaults to. +fn index_html(cfg: &Config) -> String { + let whitening = match cfg.multiplier { + 0 => "Whitening: Keccak sponge, emitting only as many bytes as the \ + health checker measured in entropy for that sample. Every output \ + byte is backed by a full byte of measured hardware entropy." + .to_string(), + m => format!( + "Whitening: Keccak sponge with output multiplier {m}. Each 512-bit \ + hardware sample (about 437 bits of measured entropy) reseeds the \ + sponge, which is then squeezed for {} bytes before the next sample \ + is absorbed. Output beyond the first few dozen bytes per sample is \ + cryptographically expanded rather than raw hardware entropy.", + u64::from(m) * 32 + ), + }; + INDEX_TEMPLATE + .replace("__VERSION__", env!("CARGO_PKG_VERSION")) + .replace("__MAX__", &cfg.max_request_bytes.to_string()) + .replace("__WHITENING__", &whitening) +} + +const INDEX_TEMPLATE: &str = r#" + + + + +naas-api + + + +

naas-api

+ +

Noise as a Service, version __VERSION__.

+ +

This server hands out random bytes read from an +Infinite Noise Multiplier, +a hardware true random number generator on a USB stick. Bytes are produced +by the device, checked, whitened, and queued in a small pool. Each byte is +served to exactly one client and then discarded.

+ +
+ +

Endpoints

+ +
+GET /api/v1/random/{n}          n raw bytes           application/octet-stream
+GET /api/v1/random/hex/{n}      n bytes, hex          text/plain
+GET /api/v1/random/base64/{n}   n bytes, base64       text/plain
+GET /api/v1/random/stream       endless chunked       application/octet-stream
+
+ +

n must be between 1 and __MAX__ inclusive. Anything else gets a 400. +The stream endpoint has no cap: it sends bytes until you disconnect.

+ +

Try it in the browser: +/api/v1/random/hex/16, +/api/v1/random/base64/32.

+ +

Examples

+ +
+NAAS=http://localhost:8080
+
+# 32 raw bytes into a file
+curl -sS "$NAAS/api/v1/random/32" -o key.bin
+
+# 16 bytes as hex
+curl -sS "$NAAS/api/v1/random/hex/16"
+
+# a 256-bit key as base64
+curl -sS "$NAAS/api/v1/random/base64/32"
+
+# stream into a file until Ctrl-C
+curl -sS "$NAAS/api/v1/random/stream" -o noise.bin
+
+# the first 1 MiB of the stream
+curl -sS "$NAAS/api/v1/random/stream" | head -c 1048576 > noise.bin
+
+# feed it to a test suite
+curl -sS "$NAAS/api/v1/random/stream" | head -c 10485760 | ent
+
+ +

Responses

+ +
+200   bytes follow
+400   n is 0, not a number, or larger than __MAX__
+503   entropy pool closed (server is shutting down)
+
+ +

A request waits until enough bytes are in the pool. If the device is +unplugged the request blocks until it comes back or the client gives up, so +set a client timeout.

+ +

How the bytes are made

+ +
    +
  1. The FT240X on the stick clocks the multiplier and returns 512 comparator +samples per USB read, one noise bit each.
  2. +
  3. A streaming health check predicts each bit from the previous 14 and +measures how often it is right. The device is designed to give about 0.88 +bits of entropy per sample bit. Samples are discarded when the measured +entropy drifts more than 3% from that, when a run of more than 20 identical +bits appears, or when the USB round trip stalled long enough to let the +analog loop settle.
  4. +
  5. __WHITENING__
  6. +
+ +

Operations

+ +

Health and metrics live on a separate listener (port 8081 by default): +/healthz returns 200 while at least one device is producing, +and /metrics is Prometheus exposition format.

+ +
+ +

Source: alpineworks/naas-api. +Driver: alpineworks/infnoise-rs, +a Rust port of the reference C driver.

+ + + +"#; + fn check_n(n: usize, max: usize) -> Result<(), ServiceError> { if n == 0 { return Err(ServiceError::EmptyRequest); diff --git a/crates/naas-api/src/main.rs b/crates/naas-api/src/main.rs index febfaea..eecd158 100644 --- a/crates/naas-api/src/main.rs +++ b/crates/naas-api/src/main.rs @@ -50,6 +50,7 @@ async fn run(cfg: Config) -> Result<()> { multiplier = cfg.multiplier, pool_bytes = cfg.pool_bytes, max_request_bytes = cfg.max_request_bytes, + max_sample_micros = cfg.max_sample_micros, serial_count = cfg.serial_allowlist.len(), "starting naas-api", ); @@ -61,7 +62,7 @@ async fn run(cfg: Config) -> Result<()> { let reader_handles = reader::spawn_all(&cfg, &pool.sender(), &shutdown).context("spawning reader threads")?; - let api_router = handlers::router(pool.clone(), cfg.max_request_bytes); + let api_router = handlers::router(pool.clone(), &cfg); let ops_router = ops::router(pool.clone(), metrics_handle); let api_listener = tokio::net::TcpListener::bind(cfg.api_addr) diff --git a/crates/naas-api/src/reader.rs b/crates/naas-api/src/reader.rs index 42a5089..319df9f 100644 --- a/crates/naas-api/src/reader.rs +++ b/crates/naas-api/src/reader.rs @@ -1,5 +1,5 @@ use std::thread::{self, JoinHandle}; -use std::time::Duration; +use std::time::{Duration, Instant}; use bytes::Bytes; use metrics::Counter; @@ -47,6 +47,7 @@ pub fn spawn_all( }; let mode = cfg.process_mode(); + let max_sample = cfg.max_sample_duration(); let mut handles = Vec::with_capacity(serials.len()); for serial in serials { @@ -56,7 +57,14 @@ pub fn spawn_all( let h = thread::Builder::new() .name(format!("infnoise:{label}")) .spawn(move || { - supervise(serial.as_deref(), &label, mode, &sender, &shutdown); + supervise( + serial.as_deref(), + &label, + mode, + max_sample, + &sender, + &shutdown, + ); })?; handles.push(h); } @@ -66,6 +74,7 @@ pub fn spawn_all( struct DeviceMetrics { entropy_bytes: Counter, health_rejections: Counter, + timing_rejections: Counter, reader_errors: Counter, } @@ -75,6 +84,7 @@ impl DeviceMetrics { Self { entropy_bytes: metrics::counter!("naas_entropy_bytes_total", "device" => device.clone()), health_rejections: metrics::counter!("naas_health_rejections_total", "device" => device.clone()), + timing_rejections: metrics::counter!("naas_timing_rejections_total", "device" => device.clone()), reader_errors: metrics::counter!("naas_reader_errors_total", "device" => device), } } @@ -84,12 +94,13 @@ fn supervise( serial: Option<&str>, label: &str, mode: ProcessMode, + max_sample: Duration, sender: &PoolSender, shutdown: &CancellationToken, ) { let m = DeviceMetrics::new(label); while !shutdown.is_cancelled() && !sender.is_closed() { - match read_loop(serial, label, mode, sender, shutdown, &m) { + match read_loop(serial, label, mode, max_sample, sender, shutdown, &m) { Ok(()) => break, Err(e) => { m.reader_errors.increment(1); @@ -108,6 +119,7 @@ fn read_loop( serial: Option<&str>, label: &str, mode: ProcessMode, + max_sample: Duration, sender: &PoolSender, shutdown: &CancellationToken, m: &DeviceMetrics, @@ -126,12 +138,14 @@ fn read_loop( if shutdown.is_cancelled() { return Ok(()); } - device.read_buffer(&mut in_buf)?; - let _ = extract_bytes(&mut bytes, &in_buf, &mut hc)?; warmup_rounds += 1; if warmup_rounds > WARMUP_LIMIT { return Err(ReaderError::WarmupTimeout(WARMUP_LIMIT)); } + if !timed_read(&mut device, &mut in_buf, max_sample, m)? { + continue; + } + let _ = extract_bytes(&mut bytes, &in_buf, &mut hc)?; } tracing::info!(device = %label, warmup_rounds, "device warmed up"); @@ -144,7 +158,9 @@ fn read_loop( continue; } - device.read_buffer(&mut in_buf)?; + if !timed_read(&mut device, &mut in_buf, max_sample, m)? { + continue; + } let entropy = extract_bytes(&mut bytes, &in_buf, &mut hc)?; if !hc.ok_to_use_data() || !hc.entropy_on_target(entropy, BUFLEN as u32) { m.health_rejections.increment(1); @@ -159,6 +175,29 @@ fn read_loop( Ok(()) } +/// Drive one INM clock cycle and time the USB round trip. Returns `Ok(false)` +/// when the sample took longer than `max_sample` and must be discarded. +/// +/// This is the `MAX_MICROSEC_FOR_SAMPLES` guard from the C reference's +/// `readData`: a stalled bit-bang clock lets the INM's analog loop settle, so +/// bits sampled after a long stall carry less entropy than the health checker +/// assumes. The health checker is a statistical estimate; this guard is the +/// independent, per-sample defense. +fn timed_read( + device: &mut Device, + in_buf: &mut [u8; BUFLEN], + max_sample: Duration, + m: &DeviceMetrics, +) -> Result { + let start = Instant::now(); + device.read_buffer(in_buf)?; + if start.elapsed() > max_sample { + m.timing_rejections.increment(1); + return Ok(false); + } + Ok(true) +} + fn push(sender: &PoolSender, data: &[u8], entropy_counter: &Counter) -> bool { let bytes = Bytes::copy_from_slice(data); let len = bytes.len() as u64;