Hardware random number generator on a Digispark (ATtiny85): watchdog timer
jitter and ADC noise, conditioned with the Ascon permutation. The design notes
are in the header comment of clockdrift_rng/clockdrift_rng.ino.
Every output bit is backed by credited entropy: the sketch estimates how much each sample is worth, and squeezes 64 bits out only once 128 have been credited. That makes it an entropy source rather than a random number generator -- about 285 bytes/s, which is the rate the sources actually produce, not a limit imposed by USB.
A 64 MB capture of x output passed PractRand (RNG_stdin8, 2^26 bytes) with
no anomalies in 95 test results, over 65 hours at the entropy rate. Note what that does and does not say: any competent
conditioner makes any input look uniform -- hash a counter and every test
suite ever written passes -- so the statistical run tests the plumbing. The
claim about the entropy rests on the SP 800-90B assessment of the raw samples
below.
Try it in the browser: the UART build runs in the Wokwi simulator at
https://wokwi.com/projects/475707136206905345 -- the s stream and the status
LED, no hardware needed. See wokwi/ for how it is wired and what the
simulator can and cannot do.
clockdrift_rng/: the sketch.~/Arduino/clockdrift_rngis a symlink to it, so the Arduino IDE finds it and the source lives here.clockdrift_rng.ino,ascon_permute.S: firmwarerawdump.py: convert raw dumps for SP 800-90B, compute health test cutoffsanalyze.py: statistics on hex output (xmode), a check for bugs only
tools/build.sh [SKETCH_DIR]: compile for the Digispark at 16.5 MHz intobuild/flash.sh [HEX]: Ctrl-C x3 into the bootloader, flash, wait for the portstump.py: capture a mode, jump to the bootloader, reboot, wait for the portrngread.c: read the random bytes over libusb (see Two transports)rng2random.c: add conditioned output to/dev/randomwith an entropy credit (RNDADDENTROPY); autodetects hex/binary; for cron (see Typical use)60-digispark-rng.rules: udev rule sorngreadneeds no rootassess.sh [ADC_MINUTES] [INTERVAL_MINUTES]: rawdandrcaptures, the SP 800-90B assessment and health test cutoffs, all incaptures/<date-time>/build-sp800-90b.sh: rebuildea_non_iidand its libraries inthird_party/
wokwi/: the Wokwi simulator diagram and notes for running the UART build in a browser (see the link above)
Not in the repository, because they belong to other projects: arduino-cli
(copied from the Arduino IDE AppImage), arduino-lint, ea_non_iid (built by
build-sp800-90b.sh), third_party/, build/ and captures/.
Needs DigiCDCFast in
~/Arduino/libraries -- 1.4.0 or newer for the libusb transport, which
uses a hook that release added; the serial build works with any version. The
Digistump AVR core provides the board.
tools/build.sh && tools/flash.sh
python3 tools/stump.py capture x 60 out.txt # conditioned output
nohup tools/assess.sh 6 0 > captures/assess.log 2>&1 & # ADC only, ~8 minutes
Board: micronucleus 2.6 bootloader (6650 bytes free). If no sketch is running,
flash.sh waits 60 s for the board to be plugged in.
Pipe the RNG into /dev/random so the CRNG mixes in real hardware entropy. On a
current kernel a plain copy once a minute -- board left in a conditioned mode
(x/X), no root needed -- is more than enough:
* * * * * bash -c 'dd if=/dev/ttyACM0 bs=1 count=32 of=/dev/random 2>/dev/null'
tools/rng2random.c does the same but also credits the entropy
(RNDADDENTROPY), which older kernels needed and current ones ignore. See
tools/README.md for when that credit still matters and how
to use it.
Pass them through EXTRA_FLAGS, as in
EXTRA_FLAGS="-DRNG_VENDOR=1" tools/build.sh.
| option | ||
|---|---|---|
RNG_VENDOR |
0 | 1: vendor control transfers instead of the serial port (see Transports) |
RNG_UART |
0 | 1: a plain UART on PB2 (TX)/PB0 (RX) instead of USB (see Transports) |
RNG_UART_BAUD |
115200 | UART baud; the send blocks per byte, so lower rates block longer (see Transports) |
DEFAULT_MODE |
s |
the power-up mode before any has been stored (see Modes); fixes a headless build to one output |
LED_STATUS |
1 | 0: no status LED |
LED_PIN |
PB1 |
the onboard LED; some clones wire it to PB0 |
STREAM_MODE |
1 | 0: no s/S streaming mode, 140 bytes less flash |
MODE_EEPROM |
1 | 1: remember the last mode in EEPROM byte 0 across power cycles; 0: always boot DEFAULT_MODE |
RNG_CDC_ECHO |
RNG_VENDOR |
1: echo the serial port back, to prove it still works while the bytes go out over libusb |
ADC_BATCH |
16 | ADC readings per loop pass. Tuned; the transmitter's timing was measured with it |
STACK_CHECK |
0 | 1: report never-used RAM after each x line |
Sizes on the Digispark, of the 6650 bytes micronucleus leaves and 512 of RAM:
| build | flash | RAM |
|---|---|---|
| default (USB CDC) | 6376 | 377 |
RNG_VENDOR=1 |
6206 | 381 |
RNG_UART=1 |
3712 | 157 |
LED_STATUS=0 |
6242 | 374 |
The UART build is half the size because it carries no USB stack at all.
Send the character over the serial port, or ask for it with rngread --mode:
Lowercase is hex text (readable on a terminal); uppercase is binary.
| mode | output |
|---|---|
s / S |
conditioned, free-running at ~5700 bytes/s, hex / binary |
x / X |
conditioned bytes, hex / binary |
r / R |
raw watchdog intervals, low 16 bits of CPU cycles, hex / binary |
d / D |
raw ADC readings, signed bytes, hex / binary |
The default is s: on power-up it gathers entropy until seeded, then streams
hex at the chip's full rate, so the first thing you see in a serial terminal is
readable random data. Send S for the same stream in binary to feed a program,
x/X for entropy-rate output, or the assessment tools drive the uppercase
R/D.
The last mode you select is remembered in EEPROM byte 0 and restored on the
next power-up, so a board keeps whatever you set it to. A blank or unrecognised
byte (0xFF on a fresh chip) falls back to DEFAULT_MODE, which is written
back so the byte is valid from then on. Turn this off with MODE_EEPROM=0, and
the board always boots DEFAULT_MODE.
s/S (the same stream, hex or binary) are a different thing from x/X,
and the difference is the whole point of this project, so it is worth stating
plainly. In X every bit is backed by
credited entropy, and the output is unpredictable even against an adversary
with unlimited computation. S keeps squeezing whether or not entropy has
been credited for it, so its output is unpredictable because Ascon is -- a
DRBG (an RBG2 construction, in SP 800-90C's terms) rather than an entropy
source. That is the ordinary architecture of every OS random generator and
perfectly sound; it is simply a different claim.
Two things keep it honest. Nothing is emitted until the capacity has been seeded four times over, so a stream never comes from a state that was never filled; and entropy keeps being absorbed, so a compromised state heals. The backtracking ratchet still runs, but only where fresh entropy paid for it, so a captured state exposes at most one credit's worth of earlier output rather than the whole stream.
The failure mode you must know about: if a source dies, S keeps streaming
perfect-looking bytes from a stale seed, forever and silently. In X the
rate visibly collapses. So a consumer of S should check the source flags and
seeded (rngread --info) rather than trusting the stream on its own.
Compile it out with STREAM_MODE=0 if you would rather it not exist; it costs
140 bytes of flash.
Measured: 5650 bytes/s, twenty times X's 284, against a theoretical
8000 that low-speed USB allows with 8-byte packets. The permutation is not
what stops it -- raw d mode, with no Ascon at all, reaches 3800 by the same
path. What is left is the shape of the sending: the sketch fills a 39-byte
burst, sends it in five USB frames and then goes quiet, so the pipe idles
between bursts. Keeping it full would want the output topped up continuously
rather than in bursts, which is a larger change than the remaining 2 kB/s is
worth. S also samples the ADC in twos rather than sixteens, since a
conversion is about 100 us and sampling in sixteens left the pipe idle half
the time (3900 -> 5650 bytes/s); X's sampling cadence is untouched.
r and d are the un-conditioned sources, for the assessment below. They
will fail PractRand, and should: a source carrying about one bit of entropy
per byte is not meant to look uniform, only to be unpredictable. Those are
different properties and only the second one matters here.
A tty starts cooked, with echo on, and a serial port's echo goes back to the
device. In the binary modes X, S, R and D that means the host echoes
random bytes back at the sketch, and about one byte in 256 is a mode character
-- so the mode changes by itself, and a stream turns into a trickle of
something else. The hex modes (x, s, r, d) are immune, hex digits being
no command, and the default s is one of them, which is why this went
unnoticed for so long.
stty -F /dev/ttyACM0 raw -echo
tools/rngread.c is unaffected (it never opens the tty) and so is anything
that sets raw mode itself. A cat or a plain open() is not. (Found by the
rngtacho session, on a device that had quietly switched itself to r.)
An LED on PB1 (the onboard one on most Digisparks) reports what the RNG is doing, so a standalone device with no serial monitor still tells you it is alive and honest:
| LED | meaning |
|---|---|
| solid on | starting up -- sources not yet validated, no output yet |
| brief blink, once a second | generating normally, both sources passing |
| fast blink, 5 times a second | a source has failed its health tests |
The fast blink takes precedence: it means at least one source is dead even if
the other is still producing output. Single-bit writes drive the pin, so it
cannot disturb V-USB on the USB build. LED_STATUS=0 removes it; LED_PIN
moves it (some clones wire the LED to PB0). It is why the UART's TX is on PB2
rather than PB1.
Three ways the bytes leave the chip, chosen at build time:
-
USB CDC serial (default): a serial port, read with any terminal or with
stump.py. -
USB vendor requests (
RNG_VENDOR=1): control requests on endpoint 0, read withtools/rngread.cand libusb. The serial port stays enumerated and completely unused, so another function of the same sketch can have it -- this chip's USB driver has no endpoint to spare for a second one. NeedsUSB_CFG_VENDOR_HOOKset to 1 in DigiCDCFast'ssrc/usbconfig.h.EXTRA_FLAGS="-DRNG_VENDOR=1 -DUSB_CFG_VENDOR_HOOK=1" tools/build.sh cc -O2 -o tools/rngread tools/rngread.c -lusb-1.0 tools/rngread --info tools/rngread | pv > /dev/nullrngreadneeds permission on the USB device: installtools/60-digispark-rng.rulesin/etc/udev/rules.d/, or run it as root. It learns how long a burst takes and sleeps through most of it rather than polling hard, because each request steals cycles from the timing the entropy comes from.In vendor builds the diagnostics that
xmode prints as#comments become requests instead (rngread --info): protocol, mode, the Ascon self-test, whether each source still passes its health tests, bursts dropped because nobody read them, and whetherSmode has been seeded.
A plain UART, with RNG_UART=1: 8N1 on PB2 (TX) and PB0 (RX), no USB. It
reads on any terminal through a USB-serial cable, or feeds another
microcontroller's UART directly -- the RNG as a serial entropy peripheral, with
no host that has to speak CDC. The receiver is the ATtiny85's USI, so a byte
costs two short interrupts rather than a busy-wait, and Ctrl-C over RX reaches
the bootloader. Three things to know:
- Timing. The 16.5 MHz clock is held precise, in the USB builds, by tuning
against the USB frame; a UART build has no such reference and rides the
internal RC oscillator, set once at boot and then drifting with temperature.
The default is 115200 (so a send blocks for only ~87 us -- see below); it
works here at room temperature but sits nearer the oscillator's margin than a
slower rate, so drop
RNG_UART_BAUD(9600, ...) if a board runs hot or cold. - Half duplex. A byte is transmitted with interrupts off so the watchdog
cannot stretch it into a framing error, which means the port is deaf while a
burst goes out. Typed commands land in the gaps between bursts -- fine for the
slower modes, but switching out of
s/S(which stream almost continuously) may take a few tries. A received byte briefly borrows Timer0, the fine-timestamp clock, to run the USI: it disturbs at most the one interval that spans the keystroke, and the next sample is clean -- the act of talking to the RNG nudging the jitter it measures, but only while you type. - Simulator. The USI receiver is clocked by a Timer0 compare match, which
Wokwi's avr8js does not implement, so reception -- and mode switching over the
UART -- does not work in the simulator, though the transmit stream and the LED
do. See
wokwi/. On real hardware the receiver works.
The USB transports both measure the same 285 bytes/s; the UART's throughput is set by its baud (11.5 kB/s at 115200), well above the entropy rate.
tools/assess.sh captures raw samples and runs NIST SP 800-90B's non-IID
estimator over them. What the sketch credits is deliberately below what the
estimator returns:
| source | estimated | samples | credited |
|---|---|---|---|
| watchdog intervals | 6.71 bits/sample | 180,291 | 1 |
| ADC readings | 1.10 bits/sample | 1,244,295 | 1 |
The estimate moves from run to run: the interval source has measured 6.44, 6.55, 6.71, 6.76 and 6.82 across an hour-long run each, on this firmware and on rngtacho's two-port variant of it. That spread is why the health test cutoffs below are computed from the lowest of them rather than the latest, and why the credit is 1.
The intervals are cut hardest because part of their jitter is USB polling latency, which the host controls rather than physics.
SP 800-90B's continuous health tests (repetition count, adaptive proportion)
run on every sample with cutoffs for alpha = 2^-40; a source that fails stops
being credited, and rngread --info says so. A higher assessed entropy gives
tighter cutoffs, so taking them from the lowest estimate seen keeps false
alarms at or under alpha in every condition measured; taking them from the
latest run would be chasing the spread.
Not yet done: the restart test (SP 800-90B section 3.1.4) -- the first samples after each of many power-ups, checked for column-wise predictability. It is the one test that catches a source which looks fine within a run but starts from the same place every boot, and within-run testing cannot see that at all.
GPL version 2, in LICENSE. The firmware links V-USB through
DigiCDCFast, and V-USB is
distributed by Objective Development under the
GPL version 2 or version 3 (with a commercial license offered separately), so
the combined work is covered by the GPL.
clockdrift_rng/ascon_permute.S is not mine: it is the AVR assembler
permutation from ascon/ascon-c
(crypto_hash/asconxof128/avr_lowsize, commit 446347f) by L. Cardoso and
J. Großschädl of the University of Luxembourg, under CC0-1.0, with only the
function name changed. Its header says so and should stay.
Ascon is the NIST lightweight cryptography standard (SP 800-232). The entropy
assessment uses NIST's SP 800-90B ea_non_iid, built by
tools/build-sp800-90b.sh. tools/rngread.c links libusb (LGPL-2.1+).
There is no circuit diagram because there is no circuit: the entropy comes from the watchdog oscillator's drift against the CPU clock and from the ADC's own amplifier noise, with nothing attached to the board.