@oxyhq/core 1.11.16 → 1.11.18

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.
@@ -160,20 +160,42 @@ export declare class KeyManager {
160
160
  /**
161
161
  * Atomically persist a key pair to secure storage with verification + backup.
162
162
  *
163
- * Write order is critical:
164
- * 1. Backup (BACKUP_PRIVATE_KEY + BACKUP_PUBLIC_KEY + BACKUP_TIMESTAMP)
165
- * 2. Primary public key
166
- * 3. Primary private key (last so a partial write leaves us in a known
167
- * "no identity yet" stateeasier to retry than a half-written one)
168
- * 4. Read back + sign/verify to confirm the storage round-trip works
169
- *
170
- * If any step throws, the caller sees the error AND any partial state is
171
- * cleaned up so the device is left either fully consistent or fully empty.
172
- * It never leaves an unusable half-identity that would fool `hasIdentity()`.
163
+ * INVARIANT (the reason this method exists): at no instant during the write
164
+ * may the device be left holding ZERO recoverable copies of a healthy
165
+ * identity. This matters most on the OVERWRITE / account-switch path: if we
166
+ * are replacing identity A with B and the write fails halfway, we MUST end
167
+ * up back on A never on a half-written B, and never on nothing.
168
+ *
169
+ * Algorithm (recoverability-preserving):
170
+ * 0. Snapshot the existing primary (privA, pubA) so we can roll back to
171
+ * EXACTLY what was there.
172
+ * 1. Write the new primary: public first, then private.
173
+ * 2. Read back + sign/verify the new primary.
174
+ * 3. ONLY after the new primary is proven durable, refresh the backup to
175
+ * the new key. The backup is NEVER touched before this point, so any
176
+ * prior identity's backup remains intact and `restoreIdentityFromBackup`
177
+ * can always recover it.
178
+ * 4. On ANY failure in steps 1–2, restore the snapshotted primary verbatim
179
+ * (or delete it if there was none), then surface the error.
180
+ *
181
+ * Earlier versions wrote the *incoming* key to the backup FIRST, which
182
+ * destroyed the previous identity's backup, and rolled back by blindly
183
+ * deleting the primary — so a failed overwrite silently switched the user
184
+ * to (or lost them into) the half-written new identity. That is fixed here.
173
185
  *
174
186
  * @internal
175
187
  */
176
188
  private static _persistIdentityAtomic;
189
+ /**
190
+ * Restore the primary slot to a previously-snapshotted (privA, pubA) pair,
191
+ * or delete it entirely if there was no prior identity. Best-effort: every
192
+ * step is wrapped so a rollback failure never masks the original error the
193
+ * caller is about to throw. Invalidates the in-memory cache so the next read
194
+ * reflects whatever actually landed on disk.
195
+ *
196
+ * @internal
197
+ */
198
+ private static _rollbackPrimary;
177
199
  /**
178
200
  * Generate and securely store a new key pair on the device.
179
201
  *
@@ -242,17 +264,23 @@ export declare class KeyManager {
242
264
  */
243
265
  static verifyIdentityIntegrity(): Promise<boolean>;
244
266
  /**
245
- * Restore identity from backup if primary storage is corrupted.
246
- *
247
- * SAFETY: this method will NEVER overwrite a verifying primary identity.
248
- * If the primary passes a sign/verify probe, the backup is left untouched
249
- * and `false` is returned this protects against a transient
250
- * `verifyIdentityIntegrity()` blip clobbering valid keys with stale
251
- * backup keys (e.g., from a previous account before an import).
252
- *
253
- * Additionally, if the backup public key does NOT match the (still-
254
- * present-but-failing) primary public key, we refuse to overwrite — the
255
- * backup may belong to a different identity entirely.
267
+ * Restore identity from backup if primary storage is genuinely missing or
268
+ * corrupt.
269
+ *
270
+ * SAFETY (three independent guards against silently switching accounts):
271
+ * 1. If the primary passes a full sign/verify probe, do nothing.
272
+ * 2. If the primary keys CANNOT BE READ (storage threw e.g. a transient
273
+ * keychain lock during a background launch), do nothing. We must NOT
274
+ * treat "couldn't read" as "corrupted" and restore a possibly-stale
275
+ * backup over an identity that is actually fine but momentarily
276
+ * inaccessible.
277
+ * 3. If a primary private/public key IS present but does not match the
278
+ * backup, the backup may belong to a different identity — refuse, so we
279
+ * never silently switch the user to another account.
280
+ *
281
+ * Only when the primary is provably absent (read succeeded, returned
282
+ * null/empty) or provably corrupt (read succeeded, bytes malformed AND no
283
+ * conflicting key material is present) do we rebuild it from the backup.
256
284
  */
257
285
  static restoreIdentityFromBackup(): Promise<boolean>;
258
286
  /**
@@ -193,11 +193,47 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
193
193
  }>>;
194
194
  /**
195
195
  * Get access token by session ID
196
+ *
197
+ * SECURITY: this endpoint requires the caller to already hold a
198
+ * bearer token whose user owns the referenced session (C1 hardening
199
+ * in the API). For the device-flow / QR sign-in case where the
200
+ * client has no bearer token yet, use `claimSessionByToken` instead.
196
201
  */
197
202
  getTokenBySession(sessionId: string): Promise<{
198
203
  accessToken: string;
199
204
  expiresAt: string;
200
205
  }>;
206
+ /**
207
+ * Exchange a device-flow sessionToken for the first access token.
208
+ *
209
+ * The originating client holds a 128-bit `sessionToken` that nobody
210
+ * else has seen — it was generated client-side, sent once on
211
+ * `POST /auth/session/create`, and is never echoed back. After
212
+ * another authenticated device approves the session via
213
+ * `POST /auth/session/authorize/{sessionToken}` (bearer-authed) and
214
+ * the auth socket / poll loop notifies this client, the client
215
+ * exchanges its `sessionToken` here for the first access token,
216
+ * refresh token, sessionId, and the authorized user.
217
+ *
218
+ * This call requires no Authorization header — the high-entropy
219
+ * `sessionToken` IS the credential (RFC 8628 §3.4). The exchange is
220
+ * single-use; replay attempts are rejected with 401.
221
+ *
222
+ * @param sessionToken - The same sessionToken the SDK passed to
223
+ * `POST /auth/session/create` at the start of the flow.
224
+ * @param options.deviceFingerprint - Optional fingerprint of the
225
+ * originating client device.
226
+ */
227
+ claimSessionByToken(sessionToken: string, options?: {
228
+ deviceFingerprint?: string;
229
+ }): Promise<{
230
+ accessToken: string;
231
+ refreshToken: string;
232
+ sessionId: string;
233
+ deviceId: string;
234
+ expiresAt: string;
235
+ user: User;
236
+ }>;
201
237
  /**
202
238
  * Get sessions by session ID
203
239
  */
@@ -140,11 +140,47 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
140
140
  */
141
141
  getFedCMConfig(): FedCMConfig;
142
142
  /**
143
- * Generate a cryptographically secure nonce for FedCM
143
+ * Generate a cryptographically secure local nonce for FedCM.
144
+ *
145
+ * NOTE: this is a *local* fallback only. The server-side `/fedcm/exchange`
146
+ * endpoint requires the nonce embedded in the ID token to have been minted
147
+ * by `POST /fedcm/nonce` (see {@link mintServerNonce}) and bound to this
148
+ * origin. A purely local nonce will be rejected with `invalid_nonce`. Use
149
+ * {@link getFedcmNonce}, which prefers a server-minted nonce and only falls
150
+ * back to this generator when the mint endpoint is unreachable.
144
151
  *
145
152
  * @private
146
153
  */
147
154
  generateNonce(): string;
155
+ /**
156
+ * Mint a single-use, origin-bound nonce from the Oxy API.
157
+ *
158
+ * The FedCM ID token issued by the IdP embeds this nonce as the `nonce`
159
+ * claim. When the consuming app calls `POST /fedcm/exchange`, the API burns
160
+ * the nonce (atomic `usedAt` transition) and verifies it was minted for the
161
+ * same origin as the token `aud`. This is the anti-replay binding required
162
+ * by the API's H9 hardening — without a server-minted nonce the exchange
163
+ * always fails.
164
+ *
165
+ * The browser attaches the `Origin` header automatically on this
166
+ * cross-origin request, so the API binds the nonce to the calling app's
167
+ * origin (which also becomes the FedCM `clientId`/token `aud`).
168
+ *
169
+ * @private
170
+ */
171
+ mintServerNonce(): Promise<string>;
172
+ /**
173
+ * Resolve the nonce to use for a FedCM credential request.
174
+ *
175
+ * Prefers a server-minted, origin-bound nonce (required for the token
176
+ * exchange to succeed). If the mint endpoint is unreachable we fall back to
177
+ * a locally generated nonce so the browser flow can still proceed; the
178
+ * exchange may then fail server-side, but that is strictly better than
179
+ * throwing before the browser ever shows its UI.
180
+ *
181
+ * @private
182
+ */
183
+ getFedcmNonce(): Promise<string>;
148
184
  /**
149
185
  * Get the client ID for this origin
150
186
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.11.16",
3
+ "version": "1.11.18",
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,214 @@
1
+ /**
2
+ * Tests for KeyManager atomic-write & backup-restore safety under FLAKY storage.
3
+ *
4
+ * These pin the "never leave the device with zero recoverable copies of a
5
+ * healthy identity" invariant. Each scenario corresponds to a real
6
+ * account-loss / account-switch bug that a half-failed SecureStore write could
7
+ * otherwise cause:
8
+ *
9
+ * 1. A failed OVERWRITE must roll the primary back to the ORIGINAL identity
10
+ * and must keep a recoverable backup of it — never switch to the
11
+ * half-written new identity.
12
+ * 2. A transient read failure must not cause restoreIdentityFromBackup() to
13
+ * clobber a healthy-but-momentarily-locked primary with a stale backup.
14
+ * 3. restoreIdentityFromBackup() must refuse when the backup identifies a
15
+ * different account than a private key still present in the primary slot.
16
+ * 4. A first-time create still writes a backup (no regression).
17
+ */
18
+
19
+ import { setPlatformOS } from '../../utils/platform';
20
+
21
+ // Fault-injectable in-memory secure store. `failPlan` lets a test make a
22
+ // specific (op, key) pair throw to simulate a keychain that fails mid-write or
23
+ // is transiently locked.
24
+ const failPlan: { failKey?: string; failOp?: 'set' | 'get' } = {};
25
+
26
+ jest.mock(
27
+ 'expo-secure-store',
28
+ () => {
29
+ const store = new Map<string, string>();
30
+ const maybeFail = (op: 'set' | 'get', key: string) => {
31
+ if (failPlan.failOp === op && failPlan.failKey === key) {
32
+ throw new Error(`Simulated ${op} failure for ${key}`);
33
+ }
34
+ };
35
+ return {
36
+ __esModule: true,
37
+ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
38
+ WHEN_UNLOCKED: 'WHEN_UNLOCKED',
39
+ setItemAsync: jest.fn(async (key: string, value: string) => {
40
+ maybeFail('set', key);
41
+ store.set(key, value);
42
+ }),
43
+ getItemAsync: jest.fn(async (key: string) => {
44
+ maybeFail('get', key);
45
+ return store.get(key) ?? null;
46
+ }),
47
+ deleteItemAsync: jest.fn(async (key: string) => {
48
+ store.delete(key);
49
+ }),
50
+ __resetStore__: () => {
51
+ store.clear();
52
+ failPlan.failKey = undefined;
53
+ failPlan.failOp = undefined;
54
+ },
55
+ __getStore__: () => store,
56
+ __failPlan__: failPlan,
57
+ };
58
+ },
59
+ { virtual: true },
60
+ );
61
+
62
+ jest.mock(
63
+ 'expo-crypto',
64
+ () => ({
65
+ __esModule: true,
66
+ getRandomBytes: (length: number) => {
67
+ const out = new Uint8Array(length);
68
+ for (let i = 0; i < length; i++) out[i] = (Math.random() * 256) & 0xff;
69
+ return out;
70
+ },
71
+ digestStringAsync: async () => '0'.repeat(64),
72
+ CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
73
+ }),
74
+ { virtual: true },
75
+ );
76
+
77
+ jest.mock('../../utils/platformCrypto', () => ({
78
+ __esModule: true,
79
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
80
+ loadExpoCrypto: async () => require('expo-crypto'),
81
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
82
+ loadSecureStore: async () => require('expo-secure-store'),
83
+ loadAsyncStorage: async () => ({
84
+ default: { getItem: async () => null, setItem: async () => undefined, removeItem: async () => undefined },
85
+ }),
86
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
87
+ loadNodeCrypto: async () => require('crypto'),
88
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
89
+ getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
90
+ }));
91
+
92
+ interface SecureStoreTestHandle {
93
+ __resetStore__: () => void;
94
+ __getStore__: () => Map<string, string>;
95
+ __failPlan__: { failKey?: string; failOp?: 'set' | 'get' };
96
+ }
97
+
98
+ describe('KeyManager atomicity & recoverability under flaky storage', () => {
99
+ let KeyManager: typeof import('../keyManager').KeyManager;
100
+
101
+ const resetCaches = () => {
102
+ const km = KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown };
103
+ km.cachedPublicKey = null;
104
+ km.cachedHasIdentity = null;
105
+ };
106
+
107
+ beforeAll(() => {
108
+ setPlatformOS('ios');
109
+ (globalThis as unknown as { navigator: unknown }).navigator = { product: 'ReactNative' };
110
+ });
111
+
112
+ beforeEach(async () => {
113
+ jest.resetModules();
114
+ setPlatformOS('ios');
115
+ const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
116
+ ss.__resetStore__();
117
+ const km = await import('../keyManager');
118
+ KeyManager = km.KeyManager;
119
+ resetCaches();
120
+ });
121
+
122
+ it('a failed OVERWRITE leaves the ORIGINAL identity intact and recoverable (no silent switch)', async () => {
123
+ const originalPublic = await KeyManager.createIdentity();
124
+ const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
125
+ const originalPriv = ss.__getStore__().get('oxy_identity_private_key');
126
+ resetCaches();
127
+
128
+ // The new primary private write fails mid-overwrite.
129
+ ss.__failPlan__.failOp = 'set';
130
+ ss.__failPlan__.failKey = 'oxy_identity_private_key';
131
+ await expect(KeyManager.createIdentity({ overwrite: true })).rejects.toBeDefined();
132
+
133
+ // Recover from the simulated fault.
134
+ ss.__failPlan__.failKey = undefined;
135
+ ss.__failPlan__.failOp = undefined;
136
+ resetCaches();
137
+
138
+ // Primary must still be the original identity (rolled back).
139
+ expect(await KeyManager.hasIdentity()).toBe(true);
140
+ expect(await KeyManager.getPublicKey()).toBe(originalPublic);
141
+ expect(ss.__getStore__().get('oxy_identity_private_key')).toBe(originalPriv);
142
+
143
+ // And the backup must still hold the ORIGINAL identity, never the new one.
144
+ const m = ss.__getStore__();
145
+ expect(m.get('oxy_identity_backup_private_key')).toBe(originalPriv);
146
+ expect(m.get('oxy_identity_backup_public_key')).toBe(originalPublic);
147
+ });
148
+
149
+ it('restoreIdentityFromBackup does NOT clobber a healthy primary that is only transiently unreadable', async () => {
150
+ const original = await KeyManager.createIdentity();
151
+ const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
152
+
153
+ // Put a DIFFERENT identity in the backup slot (simulates a stale backup
154
+ // from a previous account that a failed backup-refresh left behind).
155
+ const other = await KeyManager.generateKeyPair();
156
+ ss.__getStore__().set('oxy_identity_backup_private_key', other.privateKey);
157
+ ss.__getStore__().set('oxy_identity_backup_public_key', other.publicKey);
158
+ resetCaches();
159
+
160
+ // Make the primary PRIVATE read throw (transient keychain lock).
161
+ ss.__failPlan__.failOp = 'get';
162
+ ss.__failPlan__.failKey = 'oxy_identity_private_key';
163
+
164
+ const restored = await KeyManager.restoreIdentityFromBackup();
165
+ expect(restored).toBe(false); // refused — transient read must not trigger a restore
166
+
167
+ // Clear the fault; the original primary must be intact and unchanged.
168
+ ss.__failPlan__.failKey = undefined;
169
+ ss.__failPlan__.failOp = undefined;
170
+ resetCaches();
171
+ expect(await KeyManager.getPublicKey()).toBe(original);
172
+ expect(ss.__getStore__().get('oxy_identity_public_key')).toBe(original);
173
+ });
174
+
175
+ it('restoreIdentityFromBackup refuses when a present primary private key identifies a different account than the backup', async () => {
176
+ // Primary holds identity A (valid). Corrupt ONLY the public key so the
177
+ // primary fails its consistency check but the private key still derives a
178
+ // real, different identity than the backup.
179
+ const a = await KeyManager.createIdentity();
180
+ const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
181
+ const m = ss.__getStore__();
182
+
183
+ // Backup holds a DIFFERENT identity B.
184
+ const b = await KeyManager.generateKeyPair();
185
+ m.set('oxy_identity_backup_private_key', b.privateKey);
186
+ m.set('oxy_identity_backup_public_key', b.publicKey);
187
+ // Corrupt A's stored public key (private key A still present & valid).
188
+ m.set('oxy_identity_public_key', `04${'e'.repeat(128)}`);
189
+ resetCaches();
190
+
191
+ const restored = await KeyManager.restoreIdentityFromBackup();
192
+ expect(restored).toBe(false);
193
+ // Must NOT have switched the primary to B.
194
+ expect(m.get('oxy_identity_public_key')).not.toBe(b.publicKey);
195
+ expect(m.get('oxy_identity_private_key')).not.toBe(b.privateKey);
196
+ // The original A private key is still in place (untouched).
197
+ expect(KeyManager.derivePublicKey(m.get('oxy_identity_private_key') as string)).toBe(a);
198
+ });
199
+
200
+ it('restores a provably-absent primary from a valid backup', async () => {
201
+ const original = await KeyManager.createIdentity();
202
+ const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
203
+ const m = ss.__getStore__();
204
+ // Wipe the primary entirely (backup remains).
205
+ m.delete('oxy_identity_private_key');
206
+ m.delete('oxy_identity_public_key');
207
+ resetCaches();
208
+
209
+ const restored = await KeyManager.restoreIdentityFromBackup();
210
+ expect(restored).toBe(true);
211
+ expect(await KeyManager.getPublicKey()).toBe(original);
212
+ expect(await KeyManager.verifyIdentityIntegrity()).toBe(true);
213
+ });
214
+ });