Randomness doesn't come from the weather
26-08-03

I wanted to build the most obviously random thing I could. A script tag you drop on any page, harvesting entropy from the visitor’s mouse and keystrokes, then stirring in the weather, the positions of the planets, and the most recent Bitcoin block. Chaos from the real world, hashed into your random numbers.
None of the outside world survived. superandom shipped without a single network call, and the reason it didn’t is more interesting than the library.
Entropy is measured against what an attacker knows
Randomness isn’t a property a number has. There’s no such thing as a random 256-bit value sitting on its own. Entropy measures an adversary’s uncertainty, so the question is never “was this chaotic”. It’s “does the attacker already know it”.
Run my three sources through that:
Planetary positions. A deterministic function of the clock. The attacker has the same clock. Zero bits. Not “a few”, not “hard to compute”. Zero.
Weather. Public API responses are model output, identical for every visitor. And fetching local conditions means sending the visitor’s coordinates to a third party, so you pay a real privacy cost for nothing.
Bitcoin block hashes, drand, the NIST beacon. Genuinely unpredictable before publication, completely public after. Everyone reading them sees the same value. They can prove a draw happened after a certain moment. They can’t make it secret.
That last one is the seductive case. The physics really is chaotic, the value really was unguessable an hour ago. Doesn’t matter. By the time your page reads it, so can everyone else.
So all of it came out. What surprised me is that removing it made the library better rather than thinner. No CORS. No rate limits. Nothing to depend on. No consent question about pinging some API from other people’s visitors’ browsers. Works offline. And “nothing ever leaves your browser” becomes a guarantee instead of a caveat, which is worth more than everything I cut.
That guarantee is enforced, not promised. The build greps the minified bundle for fetch, XMLHttpRequest, WebSocket, sendBeacon and friends, and fails if it finds any.
Your mouse doesn’t beat the operating system either
Second uncomfortable thing. crypto.getRandomValues() is already good. Seeded from OS entropy, a real CSPRNG, and no amount of mouse wiggling improves it. Every library in this category implies otherwise. They’re all wrong.
So what’s the point?
Three things. An independent source, so you aren’t resting everything on one implementation. An auditable process, so you can show what was collected. And a posture where a backdoored or badly seeded platform generator stops being fatal, which isn’t hypothetical: the Coldcard bug was exactly a generator quietly falling back to a deterministic path for five years.
The way to get all three without lying is to never bet on your own cleverness. Every byte superandom hands out gets XORed with a fresh, independent platform draw:
const out = drbg.generate(n); // harvested entropy, extracted and expanded
const mask = new Uint8Array(n);
crypto.getRandomValues(mask); // independent draw, taken after
for (let i = 0; i < n; i++) out[i] ^= mask[i];
For any value independent of a uniform one, the XOR is uniform. Which means if the pool is empty, or my estimate of it is wrong, or an attacker controls every event, or my library has a bug, the output is still exactly as good as crypto.getRandomValues(). And if the platform generator is the broken one, it’s still as good as mine. Both have to fail before you lose anything.
That property is why I can relax about the parts that are guesswork, and estimating the entropy in a mouse path is pure guesswork. I claim two bits per pointer move, one per keystroke interval, an eighth of a bit per clock measurement. Those numbers come from the literature, not from measuring your hardware. If they’re generous, the only consequence is a progress bar filling early. They can’t make the output worse, because it never depended on them.
Things I got wrong
Worth writing down.
Output was originally generated one call at a time, so a four byte draw cost a full DRBG generate plus its mandated update. Four HMAC invocations for four bytes. Correct, and roughly a hundred times slower than it needed to be, which means nobody uses it in a render loop and everyone keeps Math.random() for the hot path. Buffering fixed it without touching the guarantee.
The statistical suite found a real bug immediately, which is the entire argument for having one. crypto.getRandomValues() refuses any request over 65536 bytes. I was drawing the XOR mask in one call sized to the whole request, so every draw above 64 KiB threw. A megabyte of output, exactly what you’d ask for if you were doing something serious, was broken.
Then a smaller one, on the very first version bump: the exported VERSION constant is hand written and didn’t track package.json, so 1.0.1 would have reported itself as 1.0.0. A test caught it. The build now refuses to run when the two disagree.
My favourite is different. An integration test fired synthetic mouse events on a clean 20ms cadence, and the library flagged the source as unhealthy and refused to credit it. That was correct. No hand produces events on an exact metronome, so a stream that does is a replay, a bot, or a driver quantising everything, and the SP 800-90B repetition test exists to notice. The test was wrong, not the code. I made the fake mouse jitter and kept the behaviour as its own test.
Try it
There’s a live demo with the entropy meter climbing as you move, the raw output as a bit grid, the statistical battery running in your tab, and a receipt you can verify and then tamper with to watch the chain break.
There’s also The Keywright, which is the same library doing something considerably less serious: five dice, cast through randomInt, cutting you one key out of 7,776. Each one comes with the door it opens, and none of the doors exist.
npm install superandom
MIT, on GitHub, and unaudited cryptographic code. The fold means the realistic worst case is that you get exactly crypto.getRandomValues(), which is the point. Don’t treat “unaudited” as a formality.