@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.
Files changed (56) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +16 -3
  3. package/dist/cjs/crypto/identityMarker.js +255 -0
  4. package/dist/cjs/crypto/keyManager.js +844 -106
  5. package/dist/cjs/index.js +8 -4
  6. package/dist/cjs/mixins/OxyServices.auth.js +21 -6
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
  8. package/dist/cjs/mixins/OxyServices.utility.js +11 -1
  9. package/dist/cjs/server/auth.js +3 -0
  10. package/dist/cjs/server/index.js +2 -1
  11. package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
  12. package/dist/esm/.tsbuildinfo +1 -1
  13. package/dist/esm/boot/sessionColdBoot.js +16 -3
  14. package/dist/esm/crypto/identityMarker.js +248 -0
  15. package/dist/esm/crypto/keyManager.js +843 -106
  16. package/dist/esm/index.js +2 -1
  17. package/dist/esm/mixins/OxyServices.auth.js +21 -6
  18. package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
  19. package/dist/esm/mixins/OxyServices.utility.js +11 -1
  20. package/dist/esm/server/auth.js +2 -0
  21. package/dist/esm/server/index.js +1 -1
  22. package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/boot/sessionColdBoot.d.ts +25 -0
  25. package/dist/types/crypto/identityMarker.d.ts +94 -0
  26. package/dist/types/crypto/keyManager.d.ts +212 -3
  27. package/dist/types/index.d.ts +4 -2
  28. package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
  29. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
  30. package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
  31. package/dist/types/server/auth.d.ts +4 -0
  32. package/dist/types/server/index.d.ts +2 -2
  33. package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
  34. package/package.json +1 -1
  35. package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
  36. package/src/boot/sessionColdBoot.ts +42 -3
  37. package/src/crypto/__tests__/identityMocks.ts +125 -0
  38. package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
  39. package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
  40. package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
  41. package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
  42. package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
  43. package/src/crypto/__tests__/keyManager.test.ts +77 -87
  44. package/src/crypto/identityMarker.ts +291 -0
  45. package/src/crypto/keyManager.ts +1026 -105
  46. package/src/index.ts +7 -1
  47. package/src/mixins/OxyServices.auth.ts +31 -7
  48. package/src/mixins/OxyServices.deviceBoot.ts +9 -1
  49. package/src/mixins/OxyServices.utility.ts +19 -1
  50. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
  51. package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
  52. package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
  53. package/src/server/auth.ts +5 -0
  54. package/src/server/index.ts +2 -0
  55. package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
  56. 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
- const store = new Map<string, string>();
30
- const maybeFail = (op: 'set' | 'get', key: string) => {
31
- if (failPlan.failOp === op && failPlan.failKey === key) {
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
- __esModule: true,
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
- loadNodeCrypto: async () => require('crypto'),
98
- // eslint-disable-next-line @typescript-eslint/no-require-imports
99
- getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
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
- __getStore__: () => Map<string, string>;
105
- __failPlan__: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number };
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 { cachedPublicKey: unknown; cachedHasIdentity: unknown };
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.__getStore__().get('oxy_identity_private_key');
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 = 'oxy_identity_private_key';
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.__getStore__().get('oxy_identity_private_key')).toBe(originalPriv);
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
- const m = ss.__getStore__();
155
- expect(m.get('oxy_identity_backup_private_key')).toBe(originalPriv);
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.__getStore__().get('oxy_identity_private_key');
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 = 'oxy_identity_backup_public_key';
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
- const m = ss.__getStore__();
182
- expect(m.get('oxy_identity_private_key')).toBe(originalPriv);
183
- expect(m.get('oxy_identity_public_key')).toBe(originalPublic);
184
- expect(m.get('oxy_identity_backup_private_key')).toBe(originalPriv);
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.__getStore__().set('oxy_identity_backup_private_key', other.privateKey);
197
- ss.__getStore__().set('oxy_identity_backup_public_key', other.publicKey);
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 = 'oxy_identity_private_key';
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.__getStore__().get('oxy_identity_public_key')).toBe(original);
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
- m.set('oxy_identity_backup_private_key', b.privateKey);
226
- m.set('oxy_identity_backup_public_key', b.publicKey);
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
- m.set('oxy_identity_public_key', `04${'e'.repeat(128)}`);
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(m.get('oxy_identity_public_key')).not.toBe(b.publicKey);
235
- expect(m.get('oxy_identity_private_key')).not.toBe(b.privateKey);
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(m.get('oxy_identity_private_key') as string)).toBe(a);
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.__getStore__().get('oxy_identity_private_key');
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 = 'oxy_identity_private_key';
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
- const m = ss.__getStore__();
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(m.get('oxy_identity_backup_private_key')).toBe(oldPriv);
270
- expect(m.get('oxy_identity_backup_public_key')).toBe(oldPublic);
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
- m.delete('oxy_identity_private_key');
279
- m.delete('oxy_identity_public_key');
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();