Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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/<n>` | 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/<n>` | Same bytes, hex-encoded `text/plain; charset=us-ascii`. 1 MiB cap on the underlying byte count. |
Expand All @@ -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`). |

Expand Down
11 changes: 11 additions & 0 deletions crates/naas-api/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::net::SocketAddr;
use std::time::Duration;

use clap::Parser;

Expand Down Expand Up @@ -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<String>,

Expand All @@ -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,
Expand Down
141 changes: 138 additions & 3 deletions crates/naas-api/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,23 @@ 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;
use futures::stream;
use metrics::Counter;
use tower_http::trace::TraceLayer;

use crate::config::Config;
use crate::error::ServiceError;
use crate::pool::Pool;

#[derive(Clone)]
struct ApiState {
pool: Pool,
max_bytes: usize,
index_html: Bytes,
metrics: ResponseMetrics,
}

Expand All @@ -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))
Expand All @@ -54,6 +58,137 @@ pub fn router(pool: Pool, max_bytes: usize) -> Router {
.with_state(state)
}

async fn index(State(state): State<ApiState>) -> Html<Bytes> {
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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>naas-api</title>
</head>
<body>

<h1>naas-api</h1>

<p>Noise as a Service, version __VERSION__.</p>

<p>This server hands out random bytes read from an
<a href="https://github.com/leetronics/infnoise">Infinite Noise Multiplier</a>,
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.</p>

<hr>

<h2>Endpoints</h2>

<pre>
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
</pre>

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

<p>Try it in the browser:
<a href="/api/v1/random/hex/16">/api/v1/random/hex/16</a>,
<a href="/api/v1/random/base64/32">/api/v1/random/base64/32</a>.</p>

<h2>Examples</h2>

<pre>
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
</pre>

<h2>Responses</h2>

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

<p>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.</p>

<h2>How the bytes are made</h2>

<ol>
<li>The FT240X on the stick clocks the multiplier and returns 512 comparator
samples per USB read, one noise bit each.</li>
<li>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.</li>
<li>__WHITENING__</li>
</ol>

<h2>Operations</h2>

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

<hr>

<p>Source: <a href="https://github.com/alpineworks/naas-api">alpineworks/naas-api</a>.
Driver: <a href="https://github.com/alpineworks/infnoise-rs">alpineworks/infnoise-rs</a>,
a Rust port of the reference C driver.</p>

</body>
</html>
"#;

fn check_n(n: usize, max: usize) -> Result<(), ServiceError> {
if n == 0 {
return Err(ServiceError::EmptyRequest);
Expand Down
3 changes: 2 additions & 1 deletion crates/naas-api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand All @@ -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)
Expand Down
Loading
Loading