Fix infinite recursion in web crypto randomUUID polyfill (#858)
On web (browser/Electron), expo-crypto's randomUUID() and getRandomValues() just forward to globalThis.crypto.* (see expo-crypto/src/ExpoCrypto.web.ts). The previous polyfill installed `g.crypto.randomUUID = () => ExpoCrypto.randomUUID()`, which on web reads back through the same `globalThis.crypto.randomUUID` it just installed, recursing until the stack overflows. The same trap exists for getRandomValues, but it rarely triggers because the native version is almost always present. Capture a bound reference to the native getRandomValues *before* installing the polyfill, and generate UUID v4 in JS from 16 random bytes (RFC 4122 version + variant bits). The fallback path stays on ExpoCrypto.getRandomValues (non-recursive on native), and ExpoCrypto.randomUUID is no longer used.
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const expoCryptoMock = vi.hoisted(() => ({
|
||||||
|
getRandomValues: vi.fn(<T extends ArrayBufferView>(array: T): T => array),
|
||||||
|
randomUUID: vi.fn(() => {
|
||||||
|
throw new Error("ExpoCrypto.randomUUID should not be used for the web fallback");
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("expo-crypto", () => expoCryptoMock);
|
||||||
|
|
||||||
|
describe("polyfillCrypto", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
expoCryptoMock.getRandomValues.mockClear();
|
||||||
|
expoCryptoMock.randomUUID.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates randomUUID from getRandomValues when Web Crypto randomUUID is unavailable", async () => {
|
||||||
|
const sourceBytes = Uint8Array.from([
|
||||||
|
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
|
||||||
|
0xff,
|
||||||
|
]);
|
||||||
|
const getRandomValues = vi.fn(<T extends ArrayBufferView | null>(array: T): T => {
|
||||||
|
if (array && ArrayBuffer.isView(array)) {
|
||||||
|
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(sourceBytes);
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("crypto", { getRandomValues });
|
||||||
|
|
||||||
|
const { polyfillCrypto } = await import("./crypto");
|
||||||
|
polyfillCrypto();
|
||||||
|
|
||||||
|
expect(globalThis.crypto.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff");
|
||||||
|
expect(getRandomValues).toHaveBeenCalledTimes(1);
|
||||||
|
expect(expoCryptoMock.randomUUID).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,8 +13,26 @@ interface MutableGlobal {
|
|||||||
crypto?: Crypto;
|
crypto?: Crypto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RandomUUID = `${string}-${string}-${string}-${string}-${string}`;
|
||||||
|
type FillRandomValues = <T extends ArrayBufferView | null>(array: T) => T;
|
||||||
|
|
||||||
|
function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID {
|
||||||
|
const bytes = fillRandomValues(new Uint8Array(16));
|
||||||
|
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||||
|
|
||||||
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||||
|
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
|
||||||
|
.slice(6, 8)
|
||||||
|
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID;
|
||||||
|
}
|
||||||
|
|
||||||
export function polyfillCrypto(): void {
|
export function polyfillCrypto(): void {
|
||||||
const g = globalThis as unknown as MutableGlobal;
|
const g = globalThis as unknown as MutableGlobal;
|
||||||
|
const nativeGetRandomValues =
|
||||||
|
typeof g.crypto?.getRandomValues === "function"
|
||||||
|
? g.crypto.getRandomValues.bind(g.crypto)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport).
|
// Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport).
|
||||||
// Hermes may not provide them in all configurations.
|
// Hermes may not provide them in all configurations.
|
||||||
@@ -47,17 +65,21 @@ export function polyfillCrypto(): void {
|
|||||||
g.crypto = {} as Crypto;
|
g.crypto = {} as Crypto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fillRandomValues: FillRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||||
|
if (array === null) return array;
|
||||||
|
if (nativeGetRandomValues) {
|
||||||
|
return nativeGetRandomValues(array as unknown as ArrayBufferView<ArrayBuffer>) as T;
|
||||||
|
}
|
||||||
|
return ExpoCrypto.getRandomValues(
|
||||||
|
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
|
||||||
|
) as unknown as T;
|
||||||
|
};
|
||||||
|
|
||||||
if (typeof g.crypto.randomUUID !== "function") {
|
if (typeof g.crypto.randomUUID !== "function") {
|
||||||
g.crypto.randomUUID = () =>
|
g.crypto.randomUUID = () => createUuidV4(fillRandomValues);
|
||||||
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof g.crypto.getRandomValues !== "function") {
|
if (typeof g.crypto.getRandomValues !== "function") {
|
||||||
g.crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
g.crypto.getRandomValues = fillRandomValues;
|
||||||
if (array === null) return array;
|
|
||||||
return ExpoCrypto.getRandomValues(
|
|
||||||
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
|
|
||||||
) as unknown as T;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user