@oxyhq/core 1.11.12 → 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 (125) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +3 -1
  3. package/dist/cjs/HttpService.js +214 -33
  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 +59 -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/deviceManager.js +5 -36
  29. package/dist/cjs/utils/platformCrypto.js +165 -0
  30. package/dist/cjs/utils/platformCrypto.native.js +123 -0
  31. package/dist/esm/.tsbuildinfo +1 -1
  32. package/dist/esm/CrossDomainAuth.js +3 -1
  33. package/dist/esm/HttpService.js +215 -34
  34. package/dist/esm/OxyServices.base.js +9 -0
  35. package/dist/esm/OxyServices.js +8 -3
  36. package/dist/esm/crypto/index.js +1 -1
  37. package/dist/esm/crypto/keyManager.js +473 -138
  38. package/dist/esm/crypto/polyfill.js +14 -32
  39. package/dist/esm/crypto/recoveryPhrase.js +30 -11
  40. package/dist/esm/crypto/signatureService.js +25 -27
  41. package/dist/esm/i18n/locales/en-US.json +46 -1
  42. package/dist/esm/i18n/locales/es-ES.json +46 -1
  43. package/dist/esm/i18n/locales/locales/en-US.json +46 -1
  44. package/dist/esm/i18n/locales/locales/es-ES.json +46 -1
  45. package/dist/esm/index.js +2 -2
  46. package/dist/esm/mixins/OxyServices.assets.js +9 -4
  47. package/dist/esm/mixins/OxyServices.auth.js +27 -0
  48. package/dist/esm/mixins/OxyServices.contacts.js +47 -0
  49. package/dist/esm/mixins/OxyServices.features.js +0 -11
  50. package/dist/esm/mixins/OxyServices.fedcm.js +4 -3
  51. package/dist/esm/mixins/OxyServices.language.js +5 -3
  52. package/dist/esm/mixins/OxyServices.redirect.js +6 -2
  53. package/dist/esm/mixins/OxyServices.security.js +13 -2
  54. package/dist/esm/mixins/OxyServices.user.js +59 -38
  55. package/dist/esm/mixins/OxyServices.utility.js +19 -10
  56. package/dist/esm/mixins/index.js +11 -3
  57. package/dist/esm/utils/accountUtils.js +67 -1
  58. package/dist/esm/utils/deviceManager.js +5 -3
  59. package/dist/esm/utils/platformCrypto.js +125 -0
  60. package/dist/esm/utils/platformCrypto.native.js +80 -0
  61. package/dist/types/.tsbuildinfo +1 -1
  62. package/dist/types/HttpService.d.ts +47 -3
  63. package/dist/types/OxyServices.base.d.ts +7 -0
  64. package/dist/types/OxyServices.d.ts +36 -3
  65. package/dist/types/crypto/index.d.ts +1 -1
  66. package/dist/types/crypto/keyManager.d.ts +110 -9
  67. package/dist/types/crypto/polyfill.d.ts +3 -1
  68. package/dist/types/crypto/recoveryPhrase.d.ts +31 -7
  69. package/dist/types/crypto/signatureService.d.ts +4 -0
  70. package/dist/types/index.d.ts +4 -3
  71. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  72. package/dist/types/mixins/OxyServices.assets.d.ts +6 -10
  73. package/dist/types/mixins/OxyServices.auth.d.ts +16 -0
  74. package/dist/types/mixins/OxyServices.contacts.d.ts +99 -0
  75. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  76. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  77. package/dist/types/mixins/OxyServices.features.d.ts +2 -7
  78. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  79. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  80. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  82. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  83. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  87. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  88. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.user.d.ts +28 -11
  90. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  91. package/dist/types/mixins/index.d.ts +52 -4
  92. package/dist/types/models/interfaces.d.ts +62 -3
  93. package/dist/types/utils/accountUtils.d.ts +41 -1
  94. package/dist/types/utils/platformCrypto.d.ts +87 -0
  95. package/dist/types/utils/platformCrypto.native.d.ts +54 -0
  96. package/package.json +28 -1
  97. package/src/CrossDomainAuth.ts +12 -10
  98. package/src/HttpService.ts +251 -40
  99. package/src/OxyServices.base.ts +10 -0
  100. package/src/OxyServices.ts +9 -4
  101. package/src/crypto/__tests__/keyManager.test.ts +336 -0
  102. package/src/crypto/index.ts +6 -1
  103. package/src/crypto/keyManager.ts +529 -151
  104. package/src/crypto/polyfill.ts +14 -34
  105. package/src/crypto/recoveryPhrase.ts +56 -17
  106. package/src/crypto/signatureService.ts +25 -30
  107. package/src/i18n/locales/en-US.json +46 -1
  108. package/src/i18n/locales/es-ES.json +46 -1
  109. package/src/index.ts +16 -3
  110. package/src/mixins/OxyServices.assets.ts +15 -11
  111. package/src/mixins/OxyServices.auth.ts +28 -0
  112. package/src/mixins/OxyServices.contacts.ts +73 -0
  113. package/src/mixins/OxyServices.features.ts +2 -12
  114. package/src/mixins/OxyServices.fedcm.ts +4 -3
  115. package/src/mixins/OxyServices.language.ts +6 -4
  116. package/src/mixins/OxyServices.redirect.ts +6 -2
  117. package/src/mixins/OxyServices.security.ts +18 -8
  118. package/src/mixins/OxyServices.user.ts +72 -49
  119. package/src/mixins/OxyServices.utility.ts +19 -10
  120. package/src/mixins/index.ts +58 -7
  121. package/src/models/interfaces.ts +65 -3
  122. package/src/utils/accountUtils.ts +82 -2
  123. package/src/utils/deviceManager.ts +7 -4
  124. package/src/utils/platformCrypto.native.ts +101 -0
  125. package/src/utils/platformCrypto.ts +145 -0
@@ -1,19 +1,71 @@
1
1
  /**
2
2
  * Key Manager - ECDSA secp256k1 Key Generation and Storage
3
- *
3
+ *
4
4
  * Handles secure generation, storage, and retrieval of cryptographic keys.
5
5
  * Private keys are stored securely using expo-secure-store and never leave the device.
6
6
  */
7
7
 
8
8
  import { ec as EC } from 'elliptic';
9
9
  import type { ECKeyPair } from 'elliptic';
10
+ import type { SecureStoreOptions } from 'expo-secure-store';
10
11
  import { isWeb, isIOS, isAndroid, isReactNative, isNodeJS } from '../utils/platform';
12
+ import { loadExpoCrypto, loadNodeCrypto, loadSecureStore } from '../utils/platformCrypto';
11
13
  import { logger } from '../utils/loggerUtils';
12
14
  import { isDev } from '../shared/utils/debugUtils';
13
15
 
14
- // Lazy imports for React Native specific modules
15
- let SecureStore: typeof import('expo-secure-store') | null = null;
16
- let ExpoCrypto: typeof import('expo-crypto') | null = null;
16
+ /**
17
+ * Extended SecureStoreOptions that explicitly includes `keychainAccessGroup`.
18
+ *
19
+ * The shipped `expo-secure-store` types in this repo (see
20
+ * `src/types/expo-secure-store.d.ts`) already declare `keychainAccessGroup`,
21
+ * but we redeclare it here as an `interface extends ...` so the field stays
22
+ * type-safe even when the upstream package types drift. Older versions of
23
+ * `@types/expo-secure-store` omitted this field, which is why the code base
24
+ * used to fall back to `as any` — that escape hatch is now removed.
25
+ */
26
+ interface OxySecureStoreOptions extends SecureStoreOptions {
27
+ /**
28
+ * iOS Keychain access group. Required for sharing identity material across
29
+ * apps in the Oxy ecosystem via Keychain Sharing entitlements. The
30
+ * underlying `expo-secure-store` runtime supports this option.
31
+ */
32
+ keychainAccessGroup?: string;
33
+ }
34
+
35
+ /**
36
+ * Thrown when an identity-mutating operation (createIdentity / importKeyPair)
37
+ * is invoked while a valid identity already exists on the device.
38
+ *
39
+ * The local private key IS the user's identity — overwriting it without
40
+ * explicit consent permanently loses access to their account (unless
41
+ * they previously saved their recovery phrase). This error forces callers
42
+ * to make an explicit, audited decision instead of silently clobbering.
43
+ */
44
+ export class IdentityAlreadyExistsError extends Error {
45
+ override readonly name = 'IdentityAlreadyExistsError';
46
+ readonly existingPublicKey: string;
47
+ constructor(existingPublicKey: string) {
48
+ super(
49
+ 'An identity already exists on this device. Refusing to overwrite without explicit consent. ' +
50
+ 'If you really want to replace it, ensure the user has saved their recovery phrase, then call ' +
51
+ 'the operation with { overwrite: true }.'
52
+ );
53
+ this.existingPublicKey = existingPublicKey;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Thrown when a freshly written identity cannot be read back, parsed, or
59
+ * round-tripped through sign/verify. Indicates a storage failure or
60
+ * corruption that would otherwise silently leave the user with an
61
+ * unusable account.
62
+ */
63
+ export class IdentityPersistError extends Error {
64
+ override readonly name = 'IdentityPersistError';
65
+ constructor(message: string, readonly cause?: unknown) {
66
+ super(message);
67
+ }
68
+ }
17
69
 
18
70
  const ec = new EC('secp256k1');
19
71
 
@@ -45,23 +97,23 @@ const ANDROID_ACCOUNT_TYPE = 'com.oxy.account';
45
97
 
46
98
  /**
47
99
  * Initialize React Native specific modules
48
- * This allows the module to work in both Node.js and React Native environments
100
+ *
101
+ * Delegates to `platformCrypto`, which is a per-platform module
102
+ * (`platformCrypto.ts` vs `platformCrypto.react-native.ts`) selected by the
103
+ * consumer's bundler. On RN it returns a statically-imported handle to
104
+ * `expo-secure-store`; off RN it throws (and is never called because every
105
+ * caller is gated by `isWebPlatform()` / native-only paths).
49
106
  */
50
107
  async function initSecureStore(): Promise<typeof import('expo-secure-store')> {
51
- if (!SecureStore) {
52
- try {
53
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
54
- const moduleName = 'expo-secure-store';
55
- SecureStore = await import(/* @vite-ignore */ moduleName);
56
- } catch (error) {
57
- const errorMessage = error instanceof Error ? error.message : String(error);
58
- throw new Error(`Failed to load expo-secure-store: ${errorMessage}. Make sure expo-secure-store is installed and properly configured.`);
59
- }
60
- }
61
- if (!SecureStore) {
62
- throw new Error('expo-secure-store module is not available');
108
+ try {
109
+ return await loadSecureStore();
110
+ } catch (error) {
111
+ const errorMessage = error instanceof Error ? error.message : String(error);
112
+ throw new Error(
113
+ `Failed to load expo-secure-store: ${errorMessage}. ` +
114
+ 'Make sure expo-secure-store is installed and properly configured.',
115
+ );
63
116
  }
64
- return SecureStore;
65
117
  }
66
118
 
67
119
  /**
@@ -73,12 +125,8 @@ function isWebPlatform(): boolean {
73
125
  }
74
126
 
75
127
  async function initExpoCrypto(): Promise<typeof import('expo-crypto')> {
76
- if (!ExpoCrypto) {
77
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
78
- const moduleName = 'expo-crypto';
79
- ExpoCrypto = await import(/* @vite-ignore */ moduleName);
80
- }
81
- return ExpoCrypto!;
128
+ // Same per-platform delegation as initSecureStore — see comment there.
129
+ return loadExpoCrypto();
82
130
  }
83
131
 
84
132
  /**
@@ -101,14 +149,18 @@ async function getSecureRandomBytes(length: number): Promise<Uint8Array> {
101
149
  return Crypto.getRandomBytes(length);
102
150
  }
103
151
 
104
- // In Node.js, use Node's crypto module
105
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
152
+ // In Node.js, use Node's crypto module.
153
+ //
154
+ // `loadNodeCrypto` is per-platform: the default variant performs
155
+ // `await import('crypto')`, the RN variant throws (and we'd never reach
156
+ // here on RN because the early-return above caught it).
106
157
  try {
107
- const cryptoModuleName = 'crypto';
108
- const nodeCrypto = await import(/* @vite-ignore */ cryptoModuleName);
158
+ const nodeCrypto = await loadNodeCrypto();
109
159
  return new Uint8Array(nodeCrypto.randomBytes(length));
110
160
  } catch (error) {
111
- // Fallback to expo-crypto if Node crypto fails
161
+ // Fallback to expo-crypto if Node crypto fails (defensive — should not
162
+ // happen on real Node, but the platform-detection edge cases are
163
+ // surprisingly varied).
112
164
  const Crypto = await initExpoCrypto();
113
165
  return Crypto.getRandomBytes(length);
114
166
  }
@@ -144,14 +196,33 @@ export class KeyManager {
144
196
  KeyManager.cachedHasSharedIdentity = null;
145
197
  }
146
198
 
199
+ /**
200
+ * Lowercase and pad to canonical 64-hex-char form.
201
+ *
202
+ * Tolerates the 1-in-256 leading-zero-strip that elliptic's
203
+ * `getPrivate('hex')` produces, and the externally-imported uppercase-hex
204
+ * legacy keys. EVERY `ec.keyFromPrivate(...)` call site in this file must
205
+ * canonicalize first so that derivation is stable regardless of storage
206
+ * representation.
207
+ *
208
+ * Private (used only inside KeyManager) — public consumers should not need
209
+ * to think about hex representation.
210
+ */
211
+ private static canonicalPrivateKey(key: string): string {
212
+ return key.toLowerCase().padStart(64, '0');
213
+ }
214
+
147
215
  /**
148
216
  * Generate a new ECDSA secp256k1 key pair
149
217
  * Returns the keys in hexadecimal format
150
218
  */
151
219
  static generateKeyPairSync(): KeyPair {
152
220
  const keyPair = ec.genKeyPair();
221
+ // Pad to canonical 64 hex chars. `elliptic`'s `getPrivate('hex')` strips
222
+ // leading zero bytes which would otherwise corrupt strict-length checks
223
+ // and signature derivation on the read path.
153
224
  return {
154
- privateKey: keyPair.getPrivate('hex'),
225
+ privateKey: keyPair.getPrivate('hex').padStart(64, '0'),
155
226
  publicKey: keyPair.getPublic('hex'),
156
227
  };
157
228
  }
@@ -162,10 +233,10 @@ export class KeyManager {
162
233
  static async generateKeyPair(): Promise<KeyPair> {
163
234
  const randomBytes = await getSecureRandomBytes(32);
164
235
  const privateKeyHex = uint8ArrayToHex(randomBytes);
165
- const keyPair = ec.keyFromPrivate(privateKeyHex);
166
-
236
+ const keyPair = ec.keyFromPrivate(KeyManager.canonicalPrivateKey(privateKeyHex));
237
+
167
238
  return {
168
- privateKey: keyPair.getPrivate('hex'),
239
+ privateKey: keyPair.getPrivate('hex').padStart(64, '0'),
169
240
  publicKey: keyPair.getPublic('hex'),
170
241
  };
171
242
  }
@@ -200,14 +271,16 @@ export class KeyManager {
200
271
  // iOS: Store in shared keychain group
201
272
  // Note: keychainAccessGroup requires Keychain Sharing capability in Xcode
202
273
  try {
203
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
274
+ const privateOpts: OxySecureStoreOptions = {
204
275
  keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
205
- keychainAccessGroup: IOS_KEYCHAIN_GROUP, // This enables sharing across apps
206
- } as any); // Type assertion: keychainAccessGroup may not be in older @types but is supported
276
+ keychainAccessGroup: IOS_KEYCHAIN_GROUP, // Enables sharing across apps
277
+ };
278
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
207
279
 
208
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, {
280
+ const publicOpts: OxySecureStoreOptions = {
209
281
  keychainAccessGroup: IOS_KEYCHAIN_GROUP,
210
- } as any);
282
+ };
283
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
211
284
  } catch (error) {
212
285
  throw new Error(
213
286
  `Failed to create shared identity on iOS. Ensure your app has the Keychain Sharing capability enabled with access group "${IOS_KEYCHAIN_GROUP}". Error: ${error}`
@@ -254,9 +327,8 @@ export class KeyManager {
254
327
  let publicKey: string | null = null;
255
328
 
256
329
  if (isIOS()) {
257
- publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, {
258
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
259
- } as any);
330
+ const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
331
+ publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
260
332
  } else if (isAndroid()) {
261
333
  publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
262
334
  }
@@ -292,9 +364,8 @@ export class KeyManager {
292
364
  let privateKey: string | null = null;
293
365
 
294
366
  if (isIOS()) {
295
- privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, {
296
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
297
- } as any);
367
+ const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
368
+ privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
298
369
  } else if (isAndroid()) {
299
370
  privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
300
371
  }
@@ -357,20 +428,26 @@ export class KeyManager {
357
428
  }
358
429
 
359
430
  const store = await initSecureStore();
360
- const keyPair = ec.keyFromPrivate(privateKey);
431
+ // Canonicalize incoming key BEFORE storage so the stored value is always
432
+ // in canonical 64-hex-char lowercase form going forward. Without this,
433
+ // legacy short keys would derive a different public key on the read path.
434
+ const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
435
+ const keyPair = ec.keyFromPrivate(canonicalPrivate);
361
436
  const publicKey = keyPair.getPublic('hex');
362
437
 
363
438
  if (isIOS()) {
364
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
439
+ const privateOpts: OxySecureStoreOptions = {
365
440
  keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
366
441
  keychainAccessGroup: IOS_KEYCHAIN_GROUP,
367
- } as any);
442
+ };
443
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, privateOpts);
368
444
 
369
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, {
445
+ const publicOpts: OxySecureStoreOptions = {
370
446
  keychainAccessGroup: IOS_KEYCHAIN_GROUP,
371
- } as any);
447
+ };
448
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
372
449
  } else if (isAndroid()) {
373
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
450
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, {
374
451
  keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
375
452
  });
376
453
 
@@ -407,14 +484,16 @@ export class KeyManager {
407
484
  const store = await initSecureStore();
408
485
 
409
486
  if (isIOS()) {
410
- await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, sessionId, {
487
+ const sessionIdOpts: OxySecureStoreOptions = {
411
488
  keychainAccessGroup: IOS_KEYCHAIN_GROUP,
412
- } as any);
489
+ };
490
+ await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, sessionId, sessionIdOpts);
413
491
 
414
- await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, accessToken, {
492
+ const tokenOpts: OxySecureStoreOptions = {
415
493
  keychainAccessible: store.WHEN_UNLOCKED,
416
494
  keychainAccessGroup: IOS_KEYCHAIN_GROUP,
417
- } as any);
495
+ };
496
+ await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, accessToken, tokenOpts);
418
497
  } else if (isAndroid()) {
419
498
  await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, sessionId);
420
499
  await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, accessToken);
@@ -450,13 +529,9 @@ export class KeyManager {
450
529
  let accessToken: string | null = null;
451
530
 
452
531
  if (isIOS()) {
453
- sessionId = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, {
454
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
455
- } as any);
456
-
457
- accessToken = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, {
458
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
459
- } as any);
532
+ const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
533
+ sessionId = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, opts);
534
+ accessToken = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, opts);
460
535
  } else if (isAndroid()) {
461
536
  sessionId = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_ID);
462
537
  accessToken = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN);
@@ -490,12 +565,9 @@ export class KeyManager {
490
565
  const store = await initSecureStore();
491
566
 
492
567
  if (isIOS()) {
493
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, {
494
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
495
- } as any);
496
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, {
497
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
498
- } as any);
568
+ const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
569
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, opts);
570
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, opts);
499
571
  } else if (isAndroid()) {
500
572
  await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_ID);
501
573
  await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN);
@@ -563,50 +635,186 @@ export class KeyManager {
563
635
  // ==================== END SHARED IDENTITY METHODS ====================
564
636
 
565
637
  /**
566
- * Generate and securely store a new key pair on the device
567
- * Returns only the public key (private key is stored securely)
638
+ * Atomically persist a key pair to secure storage with verification + backup.
639
+ *
640
+ * Write order is critical:
641
+ * 1. Backup (BACKUP_PRIVATE_KEY + BACKUP_PUBLIC_KEY + BACKUP_TIMESTAMP)
642
+ * 2. Primary public key
643
+ * 3. Primary private key (last so a partial write leaves us in a known
644
+ * "no identity yet" state — easier to retry than a half-written one)
645
+ * 4. Read back + sign/verify to confirm the storage round-trip works
646
+ *
647
+ * If any step throws, the caller sees the error AND any partial state is
648
+ * cleaned up so the device is left either fully consistent or fully empty.
649
+ * It never leaves an unusable half-identity that would fool `hasIdentity()`.
650
+ *
651
+ * @internal
568
652
  */
569
- static async createIdentity(): Promise<string> {
570
- if (isWebPlatform()) {
571
- throw new Error('Identity creation is only available on native platforms (iOS/Android). Please use the native app to create your identity.');
572
- }
653
+ private static async _persistIdentityAtomic(
654
+ privateKey: string,
655
+ publicKey: string,
656
+ ): Promise<void> {
573
657
  const store = await initSecureStore();
574
- const { privateKey, publicKey } = await KeyManager.generateKeyPair();
575
658
 
576
- await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, privateKey, {
577
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
578
- });
659
+ // Canonicalize BEFORE persistence so the stored value is always in
660
+ // canonical 64-hex-char lowercase form going forward. This is the single
661
+ // place all primary writes flow through, so once a value lands here all
662
+ // subsequent reads see a stable representation.
663
+ const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
664
+ const canonicalPublic = publicKey.toLowerCase();
665
+
666
+ // Step 1: Backup BEFORE touching primary storage so we always have a
667
+ // recoverable copy even if the device crashes mid-write. Store the
668
+ // backup in canonical form too so a backup-restore cycle preserves
669
+ // canonicalization.
670
+ try {
671
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
672
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
673
+ });
674
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
675
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
676
+ } catch (error) {
677
+ logger.error('Failed to write identity backup before primary', error, { component: 'KeyManager' });
678
+ throw new IdentityPersistError('Failed to write identity backup', error);
679
+ }
579
680
 
580
- await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, publicKey);
681
+ // Step 2 + 3: Write primary keys. Public first so that if private write
682
+ // fails we are still missing the most critical bit.
683
+ try {
684
+ await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, canonicalPublic);
685
+ await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, canonicalPrivate, {
686
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
687
+ });
688
+ } catch (error) {
689
+ logger.error('Failed to write primary identity to secure store', error, { component: 'KeyManager' });
690
+ // Roll back the public-key half-write so hasIdentity() doesn't lie later.
691
+ try { await store.deleteItemAsync(STORAGE_KEYS.PUBLIC_KEY); } catch { /* best effort */ }
692
+ try { await store.deleteItemAsync(STORAGE_KEYS.PRIVATE_KEY); } catch { /* best effort */ }
693
+ throw new IdentityPersistError('Failed to write identity to secure store', error);
694
+ }
581
695
 
582
- // Update cache
583
- KeyManager.cachedPublicKey = publicKey;
696
+ // Step 4: Verify round-trip. If the store silently drops our writes
697
+ // (e.g., a misconfigured keychain access group), we MUST surface it
698
+ // before declaring success — otherwise the caller will think the
699
+ // identity was saved and discard the in-memory copy.
700
+ let readBackPrivate: string | null;
701
+ let readBackPublic: string | null;
702
+ try {
703
+ readBackPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
704
+ readBackPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
705
+ } catch (error) {
706
+ logger.error('Failed to read identity back after write', error, { component: 'KeyManager' });
707
+ throw new IdentityPersistError('Failed to verify identity after write', error);
708
+ }
709
+
710
+ // Hex comparisons are case-insensitive — normalize on both sides so a
711
+ // store that uppercases on round-trip (some keychain backends) doesn't
712
+ // trigger a spurious mismatch.
713
+ if (
714
+ readBackPrivate?.toLowerCase() !== canonicalPrivate ||
715
+ readBackPublic?.toLowerCase() !== canonicalPublic
716
+ ) {
717
+ logger.error('Identity round-trip mismatch after write', undefined, { component: 'KeyManager' });
718
+ throw new IdentityPersistError('Identity write was not persisted correctly (round-trip mismatch).');
719
+ }
720
+
721
+ // Final sanity: derive public from the stored private and confirm the
722
+ // pair signs/verifies cleanly. Catches a (theoretical) elliptic library
723
+ // corruption immediately rather than the next time the user tries to
724
+ // sign in.
725
+ try {
726
+ const keyPair = ec.keyFromPrivate(KeyManager.canonicalPrivateKey(readBackPrivate));
727
+ const derived = keyPair.getPublic('hex');
728
+ if (derived.toLowerCase() !== readBackPublic.toLowerCase()) {
729
+ throw new IdentityPersistError('Stored public key does not match derived public key.');
730
+ }
731
+ // Sign/verify roundtrip using a known test vector
732
+ const probeHash = '0'.repeat(64);
733
+ const signature = keyPair.sign(probeHash);
734
+ if (!keyPair.verify(probeHash, signature)) {
735
+ throw new IdentityPersistError('Sign/verify roundtrip failed for newly stored identity.');
736
+ }
737
+ } catch (error) {
738
+ if (error instanceof IdentityPersistError) throw error;
739
+ logger.error('Identity sign/verify probe failed', error, { component: 'KeyManager' });
740
+ throw new IdentityPersistError('Stored identity failed crypto self-test', error);
741
+ }
742
+
743
+ // Update cache only after we are certain the identity is durable.
744
+ KeyManager.cachedPublicKey = canonicalPublic;
584
745
  KeyManager.cachedHasIdentity = true;
746
+ }
585
747
 
748
+ /**
749
+ * Generate and securely store a new key pair on the device.
750
+ *
751
+ * Refuses to overwrite an existing identity unless `options.overwrite === true`.
752
+ * Returns the public key. The private key never leaves secure storage.
753
+ *
754
+ * @throws IdentityAlreadyExistsError if an identity already exists and overwrite is not set
755
+ * @throws IdentityPersistError if the key cannot be durably written
756
+ */
757
+ static async createIdentity(options?: { overwrite?: boolean }): Promise<string> {
758
+ if (isWebPlatform()) {
759
+ throw new Error('Identity creation is only available on native platforms (iOS/Android). Please use the native app to create your identity.');
760
+ }
761
+
762
+ // CRITICAL SAFEGUARD: never silently overwrite an existing identity.
763
+ // The local key IS the account — clobbering it without consent is
764
+ // catastrophic. Callers must opt in explicitly when they have already
765
+ // confirmed (via UI) that the user has saved their recovery phrase.
766
+ if (!options?.overwrite) {
767
+ const existing = await KeyManager.getPublicKey();
768
+ if (existing) {
769
+ throw new IdentityAlreadyExistsError(existing);
770
+ }
771
+ }
772
+
773
+ const { privateKey, publicKey } = await KeyManager.generateKeyPair();
774
+ await KeyManager._persistIdentityAtomic(privateKey, publicKey);
586
775
  return publicKey;
587
776
  }
588
777
 
589
778
  /**
590
- * Import an existing key pair (e.g., from recovery phrase)
779
+ * Import an existing key pair (e.g., from recovery phrase).
780
+ *
781
+ * Refuses to overwrite an existing identity unless `options.overwrite === true`.
782
+ *
783
+ * @throws IdentityAlreadyExistsError if an identity already exists and overwrite is not set
784
+ * @throws IdentityPersistError if the key cannot be durably written
591
785
  */
592
- static async importKeyPair(privateKey: string): Promise<string> {
786
+ static async importKeyPair(
787
+ privateKey: string,
788
+ options?: { overwrite?: boolean },
789
+ ): Promise<string> {
593
790
  if (isWebPlatform()) {
594
791
  throw new Error('Identity import is only available on native platforms (iOS/Android). Please use the native app to import your identity.');
595
792
  }
596
- const store = await initSecureStore();
597
-
598
- const keyPair = ec.keyFromPrivate(privateKey);
599
- const publicKey = keyPair.getPublic('hex');
600
793
 
601
- await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, privateKey, {
602
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
603
- });
604
- await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, publicKey);
794
+ if (!KeyManager.isValidPrivateKey(privateKey)) {
795
+ throw new Error('Invalid private key supplied to importKeyPair.');
796
+ }
605
797
 
606
- // Update cache
607
- KeyManager.cachedPublicKey = publicKey;
608
- KeyManager.cachedHasIdentity = true;
798
+ // Canonicalize the incoming private key so the stored value (and the
799
+ // derived public key) are always in canonical form. Without this, an
800
+ // externally-imported short or uppercase key would derive one public
801
+ // key here and a different one when later read back unpadded.
802
+ const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
803
+ const keyPair = ec.keyFromPrivate(canonicalPrivate);
804
+ const publicKey = keyPair.getPublic('hex');
805
+
806
+ // Refuse silent overwrite — see createIdentity() for rationale.
807
+ if (!options?.overwrite) {
808
+ const existing = await KeyManager.getPublicKey();
809
+ if (existing && existing.toLowerCase() !== publicKey.toLowerCase()) {
810
+ throw new IdentityAlreadyExistsError(existing);
811
+ }
812
+ // If existing === publicKey, the device already has this exact identity;
813
+ // re-persisting is a no-op but harmless. Fall through to ensure backup
814
+ // is up to date.
815
+ }
609
816
 
817
+ await KeyManager._persistIdentityAtomic(canonicalPrivate, publicKey);
610
818
  return publicKey;
611
819
  }
612
820
 
@@ -662,7 +870,15 @@ export class KeyManager {
662
870
  }
663
871
 
664
872
  /**
665
- * Check if an identity (key pair) exists on this device (cached for performance)
873
+ * Check if a complete, parseable identity exists on this device.
874
+ *
875
+ * Returns `true` only when BOTH the private and public keys are present,
876
+ * both are well-formed, AND the public key derives from the private key.
877
+ * A partially-written or corrupted identity returns `false` so that
878
+ * downstream code can resume the create / restore flow correctly.
879
+ *
880
+ * Note: this does NOT perform the full sign/verify roundtrip — call
881
+ * `verifyIdentityIntegrity()` for that.
666
882
  */
667
883
  static async hasIdentity(): Promise<boolean> {
668
884
  if (isWebPlatform()) {
@@ -672,23 +888,88 @@ export class KeyManager {
672
888
  return KeyManager.cachedHasIdentity;
673
889
  }
674
890
 
891
+ let privateKey: string | null;
892
+ let publicKey: string | null;
675
893
  try {
676
- const privateKey = await KeyManager.getPrivateKey();
677
- const hasIdentity = privateKey !== null;
678
-
679
- // Cache result
680
- KeyManager.cachedHasIdentity = hasIdentity;
681
-
682
- return hasIdentity;
894
+ const store = await initSecureStore();
895
+ [privateKey, publicKey] = await Promise.all([
896
+ store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY),
897
+ store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY),
898
+ ]);
683
899
  } catch (error) {
684
- // If we can't check, assume no identity (safer default)
685
- // Cache false to avoid repeated failed attempts
686
- KeyManager.cachedHasIdentity = false;
687
- if (isDev()) {
688
- logger.warn('Failed to check identity', { component: 'KeyManager' }, error);
689
- }
900
+ // Storage threw could be a transient keychain lock (e.g., background
901
+ // fetch before the device is unlocked). Do NOT cache `false`: if we
902
+ // did, the next call would skip storage entirely and return false even
903
+ // after the device is unlocked. Just return false and let the next
904
+ // call retry from storage.
905
+ logger.error('Failed to read identity from secure storage', error, { component: 'KeyManager' });
690
906
  return false;
691
907
  }
908
+
909
+ // Storage succeeded. Now classify the result. From here onward, any
910
+ // outcome is stable and safe to cache (the bytes won't change between
911
+ // calls).
912
+ let hasIdentity = false;
913
+ if (privateKey && publicKey) {
914
+ // Require BOTH bytes-present AND parseable AND matching. Any weaker
915
+ // check would let a half-written identity (private without public)
916
+ // pretend to be a real one, which then fails opaquely later in the
917
+ // sign-in flow when SignatureService.sign() can't find the keypair.
918
+ if (KeyManager.isValidPrivateKey(privateKey) && KeyManager.isValidPublicKey(publicKey)) {
919
+ try {
920
+ const derived = ec
921
+ .keyFromPrivate(KeyManager.canonicalPrivateKey(privateKey))
922
+ .getPublic('hex');
923
+ // Hex equality is case-insensitive; normalize on both sides to
924
+ // tolerate legacy uppercase-stored public keys.
925
+ hasIdentity = derived.toLowerCase() === publicKey.toLowerCase();
926
+ if (!hasIdentity) {
927
+ logger.warn(
928
+ 'KeyManager.hasIdentity: stored public key does not match derived public key',
929
+ { component: 'KeyManager' },
930
+ );
931
+ }
932
+ } catch (error) {
933
+ logger.warn(
934
+ 'KeyManager.hasIdentity: failed to derive public key from stored private key',
935
+ { component: 'KeyManager' },
936
+ error,
937
+ );
938
+ }
939
+ } else {
940
+ logger.warn(
941
+ 'KeyManager.hasIdentity: stored key material is malformed',
942
+ { component: 'KeyManager' },
943
+ );
944
+ }
945
+ }
946
+
947
+ // Cache result. Storage succeeded, so this verdict is stable:
948
+ // - true → identity exists and round-trips cleanly
949
+ // - false → storage is empty / partial / malformed (a stable result;
950
+ // callers should run integrity-recovery / restore from
951
+ // backup explicitly)
952
+ KeyManager.cachedHasIdentity = hasIdentity;
953
+ if (hasIdentity && publicKey) {
954
+ KeyManager.cachedPublicKey = publicKey;
955
+ }
956
+ // Diagnostic breadcrumb (dev only). Logs lengths + validity flags so we
957
+ // can tell from `adb logcat` exactly WHY hasIdentity returned what it
958
+ // did. Never log the key material itself.
959
+ if (isDev()) {
960
+ logger.debug(
961
+ 'KeyManager.hasIdentity result',
962
+ { component: 'KeyManager' },
963
+ {
964
+ privateLen: privateKey?.length ?? 0,
965
+ publicLen: publicKey?.length ?? 0,
966
+ privateValid: privateKey ? KeyManager.isValidPrivateKey(privateKey) : null,
967
+ publicValid: publicKey ? KeyManager.isValidPublicKey(publicKey) : null,
968
+ derived: hasIdentity,
969
+ },
970
+ );
971
+ }
972
+ return hasIdentity;
692
973
  }
693
974
 
694
975
  /**
@@ -787,7 +1068,12 @@ export class KeyManager {
787
1068
  }
788
1069
 
789
1070
  /**
790
- * Verify identity integrity - checks if keys are valid and accessible
1071
+ * Verify identity integrity checks keys are valid, accessible, derive
1072
+ * consistently, AND can sign + verify a probe message.
1073
+ *
1074
+ * Returns true only when the full sign/verify roundtrip succeeds. Use
1075
+ * this on app start to detect silent corruption before the user finds
1076
+ * out by failing to sign in.
791
1077
  */
792
1078
  static async verifyIdentityIntegrity(): Promise<boolean> {
793
1079
  if (isWebPlatform()) {
@@ -801,39 +1087,52 @@ export class KeyManager {
801
1087
  return false;
802
1088
  }
803
1089
 
804
- // Validate private key format
1090
+ // Validate formats
805
1091
  if (!KeyManager.isValidPrivateKey(privateKey)) {
806
1092
  return false;
807
1093
  }
808
-
809
- // Validate public key format
810
1094
  if (!KeyManager.isValidPublicKey(publicKey)) {
811
1095
  return false;
812
1096
  }
813
1097
 
814
- // Verify public key can be derived from private key
1098
+ // Verify public key derives from private key (case-insensitive
1099
+ // because hex is case-insensitive — legacy uppercase stored values
1100
+ // must still validate).
815
1101
  const derivedPublicKey = KeyManager.derivePublicKey(privateKey);
816
- if (derivedPublicKey !== publicKey) {
1102
+ if (derivedPublicKey.toLowerCase() !== publicKey.toLowerCase()) {
817
1103
  return false; // Keys don't match
818
1104
  }
819
1105
 
820
- // Verify we can create a key pair object (tests elliptic curve operations)
821
- const keyPair = await KeyManager.getKeyPairObject();
822
- if (!keyPair) {
1106
+ // Full sign/verify probe proves the keypair is functional, not just
1107
+ // bytewise parseable. A previous version of this method would return
1108
+ // true even when the underlying elliptic curve state was wedged.
1109
+ const keyPair = ec.keyFromPrivate(KeyManager.canonicalPrivateKey(privateKey));
1110
+ const probeHash = '0'.repeat(64);
1111
+ const signature = keyPair.sign(probeHash);
1112
+ if (!keyPair.verify(probeHash, signature)) {
1113
+ logger.error('Identity sign/verify probe failed during integrity check', undefined, { component: 'KeyManager' });
823
1114
  return false;
824
1115
  }
825
1116
 
826
1117
  return true;
827
1118
  } catch (error) {
828
- if (isDev()) {
829
- logger.error('Identity integrity check failed', error, { component: 'KeyManager' });
830
- }
1119
+ logger.error('Identity integrity check failed', error, { component: 'KeyManager' });
831
1120
  return false;
832
1121
  }
833
1122
  }
834
1123
 
835
1124
  /**
836
- * Restore identity from backup if primary storage is corrupted
1125
+ * Restore identity from backup if primary storage is corrupted.
1126
+ *
1127
+ * SAFETY: this method will NEVER overwrite a verifying primary identity.
1128
+ * If the primary passes a sign/verify probe, the backup is left untouched
1129
+ * and `false` is returned — this protects against a transient
1130
+ * `verifyIdentityIntegrity()` blip clobbering valid keys with stale
1131
+ * backup keys (e.g., from a previous account before an import).
1132
+ *
1133
+ * Additionally, if the backup public key does NOT match the (still-
1134
+ * present-but-failing) primary public key, we refuse to overwrite — the
1135
+ * backup may belong to a different identity entirely.
837
1136
  */
838
1137
  static async restoreIdentityFromBackup(): Promise<boolean> {
839
1138
  if (isWebPlatform()) {
@@ -841,7 +1140,15 @@ export class KeyManager {
841
1140
  }
842
1141
  try {
843
1142
  const store = await initSecureStore();
844
-
1143
+
1144
+ // First: if the primary still works, do nothing. Returning true here
1145
+ // would be misleading; returning false (no restore needed) is the
1146
+ // honest answer.
1147
+ const primaryOk = await KeyManager.verifyIdentityIntegrity();
1148
+ if (primaryOk) {
1149
+ return false;
1150
+ }
1151
+
845
1152
  // Check if backup exists
846
1153
  const backupPrivateKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
847
1154
  const backupPublicKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
@@ -851,40 +1158,50 @@ export class KeyManager {
851
1158
  }
852
1159
 
853
1160
  // Verify backup integrity
854
- if (!KeyManager.isValidPrivateKey(backupPrivateKey)) {
1161
+ if (!KeyManager.isValidPrivateKey(backupPrivateKey) || !KeyManager.isValidPublicKey(backupPublicKey)) {
1162
+ logger.warn('Backup identity is malformed; refusing to restore', { component: 'KeyManager' });
855
1163
  return false;
856
1164
  }
857
1165
 
858
- if (!KeyManager.isValidPublicKey(backupPublicKey)) {
1166
+ // Verify backup keys derive consistently. Hex is case-insensitive so
1167
+ // normalize both sides — a legacy uppercase-stored backup must still
1168
+ // be considered valid.
1169
+ const derivedPublicKey = KeyManager.derivePublicKey(backupPrivateKey);
1170
+ if (derivedPublicKey.toLowerCase() !== backupPublicKey.toLowerCase()) {
1171
+ logger.warn('Backup public key does not match derived; refusing to restore', { component: 'KeyManager' });
859
1172
  return false;
860
1173
  }
861
1174
 
862
- // Verify keys match
863
- const derivedPublicKey = KeyManager.derivePublicKey(backupPrivateKey);
864
- if (derivedPublicKey !== backupPublicKey) {
865
- return false; // Backup keys don't match
1175
+ // CRITICAL: if there is still a (broken) primary public key present
1176
+ // that does NOT match the backup, the backup may be from a completely
1177
+ // different identity. Better to surface a corrupted state than
1178
+ // silently switch the user to a different account.
1179
+ const currentPrimaryPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY).catch(() => null);
1180
+ if (
1181
+ currentPrimaryPublic &&
1182
+ currentPrimaryPublic.toLowerCase() !== backupPublicKey.toLowerCase()
1183
+ ) {
1184
+ logger.error(
1185
+ 'Primary identity is corrupted AND does not match the backup. Refusing to restore to avoid switching accounts.',
1186
+ undefined,
1187
+ { component: 'KeyManager' },
1188
+ );
1189
+ return false;
866
1190
  }
867
1191
 
868
- await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, backupPrivateKey, {
869
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
870
- });
871
- await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, backupPublicKey);
872
-
873
- const restored = await KeyManager.verifyIdentityIntegrity();
874
- if (restored) {
875
- // Update cache
876
- KeyManager.cachedPublicKey = backupPublicKey;
877
- KeyManager.cachedHasIdentity = true;
878
-
879
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
880
- return true;
1192
+ // Safe to restore: rebuild the primary using the same atomic write
1193
+ // path createIdentity uses, including verification.
1194
+ try {
1195
+ await KeyManager._persistIdentityAtomic(backupPrivateKey, backupPublicKey);
1196
+ } catch (error) {
1197
+ logger.error('Failed to persist identity restored from backup', error, { component: 'KeyManager' });
1198
+ return false;
881
1199
  }
882
1200
 
883
- return false;
1201
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
1202
+ return true;
884
1203
  } catch (error) {
885
- if (isDev()) {
886
- logger.error('Failed to restore identity from backup', error, { component: 'KeyManager' });
887
- }
1204
+ logger.error('Failed to restore identity from backup', error, { component: 'KeyManager' });
888
1205
  return false;
889
1206
  }
890
1207
  }
@@ -899,39 +1216,100 @@ export class KeyManager {
899
1216
  }
900
1217
  const privateKey = await KeyManager.getPrivateKey();
901
1218
  if (!privateKey) return null;
902
- return ec.keyFromPrivate(privateKey);
1219
+ return ec.keyFromPrivate(KeyManager.canonicalPrivateKey(privateKey));
903
1220
  }
904
1221
 
905
1222
  /**
906
1223
  * Derive public key from a private key (without storing)
907
1224
  */
908
1225
  static derivePublicKey(privateKey: string): string {
909
- const keyPair = ec.keyFromPrivate(privateKey);
1226
+ const keyPair = ec.keyFromPrivate(KeyManager.canonicalPrivateKey(privateKey));
910
1227
  return keyPair.getPublic('hex');
911
1228
  }
912
1229
 
913
1230
  /**
914
1231
  * Validate that a string is a valid public key
1232
+ *
1233
+ * Returns false on parse errors (invalid input is the expected fail mode here).
1234
+ * Errors are logged at debug level so they're available when troubleshooting
1235
+ * but don't pollute production logs.
915
1236
  */
916
1237
  static isValidPublicKey(publicKey: string): boolean {
1238
+ if (typeof publicKey !== 'string' || publicKey.length === 0) {
1239
+ return false;
1240
+ }
1241
+ // secp256k1 public keys are either uncompressed (130 hex chars, starts with 04)
1242
+ // or compressed (66 hex chars, starts with 02 or 03). Anything else is
1243
+ // clearly bogus; reject up front so we never silently widen the trust
1244
+ // boundary by accepting whatever BN(...) parses out of junk input.
1245
+ if (!/^[0-9a-fA-F]+$/.test(publicKey)) {
1246
+ return false;
1247
+ }
1248
+ if (publicKey.length !== 130 && publicKey.length !== 66) {
1249
+ return false;
1250
+ }
917
1251
  try {
918
1252
  ec.keyFromPublic(publicKey, 'hex');
919
1253
  return true;
920
- } catch {
1254
+ } catch (error) {
1255
+ if (isDev()) {
1256
+ logger.debug('[oxy.crypto] isValidPublicKey rejected input', { component: 'KeyManager' }, error);
1257
+ }
921
1258
  return false;
922
1259
  }
923
1260
  }
924
1261
 
925
1262
  /**
926
- * Validate that a string is a valid private key
1263
+ * Validate that a string is a valid private key.
1264
+ *
1265
+ * secp256k1 private keys are 256-bit, so 64 hex chars. We require strict
1266
+ * hex-only input because `elliptic`'s underlying `BN(input, 16)` happily
1267
+ * accepts non-hex characters (treating them as zero), which would let
1268
+ * "not-hex" pass through as a valid (but compromised, near-zero) key.
927
1269
  */
928
1270
  static isValidPrivateKey(privateKey: string): boolean {
1271
+ if (typeof privateKey !== 'string' || privateKey.length === 0) {
1272
+ return false;
1273
+ }
1274
+ if (!/^[0-9a-fA-F]+$/.test(privateKey)) {
1275
+ return false;
1276
+ }
1277
+ // secp256k1 private keys are 32 bytes (64 hex chars). `elliptic`'s
1278
+ // `getPrivate('hex')` strips leading zero bytes, so a valid key whose
1279
+ // leading byte is 0 ends up as 62 hex chars in storage. Accept any
1280
+ // length from 1..64 here — we re-pad before deriving below — and
1281
+ // reject longer than 64.
1282
+ if (privateKey.length > 64) {
1283
+ return false;
1284
+ }
1285
+ const padded = privateKey.padStart(64, '0').toLowerCase();
1286
+ // After padding, require minimum entropy: reject obvious low-scalar
1287
+ // keys. A scalar that fits in 8 hex chars (~32 bits of entropy) is a
1288
+ // degenerate / accidental key, not a real one. The existing isZero()
1289
+ // check below covers literal 0; this also rejects trivially small
1290
+ // scalars like '1', '2', etc. that would otherwise pad to a valid but
1291
+ // weak key whose public point is trivially derivable.
1292
+ if (/^0{56}/.test(padded)) {
1293
+ return false;
1294
+ }
929
1295
  try {
930
- const keyPair = ec.keyFromPrivate(privateKey);
1296
+ const keyPair = ec.keyFromPrivate(padded);
1297
+ const priv = keyPair.getPrivate();
1298
+ // Private key must be > 0 and < curve order n. elliptic doesn't
1299
+ // enforce this on keyFromPrivate, so we do it here.
1300
+ if (priv.isZero() || priv.cmp(ec.curve.n) >= 0) {
1301
+ return false;
1302
+ }
931
1303
  // Verify it can derive a public key
932
- keyPair.getPublic('hex');
1304
+ const pub = keyPair.getPublic('hex');
1305
+ if (!pub || pub.length === 0) {
1306
+ return false;
1307
+ }
933
1308
  return true;
934
- } catch {
1309
+ } catch (error) {
1310
+ if (isDev()) {
1311
+ logger.debug('[oxy.crypto] isValidPrivateKey rejected input', { component: 'KeyManager' }, error);
1312
+ }
935
1313
  return false;
936
1314
  }
937
1315
  }