@oxyhq/core 12.5.0 → 12.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,10 +4,22 @@
4
4
  * Ensures Buffer and crypto.getRandomValues are available
5
5
  * across all platforms (Node.js, Browser, React Native).
6
6
  *
7
- * - Browser/Node.js: Uses native crypto
8
- * - React Native: Uses expo-crypto (statically imported via the
9
- * per-platform `platform/crypto` module in `@oxyhq/protocol` — see that
10
- * file's doc-comment for how platform routing works).
7
+ * Guard order when installing a `getRandomValues` shim (see bottom of file and
8
+ * {@link cryptoPolyfill}):
9
+ *
10
+ * 1. A REAL `globalThis.crypto.getRandomValues` used as-is (browser, Node
11
+ * >= 20, modern Hermes). The shim below is only installed when the host is
12
+ * missing it, so this branch is the install-time gate.
13
+ * 2. Node — backed by the built-in `node:crypto` module (`webcrypto`, else
14
+ * `randomFillSync`). This is what a Node runtime WITHOUT a global WebCrypto
15
+ * (Node 18 script entrypoints, some embedded hosts) falls back to.
16
+ * 3. React Native — `expo-crypto.getRandomBytes` (statically imported via the
17
+ * per-platform `platform/crypto` module in `@oxyhq/protocol`).
18
+ *
19
+ * Historically step (2) delegated to `@oxyhq/protocol`'s RN-only
20
+ * `getRandomBytesRN`, which THROWS on Node — so any Node host lacking a global
21
+ * WebCrypto crashed here instead of getting randomness. It is now a proper
22
+ * Node-backed implementation.
11
23
  */
12
24
  import { Buffer } from 'buffer';
13
25
  export { Buffer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.5.0",
3
+ "version": "12.5.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Crypto polyfill — `getRandomValues` install + platform routing.
3
+ *
4
+ * Regression coverage for the latent Node crash: when a Node runtime ships
5
+ * WITHOUT a global WebCrypto (Node 18 script entrypoints, some embedded hosts),
6
+ * the installed `getRandomValues` shim must be backed by `node:crypto` and MUST
7
+ * NOT fall through to `@oxyhq/protocol`'s RN-only `getRandomBytesRN` stub (which
8
+ * throws `Tried to load 'expo-crypto...' outside React Native`).
9
+ */
10
+
11
+ // Controllable stand-ins for `@oxyhq/protocol`'s platform predicates. Names are
12
+ // `mock`-prefixed so the (hoisted) `jest.mock` factory may reference them.
13
+ const mockGetRandomBytesRN = jest.fn<Uint8Array, [number]>();
14
+ const mockState = { isNodeJS: true };
15
+
16
+ jest.mock('@oxyhq/protocol', () => ({
17
+ isNodeJS: () => mockState.isNodeJS,
18
+ getRandomBytesRN: (byteCount: number) => mockGetRandomBytesRN(byteCount),
19
+ }));
20
+
21
+ type CryptoLike = {
22
+ getRandomValues: <T extends ArrayBufferView>(array: T) => T;
23
+ };
24
+
25
+ const ORIGINAL_CRYPTO_DESCRIPTOR = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
26
+
27
+ /**
28
+ * Re-run the polyfill module with NO host `globalThis.crypto`, returning the
29
+ * `getRandomValues` shim it installs. Restores the real global afterwards so
30
+ * the manipulation never leaks into other tests.
31
+ */
32
+ function installShimWithoutHostCrypto(): CryptoLike {
33
+ Object.defineProperty(globalThis, 'crypto', {
34
+ value: undefined,
35
+ configurable: true,
36
+ writable: true,
37
+ });
38
+ try {
39
+ jest.isolateModules(() => {
40
+ require('../polyfill');
41
+ });
42
+ const installed = (globalThis as { crypto?: CryptoLike }).crypto;
43
+ if (!installed || typeof installed.getRandomValues !== 'function') {
44
+ throw new Error('polyfill did not install a getRandomValues shim');
45
+ }
46
+ return installed;
47
+ } finally {
48
+ Object.defineProperty(
49
+ globalThis,
50
+ 'crypto',
51
+ ORIGINAL_CRYPTO_DESCRIPTOR ?? { value: undefined, configurable: true, writable: true },
52
+ );
53
+ }
54
+ }
55
+
56
+ beforeEach(() => {
57
+ mockGetRandomBytesRN.mockReset();
58
+ mockState.isNodeJS = true;
59
+ });
60
+
61
+ describe('crypto polyfill getRandomValues', () => {
62
+ it('on Node without global WebCrypto, fills from node:crypto and never calls the RN stub', () => {
63
+ mockState.isNodeJS = true;
64
+ // If the shim ever fell through to the RN path on Node, this would throw —
65
+ // exactly the latent crash we are guarding against.
66
+ mockGetRandomBytesRN.mockImplementation(() => {
67
+ throw new Error('RN getRandomBytesRN must not be called on Node');
68
+ });
69
+
70
+ const shim = installShimWithoutHostCrypto();
71
+ const array = new Uint8Array(16);
72
+
73
+ expect(() => shim.getRandomValues(array)).not.toThrow();
74
+ // Backed by a real CSPRNG: all-zero output is cryptographically impossible.
75
+ expect(array.some((byte) => byte !== 0)).toBe(true);
76
+ expect(mockGetRandomBytesRN).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('on Node, routes a non-integer view (DataView) through randomFillSync', () => {
80
+ mockState.isNodeJS = true;
81
+ mockGetRandomBytesRN.mockImplementation(() => {
82
+ throw new Error('RN getRandomBytesRN must not be called on Node');
83
+ });
84
+
85
+ const shim = installShimWithoutHostCrypto();
86
+ const view = new DataView(new ArrayBuffer(16));
87
+
88
+ expect(() => shim.getRandomValues(view)).not.toThrow();
89
+ let anyNonZero = false;
90
+ for (let i = 0; i < view.byteLength; i += 1) {
91
+ if (view.getUint8(i) !== 0) anyNonZero = true;
92
+ }
93
+ expect(anyNonZero).toBe(true);
94
+ expect(mockGetRandomBytesRN).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it('on React Native, delegates to expo-crypto via getRandomBytesRN (path unchanged)', () => {
98
+ mockState.isNodeJS = false;
99
+ const rnBytes = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
100
+ mockGetRandomBytesRN.mockReturnValue(rnBytes);
101
+
102
+ const shim = installShimWithoutHostCrypto();
103
+ const array = new Uint8Array(8);
104
+ const result = shim.getRandomValues(array);
105
+
106
+ expect(mockGetRandomBytesRN).toHaveBeenCalledWith(8);
107
+ expect(Array.from(result)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
108
+ });
109
+ });
@@ -4,14 +4,26 @@
4
4
  * Ensures Buffer and crypto.getRandomValues are available
5
5
  * across all platforms (Node.js, Browser, React Native).
6
6
  *
7
- * - Browser/Node.js: Uses native crypto
8
- * - React Native: Uses expo-crypto (statically imported via the
9
- * per-platform `platform/crypto` module in `@oxyhq/protocol` — see that
10
- * file's doc-comment for how platform routing works).
7
+ * Guard order when installing a `getRandomValues` shim (see bottom of file and
8
+ * {@link cryptoPolyfill}):
9
+ *
10
+ * 1. A REAL `globalThis.crypto.getRandomValues` used as-is (browser, Node
11
+ * >= 20, modern Hermes). The shim below is only installed when the host is
12
+ * missing it, so this branch is the install-time gate.
13
+ * 2. Node — backed by the built-in `node:crypto` module (`webcrypto`, else
14
+ * `randomFillSync`). This is what a Node runtime WITHOUT a global WebCrypto
15
+ * (Node 18 script entrypoints, some embedded hosts) falls back to.
16
+ * 3. React Native — `expo-crypto.getRandomBytes` (statically imported via the
17
+ * per-platform `platform/crypto` module in `@oxyhq/protocol`).
18
+ *
19
+ * Historically step (2) delegated to `@oxyhq/protocol`'s RN-only
20
+ * `getRandomBytesRN`, which THROWS on Node — so any Node host lacking a global
21
+ * WebCrypto crashed here instead of getting randomness. It is now a proper
22
+ * Node-backed implementation.
11
23
  */
12
24
 
13
25
  import { Buffer } from 'buffer';
14
- import { getRandomBytesRN } from '@oxyhq/protocol';
26
+ import { getRandomBytesRN, isNodeJS } from '@oxyhq/protocol';
15
27
 
16
28
  const getGlobalObject = (): typeof globalThis => {
17
29
  if (typeof globalThis !== 'undefined') return globalThis;
@@ -32,25 +44,89 @@ type CryptoLike = {
32
44
  getRandomValues: <T extends ArrayBufferView>(array: T) => T;
33
45
  };
34
46
 
47
+ /** Minimal structural shape of the parts of `node:crypto` this polyfill uses. */
48
+ interface NodeCryptoLike {
49
+ webcrypto?: {
50
+ getRandomValues?: <T extends ArrayBufferView>(array: T) => T;
51
+ };
52
+ randomFillSync?: <T extends ArrayBufferView>(buffer: T) => T;
53
+ }
54
+
55
+ /**
56
+ * Lazily-resolved `node:crypto` module, cached after the first attempt.
57
+ * `undefined` = not tried yet; `null` = tried and unavailable (non-Node host).
58
+ */
59
+ let cachedNodeCrypto: NodeCryptoLike | null | undefined;
60
+
61
+ /**
62
+ * Synchronously load `node:crypto` on a Node runtime, or `null` elsewhere.
63
+ *
64
+ * Uses a guarded, Node-only `require`. Every runtime that actually reaches this
65
+ * branch has a working CommonJS `require`: `@oxyhq/core` publishes no
66
+ * `"type": "module"`, so Node loads it as CommonJS and the `require` free
67
+ * variable is present. Browsers never reach here (they own `globalThis.crypto`,
68
+ * so this polyfill is never installed) and React Native takes the
69
+ * `getRandomBytesRN` branch — so the `node:crypto` reference is dead code in
70
+ * those bundles, and Expo's Metro resolver shims `node:*` builtins, keeping
71
+ * web/native bundles green.
72
+ */
73
+ function loadNodeCryptoSync(): NodeCryptoLike | null {
74
+ if (cachedNodeCrypto !== undefined) {
75
+ return cachedNodeCrypto;
76
+ }
77
+ if (typeof require !== 'function') {
78
+ cachedNodeCrypto = null;
79
+ return cachedNodeCrypto;
80
+ }
81
+ try {
82
+ cachedNodeCrypto = require('node:crypto') as NodeCryptoLike;
83
+ } catch {
84
+ // No Node crypto (unexpected on a real Node host) — degrade to the next
85
+ // mechanism rather than crash.
86
+ cachedNodeCrypto = null;
87
+ }
88
+ return cachedNodeCrypto;
89
+ }
90
+
35
91
  /**
36
- * Synchronous random-bytes shim. On RN, this delegates to
37
- * `expo-crypto.getRandomBytes` (statically imported by the RN variant of
38
- * `@oxyhq/protocol`'s `platform/crypto`, so available without any async
39
- * warm-up). On Node /
40
- * browser, this throws — but is never called there because both platforms
41
- * already provide `globalThis.crypto.getRandomValues` natively.
92
+ * Fill `array` with cryptographically-secure random bytes from `node:crypto`.
93
+ * Prefers `webcrypto.getRandomValues`; falls back to `randomFillSync`. Returns
94
+ * `false` when Node crypto is unavailable so the caller can try the next
95
+ * mechanism.
42
96
  */
43
- function getRandomBytesSync(byteCount: number): Uint8Array {
44
- // `getRandomBytesRN` throws on non-RN platforms. That's fine: this
45
- // function is only ever called as a fallback when the native
46
- // `globalThis.crypto.getRandomValues` is missing, which on a normal
47
- // Node/browser host never happens.
48
- return getRandomBytesRN(byteCount);
97
+ function fillFromNodeCrypto(array: ArrayBufferView): boolean {
98
+ const nodeCrypto = loadNodeCryptoSync();
99
+ if (!nodeCrypto) {
100
+ return false;
101
+ }
102
+ const webcrypto = nodeCrypto.webcrypto;
103
+ if (webcrypto && typeof webcrypto.getRandomValues === 'function') {
104
+ try {
105
+ webcrypto.getRandomValues(array);
106
+ return true;
107
+ } catch {
108
+ // `webcrypto.getRandomValues` rejects non-integer views (Float*Array,
109
+ // DataView); fall through to `randomFillSync`, which accepts any view.
110
+ }
111
+ }
112
+ if (typeof nodeCrypto.randomFillSync === 'function') {
113
+ nodeCrypto.randomFillSync(array);
114
+ return true;
115
+ }
116
+ return false;
49
117
  }
50
118
 
51
119
  const cryptoPolyfill: CryptoLike = {
52
120
  getRandomValues<T extends ArrayBufferView>(array: T): T {
53
- const bytes = getRandomBytesSync(array.byteLength);
121
+ // Node: back the CSPRNG with `node:crypto`. This is the path that matters on
122
+ // Node runtimes shipping WITHOUT a global WebCrypto — where delegating to
123
+ // the RN-only expo-crypto stub would throw.
124
+ if (isNodeJS() && fillFromNodeCrypto(array)) {
125
+ return array;
126
+ }
127
+ // React Native (and any non-Node host without WebCrypto): synchronous
128
+ // expo-crypto via @oxyhq/protocol's RN `platform/crypto` variant.
129
+ const bytes = getRandomBytesRN(array.byteLength);
54
130
  const uint8View = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
55
131
  uint8View.set(bytes);
56
132
  return array;