From 2fed0f09bb96d804285902702b192a7bf09be665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E4=B9=90=E5=B0=8F=E7=8C=AB?= Date: Sun, 10 May 2026 15:07:59 +0800 Subject: [PATCH] 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. --- packages/app/src/polyfills/crypto.test.ts | 40 +++++++++++++++++++++++ packages/app/src/polyfills/crypto.ts | 38 ++++++++++++++++----- 2 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 packages/app/src/polyfills/crypto.test.ts diff --git a/packages/app/src/polyfills/crypto.test.ts b/packages/app/src/polyfills/crypto.test.ts new file mode 100644 index 000000000..2eec3cc79 --- /dev/null +++ b/packages/app/src/polyfills/crypto.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const expoCryptoMock = vi.hoisted(() => ({ + getRandomValues: vi.fn((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((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(); + }); +}); diff --git a/packages/app/src/polyfills/crypto.ts b/packages/app/src/polyfills/crypto.ts index 15f3c89b9..968ee68f5 100644 --- a/packages/app/src/polyfills/crypto.ts +++ b/packages/app/src/polyfills/crypto.ts @@ -13,8 +13,26 @@ interface MutableGlobal { crypto?: Crypto; } +type RandomUUID = `${string}-${string}-${string}-${string}-${string}`; +type FillRandomValues = (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 { 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). // Hermes may not provide them in all configurations. @@ -47,17 +65,21 @@ export function polyfillCrypto(): void { g.crypto = {} as Crypto; } + const fillRandomValues: FillRandomValues = (array: T): T => { + if (array === null) return array; + if (nativeGetRandomValues) { + return nativeGetRandomValues(array as unknown as ArrayBufferView) as T; + } + return ExpoCrypto.getRandomValues( + array as unknown as Parameters[0], + ) as unknown as T; + }; + if (typeof g.crypto.randomUUID !== "function") { - g.crypto.randomUUID = () => - ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`; + g.crypto.randomUUID = () => createUuidV4(fillRandomValues); } if (typeof g.crypto.getRandomValues !== "function") { - g.crypto.getRandomValues = (array: T): T => { - if (array === null) return array; - return ExpoCrypto.getRandomValues( - array as unknown as Parameters[0], - ) as unknown as T; - }; + g.crypto.getRandomValues = fillRandomValues; } }