@oxyhq/core 12.7.0 → 12.9.0
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/boot/sessionColdBoot.js +16 -3
- package/dist/cjs/crypto/identityMarker.js +255 -0
- package/dist/cjs/crypto/keyManager.js +844 -106
- package/dist/cjs/index.js +8 -4
- package/dist/cjs/mixins/OxyServices.auth.js +21 -6
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/cjs/mixins/OxyServices.utility.js +11 -1
- package/dist/cjs/server/auth.js +3 -0
- package/dist/cjs/server/index.js +2 -1
- package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +16 -3
- package/dist/esm/crypto/identityMarker.js +248 -0
- package/dist/esm/crypto/keyManager.js +843 -106
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.auth.js +21 -6
- package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/esm/mixins/OxyServices.utility.js +11 -1
- package/dist/esm/server/auth.js +2 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +25 -0
- package/dist/types/crypto/identityMarker.d.ts +94 -0
- package/dist/types/crypto/keyManager.d.ts +212 -3
- package/dist/types/index.d.ts +4 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
- package/dist/types/server/auth.d.ts +4 -0
- package/dist/types/server/index.d.ts +2 -2
- package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
- package/package.json +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
- package/src/boot/sessionColdBoot.ts +42 -3
- package/src/crypto/__tests__/identityMocks.ts +125 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
- package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
- package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
- package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
- package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
- package/src/crypto/__tests__/keyManager.test.ts +77 -87
- package/src/crypto/identityMarker.ts +291 -0
- package/src/crypto/keyManager.ts +1026 -105
- package/src/index.ts +7 -1
- package/src/mixins/OxyServices.auth.ts +31 -7
- package/src/mixins/OxyServices.deviceBoot.ts +9 -1
- package/src/mixins/OxyServices.utility.ts +19 -1
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
- package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
- package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
- package/src/server/auth.ts +5 -0
- package/src/server/index.ts +2 -0
- package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
- package/src/utils/oxyServiceEnvironment.ts +17 -0
|
@@ -455,3 +455,116 @@ describe('runSessionColdBoot — signed out', () => {
|
|
|
455
455
|
expect(setTokens).not.toHaveBeenCalled();
|
|
456
456
|
});
|
|
457
457
|
});
|
|
458
|
+
|
|
459
|
+
describe('runSessionColdBoot — offline gating (isOffline)', () => {
|
|
460
|
+
/** Comfortably beyond the 60s refresh lead window. */
|
|
461
|
+
const farFuture = () => new Date(Date.now() + 3_600_000).toISOString();
|
|
462
|
+
|
|
463
|
+
const sharedSession: SessionLoginResponse = {
|
|
464
|
+
sessionId: 'sess-shared',
|
|
465
|
+
deviceId: 'dev-1',
|
|
466
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
467
|
+
user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
|
|
468
|
+
accessToken: 'access-shared',
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
it('offline: skips BOTH network steps (no mint, no shared-key) → signed out', async () => {
|
|
472
|
+
// Credential present, no warm token — the ONLY things that could resolve are
|
|
473
|
+
// the two network steps, which the offline hint must gate off.
|
|
474
|
+
const { store, seed } = seedCredStore();
|
|
475
|
+
await seed();
|
|
476
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
477
|
+
const signInWithSharedIdentity = jest.fn(async () => sharedSession);
|
|
478
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
|
|
479
|
+
const onSignedOut = jest.fn();
|
|
480
|
+
|
|
481
|
+
const outcome = await runSessionColdBoot({
|
|
482
|
+
oxy,
|
|
483
|
+
store,
|
|
484
|
+
platform: NATIVE,
|
|
485
|
+
isOffline: () => true,
|
|
486
|
+
onSignedOut,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
490
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
491
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
492
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('offline: the pure-local warm-token-plant STILL runs (returning user boots authenticated offline)', async () => {
|
|
496
|
+
const { store, seed } = seedCredStore({ accessToken: 'warm-access', expiresAt: farFuture() });
|
|
497
|
+
await seed();
|
|
498
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
499
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
500
|
+
|
|
501
|
+
const outcome = await runSessionColdBoot({
|
|
502
|
+
oxy,
|
|
503
|
+
store,
|
|
504
|
+
platform: NATIVE,
|
|
505
|
+
isOffline: () => true,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// Warm plant is a pure-local read — never gated by the offline hint.
|
|
509
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
|
|
510
|
+
expect(setTokens).toHaveBeenCalledWith('warm-access');
|
|
511
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('online (isOffline:()=>false): the network mint runs as normal', async () => {
|
|
515
|
+
const { store, seed } = seedCredStore();
|
|
516
|
+
await seed();
|
|
517
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
518
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
519
|
+
|
|
520
|
+
const outcome = await runSessionColdBoot({
|
|
521
|
+
oxy,
|
|
522
|
+
store,
|
|
523
|
+
platform: WEB,
|
|
524
|
+
isOffline: () => false,
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
|
|
528
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('shared-key-signin passes { requestOptions: { retry: false } } (cold-boot single-attempt)', async () => {
|
|
532
|
+
const store = createMemoryAuthStateStore(); // no mint credential → mint step no-secret skip
|
|
533
|
+
const signInWithSharedIdentity = jest.fn(async () => sharedSession);
|
|
534
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
535
|
+
|
|
536
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
|
|
537
|
+
|
|
538
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'shared-key-signin' });
|
|
539
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledWith({ requestOptions: { retry: false } });
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe('runSessionColdBoot — overall deadline (overallDeadlineMs + onStepDeadline)', () => {
|
|
544
|
+
it('forwards the deadline: a non-settling mint step is abandoned via onStepDeadline and the boot ends bounded', async () => {
|
|
545
|
+
const { store, seed } = seedCredStore();
|
|
546
|
+
await seed();
|
|
547
|
+
// A mint that NEVER settles — without the overall deadline this hangs the
|
|
548
|
+
// whole boot (and therefore app routing) forever.
|
|
549
|
+
const mintFromDeviceSecret = jest.fn(
|
|
550
|
+
() => new Promise<DeviceTokenMintResponse>(() => undefined),
|
|
551
|
+
);
|
|
552
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
553
|
+
const onStepDeadline = jest.fn();
|
|
554
|
+
const onSignedOut = jest.fn();
|
|
555
|
+
|
|
556
|
+
const outcome = await runSessionColdBoot({
|
|
557
|
+
oxy,
|
|
558
|
+
store,
|
|
559
|
+
platform: WEB,
|
|
560
|
+
overallDeadlineMs: 50,
|
|
561
|
+
onStepDeadline,
|
|
562
|
+
onSignedOut,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
566
|
+
expect(onStepDeadline).toHaveBeenCalledWith('device-secret-mint');
|
|
567
|
+
// A deadline trip is not an error — the boot resolves signed-out, not `error`.
|
|
568
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
569
|
+
});
|
|
570
|
+
});
|
|
@@ -47,6 +47,31 @@ export interface RunSessionColdBootOptions {
|
|
|
47
47
|
/** Invoked when the boot ended signed out. */
|
|
48
48
|
onSignedOut?: (reason: SignedOutReason) => void | Promise<void>;
|
|
49
49
|
onStepError?: (id: string, error: unknown) => void;
|
|
50
|
+
/**
|
|
51
|
+
* HARD overall deadline (ms) for the whole ordered step chain, forwarded to
|
|
52
|
+
* {@link runColdBoot}. Defense-in-depth so a single non-settling network step
|
|
53
|
+
* (a black-hole network that neither connects nor rejects) can NEVER hang the
|
|
54
|
+
* boot — and therefore app routing — indefinitely. Inert on healthy loads
|
|
55
|
+
* (every step settles well under it); only trips on pathological networks.
|
|
56
|
+
* When omitted there is no overall deadline (unchanged behavior).
|
|
57
|
+
*/
|
|
58
|
+
overallDeadlineMs?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Invoked once per step abandoned because {@link overallDeadlineMs} expired
|
|
61
|
+
* before it settled. Forwarded to {@link runColdBoot}. Must not throw.
|
|
62
|
+
*/
|
|
63
|
+
onStepDeadline?: (stepId: string) => void;
|
|
64
|
+
/**
|
|
65
|
+
* Best-effort connectivity hint. When it returns `true` the two NETWORK steps
|
|
66
|
+
* (`device-secret-mint`, `shared-key-signin`) are skipped — an offline device
|
|
67
|
+
* cannot mint, and attempting to would burn the whole deadline on a doomed
|
|
68
|
+
* request before routing settles. The pure-local `warm-token-plant` step is
|
|
69
|
+
* NEVER gated by this: an offline returning user with an unexpired persisted
|
|
70
|
+
* token must still boot authenticated. Only an EXPLICIT offline verdict should
|
|
71
|
+
* be returned; the caller resolves unknown/timeout to `false` (assume online)
|
|
72
|
+
* so a flaky probe can never falsely skip a real sign-in.
|
|
73
|
+
*/
|
|
74
|
+
isOffline?: () => boolean;
|
|
50
75
|
}
|
|
51
76
|
|
|
52
77
|
/**
|
|
@@ -60,6 +85,11 @@ export async function runSessionColdBoot(
|
|
|
60
85
|
const { oxy, store } = opts;
|
|
61
86
|
const isNative = opts.platform?.isNative ?? detectNative();
|
|
62
87
|
|
|
88
|
+
// Best-effort connectivity gate for the NETWORK steps only. A missing hint or
|
|
89
|
+
// any non-`true` verdict means "assume online" — never falsely skip a real
|
|
90
|
+
// sign-in on an ambiguous probe.
|
|
91
|
+
const isOffline = (): boolean => opts.isOffline?.() ?? false;
|
|
92
|
+
|
|
63
93
|
// Boot-local (not module-level) so it cannot leak across boots or break under
|
|
64
94
|
// bundler re-evaluation.
|
|
65
95
|
let signedOutReason: SignedOutReason = 'no_session';
|
|
@@ -114,6 +144,9 @@ export async function runSessionColdBoot(
|
|
|
114
144
|
// the durable store always converges on the true `current` secret.
|
|
115
145
|
steps.push({
|
|
116
146
|
id: 'device-secret-mint',
|
|
147
|
+
// Network step — skip entirely when the caller reports the device offline so
|
|
148
|
+
// a doomed mint cannot burn the overall deadline before routing settles.
|
|
149
|
+
enabled: () => !isOffline(),
|
|
117
150
|
run: async () => {
|
|
118
151
|
const result = await refreshDeviceSecretArm({ oxy, store });
|
|
119
152
|
switch (result.status) {
|
|
@@ -166,12 +199,16 @@ export async function runSessionColdBoot(
|
|
|
166
199
|
},
|
|
167
200
|
});
|
|
168
201
|
|
|
169
|
-
// 3. shared-key-signin (native) — re-mint from the shared identity.
|
|
202
|
+
// 3. shared-key-signin (native) — re-mint from the shared identity. Native
|
|
203
|
+
// AND online: it is a network step (challenge + verify round-trips), so it
|
|
204
|
+
// is gated by the same offline hint as the mint lane. `{ retry: false }`
|
|
205
|
+
// keeps the two round-trips as single attempts — the refresh scheduler /
|
|
206
|
+
// 401 lane own later retries — so this step cannot multiply boot latency.
|
|
170
207
|
steps.push({
|
|
171
208
|
id: 'shared-key-signin',
|
|
172
|
-
enabled: () => isNative,
|
|
209
|
+
enabled: () => isNative && !isOffline(),
|
|
173
210
|
run: async () => {
|
|
174
|
-
const session = await oxy.signInWithSharedIdentity();
|
|
211
|
+
const session = await oxy.signInWithSharedIdentity({ requestOptions: { retry: false } });
|
|
175
212
|
if (!session?.accessToken) {
|
|
176
213
|
return { kind: 'skip' };
|
|
177
214
|
}
|
|
@@ -201,6 +238,8 @@ export async function runSessionColdBoot(
|
|
|
201
238
|
|
|
202
239
|
const outcome = await runColdBoot<DeviceBootSession>({
|
|
203
240
|
steps,
|
|
241
|
+
overallDeadlineMs: opts.overallDeadlineMs,
|
|
242
|
+
onStepDeadline: opts.onStepDeadline,
|
|
204
243
|
onStepError: (id, error) => {
|
|
205
244
|
signedOutReason = 'error';
|
|
206
245
|
opts.onStepError?.(id, error);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared in-memory mocks for KeyManager identity tests.
|
|
3
|
+
*
|
|
4
|
+
* The secure-store mock keys every entry by `(keychainService ?? 'default') + ' '
|
|
5
|
+
* + key` so the v2 slot layout — which gives the primary and backup DISTINCT
|
|
6
|
+
* keychain services — is faithfully modeled (a write under one service is
|
|
7
|
+
* invisible to a read under another, exactly like Android's per-service
|
|
8
|
+
* AndroidKeyStore keys).
|
|
9
|
+
*
|
|
10
|
+
* `__simulateKeystoreDeath__(service)` reproduces the SDK 57 Android failure mode
|
|
11
|
+
* this whole hardening defends against: an undecryptable entry is DELETED on the
|
|
12
|
+
* READ path and the read returns `null` (no throw). A subsequent write under that
|
|
13
|
+
* service creates a fresh, readable entry (a new keystore key), so recovery can
|
|
14
|
+
* re-persist afterwards.
|
|
15
|
+
*
|
|
16
|
+
* The AsyncStorage mock is a real in-memory map (the identity marker + the
|
|
17
|
+
* advisory migration flag live here — independent of the keychain).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
type FailOp = 'set' | 'get';
|
|
21
|
+
|
|
22
|
+
export interface FailPlan {
|
|
23
|
+
failKey?: string;
|
|
24
|
+
failOp?: FailOp;
|
|
25
|
+
failTimes?: number;
|
|
26
|
+
/** Restrict the fault to one keychain service (`'default'` for unscoped keys). */
|
|
27
|
+
failService?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const compositeKey = (service: string | undefined, key: string): string =>
|
|
31
|
+
`${service ?? 'default'} ${key}`;
|
|
32
|
+
|
|
33
|
+
export function createSecureStoreMock() {
|
|
34
|
+
const store = new Map<string, string>(); // keyed by `${service} ${key}`
|
|
35
|
+
const poisoned = new Set<string>(); // composite keys pending delete-on-read (keystore death)
|
|
36
|
+
const failPlan: FailPlan = {};
|
|
37
|
+
|
|
38
|
+
const maybeFail = (op: FailOp, key: string, service?: string): void => {
|
|
39
|
+
if (
|
|
40
|
+
failPlan.failOp === op &&
|
|
41
|
+
failPlan.failKey === key &&
|
|
42
|
+
(failPlan.failService === undefined || failPlan.failService === (service ?? 'default'))
|
|
43
|
+
) {
|
|
44
|
+
if (failPlan.failTimes !== undefined) {
|
|
45
|
+
failPlan.failTimes -= 1;
|
|
46
|
+
if (failPlan.failTimes <= 0) {
|
|
47
|
+
failPlan.failKey = undefined;
|
|
48
|
+
failPlan.failOp = undefined;
|
|
49
|
+
failPlan.failTimes = undefined;
|
|
50
|
+
failPlan.failService = undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Simulated ${op} failure for ${key}`);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
__esModule: true,
|
|
59
|
+
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
|
|
60
|
+
WHEN_UNLOCKED: 'WHEN_UNLOCKED',
|
|
61
|
+
setItemAsync: jest.fn(async (key: string, value: string, opts?: { keychainService?: string }) => {
|
|
62
|
+
maybeFail('set', key, opts?.keychainService);
|
|
63
|
+
const ck = compositeKey(opts?.keychainService, key);
|
|
64
|
+
poisoned.delete(ck); // a fresh write mints a new (readable) keystore entry
|
|
65
|
+
store.set(ck, value);
|
|
66
|
+
}),
|
|
67
|
+
getItemAsync: jest.fn(async (key: string, opts?: { keychainService?: string }) => {
|
|
68
|
+
maybeFail('get', key, opts?.keychainService);
|
|
69
|
+
const ck = compositeKey(opts?.keychainService, key);
|
|
70
|
+
if (poisoned.has(ck)) {
|
|
71
|
+
// Android SDK 57: undecryptable ciphertext is DELETED on read → null.
|
|
72
|
+
poisoned.delete(ck);
|
|
73
|
+
store.delete(ck);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return store.get(ck) ?? null;
|
|
77
|
+
}),
|
|
78
|
+
deleteItemAsync: jest.fn(async (key: string, opts?: { keychainService?: string }) => {
|
|
79
|
+
const ck = compositeKey(opts?.keychainService, key);
|
|
80
|
+
poisoned.delete(ck);
|
|
81
|
+
store.delete(ck);
|
|
82
|
+
}),
|
|
83
|
+
__resetStore__: () => {
|
|
84
|
+
store.clear();
|
|
85
|
+
poisoned.clear();
|
|
86
|
+
failPlan.failKey = undefined;
|
|
87
|
+
failPlan.failOp = undefined;
|
|
88
|
+
failPlan.failTimes = undefined;
|
|
89
|
+
failPlan.failService = undefined;
|
|
90
|
+
},
|
|
91
|
+
__getStore__: () => store,
|
|
92
|
+
__getRaw__: (key: string, service?: string): string | null => store.get(compositeKey(service, key)) ?? null,
|
|
93
|
+
__setRaw__: (key: string, value: string, service?: string): void => {
|
|
94
|
+
store.set(compositeKey(service, key), value);
|
|
95
|
+
},
|
|
96
|
+
__deleteRaw__: (key: string, service?: string): void => {
|
|
97
|
+
store.delete(compositeKey(service, key));
|
|
98
|
+
},
|
|
99
|
+
__simulateKeystoreDeath__: (service: string): void => {
|
|
100
|
+
// Mark every existing entry under this service as undecryptable; the next
|
|
101
|
+
// read of each deletes it and returns null.
|
|
102
|
+
for (const ck of store.keys()) {
|
|
103
|
+
if (ck.startsWith(`${service} `)) {
|
|
104
|
+
poisoned.add(ck);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
__failPlan__: failPlan,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createAsyncStorageMock() {
|
|
113
|
+
const map = new Map<string, string>();
|
|
114
|
+
return {
|
|
115
|
+
getItem: async (key: string): Promise<string | null> => map.get(key) ?? null,
|
|
116
|
+
setItem: async (key: string, value: string): Promise<void> => {
|
|
117
|
+
map.set(key, value);
|
|
118
|
+
},
|
|
119
|
+
removeItem: async (key: string): Promise<void> => {
|
|
120
|
+
map.delete(key);
|
|
121
|
+
},
|
|
122
|
+
__map__: map,
|
|
123
|
+
__reset__: (): void => map.clear(),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -14,56 +14,20 @@
|
|
|
14
14
|
* 3. restoreIdentityFromBackup() must refuse when the backup identifies a
|
|
15
15
|
* different account than a private key still present in the primary slot.
|
|
16
16
|
* 4. A first-time create still writes a backup (no regression).
|
|
17
|
+
*
|
|
18
|
+
* The identity now lives in the isolated v2 slots (primary service `oxy_identity`,
|
|
19
|
+
* backup service `oxy_identity_backup`), so assertions read/write via the
|
|
20
|
+
* service-scoped mock helpers.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
import { setPlatformOS } from '../../utils/platform';
|
|
20
24
|
|
|
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'; failTimes?: number } = {};
|
|
25
|
-
|
|
26
25
|
jest.mock(
|
|
27
26
|
'expo-secure-store',
|
|
28
27
|
() => {
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
if (failPlan.failTimes !== undefined) {
|
|
33
|
-
failPlan.failTimes -= 1;
|
|
34
|
-
if (failPlan.failTimes <= 0) {
|
|
35
|
-
failPlan.failKey = undefined;
|
|
36
|
-
failPlan.failOp = undefined;
|
|
37
|
-
failPlan.failTimes = undefined;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
throw new Error(`Simulated ${op} failure for ${key}`);
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
return {
|
|
44
|
-
__esModule: true,
|
|
45
|
-
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
|
|
46
|
-
WHEN_UNLOCKED: 'WHEN_UNLOCKED',
|
|
47
|
-
setItemAsync: jest.fn(async (key: string, value: string) => {
|
|
48
|
-
maybeFail('set', key);
|
|
49
|
-
store.set(key, value);
|
|
50
|
-
}),
|
|
51
|
-
getItemAsync: jest.fn(async (key: string) => {
|
|
52
|
-
maybeFail('get', key);
|
|
53
|
-
return store.get(key) ?? null;
|
|
54
|
-
}),
|
|
55
|
-
deleteItemAsync: jest.fn(async (key: string) => {
|
|
56
|
-
store.delete(key);
|
|
57
|
-
}),
|
|
58
|
-
__resetStore__: () => {
|
|
59
|
-
store.clear();
|
|
60
|
-
failPlan.failKey = undefined;
|
|
61
|
-
failPlan.failOp = undefined;
|
|
62
|
-
failPlan.failTimes = undefined;
|
|
63
|
-
},
|
|
64
|
-
__getStore__: () => store,
|
|
65
|
-
__failPlan__: failPlan,
|
|
66
|
-
};
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
29
|
+
const { createSecureStoreMock } = require('./identityMocks');
|
|
30
|
+
return createSecureStoreMock();
|
|
67
31
|
},
|
|
68
32
|
{ virtual: true },
|
|
69
33
|
);
|
|
@@ -83,35 +47,55 @@ jest.mock(
|
|
|
83
47
|
{ virtual: true },
|
|
84
48
|
);
|
|
85
49
|
|
|
86
|
-
jest.mock('@oxyhq/protocol', () =>
|
|
87
|
-
|
|
88
|
-
...jest.requireActual('@oxyhq/protocol'),
|
|
89
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
90
|
-
loadExpoCrypto: async () => require('expo-crypto'),
|
|
91
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
92
|
-
loadSecureStore: async () => require('expo-secure-store'),
|
|
93
|
-
loadAsyncStorage: async () => ({
|
|
94
|
-
default: { getItem: async () => null, setItem: async () => undefined, removeItem: async () => undefined },
|
|
95
|
-
}),
|
|
50
|
+
jest.mock('@oxyhq/protocol', () => {
|
|
51
|
+
const actual = jest.requireActual('@oxyhq/protocol');
|
|
96
52
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
53
|
+
const { createAsyncStorageMock } = require('./identityMocks');
|
|
54
|
+
const asyncStorage = createAsyncStorageMock();
|
|
55
|
+
return {
|
|
56
|
+
__esModule: true,
|
|
57
|
+
...actual,
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
59
|
+
loadExpoCrypto: async () => require('expo-crypto'),
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
61
|
+
loadSecureStore: async () => require('expo-secure-store'),
|
|
62
|
+
loadAsyncStorage: async () => ({ default: asyncStorage }),
|
|
63
|
+
loadSharedIdentityBridge: async () => null,
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
65
|
+
loadNodeCrypto: async () => require('crypto'),
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
67
|
+
getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// v2 slot key names + keychain services (must match keyManager.ts).
|
|
72
|
+
const PRIMARY_SVC = 'oxy_identity';
|
|
73
|
+
const BACKUP_SVC = 'oxy_identity_backup';
|
|
74
|
+
const V2_PRIV = 'oxy_identity_private_key_v2';
|
|
75
|
+
const V2_PUB = 'oxy_identity_public_key_v2';
|
|
76
|
+
const V2_BPRIV = 'oxy_identity_backup_private_key_v2';
|
|
77
|
+
const V2_BPUB = 'oxy_identity_backup_public_key_v2';
|
|
101
78
|
|
|
102
79
|
interface SecureStoreTestHandle {
|
|
103
80
|
__resetStore__: () => void;
|
|
104
|
-
|
|
105
|
-
|
|
81
|
+
__getRaw__: (key: string, service?: string) => string | null;
|
|
82
|
+
__setRaw__: (key: string, value: string, service?: string) => void;
|
|
83
|
+
__deleteRaw__: (key: string, service?: string) => void;
|
|
84
|
+
__failPlan__: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number; failService?: string };
|
|
106
85
|
}
|
|
107
86
|
|
|
108
87
|
describe('KeyManager atomicity & recoverability under flaky storage', () => {
|
|
109
88
|
let KeyManager: typeof import('../keyManager').KeyManager;
|
|
110
89
|
|
|
111
90
|
const resetCaches = () => {
|
|
112
|
-
const km = KeyManager as unknown as {
|
|
91
|
+
const km = KeyManager as unknown as {
|
|
92
|
+
cachedPublicKey: unknown;
|
|
93
|
+
cachedHasIdentity: unknown;
|
|
94
|
+
cachedPublicKeyResolved: unknown;
|
|
95
|
+
};
|
|
113
96
|
km.cachedPublicKey = null;
|
|
114
97
|
km.cachedHasIdentity = null;
|
|
98
|
+
km.cachedPublicKeyResolved = false;
|
|
115
99
|
};
|
|
116
100
|
|
|
117
101
|
beforeAll(() => {
|
|
@@ -132,34 +116,35 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
|
|
|
132
116
|
it('a failed OVERWRITE leaves the ORIGINAL identity intact and recoverable (no silent switch)', async () => {
|
|
133
117
|
const originalPublic = await KeyManager.createIdentity();
|
|
134
118
|
const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
135
|
-
const originalPriv = ss.
|
|
119
|
+
const originalPriv = ss.__getRaw__(V2_PRIV, PRIMARY_SVC);
|
|
136
120
|
resetCaches();
|
|
137
121
|
|
|
138
122
|
// The new primary private write fails mid-overwrite.
|
|
139
123
|
ss.__failPlan__.failOp = 'set';
|
|
140
|
-
ss.__failPlan__.failKey =
|
|
124
|
+
ss.__failPlan__.failKey = V2_PRIV;
|
|
125
|
+
ss.__failPlan__.failService = PRIMARY_SVC;
|
|
141
126
|
await expect(KeyManager.createIdentity({ overwrite: true })).rejects.toBeDefined();
|
|
142
127
|
|
|
143
128
|
// Recover from the simulated fault.
|
|
144
129
|
ss.__failPlan__.failKey = undefined;
|
|
145
130
|
ss.__failPlan__.failOp = undefined;
|
|
131
|
+
ss.__failPlan__.failService = undefined;
|
|
146
132
|
resetCaches();
|
|
147
133
|
|
|
148
134
|
// Primary must still be the original identity (rolled back).
|
|
149
135
|
expect(await KeyManager.hasIdentity()).toBe(true);
|
|
150
136
|
expect(await KeyManager.getPublicKey()).toBe(originalPublic);
|
|
151
|
-
expect(ss.
|
|
137
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(originalPriv);
|
|
152
138
|
|
|
153
139
|
// And the backup must still hold the ORIGINAL identity, never the new one.
|
|
154
|
-
|
|
155
|
-
expect(
|
|
156
|
-
expect(m.get('oxy_identity_backup_public_key')).toBe(originalPublic);
|
|
140
|
+
expect(ss.__getRaw__(V2_BPRIV, BACKUP_SVC)).toBe(originalPriv);
|
|
141
|
+
expect(ss.__getRaw__(V2_BPUB, BACKUP_SVC)).toBe(originalPublic);
|
|
157
142
|
});
|
|
158
143
|
|
|
159
144
|
it('a failed final backup refresh rejects and rolls back instead of succeeding with a stale backup', async () => {
|
|
160
145
|
const originalPublic = await KeyManager.createIdentity();
|
|
161
146
|
const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
162
|
-
const originalPriv = ss.
|
|
147
|
+
const originalPriv = ss.__getRaw__(V2_PRIV, PRIMARY_SVC);
|
|
163
148
|
resetCaches();
|
|
164
149
|
|
|
165
150
|
// Let the new primary write and verify, then fail exactly once while
|
|
@@ -167,7 +152,8 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
|
|
|
167
152
|
// with primary=B and backup=A, enabling a later absent-primary restore to
|
|
168
153
|
// silently switch back to A.
|
|
169
154
|
ss.__failPlan__.failOp = 'set';
|
|
170
|
-
ss.__failPlan__.failKey =
|
|
155
|
+
ss.__failPlan__.failKey = V2_BPUB;
|
|
156
|
+
ss.__failPlan__.failService = BACKUP_SVC;
|
|
171
157
|
ss.__failPlan__.failTimes = 1;
|
|
172
158
|
await expect(KeyManager.createIdentity({ overwrite: true })).rejects.toBeDefined();
|
|
173
159
|
|
|
@@ -178,14 +164,12 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
|
|
|
178
164
|
// cross-account backup.
|
|
179
165
|
expect(await KeyManager.hasIdentity()).toBe(true);
|
|
180
166
|
expect(await KeyManager.getPublicKey()).toBe(originalPublic);
|
|
181
|
-
|
|
182
|
-
expect(
|
|
183
|
-
expect(
|
|
184
|
-
expect(
|
|
185
|
-
expect(m.get('oxy_identity_backup_public_key')).toBe(originalPublic);
|
|
167
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(originalPriv);
|
|
168
|
+
expect(ss.__getRaw__(V2_PUB, PRIMARY_SVC)).toBe(originalPublic);
|
|
169
|
+
expect(ss.__getRaw__(V2_BPRIV, BACKUP_SVC)).toBe(originalPriv);
|
|
170
|
+
expect(ss.__getRaw__(V2_BPUB, BACKUP_SVC)).toBe(originalPublic);
|
|
186
171
|
});
|
|
187
172
|
|
|
188
|
-
|
|
189
173
|
it('restoreIdentityFromBackup does NOT clobber a healthy primary that is only transiently unreadable', async () => {
|
|
190
174
|
const original = await KeyManager.createIdentity();
|
|
191
175
|
const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
@@ -193,13 +177,14 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
|
|
|
193
177
|
// Put a DIFFERENT identity in the backup slot (simulates a stale backup
|
|
194
178
|
// from a previous account that a failed backup-refresh left behind).
|
|
195
179
|
const other = await KeyManager.generateKeyPair();
|
|
196
|
-
ss.
|
|
197
|
-
ss.
|
|
180
|
+
ss.__setRaw__(V2_BPRIV, other.privateKey, BACKUP_SVC);
|
|
181
|
+
ss.__setRaw__(V2_BPUB, other.publicKey, BACKUP_SVC);
|
|
198
182
|
resetCaches();
|
|
199
183
|
|
|
200
184
|
// Make the primary PRIVATE read throw (transient keychain lock).
|
|
201
185
|
ss.__failPlan__.failOp = 'get';
|
|
202
|
-
ss.__failPlan__.failKey =
|
|
186
|
+
ss.__failPlan__.failKey = V2_PRIV;
|
|
187
|
+
ss.__failPlan__.failService = PRIMARY_SVC;
|
|
203
188
|
|
|
204
189
|
const restored = await KeyManager.restoreIdentityFromBackup();
|
|
205
190
|
expect(restored).toBe(false); // refused — transient read must not trigger a restore
|
|
@@ -207,9 +192,10 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
|
|
|
207
192
|
// Clear the fault; the original primary must be intact and unchanged.
|
|
208
193
|
ss.__failPlan__.failKey = undefined;
|
|
209
194
|
ss.__failPlan__.failOp = undefined;
|
|
195
|
+
ss.__failPlan__.failService = undefined;
|
|
210
196
|
resetCaches();
|
|
211
197
|
expect(await KeyManager.getPublicKey()).toBe(original);
|
|
212
|
-
expect(ss.
|
|
198
|
+
expect(ss.__getRaw__(V2_PUB, PRIMARY_SVC)).toBe(original);
|
|
213
199
|
});
|
|
214
200
|
|
|
215
201
|
it('restoreIdentityFromBackup refuses when a present primary private key identifies a different account than the backup', async () => {
|
|
@@ -218,23 +204,22 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
|
|
|
218
204
|
// real, different identity than the backup.
|
|
219
205
|
const a = await KeyManager.createIdentity();
|
|
220
206
|
const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
221
|
-
const m = ss.__getStore__();
|
|
222
207
|
|
|
223
208
|
// Backup holds a DIFFERENT identity B.
|
|
224
209
|
const b = await KeyManager.generateKeyPair();
|
|
225
|
-
|
|
226
|
-
|
|
210
|
+
ss.__setRaw__(V2_BPRIV, b.privateKey, BACKUP_SVC);
|
|
211
|
+
ss.__setRaw__(V2_BPUB, b.publicKey, BACKUP_SVC);
|
|
227
212
|
// Corrupt A's stored public key (private key A still present & valid).
|
|
228
|
-
|
|
213
|
+
ss.__setRaw__(V2_PUB, `04${'e'.repeat(128)}`, PRIMARY_SVC);
|
|
229
214
|
resetCaches();
|
|
230
215
|
|
|
231
216
|
const restored = await KeyManager.restoreIdentityFromBackup();
|
|
232
217
|
expect(restored).toBe(false);
|
|
233
218
|
// Must NOT have switched the primary to B.
|
|
234
|
-
expect(
|
|
235
|
-
expect(
|
|
219
|
+
expect(ss.__getRaw__(V2_PUB, PRIMARY_SVC)).not.toBe(b.publicKey);
|
|
220
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).not.toBe(b.privateKey);
|
|
236
221
|
// The original A private key is still in place (untouched).
|
|
237
|
-
expect(KeyManager.derivePublicKey(
|
|
222
|
+
expect(KeyManager.derivePublicKey(ss.__getRaw__(V2_PRIV, PRIMARY_SVC) as string)).toBe(a);
|
|
238
223
|
});
|
|
239
224
|
|
|
240
225
|
it('a failed primary write during the post-rotation importKeyPair leaves the OLD key recoverable from backup', async () => {
|
|
@@ -244,7 +229,7 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
|
|
|
244
229
|
// half-written new identity that can't be decrypted or backed up.
|
|
245
230
|
const oldPublic = await KeyManager.createIdentity();
|
|
246
231
|
const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
247
|
-
const oldPriv = ss.
|
|
232
|
+
const oldPriv = ss.__getRaw__(V2_PRIV, PRIMARY_SVC);
|
|
248
233
|
resetCaches();
|
|
249
234
|
|
|
250
235
|
// The NEW rotated key material (generateKeyPair does NOT persist).
|
|
@@ -252,31 +237,31 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
|
|
|
252
237
|
|
|
253
238
|
// The new primary private write fails mid-commit.
|
|
254
239
|
ss.__failPlan__.failOp = 'set';
|
|
255
|
-
ss.__failPlan__.failKey =
|
|
240
|
+
ss.__failPlan__.failKey = V2_PRIV;
|
|
241
|
+
ss.__failPlan__.failService = PRIMARY_SVC;
|
|
256
242
|
await expect(KeyManager.importKeyPair(rotated.privateKey, { overwrite: true })).rejects.toBeDefined();
|
|
257
243
|
|
|
258
244
|
// Recover from the simulated fault.
|
|
259
245
|
ss.__failPlan__.failKey = undefined;
|
|
260
246
|
ss.__failPlan__.failOp = undefined;
|
|
247
|
+
ss.__failPlan__.failService = undefined;
|
|
261
248
|
resetCaches();
|
|
262
249
|
|
|
263
250
|
// Primary is STILL the old identity (rolled back — never the new one).
|
|
264
251
|
expect(await KeyManager.hasIdentity()).toBe(true);
|
|
265
252
|
expect(await KeyManager.getPublicKey()).toBe(oldPublic);
|
|
266
|
-
|
|
267
|
-
expect(m.get('oxy_identity_private_key')).toBe(oldPriv);
|
|
253
|
+
expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(oldPriv);
|
|
268
254
|
// And the backup still holds the OLD identity, so the user can recover it.
|
|
269
|
-
expect(
|
|
270
|
-
expect(
|
|
255
|
+
expect(ss.__getRaw__(V2_BPRIV, BACKUP_SVC)).toBe(oldPriv);
|
|
256
|
+
expect(ss.__getRaw__(V2_BPUB, BACKUP_SVC)).toBe(oldPublic);
|
|
271
257
|
});
|
|
272
258
|
|
|
273
259
|
it('restores a provably-absent primary from a valid backup', async () => {
|
|
274
260
|
const original = await KeyManager.createIdentity();
|
|
275
261
|
const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
|
|
276
|
-
const m = ss.__getStore__();
|
|
277
262
|
// Wipe the primary entirely (backup remains).
|
|
278
|
-
|
|
279
|
-
|
|
263
|
+
ss.__deleteRaw__(V2_PRIV, PRIMARY_SVC);
|
|
264
|
+
ss.__deleteRaw__(V2_PUB, PRIMARY_SVC);
|
|
280
265
|
resetCaches();
|
|
281
266
|
|
|
282
267
|
const restored = await KeyManager.restoreIdentityFromBackup();
|