@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
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Lazy migration of the identity from the legacy shared-`key_v1` slots onto the
3
+ * isolated v2 slots (primary service `oxy_identity`, backup service
4
+ * `oxy_identity_backup`).
5
+ *
6
+ * INVARIANT under test: at every instant ≥1 readable copy of a previously
7
+ * existing identity remains — legacy is deleted ONLY after the v2 copy is
8
+ * verified re-readable; a failed v2 write serves legacy for the session; any
9
+ * read-throw defers everything (zero writes/deletes).
10
+ */
11
+
12
+ import { setPlatformOS } from '../../utils/platform';
13
+
14
+ jest.mock(
15
+ 'expo-secure-store',
16
+ () => {
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ const { createSecureStoreMock } = require('./identityMocks');
19
+ return createSecureStoreMock();
20
+ },
21
+ { virtual: true },
22
+ );
23
+
24
+ jest.mock(
25
+ 'expo-crypto',
26
+ () => ({
27
+ __esModule: true,
28
+ getRandomBytes: (length: number) => {
29
+ const out = new Uint8Array(length);
30
+ for (let i = 0; i < length; i++) out[i] = (Math.random() * 256) & 0xff;
31
+ return out;
32
+ },
33
+ digestStringAsync: async () => '0'.repeat(64),
34
+ CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
35
+ }),
36
+ { virtual: true },
37
+ );
38
+
39
+ jest.mock('@oxyhq/protocol', () => {
40
+ const actual = jest.requireActual('@oxyhq/protocol');
41
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
42
+ const { createAsyncStorageMock } = require('./identityMocks');
43
+ const asyncStorage = createAsyncStorageMock();
44
+ return {
45
+ __esModule: true,
46
+ ...actual,
47
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
48
+ loadExpoCrypto: async () => require('expo-crypto'),
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
50
+ loadSecureStore: async () => require('expo-secure-store'),
51
+ loadAsyncStorage: async () => ({ default: asyncStorage }),
52
+ loadSharedIdentityBridge: async () => null,
53
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
54
+ loadNodeCrypto: async () => require('crypto'),
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
56
+ getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
57
+ };
58
+ });
59
+
60
+ const PRIMARY_SVC = 'oxy_identity';
61
+ const BACKUP_SVC = 'oxy_identity_backup';
62
+ const V2_PRIV = 'oxy_identity_private_key_v2';
63
+ const V2_PUB = 'oxy_identity_public_key_v2';
64
+ const V2_BPRIV = 'oxy_identity_backup_private_key_v2';
65
+ const V2_BPUB = 'oxy_identity_backup_public_key_v2';
66
+ // Legacy slots (default keychain service).
67
+ const L_PRIV = 'oxy_identity_private_key';
68
+ const L_PUB = 'oxy_identity_public_key';
69
+ const L_BPRIV = 'oxy_identity_backup_private_key';
70
+ const L_BPUB = 'oxy_identity_backup_public_key';
71
+
72
+ interface SecureStoreTestHandle {
73
+ __resetStore__: () => void;
74
+ __getRaw__: (key: string, service?: string) => string | null;
75
+ __setRaw__: (key: string, value: string, service?: string) => void;
76
+ __simulateKeystoreDeath__: (service: string) => void;
77
+ __failPlan__: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number; failService?: string };
78
+ }
79
+
80
+ interface MigrationCapable {
81
+ _ensureIdentitySlotsMigrated: () => Promise<{ mode: 'v2' | 'legacy' | 'deferred' }>;
82
+ }
83
+
84
+ describe('KeyManager identity slot migration (legacy → v2)', () => {
85
+ let KeyManager: typeof import('../keyManager').KeyManager;
86
+ let ss: SecureStoreTestHandle;
87
+
88
+ const resetCaches = () => {
89
+ const km = KeyManager as unknown as {
90
+ cachedPublicKey: unknown;
91
+ cachedHasIdentity: unknown;
92
+ cachedPublicKeyResolved: unknown;
93
+ };
94
+ km.cachedPublicKey = null;
95
+ km.cachedHasIdentity = null;
96
+ km.cachedPublicKeyResolved = false;
97
+ };
98
+
99
+ beforeAll(() => {
100
+ setPlatformOS('ios');
101
+ (globalThis as unknown as { navigator: unknown }).navigator = { product: 'ReactNative' };
102
+ });
103
+
104
+ beforeEach(async () => {
105
+ jest.resetModules();
106
+ setPlatformOS('ios');
107
+ ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
108
+ ss.__resetStore__();
109
+ const km = await import('../keyManager');
110
+ KeyManager = km.KeyManager;
111
+ resetCaches();
112
+ });
113
+
114
+ /** Seed a valid legacy identity (default keychain service) without migrating. */
115
+ const seedLegacyIdentity = async (): Promise<{ privateKey: string; publicKey: string }> => {
116
+ const kp = await KeyManager.generateKeyPair();
117
+ ss.__setRaw__(L_PRIV, kp.privateKey);
118
+ ss.__setRaw__(L_PUB, kp.publicKey);
119
+ ss.__setRaw__(L_BPRIV, kp.privateKey);
120
+ ss.__setRaw__(L_BPUB, kp.publicKey);
121
+ return kp;
122
+ };
123
+
124
+ it('migrates a healthy legacy identity into the v2 slots and deletes the legacy copy', async () => {
125
+ const kp = await seedLegacyIdentity();
126
+
127
+ // Trigger migration via any accessor.
128
+ expect(await KeyManager.getPublicKey()).toBe(kp.publicKey.toLowerCase());
129
+
130
+ // v2 now owns the identity...
131
+ expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(kp.privateKey.toLowerCase());
132
+ expect(ss.__getRaw__(V2_PUB, PRIMARY_SVC)).toBe(kp.publicKey.toLowerCase());
133
+ // ...and the legacy copy is gone (only after the v2 copy verified).
134
+ expect(ss.__getRaw__(L_PRIV)).toBeNull();
135
+ expect(ss.__getRaw__(L_PUB)).toBeNull();
136
+ expect(await KeyManager.hasIdentity()).toBe(true);
137
+ });
138
+
139
+ it('is idempotent — a second run leaves v2 intact and does not resurrect legacy', async () => {
140
+ const kp = await seedLegacyIdentity();
141
+ await KeyManager.getPublicKey();
142
+ resetCaches();
143
+ // Re-run (fresh caches, same process → migration memoized as v2).
144
+ expect(await KeyManager.getPublicKey()).toBe(kp.publicKey.toLowerCase());
145
+ expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(kp.privateKey.toLowerCase());
146
+ expect(ss.__getRaw__(L_PRIV)).toBeNull();
147
+ });
148
+
149
+ it('interrupted-after-copy (v2 healthy AND legacy still present) → cleans up legacy', async () => {
150
+ // Model a prior run that copied to v2 but crashed before deleting legacy.
151
+ const kp = await KeyManager.generateKeyPair();
152
+ ss.__setRaw__(V2_PRIV, kp.privateKey.toLowerCase(), PRIMARY_SVC);
153
+ ss.__setRaw__(V2_PUB, kp.publicKey.toLowerCase(), PRIMARY_SVC);
154
+ ss.__setRaw__(L_PRIV, kp.privateKey);
155
+ ss.__setRaw__(L_PUB, kp.publicKey);
156
+
157
+ expect(await KeyManager.getPublicKey()).toBe(kp.publicKey.toLowerCase());
158
+ // v2 kept, legacy cleaned up.
159
+ expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(kp.privateKey.toLowerCase());
160
+ expect(ss.__getRaw__(L_PRIV)).toBeNull();
161
+ expect(ss.__getRaw__(L_PUB)).toBeNull();
162
+ });
163
+
164
+ it('a failed v2 write serves the identity from legacy this session and never deletes legacy', async () => {
165
+ const kp = await seedLegacyIdentity();
166
+ // Make every v2 primary public write fail → migration cannot verify v2.
167
+ ss.__failPlan__.failOp = 'set';
168
+ ss.__failPlan__.failKey = V2_PUB;
169
+ ss.__failPlan__.failService = PRIMARY_SVC;
170
+
171
+ // Reads still succeed — served from the UNTOUCHED legacy slots.
172
+ expect(await KeyManager.getPublicKey()).toBe(kp.publicKey);
173
+ expect(await KeyManager.hasIdentity()).toBe(true);
174
+ // Legacy is intact; v2 primary was not left half-written.
175
+ expect(ss.__getRaw__(L_PRIV)).toBe(kp.privateKey);
176
+ expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBeNull();
177
+ });
178
+
179
+ it('a legacy read throwing defers everything (unavailable, zero writes)', async () => {
180
+ await seedLegacyIdentity();
181
+ // v2 read succeeds (empty); the legacy private read throws → defer.
182
+ ss.__failPlan__.failOp = 'get';
183
+ ss.__failPlan__.failKey = L_PRIV;
184
+ ss.__failPlan__.failService = 'default';
185
+
186
+ const status = await KeyManager.getIdentityStatus();
187
+ expect(status.state).toBe('unavailable');
188
+ await expect(KeyManager.hasIdentity()).rejects.toMatchObject({ name: 'IdentityUnavailableError' });
189
+ // Nothing was written to v2.
190
+ expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBeNull();
191
+ });
192
+
193
+ it('a keystore death BEFORE migration (loss predates markers) → absent, not a phantom identity', async () => {
194
+ await seedLegacyIdentity();
195
+ // key_v1 death: the legacy (default-service) entries are deleted on read.
196
+ ss.__simulateKeystoreDeath__('default');
197
+
198
+ const status = await KeyManager.getIdentityStatus();
199
+ // No marker existed (loss predates markers) → a genuine fresh device.
200
+ expect(status.state).toBe('absent');
201
+ expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBeNull();
202
+ });
203
+
204
+ it('a fresh device (no legacy, no v2) resolves to the empty v2 layout', async () => {
205
+ const status = await KeyManager.getIdentityStatus();
206
+ expect(status.state).toBe('absent');
207
+ expect(await KeyManager.hasIdentity()).toBe(false);
208
+ });
209
+
210
+ it('concurrent callers share ONE migration run', async () => {
211
+ const kp = await seedLegacyIdentity();
212
+ const km = KeyManager as unknown as MigrationCapable;
213
+ const [a, b] = await Promise.all([km._ensureIdentitySlotsMigrated(), km._ensureIdentitySlotsMigrated()]);
214
+ // Same memoized result object → a single shared run.
215
+ expect(a).toBe(b);
216
+ expect(a.mode).toBe('v2');
217
+ expect(ss.__getRaw__(V2_PRIV, PRIMARY_SVC)).toBe(kp.privateKey.toLowerCase());
218
+ });
219
+
220
+ it('migrates a healthy legacy backup into the v2 backup slot', async () => {
221
+ const kp = await seedLegacyIdentity();
222
+ await KeyManager.getPublicKey();
223
+ expect(ss.__getRaw__(V2_BPRIV, BACKUP_SVC)).toBe(kp.privateKey.toLowerCase());
224
+ expect(ss.__getRaw__(V2_BPUB, BACKUP_SVC)).toBe(kp.publicKey.toLowerCase());
225
+ expect(ss.__getRaw__(L_BPRIV)).toBeNull();
226
+ });
227
+ });
@@ -24,25 +24,16 @@
24
24
  import { setPlatformOS } from '../../utils/platform';
25
25
 
26
26
  // Mock expo-secure-store BEFORE importing KeyManager so the lazy import
27
- // inside keyManager picks up our in-memory implementation.
27
+ // inside keyManager picks up our in-memory implementation. The shared mock keys
28
+ // entries by `(keychainService ?? 'default') + ' ' + key` so the v2 slot layout
29
+ // (primary service `oxy_identity`, backup service `oxy_identity_backup`) is
30
+ // modeled faithfully.
28
31
  jest.mock(
29
32
  'expo-secure-store',
30
33
  () => {
31
- const store = new Map<string, string>();
32
- return {
33
- __esModule: true,
34
- WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
35
- WHEN_UNLOCKED: 'WHEN_UNLOCKED',
36
- setItemAsync: jest.fn(async (key: string, value: string) => {
37
- store.set(key, value);
38
- }),
39
- getItemAsync: jest.fn(async (key: string) => store.get(key) ?? null),
40
- deleteItemAsync: jest.fn(async (key: string) => {
41
- store.delete(key);
42
- }),
43
- __resetStore__: () => store.clear(),
44
- __getStore__: () => store,
45
- };
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const { createSecureStoreMock } = require('./identityMocks');
36
+ return createSecureStoreMock();
46
37
  },
47
38
  { virtual: true },
48
39
  );
@@ -72,33 +63,44 @@ jest.mock(
72
63
  // Node's built-in `crypto`, not `expo-*`. For the test suite to exercise the
73
64
  // RN code paths, we override only the platform loaders to delegate to the
74
65
  // virtual `expo-*` modules registered above, keeping every other protocol
75
- // export (canonical bytes, signing, the platform predicates) real.
76
- jest.mock('@oxyhq/protocol', () => ({
77
- __esModule: true,
78
- ...jest.requireActual('@oxyhq/protocol'),
79
- loadExpoCrypto: async () => {
66
+ // export (canonical bytes, signing, the platform predicates) real. AsyncStorage
67
+ // is a real in-memory map so the identity marker + advisory migration flag work.
68
+ jest.mock('@oxyhq/protocol', () => {
69
+ const actual = jest.requireActual('@oxyhq/protocol');
70
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
71
+ const { createAsyncStorageMock } = require('./identityMocks');
72
+ const asyncStorage = createAsyncStorageMock();
73
+ return {
74
+ __esModule: true,
75
+ ...actual,
80
76
  // eslint-disable-next-line @typescript-eslint/no-require-imports
81
- return require('expo-crypto');
82
- },
83
- loadSecureStore: async () => {
77
+ loadExpoCrypto: async () => require('expo-crypto'),
84
78
  // eslint-disable-next-line @typescript-eslint/no-require-imports
85
- return require('expo-secure-store');
86
- },
87
- loadAsyncStorage: async () => {
88
- // Tests don't currently exercise AsyncStorage paths; return a stub
89
- // shaped like the real module so accidental calls fail loudly.
90
- return { default: { getItem: async () => null, setItem: async () => undefined, removeItem: async () => undefined } };
91
- },
92
- loadNodeCrypto: async () => {
79
+ loadSecureStore: async () => require('expo-secure-store'),
80
+ loadAsyncStorage: async () => ({ default: asyncStorage }),
81
+ loadSharedIdentityBridge: async () => null,
93
82
  // eslint-disable-next-line @typescript-eslint/no-require-imports
94
- return require('crypto');
95
- },
96
- getRandomBytesRN: (n: number) => {
83
+ loadNodeCrypto: async () => require('crypto'),
97
84
  // eslint-disable-next-line @typescript-eslint/no-require-imports
98
- const crypto = require('expo-crypto');
99
- return crypto.getRandomBytes(n);
100
- },
101
- }));
85
+ getRandomBytesRN: (n: number) => require('expo-crypto').getRandomBytes(n),
86
+ };
87
+ });
88
+
89
+ // v2 slot key names + keychain services (must match keyManager.ts).
90
+ const PRIMARY_SVC = 'oxy_identity';
91
+ const BACKUP_SVC = 'oxy_identity_backup';
92
+ const V2_PRIV = 'oxy_identity_private_key_v2';
93
+ const V2_PUB = 'oxy_identity_public_key_v2';
94
+ const V2_BPRIV = 'oxy_identity_backup_private_key_v2';
95
+ const V2_BPUB = 'oxy_identity_backup_public_key_v2';
96
+ const V2_BTS = 'oxy_identity_backup_timestamp_v2';
97
+
98
+ interface SecureStoreTestHandle {
99
+ __resetStore__: () => void;
100
+ __getRaw__: (key: string, service?: string) => string | null;
101
+ __setRaw__: (key: string, value: string, service?: string) => void;
102
+ __deleteRaw__: (key: string, service?: string) => void;
103
+ }
102
104
 
103
105
  describe('KeyManager safety invariants', () => {
104
106
  let KeyManager: typeof import('../keyManager').KeyManager;
@@ -123,14 +125,23 @@ describe('KeyManager safety invariants', () => {
123
125
  const km = await import('../keyManager');
124
126
  KeyManager = km.KeyManager;
125
127
  IdentityAlreadyExistsError = km.IdentityAlreadyExistsError;
126
- // Invalidate caches between tests.
127
- (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
128
- (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
128
+ resetCaches();
129
129
 
130
130
  const rp = await import('../recoveryPhrase');
131
131
  RecoveryPhraseService = rp.RecoveryPhraseService;
132
132
  });
133
133
 
134
+ const resetCaches = () => {
135
+ const km = KeyManager as unknown as {
136
+ cachedPublicKey: unknown;
137
+ cachedHasIdentity: unknown;
138
+ cachedPublicKeyResolved: unknown;
139
+ };
140
+ km.cachedPublicKey = null;
141
+ km.cachedHasIdentity = null;
142
+ km.cachedPublicKeyResolved = false;
143
+ };
144
+
134
145
  describe('createIdentity', () => {
135
146
  it('persists a complete identity with backup on first call', async () => {
136
147
  const publicKey = await KeyManager.createIdentity();
@@ -138,14 +149,11 @@ describe('KeyManager safety invariants', () => {
138
149
 
139
150
  expect(await KeyManager.hasIdentity()).toBe(true);
140
151
  expect(await KeyManager.verifyIdentityIntegrity()).toBe(true);
141
- // Backup was written as part of the atomic persist
142
- const store = (await import('expo-secure-store' as string)) as unknown as {
143
- __getStore__: () => Map<string, string>;
144
- };
145
- const m = store.__getStore__();
146
- expect(m.get('oxy_identity_backup_private_key')).toBeTruthy();
147
- expect(m.get('oxy_identity_backup_public_key')).toBeTruthy();
148
- expect(m.get('oxy_identity_backup_timestamp')).toBeTruthy();
152
+ // Backup was written to the isolated v2 backup slot as part of the atomic persist
153
+ const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
154
+ expect(store.__getRaw__(V2_BPRIV, BACKUP_SVC)).toBeTruthy();
155
+ expect(store.__getRaw__(V2_BPUB, BACKUP_SVC)).toBeTruthy();
156
+ expect(store.__getRaw__(V2_BTS, BACKUP_SVC)).toBeTruthy();
149
157
  });
150
158
 
151
159
  it('refuses to overwrite an existing identity without explicit consent', async () => {
@@ -193,27 +201,20 @@ describe('KeyManager safety invariants', () => {
193
201
  });
194
202
 
195
203
  it('returns false when only the private key was written (partial state)', async () => {
196
- const store = (await import('expo-secure-store' as string)) as unknown as {
197
- __getStore__: () => Map<string, string>;
198
- };
199
- const m = store.__getStore__();
204
+ const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
200
205
  // Simulate a half-written identity: private without public
201
- m.set('oxy_identity_private_key', 'a'.repeat(64));
206
+ store.__setRaw__(V2_PRIV, 'a'.repeat(64), PRIMARY_SVC);
202
207
  // Invalidate cache so the next call re-reads
203
- (KeyManager as unknown as { cachedHasIdentity: unknown }).cachedHasIdentity = null;
208
+ resetCaches();
204
209
  expect(await KeyManager.hasIdentity()).toBe(false);
205
210
  });
206
211
 
207
212
  it('returns false when the stored public key does not derive from the private key', async () => {
208
213
  await KeyManager.createIdentity();
209
- const store = (await import('expo-secure-store' as string)) as unknown as {
210
- __getStore__: () => Map<string, string>;
211
- };
212
- const m = store.__getStore__();
214
+ const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
213
215
  // Tamper with the stored public key
214
- m.set('oxy_identity_public_key', '04' + 'b'.repeat(128));
215
- (KeyManager as unknown as { cachedHasIdentity: unknown; cachedPublicKey: unknown }).cachedHasIdentity = null;
216
- (KeyManager as unknown as { cachedHasIdentity: unknown; cachedPublicKey: unknown }).cachedPublicKey = null;
216
+ store.__setRaw__(V2_PUB, '04' + 'b'.repeat(128), PRIMARY_SVC);
217
+ resetCaches();
217
218
  expect(await KeyManager.hasIdentity()).toBe(false);
218
219
  });
219
220
  });
@@ -226,11 +227,9 @@ describe('KeyManager safety invariants', () => {
226
227
 
227
228
  it('returns false when the stored keys do not match', async () => {
228
229
  await KeyManager.createIdentity();
229
- const store = (await import('expo-secure-store' as string)) as unknown as {
230
- __getStore__: () => Map<string, string>;
231
- };
232
- store.__getStore__().set('oxy_identity_public_key', '04' + 'c'.repeat(128));
233
- (KeyManager as unknown as { cachedPublicKey: unknown }).cachedPublicKey = null;
230
+ const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
231
+ store.__setRaw__(V2_PUB, '04' + 'c'.repeat(128), PRIMARY_SVC);
232
+ resetCaches();
234
233
  expect(await KeyManager.verifyIdentityIntegrity()).toBe(false);
235
234
  });
236
235
  });
@@ -246,40 +245,31 @@ describe('KeyManager safety invariants', () => {
246
245
 
247
246
  it('refuses to restore if the backup public key does not match a still-present (broken) primary', async () => {
248
247
  await KeyManager.createIdentity();
249
- const store = (await import('expo-secure-store' as string)) as unknown as {
250
- __getStore__: () => Map<string, string>;
251
- };
252
- const m = store.__getStore__();
248
+ const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
253
249
  // Corrupt the primary public key (so integrity fails), but leave the
254
250
  // broken primary in place. The backup will not match.
255
- m.set('oxy_identity_public_key', '04' + 'd'.repeat(128));
251
+ store.__setRaw__(V2_PUB, '04' + 'd'.repeat(128), PRIMARY_SVC);
256
252
  // Tamper with the backup too — write a backup from a completely
257
253
  // different identity.
258
254
  const otherPair = await KeyManager.generateKeyPair();
259
- m.set('oxy_identity_backup_private_key', otherPair.privateKey);
260
- m.set('oxy_identity_backup_public_key', otherPair.publicKey);
261
-
262
- (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
263
- (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
255
+ store.__setRaw__(V2_BPRIV, otherPair.privateKey, BACKUP_SVC);
256
+ store.__setRaw__(V2_BPUB, otherPair.publicKey, BACKUP_SVC);
257
+ resetCaches();
264
258
 
265
259
  const restored = await KeyManager.restoreIdentityFromBackup();
266
260
  expect(restored).toBe(false);
267
261
  // The (corrupted) primary public key should be unchanged, not the
268
262
  // attacker-backup public key.
269
- expect(m.get('oxy_identity_public_key')).not.toBe(otherPair.publicKey);
263
+ expect(store.__getRaw__(V2_PUB, PRIMARY_SVC)).not.toBe(otherPair.publicKey);
270
264
  });
271
265
 
272
266
  it('restores a missing primary from a valid backup', async () => {
273
267
  const original = await KeyManager.createIdentity();
274
- const store = (await import('expo-secure-store' as string)) as unknown as {
275
- __getStore__: () => Map<string, string>;
276
- };
277
- const m = store.__getStore__();
268
+ const store = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
278
269
  // Wipe primary only
279
- m.delete('oxy_identity_private_key');
280
- m.delete('oxy_identity_public_key');
281
- (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
282
- (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
270
+ store.__deleteRaw__(V2_PRIV, PRIMARY_SVC);
271
+ store.__deleteRaw__(V2_PUB, PRIMARY_SVC);
272
+ resetCaches();
283
273
 
284
274
  const restored = await KeyManager.restoreIdentityFromBackup();
285
275
  expect(restored).toBe(true);