@oxyhq/core 1.11.17 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/keyManager.js +184 -56
- package/dist/cjs/mixins/OxyServices.fedcm.js +58 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/keyManager.js +184 -56
- package/dist/esm/mixins/OxyServices.fedcm.js +58 -3
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/keyManager.d.ts +49 -21
- package/dist/types/mixins/OxyServices.fedcm.d.ts +37 -1
- package/package.json +1 -1
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +214 -0
- package/src/crypto/keyManager.ts +200 -50
- package/src/mixins/OxyServices.fedcm.ts +67 -3
- package/src/mixins/__tests__/fedcm.test.ts +187 -0
|
@@ -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
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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
|
/**
|
|
@@ -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
|
@@ -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
|
+
});
|