@oxyhq/core 1.11.11 → 1.11.13

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 (130) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +3 -1
  3. package/dist/cjs/HttpService.js +227 -51
  4. package/dist/cjs/OxyServices.base.js +9 -0
  5. package/dist/cjs/OxyServices.js +8 -3
  6. package/dist/cjs/crypto/index.js +3 -1
  7. package/dist/cjs/crypto/keyManager.js +476 -172
  8. package/dist/cjs/crypto/polyfill.js +14 -65
  9. package/dist/cjs/crypto/recoveryPhrase.js +30 -11
  10. package/dist/cjs/crypto/signatureService.js +25 -60
  11. package/dist/cjs/i18n/locales/en-US.json +46 -1
  12. package/dist/cjs/i18n/locales/es-ES.json +46 -1
  13. package/dist/cjs/i18n/locales/locales/en-US.json +46 -1
  14. package/dist/cjs/i18n/locales/locales/es-ES.json +46 -1
  15. package/dist/cjs/index.js +7 -2
  16. package/dist/cjs/mixins/OxyServices.assets.js +9 -4
  17. package/dist/cjs/mixins/OxyServices.auth.js +27 -0
  18. package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
  19. package/dist/cjs/mixins/OxyServices.features.js +0 -11
  20. package/dist/cjs/mixins/OxyServices.fedcm.js +4 -3
  21. package/dist/cjs/mixins/OxyServices.language.js +5 -36
  22. package/dist/cjs/mixins/OxyServices.redirect.js +6 -2
  23. package/dist/cjs/mixins/OxyServices.security.js +13 -2
  24. package/dist/cjs/mixins/OxyServices.user.js +70 -38
  25. package/dist/cjs/mixins/OxyServices.utility.js +19 -43
  26. package/dist/cjs/mixins/index.js +11 -3
  27. package/dist/cjs/utils/accountUtils.js +71 -2
  28. package/dist/cjs/utils/asyncUtils.js +34 -5
  29. package/dist/cjs/utils/deviceManager.js +5 -36
  30. package/dist/cjs/utils/platformCrypto.js +165 -0
  31. package/dist/cjs/utils/platformCrypto.native.js +123 -0
  32. package/dist/esm/.tsbuildinfo +1 -1
  33. package/dist/esm/CrossDomainAuth.js +3 -1
  34. package/dist/esm/HttpService.js +228 -52
  35. package/dist/esm/OxyServices.base.js +9 -0
  36. package/dist/esm/OxyServices.js +8 -3
  37. package/dist/esm/crypto/index.js +1 -1
  38. package/dist/esm/crypto/keyManager.js +473 -138
  39. package/dist/esm/crypto/polyfill.js +14 -32
  40. package/dist/esm/crypto/recoveryPhrase.js +30 -11
  41. package/dist/esm/crypto/signatureService.js +25 -27
  42. package/dist/esm/i18n/locales/en-US.json +46 -1
  43. package/dist/esm/i18n/locales/es-ES.json +46 -1
  44. package/dist/esm/i18n/locales/locales/en-US.json +46 -1
  45. package/dist/esm/i18n/locales/locales/es-ES.json +46 -1
  46. package/dist/esm/index.js +2 -2
  47. package/dist/esm/mixins/OxyServices.assets.js +9 -4
  48. package/dist/esm/mixins/OxyServices.auth.js +27 -0
  49. package/dist/esm/mixins/OxyServices.contacts.js +47 -0
  50. package/dist/esm/mixins/OxyServices.features.js +0 -11
  51. package/dist/esm/mixins/OxyServices.fedcm.js +4 -3
  52. package/dist/esm/mixins/OxyServices.language.js +5 -3
  53. package/dist/esm/mixins/OxyServices.redirect.js +6 -2
  54. package/dist/esm/mixins/OxyServices.security.js +13 -2
  55. package/dist/esm/mixins/OxyServices.user.js +70 -38
  56. package/dist/esm/mixins/OxyServices.utility.js +19 -10
  57. package/dist/esm/mixins/index.js +11 -3
  58. package/dist/esm/utils/accountUtils.js +67 -1
  59. package/dist/esm/utils/asyncUtils.js +34 -5
  60. package/dist/esm/utils/deviceManager.js +5 -3
  61. package/dist/esm/utils/platformCrypto.js +125 -0
  62. package/dist/esm/utils/platformCrypto.native.js +80 -0
  63. package/dist/types/.tsbuildinfo +1 -1
  64. package/dist/types/HttpService.d.ts +47 -3
  65. package/dist/types/OxyServices.base.d.ts +7 -0
  66. package/dist/types/OxyServices.d.ts +36 -3
  67. package/dist/types/crypto/index.d.ts +1 -1
  68. package/dist/types/crypto/keyManager.d.ts +110 -9
  69. package/dist/types/crypto/polyfill.d.ts +3 -1
  70. package/dist/types/crypto/recoveryPhrase.d.ts +31 -7
  71. package/dist/types/crypto/signatureService.d.ts +4 -0
  72. package/dist/types/index.d.ts +4 -3
  73. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  74. package/dist/types/mixins/OxyServices.assets.d.ts +6 -10
  75. package/dist/types/mixins/OxyServices.auth.d.ts +16 -0
  76. package/dist/types/mixins/OxyServices.contacts.d.ts +99 -0
  77. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  78. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  79. package/dist/types/mixins/OxyServices.features.d.ts +2 -7
  80. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  82. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  83. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
  87. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  88. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  90. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  91. package/dist/types/mixins/OxyServices.user.d.ts +40 -11
  92. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  93. package/dist/types/mixins/index.d.ts +52 -4
  94. package/dist/types/models/interfaces.d.ts +62 -3
  95. package/dist/types/utils/accountUtils.d.ts +41 -1
  96. package/dist/types/utils/asyncUtils.d.ts +6 -2
  97. package/dist/types/utils/platformCrypto.d.ts +87 -0
  98. package/dist/types/utils/platformCrypto.native.d.ts +54 -0
  99. package/package.json +28 -1
  100. package/src/CrossDomainAuth.ts +12 -10
  101. package/src/HttpService.ts +264 -51
  102. package/src/OxyServices.base.ts +10 -0
  103. package/src/OxyServices.ts +9 -4
  104. package/src/crypto/__tests__/keyManager.test.ts +336 -0
  105. package/src/crypto/index.ts +6 -1
  106. package/src/crypto/keyManager.ts +529 -151
  107. package/src/crypto/polyfill.ts +14 -34
  108. package/src/crypto/recoveryPhrase.ts +56 -17
  109. package/src/crypto/signatureService.ts +25 -29
  110. package/src/i18n/locales/en-US.json +46 -1
  111. package/src/i18n/locales/es-ES.json +46 -1
  112. package/src/index.ts +16 -3
  113. package/src/mixins/OxyServices.assets.ts +15 -11
  114. package/src/mixins/OxyServices.auth.ts +28 -0
  115. package/src/mixins/OxyServices.contacts.ts +73 -0
  116. package/src/mixins/OxyServices.features.ts +2 -12
  117. package/src/mixins/OxyServices.fedcm.ts +4 -3
  118. package/src/mixins/OxyServices.language.ts +6 -4
  119. package/src/mixins/OxyServices.redirect.ts +6 -2
  120. package/src/mixins/OxyServices.security.ts +18 -8
  121. package/src/mixins/OxyServices.user.ts +90 -49
  122. package/src/mixins/OxyServices.utility.ts +19 -10
  123. package/src/mixins/index.ts +58 -7
  124. package/src/models/interfaces.ts +65 -3
  125. package/src/utils/__tests__/asyncUtils.test.ts +187 -0
  126. package/src/utils/accountUtils.ts +82 -2
  127. package/src/utils/asyncUtils.ts +39 -9
  128. package/src/utils/deviceManager.ts +7 -4
  129. package/src/utils/platformCrypto.native.ts +101 -0
  130. package/src/utils/platformCrypto.ts +145 -0
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Tests for KeyManager safety invariants.
3
+ *
4
+ * These tests pin down the no-clobber guarantees that protect users from
5
+ * permanent account loss. Every scenario below corresponds to a real bug
6
+ * that COULD silently destroy an account's only copy of the private key:
7
+ *
8
+ * 1. createIdentity() must refuse to overwrite an existing identity
9
+ * unless { overwrite: true } is passed.
10
+ * 2. importKeyPair() must refuse to clobber a DIFFERENT existing identity.
11
+ * 3. importKeyPair() with the SAME phrase should be idempotent.
12
+ * 4. After createIdentity(), hasIdentity() must report true AND
13
+ * verifyIdentityIntegrity() must succeed AND a backup must exist.
14
+ * 5. RecoveryPhraseService must be a function (same phrase always
15
+ * produces the same public key).
16
+ * 6. RecoveryPhraseService round-trip: derive phrase → restore phrase
17
+ * must yield the exact same public key.
18
+ * 7. restoreIdentityFromBackup() must refuse to overwrite a verifying
19
+ * primary, and must refuse to switch the user to a different
20
+ * identity when the backup public key doesn't match the (broken)
21
+ * primary public key.
22
+ */
23
+
24
+ import { setPlatformOS } from '../../utils/platform';
25
+
26
+ // Mock expo-secure-store BEFORE importing KeyManager so the lazy import
27
+ // inside keyManager picks up our in-memory implementation.
28
+ jest.mock(
29
+ 'expo-secure-store',
30
+ () => {
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
+ };
46
+ },
47
+ { virtual: true },
48
+ );
49
+
50
+ jest.mock(
51
+ 'expo-crypto',
52
+ () => ({
53
+ __esModule: true,
54
+ getRandomBytes: (length: number) => {
55
+ // Deterministic-but-distinct random for tests
56
+ const out = new Uint8Array(length);
57
+ for (let i = 0; i < length; i++) {
58
+ out[i] = (Math.random() * 256) & 0xff;
59
+ }
60
+ return out;
61
+ },
62
+ digestStringAsync: async () => '0'.repeat(64),
63
+ CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
64
+ }),
65
+ { virtual: true },
66
+ );
67
+
68
+ // Production code routes platform-specific module loads through
69
+ // `platformCrypto`, which ships in two physical variants on disk
70
+ // (`platformCrypto.ts` / `platformCrypto.react-native.ts`) selected by the
71
+ // consumer's bundler. Jest runs on Node — it picks the default variant,
72
+ // which references Node's built-in `crypto`, not `expo-*`. For the test
73
+ // suite to exercise the RN code paths, we mock the helper module to
74
+ // delegate to the virtual `expo-*` modules registered above.
75
+ jest.mock('../../utils/platformCrypto', () => ({
76
+ __esModule: true,
77
+ loadExpoCrypto: async () => {
78
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
79
+ return require('expo-crypto');
80
+ },
81
+ loadSecureStore: async () => {
82
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
83
+ return require('expo-secure-store');
84
+ },
85
+ loadAsyncStorage: async () => {
86
+ // Tests don't currently exercise AsyncStorage paths; return a stub
87
+ // shaped like the real module so accidental calls fail loudly.
88
+ return { default: { getItem: async () => null, setItem: async () => undefined, removeItem: async () => undefined } };
89
+ },
90
+ loadNodeCrypto: async () => {
91
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
92
+ return require('crypto');
93
+ },
94
+ getRandomBytesRN: (n: number) => {
95
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
96
+ const crypto = require('expo-crypto');
97
+ return crypto.getRandomBytes(n);
98
+ },
99
+ }));
100
+
101
+ describe('KeyManager safety invariants', () => {
102
+ let KeyManager: typeof import('../keyManager').KeyManager;
103
+ let IdentityAlreadyExistsError: typeof import('../keyManager').IdentityAlreadyExistsError;
104
+ let RecoveryPhraseService: typeof import('../recoveryPhrase').RecoveryPhraseService;
105
+
106
+ beforeAll(() => {
107
+ // KeyManager refuses to operate on 'web'; pretend we're on iOS for tests.
108
+ setPlatformOS('ios');
109
+ // navigator.product is checked by some helpers; set it for RN-detection.
110
+ (globalThis as any).navigator = { product: 'ReactNative' };
111
+ });
112
+
113
+ beforeEach(async () => {
114
+ jest.resetModules();
115
+ setPlatformOS('ios');
116
+ const secureStore = (await import('expo-secure-store' as string)) as unknown as {
117
+ __resetStore__: () => void;
118
+ };
119
+ secureStore.__resetStore__();
120
+
121
+ const km = await import('../keyManager');
122
+ KeyManager = km.KeyManager;
123
+ IdentityAlreadyExistsError = km.IdentityAlreadyExistsError;
124
+ // Invalidate caches between tests.
125
+ (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
126
+ (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
127
+
128
+ const rp = await import('../recoveryPhrase');
129
+ RecoveryPhraseService = rp.RecoveryPhraseService;
130
+ });
131
+
132
+ describe('createIdentity', () => {
133
+ it('persists a complete identity with backup on first call', async () => {
134
+ const publicKey = await KeyManager.createIdentity();
135
+ expect(publicKey).toMatch(/^[0-9a-f]+$/i);
136
+
137
+ expect(await KeyManager.hasIdentity()).toBe(true);
138
+ expect(await KeyManager.verifyIdentityIntegrity()).toBe(true);
139
+ // Backup was written as part of the atomic persist
140
+ const store = (await import('expo-secure-store' as string)) as unknown as {
141
+ __getStore__: () => Map<string, string>;
142
+ };
143
+ const m = store.__getStore__();
144
+ expect(m.get('oxy_identity_backup_private_key')).toBeTruthy();
145
+ expect(m.get('oxy_identity_backup_public_key')).toBeTruthy();
146
+ expect(m.get('oxy_identity_backup_timestamp')).toBeTruthy();
147
+ });
148
+
149
+ it('refuses to overwrite an existing identity without explicit consent', async () => {
150
+ const firstPublicKey = await KeyManager.createIdentity();
151
+ await expect(KeyManager.createIdentity()).rejects.toBeInstanceOf(IdentityAlreadyExistsError);
152
+ // Original identity is unchanged
153
+ expect(await KeyManager.getPublicKey()).toBe(firstPublicKey);
154
+ });
155
+
156
+ it('allows overwrite when explicitly requested', async () => {
157
+ const firstPublicKey = await KeyManager.createIdentity();
158
+ const secondPublicKey = await KeyManager.createIdentity({ overwrite: true });
159
+ expect(secondPublicKey).not.toBe(firstPublicKey);
160
+ expect(await KeyManager.getPublicKey()).toBe(secondPublicKey);
161
+ });
162
+ });
163
+
164
+ describe('importKeyPair', () => {
165
+ it('refuses to clobber a DIFFERENT existing identity', async () => {
166
+ await KeyManager.createIdentity();
167
+ // Generate a separate identity to attempt importing
168
+ const otherPrivate = (await KeyManager.generateKeyPair()).privateKey;
169
+ await expect(KeyManager.importKeyPair(otherPrivate)).rejects.toBeInstanceOf(
170
+ IdentityAlreadyExistsError,
171
+ );
172
+ });
173
+
174
+ it('is a no-op refresh when importing the SAME identity', async () => {
175
+ const firstPublic = await KeyManager.createIdentity();
176
+ const currentPrivate = await KeyManager.getPrivateKey();
177
+ if (!currentPrivate) throw new Error('expected private key');
178
+ const reimported = await KeyManager.importKeyPair(currentPrivate);
179
+ expect(reimported).toBe(firstPublic);
180
+ expect(await KeyManager.getPublicKey()).toBe(firstPublic);
181
+ });
182
+
183
+ it('rejects invalid private keys', async () => {
184
+ await expect(KeyManager.importKeyPair('not-hex')).rejects.toThrow(/Invalid private key/);
185
+ });
186
+ });
187
+
188
+ describe('hasIdentity', () => {
189
+ it('returns false when no identity is stored', async () => {
190
+ expect(await KeyManager.hasIdentity()).toBe(false);
191
+ });
192
+
193
+ it('returns false when only the private key was written (partial state)', async () => {
194
+ const store = (await import('expo-secure-store' as string)) as unknown as {
195
+ __getStore__: () => Map<string, string>;
196
+ };
197
+ const m = store.__getStore__();
198
+ // Simulate a half-written identity: private without public
199
+ m.set('oxy_identity_private_key', 'a'.repeat(64));
200
+ // Invalidate cache so the next call re-reads
201
+ (KeyManager as unknown as { cachedHasIdentity: unknown }).cachedHasIdentity = null;
202
+ expect(await KeyManager.hasIdentity()).toBe(false);
203
+ });
204
+
205
+ it('returns false when the stored public key does not derive from the private key', async () => {
206
+ await KeyManager.createIdentity();
207
+ const store = (await import('expo-secure-store' as string)) as unknown as {
208
+ __getStore__: () => Map<string, string>;
209
+ };
210
+ const m = store.__getStore__();
211
+ // Tamper with the stored public key
212
+ m.set('oxy_identity_public_key', '04' + 'b'.repeat(128));
213
+ (KeyManager as unknown as { cachedHasIdentity: unknown; cachedPublicKey: unknown }).cachedHasIdentity = null;
214
+ (KeyManager as unknown as { cachedHasIdentity: unknown; cachedPublicKey: unknown }).cachedPublicKey = null;
215
+ expect(await KeyManager.hasIdentity()).toBe(false);
216
+ });
217
+ });
218
+
219
+ describe('verifyIdentityIntegrity', () => {
220
+ it('returns true for a fresh identity', async () => {
221
+ await KeyManager.createIdentity();
222
+ expect(await KeyManager.verifyIdentityIntegrity()).toBe(true);
223
+ });
224
+
225
+ it('returns false when the stored keys do not match', async () => {
226
+ await KeyManager.createIdentity();
227
+ const store = (await import('expo-secure-store' as string)) as unknown as {
228
+ __getStore__: () => Map<string, string>;
229
+ };
230
+ store.__getStore__().set('oxy_identity_public_key', '04' + 'c'.repeat(128));
231
+ (KeyManager as unknown as { cachedPublicKey: unknown }).cachedPublicKey = null;
232
+ expect(await KeyManager.verifyIdentityIntegrity()).toBe(false);
233
+ });
234
+ });
235
+
236
+ describe('restoreIdentityFromBackup', () => {
237
+ it('does NOT overwrite a verifying primary', async () => {
238
+ const firstPublic = await KeyManager.createIdentity();
239
+ const restored = await KeyManager.restoreIdentityFromBackup();
240
+ expect(restored).toBe(false);
241
+ // Primary unchanged
242
+ expect(await KeyManager.getPublicKey()).toBe(firstPublic);
243
+ });
244
+
245
+ it('refuses to restore if the backup public key does not match a still-present (broken) primary', async () => {
246
+ await KeyManager.createIdentity();
247
+ const store = (await import('expo-secure-store' as string)) as unknown as {
248
+ __getStore__: () => Map<string, string>;
249
+ };
250
+ const m = store.__getStore__();
251
+ // Corrupt the primary public key (so integrity fails), but leave the
252
+ // broken primary in place. The backup will not match.
253
+ m.set('oxy_identity_public_key', '04' + 'd'.repeat(128));
254
+ // Tamper with the backup too — write a backup from a completely
255
+ // different identity.
256
+ const otherPair = await KeyManager.generateKeyPair();
257
+ m.set('oxy_identity_backup_private_key', otherPair.privateKey);
258
+ m.set('oxy_identity_backup_public_key', otherPair.publicKey);
259
+
260
+ (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
261
+ (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
262
+
263
+ const restored = await KeyManager.restoreIdentityFromBackup();
264
+ expect(restored).toBe(false);
265
+ // The (corrupted) primary public key should be unchanged, not the
266
+ // attacker-backup public key.
267
+ expect(m.get('oxy_identity_public_key')).not.toBe(otherPair.publicKey);
268
+ });
269
+
270
+ it('restores a missing primary from a valid backup', async () => {
271
+ const original = await KeyManager.createIdentity();
272
+ const store = (await import('expo-secure-store' as string)) as unknown as {
273
+ __getStore__: () => Map<string, string>;
274
+ };
275
+ const m = store.__getStore__();
276
+ // Wipe primary only
277
+ m.delete('oxy_identity_private_key');
278
+ m.delete('oxy_identity_public_key');
279
+ (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
280
+ (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
281
+
282
+ const restored = await KeyManager.restoreIdentityFromBackup();
283
+ expect(restored).toBe(true);
284
+ expect(await KeyManager.getPublicKey()).toBe(original);
285
+ expect(await KeyManager.verifyIdentityIntegrity()).toBe(true);
286
+ });
287
+ });
288
+
289
+ describe('RecoveryPhraseService round-trip determinism', () => {
290
+ it('always derives the SAME public key from the same phrase', async () => {
291
+ const result = await RecoveryPhraseService.generateIdentityWithRecovery();
292
+ const phrase = result.phrase;
293
+ const firstPublicKey = result.publicKey;
294
+
295
+ // Restore on a "clean device" — wipe and re-import via phrase
296
+ const store = (await import('expo-secure-store' as string)) as unknown as {
297
+ __resetStore__: () => void;
298
+ };
299
+ store.__resetStore__();
300
+ (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
301
+ (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
302
+
303
+ const restoredPublicKey = await RecoveryPhraseService.restoreFromPhrase(phrase);
304
+ expect(restoredPublicKey).toBe(firstPublicKey);
305
+ });
306
+
307
+ it('derivePublicKeyFromPhrase matches the result of restoreFromPhrase', async () => {
308
+ const { phrase, publicKey } = await RecoveryPhraseService.generateIdentityWithRecovery();
309
+ const derived = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
310
+ expect(derived).toBe(publicKey);
311
+ });
312
+
313
+ it('refuses to overwrite an existing different identity during restore', async () => {
314
+ const a = await RecoveryPhraseService.generateIdentityWithRecovery();
315
+ // Reset cache but keep the on-device identity
316
+ (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedPublicKey = null;
317
+ (KeyManager as unknown as { cachedPublicKey: unknown; cachedHasIdentity: unknown }).cachedHasIdentity = null;
318
+
319
+ // Manually generate a different phrase
320
+ const bip39 = await import('bip39');
321
+ const otherPhrase = bip39.generateMnemonic(128);
322
+ // Sanity check phrases are different
323
+ expect(otherPhrase).not.toBe(a.phrase);
324
+
325
+ await expect(RecoveryPhraseService.restoreFromPhrase(otherPhrase)).rejects.toBeInstanceOf(
326
+ IdentityAlreadyExistsError,
327
+ );
328
+ });
329
+
330
+ it('rejects invalid phrases', async () => {
331
+ await expect(
332
+ RecoveryPhraseService.restoreFromPhrase('not a real phrase at all'),
333
+ ).rejects.toThrow(/Invalid recovery phrase/);
334
+ });
335
+ });
336
+ });
@@ -8,7 +8,12 @@
8
8
  // Import polyfills first - this ensures Buffer is available for bip39 and other libraries
9
9
  import './polyfill';
10
10
 
11
- export { KeyManager, type KeyPair } from './keyManager';
11
+ export {
12
+ KeyManager,
13
+ IdentityAlreadyExistsError,
14
+ IdentityPersistError,
15
+ type KeyPair,
16
+ } from './keyManager';
12
17
  export {
13
18
  SignatureService,
14
19
  type SignedMessage,