DeepBlue Dynamics / Signal Log / radio-randomness
· release · engineering · sdrrand · Kord Campbell

True randomness from a $48 radio

True randomness from a $48 radio

An RTL-SDR v4 dongle (~$48 on Amazon) is, among other things, a high-rate hardware random number generator. Its 8-bit ADC samples radio every fraction of a microsecond, and the least significant bit of each sample is dominated by thermal noise — a genuinely random physical process. Tune to an empty band, throw away everything but that LSB, and what falls out is true entropy.

We packaged that into two pieces:

  • sdr-rand — the contributor side. A small Rust binary that runs on any machine with a USB RTL-SDR plugged in. Captures IQ samples, extracts the LSBs with a 37-step stride to decorrelate consecutive reads, and either serves the bytes locally on port 9090 or POSTs them to a relay.
  • sdrrand-site — the relay. A Rust + Axum service running at sdrrand.nuts.services. Accepts authenticated pushes, runs ingress health checks, absorbs accepted bytes into a SHA-3 (Shake256) duplex sponge, and serves squeezed bytes to anyone who asks.

The interesting part isn't the radio physics — that bit is well known. It's everything else around it: how you trust the bytes a contributor sends you, how the cloud pool behaves under steady push, and what happens at the edge cases.

How a contributor pushes

Get an ahp_ API token from auth.nuts.services, plug in any RTL2832U-based dongle, and run:

sdr-rand push \
  --remote https://sdrrand.nuts.services \
  --token "$NUTS_TOKEN" \
  --frequency 433000000 \
  --interval 2

Every 2 seconds the daemon grabs ~96 KB of raw IQ samples, harvests the LSBs into ~324 entropy bytes, and POSTs them with Authorization: Bearer $NUTS_TOKEN. The cloud service credits the push against your nuts.services identity and appends the bytes to the shared pool.

How a consumer drains

No auth, no signup — just hit the endpoint:

curl https://sdrrand.nuts.services/api/entropy?bytes=32
# {"bytes_requested":32,"bytes_returned":32,"pool_remaining":...,"entropy_hex":"…"}

curl 'https://sdrrand.nuts.services/api/entropy?bytes=8&format=hex'
# a60d20ee0c940922

curl 'https://sdrrand.nuts.services/api/entropy?bytes=256&format=raw' > bytes.bin

Each call squeezes N bytes from the sponge state and re-mixes the output back. Two consumers requesting the same number of bytes back-to-back get unrelated streams. The SSE endpoint at /api/entropy/stream emits 128 bytes per second — useful for a live demo, but the bytes are public the moment they're served, so don't use them for anything you wouldn't also publish.

Trusting a contributor's bytes

If anyone with a valid token can push, anyone with a valid token can also push garbage — all zeros, a known PRNG sequence, anything. So before bytes enter the pool, the relay runs three lightweight ingress checks modeled on NIST SP 800-90B health tests:

TestCatchesThreshold
Repetition CountDead ADC, stuck driver, all-same-byte≤ 40 identical bytes in a row
Byte frequency capMostly-one-value pushesNo byte > 25% of batch
Shannon entropy floorStructured / low-entropy input≥ 6.0 bits/byte

True random in a 648-byte batch reliably scores ~7.6 bits/byte with a modal byte around 1.1% — the floors are conservative. A push that fails returns 400 health-check rejected with the specific reason, the rejection is recorded against the contributor in their stats, and the bytes never touch the pool.

Accepted bytes are then absorbed into a SHA-3 (Shake256) duplex sponge — a rolling 64-byte state seeded at boot from OsRng. Drainers squeeze bytes from the same state, and each squeeze re-mixes its output back, so two squeezes never produce correlated output. As long as at least one absorbed input has entropy the attacker doesn't know, a malicious contributor cannot determine what drainers see. The OS seed alone gives you that property even with zero contributors; honest contributors strengthen it.

This replaced an earlier v1 design (FIFO byte buffer, no conditioning) that's now only of historical interest.

What this is and isn't

What the sponge gives you: a single malicious contributor cannot poison drainer output. They can push whatever they want; their bytes mix into a state they don't fully know.

What it doesn't give you:

  • Protection against a compromised relay. If the relay process is the only sponge, an attacker with code execution there reads or replays state at will. Same trust model as any in-process RNG.
  • Multi-instance safety. The sponge lives in process memory. Cloud Run instance count is pinned to one. Scaling beyond that without external state would give every instance a divergent sponge, and drainers would see different bytes depending on routing.
  • Bytes-on-wire secrecy. If you fetch 16 bytes to seed something sensitive, anyone past the TLS terminator can see them. Mix with local entropy before use if it matters.
  • Adversarial source verification. The ingress checks reject obviously broken or trivially structured input. They can't prove a contributor's bytes came from a radio versus a PRNG crafted to pass the checks. Honest-contributor assumptions still have to be made about somebody.

Why bother

Most code that needs randomness uses os.urandom or /dev/urandom, which is fine for almost everything. Where this gets interesting:

  • Reproducible ML training seeds — the kind where you actually want a real seed, not a hash of time(). Drain 8 bytes, pipe into torch.manual_seed.
  • Air-gapped key generation — when you don't trust the host's CSPRNG to have enough seed entropy.
  • Cheap HSM-adjacent applications — signing, nonce generation, dice for blockchain bridges.
  • Anywhere you want to prove the entropy came from a physical process instead of a deterministic generator someone could replay.

The whole system is small — the cloud service is one Rust binary, <500 lines, no database. The contributor binary is another ~500 lines and one cargo install. Both are MIT.

What's next

  1. More contributors. The sponge protects against one malicious or broken contributor. With a single dongle, all the unpredictability you don't already trust comes from the OS seed and that one dongle. The most useful change anyone can make to this system right now is plugging in a second dongle.
  2. A live status panel. The metrics are already on /status and /contributors — just not streamed to the landing page. SSE, ~50 lines.
  3. External sponge state. Cloud Run pinned-to-one-instance is a soft constraint that breaks the day someone forgets and bumps max-instances. Moving the sponge state into Redis lets it scale without divergent state. Push volume is tiny; not urgent, but the constraint should be explicit somewhere louder than a code comment.
  4. More health tests. The current three are the cheap ones. NIST SP 800-90B has the rest (Markov-1, repetition LSB, lag-1 autocorrelation). Cheap to add; we haven't.

If you have an RTL-SDR in a drawer, plug it in. sdr-rand is one cargo install away. The dongle's been doing the work the whole time — we just wired it up.

// transmission ends