Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function build({
if (platform === 'browser' && bundle) {
buildOptions.alias = {
'node:async_hooks': './src/utils/async_hooks_shim.ts',
'node:crypto': './src/utils/crypto_shim.ts',
};
}

Expand Down
23 changes: 23 additions & 0 deletions core/src/utils/crypto_shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Browser stand-in for the `node:crypto` builtin, wired up by the alias in
* `build.js` so that the Node fallback in `env_aware_utils.ts` does not pull a
* Node builtin into the web bundle.
*
* `randomUUID` is reached here only after both `globalThis.crypto` branches in
* `env_aware_utils.ts` have been ruled out. In a browser that means the Web
* Crypto API is genuinely absent, so there is no secure source left to fall
* back to and the only correct move is to fail rather than degrade.
*/
export function randomUUID(): string {
throw new Error(
'randomUUID: no cryptographically secure source of randomness is ' +
'available. Neither crypto.randomUUID() nor crypto.getRandomValues() is ' +
'present in this environment.',
);
}
15 changes: 10 additions & 5 deletions core/src/utils/env_aware_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {randomUUID as nodeRandomUUID} from 'node:crypto';

/**
* Returns true if the environment is a browser.
*/
Expand All @@ -19,6 +21,13 @@ export function isBrowser() {
* `crypto.getRandomValues()` carries no such restriction, so it is used as the
* fallback rather than `Math.random()`.
*
* In Node the `globalThis.crypto` global was added in v17.4.0 and stayed behind
* `--experimental-global-webcrypto` until v19.0.0, so neither of those branches
* matches on a default Node 18 or earlier. `node:crypto` carries no such gate —
* its `randomUUID` has existed since v14.17.0 — so it is the last resort. In
* the web build that import is aliased to `crypto_shim.ts`, which throws,
* because a browser without the Web Crypto API has no secure source left.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. The doc comment claims more coverage than the alias actually gives: it says "the web build", but only the bundled web build is aliased, and that is not the one the package publishes.

 * its `randomUUID` has existed since v14.17.0  so it is the last resort. In
 * the web build that import is aliased to `crypto_shim.ts`, which throws,
 * because a browser without the Web Crypto API has no secure source left.
 * its `randomUUID` has existed since v14.17.0  so it is the last resort. In
 * the bundled web build the import is aliased to `crypto_shim.ts`, which
 * throws, because a browser without the Web Crypto API has no secure source
 * left. The non-bundle `dist/web` output that `package.json#browser` points
 * at keeps the import verbatim, as it already does for `node:async_hooks`
 * and `node:path`.

Your PR description is careful about exactly this distinction — the code comment isn't, and the comment is what the next reader gets. I confirmed the gap rather than assuming it: from dist/web/index_web.js, node:async_hooks is reachable through utils/client_labels.js, and node:path/node:fs/promises through skills/loader.js and utils/file_utils.js. So the sentence is wrong only about scope, not about the mechanism.

Same wording question applies to crypto_shim.ts's own header, though "does not pull a Node builtin into the web bundle" there is already accurate as written.

*
* Some callers use this value to make security decisions — the OAuth2 `state`
* parameter in `AuthHandler` and the session identifiers minted by the session
* services — so this function must not silently degrade to a non-cryptographic
Expand Down Expand Up @@ -48,11 +57,7 @@ export function randomUUID(): string {
.join('-');
}

throw new Error(
'randomUUID: no cryptographically secure source of randomness is ' +
'available. Neither crypto.randomUUID() nor crypto.getRandomValues() is ' +
'present in this environment.',
);
return nodeRandomUUID();
}

/**
Expand Down
22 changes: 20 additions & 2 deletions core/test/utils/env_aware_utils_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import {afterEach, describe, expect, it} from 'vitest';
import {randomUUID as shimRandomUUID} from '../../src/utils/crypto_shim.js';
import {getBooleanEnvVar, randomUUID} from '../../src/utils/env_aware_utils.js';

describe('env_aware_utils', () => {
Expand Down Expand Up @@ -109,10 +110,27 @@ describe('env_aware_utils', () => {
expect(randomUUID()).toBe('abababab-abab-4bab-abab-abababababab');
});

it('throws instead of degrading when no secure source exists', () => {
// globalThis.crypto was added in Node v17.4.0 and stayed behind
// --experimental-global-webcrypto until v19.0.0, so on a default Node 18 or
// earlier neither globalThis branch matches.
it('falls back to node:crypto when globalThis.crypto is absent', () => {
setCrypto(undefined);

expect(() => randomUUID()).toThrow(
expect(randomUUID()).toMatch(UUID_V4);
});

it('does not repeat itself across calls without globalThis.crypto', () => {
setCrypto(undefined);

const ids = new Set(Array.from({length: 1000}, () => randomUUID()));

expect(ids.size).toBe(1000);
});

// The web build aliases node:crypto to this shim, so it stands in for the
// Node fallback in a browser that has no Web Crypto API at all.
it('throws instead of degrading in the browser shim', () => {
expect(() => shimRandomUUID()).toThrow(
/no cryptographically secure source of randomness/,
);
});
Expand Down