@oxy.so/core 1.8.0 → 1.8.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.
@@ -13,8 +13,23 @@
13
13
  * 2. Node — backed by the built-in `node:crypto` module (`webcrypto`, else
14
14
  * `randomFillSync`). This is what a Node runtime WITHOUT a global WebCrypto
15
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 `@oxy.so/protocol`).
16
+ * 3. React Native — `expo-crypto.getRandomBytes`, through the dependency-free
17
+ * `@oxy.so/protocol/random` entry.
18
+ *
19
+ * # Evaluation order — this module must import NOTHING that can capture the global
20
+ *
21
+ * `@noble/hashes` 1.x reads `globalThis.crypto` exactly once, when its
22
+ * `crypto.js` is evaluated, and `randomBytes` uses that captured binding for
23
+ * the lifetime of the app. ES imports are evaluated before the importing
24
+ * module's body, so every module this file imports runs BEFORE the shim below
25
+ * is installed. This file used to import `@oxy.so/protocol`'s root entry,
26
+ * which reaches `@noble/curves` → `@noble/hashes` through the envelope signer:
27
+ * on Hermes (no `globalThis.crypto` at startup) noble captured `undefined`
28
+ * before the shim existed, and every identity creation on Android failed with
29
+ * `crypto.getRandomValues must be defined`. Its imports are therefore limited
30
+ * to `buffer` and `@oxy.so/protocol/random`, both free of crypto libraries,
31
+ * and `index.ts` imports this file first. `__tests__/polyfillOrder.test.ts`
32
+ * guards both halves.
18
33
  *
19
34
  * Historically step (2) delegated to `@oxy.so/protocol`'s RN-only
20
35
  * `getRandomBytesRN`, which THROWS on Node — so any Node host lacking a global
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.8.0",
3
+ "version": "1.8.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",
@@ -119,7 +119,7 @@
119
119
  "@noble/ciphers": "^1.3.0",
120
120
  "@noble/hashes": "^1.8.0",
121
121
  "@oxy.so/contracts": "^1.4.0",
122
- "@oxy.so/protocol": "^1.1.0",
122
+ "@oxy.so/protocol": "^1.1.1",
123
123
  "@scure/bip39": "^1.6.0",
124
124
  "buffer": "^6.0.3",
125
125
  "express-rate-limit": "^8.6.0",
@@ -13,7 +13,7 @@
13
13
  const mockGetRandomBytesRN = jest.fn<Uint8Array, [number]>();
14
14
  const mockState = { isNodeJS: true };
15
15
 
16
- jest.mock('@oxy.so/protocol', () => ({
16
+ jest.mock('@oxy.so/protocol/random', () => ({
17
17
  isNodeJS: () => mockState.isNodeJS,
18
18
  getRandomBytesRN: (byteCount: number) => mockGetRandomBytesRN(byteCount),
19
19
  }));
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The crypto polyfill must be installed before ANY `@noble/*` module evaluates.
3
+ *
4
+ * `@noble/hashes` 1.x's `crypto.js` captures `globalThis.crypto` once, at module
5
+ * evaluation, and `randomBytes` uses that captured binding forever. Hermes has
6
+ * no `globalThis.crypto` at startup, so if noble is evaluated before
7
+ * `./polyfill` installs the shim, every random draw throws
8
+ * `crypto.getRandomValues must be defined` — which is exactly how identity
9
+ * creation broke on Android: the polyfill imported `@oxy.so/protocol`'s root
10
+ * entry, whose envelope signer reaches `@noble/curves` → `@noble/hashes`, and
11
+ * imports are evaluated before the polyfill body.
12
+ *
13
+ * Node's resolver picks noble's `cryptoNode.js` (the `node` export condition),
14
+ * which never captures the global, so a plain Node run cannot see the bug. The
15
+ * suites below substitute the REAL `crypto.js` that Metro resolves for React
16
+ * Native, remove `globalThis.crypto`, and load core in a fresh module registry.
17
+ */
18
+
19
+ import { readFileSync } from 'node:fs';
20
+ import { dirname, join, resolve } from 'node:path';
21
+
22
+ type RandomBytes = (length?: number) => Uint8Array;
23
+
24
+ const ORIGINAL_CRYPTO_DESCRIPTOR = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
25
+
26
+ /** noble's browser / React Native `crypto.js`: the variant that captures the global. */
27
+ const NOBLE_CAPTURING_CRYPTO = join(dirname(require.resolve('@noble/hashes/utils')), 'crypto.js');
28
+
29
+ /** Remove `globalThis.crypto` the way a Hermes host starts: no property at all. */
30
+ function removeHostCrypto(): void {
31
+ if (!Reflect.deleteProperty(globalThis, 'crypto') || 'crypto' in globalThis) {
32
+ Object.defineProperty(globalThis, 'crypto', {
33
+ value: undefined,
34
+ configurable: true,
35
+ writable: true,
36
+ });
37
+ }
38
+ }
39
+
40
+ function restoreHostCrypto(): void {
41
+ Reflect.deleteProperty(globalThis, 'crypto');
42
+ if (ORIGINAL_CRYPTO_DESCRIPTOR) {
43
+ Object.defineProperty(globalThis, 'crypto', ORIGINAL_CRYPTO_DESCRIPTOR);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Run `body` in a fresh module registry that resolves `@noble/hashes/crypto` as
49
+ * a React Native bundle does, with no host `globalThis.crypto`.
50
+ */
51
+ function withReactNativeNobleAndNoHostCrypto(body: () => void): void {
52
+ removeHostCrypto();
53
+ try {
54
+ jest.isolateModules(() => {
55
+ jest.doMock('@noble/hashes/crypto', () => jest.requireActual(NOBLE_CAPTURING_CRYPTO));
56
+ body();
57
+ });
58
+ } finally {
59
+ restoreHostCrypto();
60
+ }
61
+ }
62
+
63
+ describe('crypto polyfill evaluation order', () => {
64
+ it('reproduces the capture: noble evaluated before the polyfill has no randomness', () => {
65
+ // Guards the harness itself — if this ever stops throwing, the substitution
66
+ // no longer models the capturing variant and the suites below prove nothing.
67
+ withReactNativeNobleAndNoHostCrypto(() => {
68
+ const { randomBytes } = require('@noble/hashes/utils') as { randomBytes: RandomBytes };
69
+ require('../polyfill');
70
+ expect(() => randomBytes(32)).toThrow('crypto.getRandomValues must be defined');
71
+ });
72
+ });
73
+
74
+ it('importing @oxy.so/core installs the shim before noble captures the global', () => {
75
+ withReactNativeNobleAndNoHostCrypto(() => {
76
+ require('../../index');
77
+ const { randomBytes } = require('@noble/hashes/utils') as { randomBytes: RandomBytes };
78
+
79
+ const bytes = randomBytes(32);
80
+ expect(bytes).toHaveLength(32);
81
+ expect(bytes.some((byte) => byte !== 0)).toBe(true);
82
+ });
83
+ });
84
+
85
+ it('generates an identity (key pair + recovery mnemonic) through core', () => {
86
+ withReactNativeNobleAndNoHostCrypto(() => {
87
+ const core = require('../../index') as typeof import('../../index');
88
+ const { generateMnemonic } = require('@scure/bip39') as typeof import('@scure/bip39');
89
+ const { wordlist } = require('@scure/bip39/wordlists/english') as {
90
+ wordlist: string[];
91
+ };
92
+
93
+ // The two random draws `RecoveryPhraseService.generateIdentityWithRecovery`
94
+ // and `KeyManager.generateKeyPairSync` make — the ones Commons failed on.
95
+ expect(generateMnemonic(wordlist, 128).split(' ')).toHaveLength(12);
96
+ const keyPair = core.KeyManager.generateKeyPairSync();
97
+ expect(keyPair.privateKey).toMatch(/^[0-9a-f]{64}$/);
98
+ });
99
+ });
100
+
101
+ it('the polyfill imports nothing that can reach a crypto library', () => {
102
+ const source = readFileSync(resolve(__dirname, '..', 'polyfill.ts'), 'utf8');
103
+ const specifiers = [...source.matchAll(/^import\s+[^'"]*?['"]([^'"]+)['"]/gm)].map(
104
+ (match) => match[1],
105
+ );
106
+ expect(specifiers.sort()).toEqual(['@oxy.so/protocol/random', 'buffer']);
107
+ });
108
+
109
+ it('the core entry imports the polyfill before anything else', () => {
110
+ const source = readFileSync(resolve(__dirname, '..', '..', 'index.ts'), 'utf8');
111
+ const firstModuleReference = source.match(/^(?:import|export)\s[^;]*?['"]([^'"]+)['"]/m);
112
+ expect(firstModuleReference?.[1]).toBe('./crypto/polyfill');
113
+ });
114
+ });
@@ -13,8 +13,23 @@
13
13
  * 2. Node — backed by the built-in `node:crypto` module (`webcrypto`, else
14
14
  * `randomFillSync`). This is what a Node runtime WITHOUT a global WebCrypto
15
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 `@oxy.so/protocol`).
16
+ * 3. React Native — `expo-crypto.getRandomBytes`, through the dependency-free
17
+ * `@oxy.so/protocol/random` entry.
18
+ *
19
+ * # Evaluation order — this module must import NOTHING that can capture the global
20
+ *
21
+ * `@noble/hashes` 1.x reads `globalThis.crypto` exactly once, when its
22
+ * `crypto.js` is evaluated, and `randomBytes` uses that captured binding for
23
+ * the lifetime of the app. ES imports are evaluated before the importing
24
+ * module's body, so every module this file imports runs BEFORE the shim below
25
+ * is installed. This file used to import `@oxy.so/protocol`'s root entry,
26
+ * which reaches `@noble/curves` → `@noble/hashes` through the envelope signer:
27
+ * on Hermes (no `globalThis.crypto` at startup) noble captured `undefined`
28
+ * before the shim existed, and every identity creation on Android failed with
29
+ * `crypto.getRandomValues must be defined`. Its imports are therefore limited
30
+ * to `buffer` and `@oxy.so/protocol/random`, both free of crypto libraries,
31
+ * and `index.ts` imports this file first. `__tests__/polyfillOrder.test.ts`
32
+ * guards both halves.
18
33
  *
19
34
  * Historically step (2) delegated to `@oxy.so/protocol`'s RN-only
20
35
  * `getRandomBytesRN`, which THROWS on Node — so any Node host lacking a global
@@ -23,7 +38,7 @@
23
38
  */
24
39
 
25
40
  import { Buffer } from 'buffer';
26
- import { getRandomBytesRN, isNodeJS } from '@oxy.so/protocol';
41
+ import { getRandomBytesRN, isNodeJS } from '@oxy.so/protocol/random';
27
42
 
28
43
  const getGlobalObject = (): typeof globalThis => {
29
44
  if (typeof globalThis !== 'undefined') return globalThis;
@@ -125,7 +140,7 @@ const cryptoPolyfill: CryptoLike = {
125
140
  return array;
126
141
  }
127
142
  // React Native (and any non-Node host without WebCrypto): synchronous
128
- // expo-crypto via @oxy.so/protocol's RN `platform/crypto` variant.
143
+ // expo-crypto via @oxy.so/protocol's RN `platform/random` variant.
129
144
  const bytes = getRandomBytesRN(array.byteLength);
130
145
  const uint8View = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
131
146
  uint8View.set(bytes);
package/src/index.ts CHANGED
@@ -17,7 +17,10 @@
17
17
  * If a symbol does not appear here, it is NOT part of the public API.
18
18
  */
19
19
 
20
- // Ensure crypto polyfills are loaded before anything else
20
+ // Ensure crypto polyfills are loaded before anything else. This MUST stay the
21
+ // first import: `@noble/hashes` captures `globalThis.crypto` when it is
22
+ // evaluated, so the shim has to exist before any module below reaches it (see
23
+ // the evaluation-order note in `./crypto/polyfill`).
21
24
  import './crypto/polyfill';
22
25
 
23
26
  // ---------------------------------------------------------------------------