I was redesigning the interface and user experience of My New Password when I noticed what I had used to generate its passwords: Math.random(). The tool was about ten years old, and I used it frequently myself. I had put the wrong source of randomness in it and continued using it for years.
There was no known compromise or password leak. The passwords were generated locally in the browser, without being sent to a server. The problem was in how I chose the characters that made up each password.
If you want a random character, you can multiply Math.random() by the length of your alphabet, round down, and pick that slot. The passwords look random, and the code runs without complaint. For picking a random color or adding variation to an animation, that is fine.
Passwords need a source designed to resist prediction. Math.random() does not provide cryptographically secure randomness. That does not establish that someone could recover a particular password from my site, but it was enough to tell me I had chosen the wrong function for the job.
The replacement was crypto.getRandomValues(), part of the Web Crypto API. It fills an integer array with cryptographically strong random values. For example:
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const value = buf[0]; // An integer from 0 through 2 ** 32 - 1.
That gives me a random integer, but I still need an index into my alphabet. Using value % alphabet.length can introduce a small bias if the alphabet size does not divide evenly into the number of possible values. Some character positions get one more value mapped to them than others.
The way to avoid that bias is to accept only a range whose size is an exact multiple of the alphabet size. If a draw falls outside that range, draw again. Here is a helper that does that and returns an integer from zero up to, but excluding, max:
function secureRandomInt(max) {
const range = 2 ** 32;
if (!Number.isInteger(max) || max < 1 || max > range) {
throw new RangeError("max must be an integer from 1 through 2 ** 32.");
}
const limit = Math.floor(range / max) * max;
const buf = new Uint32Array(1);
let n;
do {
crypto.getRandomValues(buf);
n = buf[0];
} while (n >= limit);
return n % max;
}
A 32-bit unsigned integer has 2 ** 32 possible values, including zero. That is the range size used in the calculation. For a typical password alphabet, almost every draw passes on the first try. Each character position then has the same chance of being selected.
I replaced the uses of Math.random() in the password generation with cryptographic randomness. The change was small, and the passwords still looked the same. Looking at the output would never have told me whether I had used the right function.