Skip to content

Commit 35fe7bc

Browse files
committed
Use a random impl with rejection sampling
1 parent 86494c3 commit 35fe7bc

File tree

2 files changed

+81
-9
lines changed

2 files changed

+81
-9
lines changed

spec/unit/randomstring.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ import {
2424
} from "../../src/randomstring";
2525

2626
describe("Random strings", () => {
27+
afterEach(() => {
28+
jest.restoreAllMocks();
29+
});
30+
2731
it.each([8, 16, 32])("secureRandomBase64 generates %i valid base64 bytes", (n: number) => {
2832
const randb641 = secureRandomBase64Url(n);
2933
const randb642 = secureRandomBase64Url(n);
@@ -70,4 +74,41 @@ describe("Random strings", () => {
7074
expect(rand1.toUpperCase()).toEqual(rand1);
7175
},
7276
);
77+
78+
it("throws if given character set less than 2 characters", () => {
79+
expect(() => secureRandomStringFrom(8, "a")).toThrow();
80+
});
81+
82+
it("throws if given character set more than 256 characters", () => {
83+
const charSet = Array.from({ length: 257 }, (_, i) => "a").join("");
84+
85+
expect(() => secureRandomStringFrom(8, charSet)).toThrow();
86+
});
87+
88+
it("throws if given length less than 1", () => {
89+
expect(() => secureRandomStringFrom(0, "abc")).toThrow();
90+
});
91+
92+
it("throws if given length more than 32768", () => {
93+
expect(() => secureRandomStringFrom(32769, "abc")).toThrow();
94+
});
95+
96+
it("asks for more entropy if given entropy is unusable", () => {
97+
// This is testing the internal implementation details of the function rather
98+
// than strictly the public API. The intention is to have some assertion that
99+
// the rejection sampling to make the distribution even over all possible characters
100+
// is doing what it's supposed to do.
101+
102+
// mock once to fill with 255 the first time: 255 should be unusable because
103+
// we give 10 possible characters below and 256 is not evenly divisible by 10, so
104+
// this should force it to call for more entropy.
105+
jest.spyOn(globalThis.crypto, "getRandomValues").mockImplementationOnce((arr) => {
106+
if (arr === null) throw new Error("Buffer is null");
107+
new Uint8Array(arr.buffer).fill(255);
108+
return arr;
109+
});
110+
111+
secureRandomStringFrom(8, "0123456789");
112+
expect(globalThis.crypto.getRandomValues).toHaveBeenCalledTimes(2);
113+
});
73114
});

src/randomstring.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,48 @@ export function secureRandomString(len: number): string {
4040

4141
/**
4242
* Generate a cryptographically secure random string using characters given
43-
* @param len The length of the string to generate
44-
* @param chars The characters to use in the random string.
43+
* @param len The length of the string to generate (must be positive and less than 32768)
44+
* @param chars The characters to use in the random string (between 2 and 256 characters long).
4545
* @returns Random string of characters of length `len`
4646
*/
4747
export function secureRandomStringFrom(len: number, chars: string): string {
48-
const positions = new Uint32Array(chars.length);
49-
let ret = "";
50-
crypto.getRandomValues(positions);
51-
for (let i = 0; i < len; i++) {
52-
const currentCharPlace = positions[i % chars.length] % chars.length;
53-
ret += chars[currentCharPlace];
48+
// This is intended for latin strings so 256 possibilities should be more than enough and
49+
// means we can use random bytes, minimising the amount of entropy we need to ask for.
50+
if (chars.length < 2 || chars.length > 256) {
51+
throw new Error("Character set must be between 2 and 256 characters long");
52+
}
53+
54+
if (len < 1 || len > 32768) {
55+
throw new Error("Requested random string length must be between 1 and 32768");
56+
}
57+
58+
// We'll generate random unsigned bytes, so get the largest number less than 256 that is a multiple
59+
// of the length of the character set: We'll need to discard any random values that are larger than
60+
// this as we can't possibly map them onto the character set while keeping each character equally
61+
// likely to be chosen (minus 1 to convert to indices in a string). (Essentially, we're using a d8
62+
// to choose between 7 possibilities and re-rolling on an 8, keeping all 7 outcomes equally likely.)
63+
const maxRandValue = Math.floor(255 / chars.length) * chars.length - 1;
64+
65+
// Grab 30% more entropy than we need. This should be enough that we can discard the values that are
66+
// too high without having to go back and grab more unless we're super unlucky.
67+
const entropyBuffer = new Uint8Array(Math.floor(len * 1.3));
68+
// Mark all of this buffer as used to start with (we haven't populated it with entropy yet) so it will
69+
// be filled on the first iteration.
70+
let entropyBufferPos = entropyBuffer.length;
71+
72+
const result = [];
73+
while (result.length < len) {
74+
if (entropyBufferPos === entropyBuffer.length) {
75+
globalThis.crypto.getRandomValues(entropyBuffer);
76+
entropyBufferPos = 0;
77+
}
78+
79+
const randomByte = entropyBuffer[entropyBufferPos++];
80+
81+
if (randomByte < maxRandValue) {
82+
result.push(chars[randomByte % chars.length]);
83+
}
5484
}
55-
return ret;
85+
86+
return result.join("");
5687
}

0 commit comments

Comments
 (0)