@oxyhq/core 1.11.12 → 1.11.14

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 +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 +10 -2
  16. package/dist/cjs/mixins/OxyServices.assets.js +9 -4
  17. package/dist/cjs/mixins/OxyServices.auth.js +147 -14
  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 +416 -110
  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/languageUtils.js +22 -0
  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 +215 -34
  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 +4 -3
  47. package/dist/esm/mixins/OxyServices.assets.js +9 -4
  48. package/dist/esm/mixins/OxyServices.auth.js +145 -14
  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 +59 -38
  56. package/dist/esm/mixins/OxyServices.utility.js +416 -77
  57. package/dist/esm/mixins/index.js +11 -3
  58. package/dist/esm/utils/accountUtils.js +67 -1
  59. package/dist/esm/utils/deviceManager.js +5 -3
  60. package/dist/esm/utils/languageUtils.js +21 -0
  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 +50 -7
  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 +7 -5
  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 +82 -5
  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 +28 -11
  92. package/dist/types/mixins/OxyServices.utility.d.ts +145 -10
  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/languageUtils.d.ts +1 -0
  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 +45 -2
  100. package/src/CrossDomainAuth.ts +12 -10
  101. package/src/HttpService.ts +251 -40
  102. package/src/OxyServices.base.ts +10 -0
  103. package/src/OxyServices.ts +26 -7
  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 -30
  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 +19 -4
  113. package/src/mixins/OxyServices.assets.ts +15 -11
  114. package/src/mixins/OxyServices.auth.ts +175 -15
  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 +72 -49
  122. package/src/mixins/OxyServices.utility.ts +562 -89
  123. package/src/mixins/__tests__/serviceAuth.test.ts +623 -0
  124. package/src/mixins/index.ts +58 -7
  125. package/src/models/interfaces.ts +65 -3
  126. package/src/utils/accountUtils.ts +82 -2
  127. package/src/utils/deviceManager.ts +7 -4
  128. package/src/utils/languageUtils.ts +23 -2
  129. package/src/utils/platformCrypto.native.ts +101 -0
  130. package/src/utils/platformCrypto.ts +145 -0
@@ -7,11 +7,40 @@
7
7
  import _cjs_elliptic from 'elliptic';
8
8
  const { ec: EC } = _cjs_elliptic;
9
9
  import { isWeb, isIOS, isAndroid, isReactNative, isNodeJS } from '../utils/platform.js';
10
+ import { loadExpoCrypto, loadNodeCrypto, loadSecureStore } from '../utils/platformCrypto.js';
10
11
  import { logger } from '../utils/loggerUtils.js';
11
12
  import { isDev } from '../shared/utils/debugUtils.js';
12
- // Lazy imports for React Native specific modules
13
- let SecureStore = null;
14
- let ExpoCrypto = null;
13
+ /**
14
+ * Thrown when an identity-mutating operation (createIdentity / importKeyPair)
15
+ * is invoked while a valid identity already exists on the device.
16
+ *
17
+ * The local private key IS the user's identity — overwriting it without
18
+ * explicit consent permanently loses access to their account (unless
19
+ * they previously saved their recovery phrase). This error forces callers
20
+ * to make an explicit, audited decision instead of silently clobbering.
21
+ */
22
+ export class IdentityAlreadyExistsError extends Error {
23
+ constructor(existingPublicKey) {
24
+ super('An identity already exists on this device. Refusing to overwrite without explicit consent. ' +
25
+ 'If you really want to replace it, ensure the user has saved their recovery phrase, then call ' +
26
+ 'the operation with { overwrite: true }.');
27
+ this.name = 'IdentityAlreadyExistsError';
28
+ this.existingPublicKey = existingPublicKey;
29
+ }
30
+ }
31
+ /**
32
+ * Thrown when a freshly written identity cannot be read back, parsed, or
33
+ * round-tripped through sign/verify. Indicates a storage failure or
34
+ * corruption that would otherwise silently leave the user with an
35
+ * unusable account.
36
+ */
37
+ export class IdentityPersistError extends Error {
38
+ constructor(message, cause) {
39
+ super(message);
40
+ this.cause = cause;
41
+ this.name = 'IdentityPersistError';
42
+ }
43
+ }
15
44
  const ec = new EC('secp256k1');
16
45
  const STORAGE_KEYS = {
17
46
  PRIVATE_KEY: 'oxy_identity_private_key',
@@ -38,24 +67,22 @@ const IOS_KEYCHAIN_GROUP = 'group.com.oxy.shared';
38
67
  const ANDROID_ACCOUNT_TYPE = 'com.oxy.account';
39
68
  /**
40
69
  * Initialize React Native specific modules
41
- * This allows the module to work in both Node.js and React Native environments
70
+ *
71
+ * Delegates to `platformCrypto`, which is a per-platform module
72
+ * (`platformCrypto.ts` vs `platformCrypto.react-native.ts`) selected by the
73
+ * consumer's bundler. On RN it returns a statically-imported handle to
74
+ * `expo-secure-store`; off RN it throws (and is never called because every
75
+ * caller is gated by `isWebPlatform()` / native-only paths).
42
76
  */
43
77
  async function initSecureStore() {
44
- if (!SecureStore) {
45
- try {
46
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
47
- const moduleName = 'expo-secure-store';
48
- SecureStore = await import(/* @vite-ignore */ moduleName);
49
- }
50
- catch (error) {
51
- const errorMessage = error instanceof Error ? error.message : String(error);
52
- throw new Error(`Failed to load expo-secure-store: ${errorMessage}. Make sure expo-secure-store is installed and properly configured.`);
53
- }
78
+ try {
79
+ return await loadSecureStore();
54
80
  }
55
- if (!SecureStore) {
56
- throw new Error('expo-secure-store module is not available');
81
+ catch (error) {
82
+ const errorMessage = error instanceof Error ? error.message : String(error);
83
+ throw new Error(`Failed to load expo-secure-store: ${errorMessage}. ` +
84
+ 'Make sure expo-secure-store is installed and properly configured.');
57
85
  }
58
- return SecureStore;
59
86
  }
60
87
  /**
61
88
  * Check if we're on web platform
@@ -65,12 +92,8 @@ function isWebPlatform() {
65
92
  return isWeb();
66
93
  }
67
94
  async function initExpoCrypto() {
68
- if (!ExpoCrypto) {
69
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
70
- const moduleName = 'expo-crypto';
71
- ExpoCrypto = await import(/* @vite-ignore */ moduleName);
72
- }
73
- return ExpoCrypto;
95
+ // Same per-platform delegation as initSecureStore — see comment there.
96
+ return loadExpoCrypto();
74
97
  }
75
98
  /**
76
99
  * Convert Uint8Array to hexadecimal string
@@ -90,15 +113,19 @@ async function getSecureRandomBytes(length) {
90
113
  const Crypto = await initExpoCrypto();
91
114
  return Crypto.getRandomBytes(length);
92
115
  }
93
- // In Node.js, use Node's crypto module
94
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
116
+ // In Node.js, use Node's crypto module.
117
+ //
118
+ // `loadNodeCrypto` is per-platform: the default variant performs
119
+ // `await import('crypto')`, the RN variant throws (and we'd never reach
120
+ // here on RN because the early-return above caught it).
95
121
  try {
96
- const cryptoModuleName = 'crypto';
97
- const nodeCrypto = await import(/* @vite-ignore */ cryptoModuleName);
122
+ const nodeCrypto = await loadNodeCrypto();
98
123
  return new Uint8Array(nodeCrypto.randomBytes(length));
99
124
  }
100
125
  catch (error) {
101
- // Fallback to expo-crypto if Node crypto fails
126
+ // Fallback to expo-crypto if Node crypto fails (defensive — should not
127
+ // happen on real Node, but the platform-detection edge cases are
128
+ // surprisingly varied).
102
129
  const Crypto = await initExpoCrypto();
103
130
  return Crypto.getRandomBytes(length);
104
131
  }
@@ -120,14 +147,32 @@ export class KeyManager {
120
147
  KeyManager.cachedSharedPublicKey = null;
121
148
  KeyManager.cachedHasSharedIdentity = null;
122
149
  }
150
+ /**
151
+ * Lowercase and pad to canonical 64-hex-char form.
152
+ *
153
+ * Tolerates the 1-in-256 leading-zero-strip that elliptic's
154
+ * `getPrivate('hex')` produces, and the externally-imported uppercase-hex
155
+ * legacy keys. EVERY `ec.keyFromPrivate(...)` call site in this file must
156
+ * canonicalize first so that derivation is stable regardless of storage
157
+ * representation.
158
+ *
159
+ * Private (used only inside KeyManager) — public consumers should not need
160
+ * to think about hex representation.
161
+ */
162
+ static canonicalPrivateKey(key) {
163
+ return key.toLowerCase().padStart(64, '0');
164
+ }
123
165
  /**
124
166
  * Generate a new ECDSA secp256k1 key pair
125
167
  * Returns the keys in hexadecimal format
126
168
  */
127
169
  static generateKeyPairSync() {
128
170
  const keyPair = ec.genKeyPair();
171
+ // Pad to canonical 64 hex chars. `elliptic`'s `getPrivate('hex')` strips
172
+ // leading zero bytes which would otherwise corrupt strict-length checks
173
+ // and signature derivation on the read path.
129
174
  return {
130
- privateKey: keyPair.getPrivate('hex'),
175
+ privateKey: keyPair.getPrivate('hex').padStart(64, '0'),
131
176
  publicKey: keyPair.getPublic('hex'),
132
177
  };
133
178
  }
@@ -137,9 +182,9 @@ export class KeyManager {
137
182
  static async generateKeyPair() {
138
183
  const randomBytes = await getSecureRandomBytes(32);
139
184
  const privateKeyHex = uint8ArrayToHex(randomBytes);
140
- const keyPair = ec.keyFromPrivate(privateKeyHex);
185
+ const keyPair = ec.keyFromPrivate(KeyManager.canonicalPrivateKey(privateKeyHex));
141
186
  return {
142
- privateKey: keyPair.getPrivate('hex'),
187
+ privateKey: keyPair.getPrivate('hex').padStart(64, '0'),
143
188
  publicKey: keyPair.getPublic('hex'),
144
189
  };
145
190
  }
@@ -170,13 +215,15 @@ export class KeyManager {
170
215
  // iOS: Store in shared keychain group
171
216
  // Note: keychainAccessGroup requires Keychain Sharing capability in Xcode
172
217
  try {
173
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
218
+ const privateOpts = {
174
219
  keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
175
- keychainAccessGroup: IOS_KEYCHAIN_GROUP, // This enables sharing across apps
176
- }); // Type assertion: keychainAccessGroup may not be in older @types but is supported
177
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, {
220
+ keychainAccessGroup: IOS_KEYCHAIN_GROUP, // Enables sharing across apps
221
+ };
222
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
223
+ const publicOpts = {
178
224
  keychainAccessGroup: IOS_KEYCHAIN_GROUP,
179
- });
225
+ };
226
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
180
227
  }
181
228
  catch (error) {
182
229
  throw new Error(`Failed to create shared identity on iOS. Ensure your app has the Keychain Sharing capability enabled with access group "${IOS_KEYCHAIN_GROUP}". Error: ${error}`);
@@ -215,9 +262,8 @@ export class KeyManager {
215
262
  const store = await initSecureStore();
216
263
  let publicKey = null;
217
264
  if (isIOS()) {
218
- publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, {
219
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
220
- });
265
+ const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
266
+ publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
221
267
  }
222
268
  else if (isAndroid()) {
223
269
  publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
@@ -250,9 +296,8 @@ export class KeyManager {
250
296
  const store = await initSecureStore();
251
297
  let privateKey = null;
252
298
  if (isIOS()) {
253
- privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, {
254
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
255
- });
299
+ const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
300
+ privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
256
301
  }
257
302
  else if (isAndroid()) {
258
303
  privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
@@ -310,19 +355,25 @@ export class KeyManager {
310
355
  throw new Error('Shared identity import is only available on native platforms.');
311
356
  }
312
357
  const store = await initSecureStore();
313
- const keyPair = ec.keyFromPrivate(privateKey);
358
+ // Canonicalize incoming key BEFORE storage so the stored value is always
359
+ // in canonical 64-hex-char lowercase form going forward. Without this,
360
+ // legacy short keys would derive a different public key on the read path.
361
+ const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
362
+ const keyPair = ec.keyFromPrivate(canonicalPrivate);
314
363
  const publicKey = keyPair.getPublic('hex');
315
364
  if (isIOS()) {
316
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
365
+ const privateOpts = {
317
366
  keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
318
367
  keychainAccessGroup: IOS_KEYCHAIN_GROUP,
319
- });
320
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, {
368
+ };
369
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, privateOpts);
370
+ const publicOpts = {
321
371
  keychainAccessGroup: IOS_KEYCHAIN_GROUP,
322
- });
372
+ };
373
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
323
374
  }
324
375
  else if (isAndroid()) {
325
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
376
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, {
326
377
  keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
327
378
  });
328
379
  await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
@@ -352,13 +403,15 @@ export class KeyManager {
352
403
  try {
353
404
  const store = await initSecureStore();
354
405
  if (isIOS()) {
355
- await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, sessionId, {
406
+ const sessionIdOpts = {
356
407
  keychainAccessGroup: IOS_KEYCHAIN_GROUP,
357
- });
358
- await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, accessToken, {
408
+ };
409
+ await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, sessionId, sessionIdOpts);
410
+ const tokenOpts = {
359
411
  keychainAccessible: store.WHEN_UNLOCKED,
360
412
  keychainAccessGroup: IOS_KEYCHAIN_GROUP,
361
- });
413
+ };
414
+ await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, accessToken, tokenOpts);
362
415
  }
363
416
  else if (isAndroid()) {
364
417
  await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, sessionId);
@@ -392,12 +445,9 @@ export class KeyManager {
392
445
  let sessionId = null;
393
446
  let accessToken = null;
394
447
  if (isIOS()) {
395
- sessionId = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, {
396
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
397
- });
398
- accessToken = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, {
399
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
400
- });
448
+ const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
449
+ sessionId = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, opts);
450
+ accessToken = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, opts);
401
451
  }
402
452
  else if (isAndroid()) {
403
453
  sessionId = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_ID);
@@ -428,12 +478,9 @@ export class KeyManager {
428
478
  try {
429
479
  const store = await initSecureStore();
430
480
  if (isIOS()) {
431
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, {
432
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
433
- });
434
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, {
435
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
436
- });
481
+ const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
482
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, opts);
483
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, opts);
437
484
  }
438
485
  else if (isAndroid()) {
439
486
  await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_ID);
@@ -495,41 +542,174 @@ export class KeyManager {
495
542
  }
496
543
  // ==================== END SHARED IDENTITY METHODS ====================
497
544
  /**
498
- * Generate and securely store a new key pair on the device
499
- * Returns only the public key (private key is stored securely)
545
+ * Atomically persist a key pair to secure storage with verification + backup.
546
+ *
547
+ * Write order is critical:
548
+ * 1. Backup (BACKUP_PRIVATE_KEY + BACKUP_PUBLIC_KEY + BACKUP_TIMESTAMP)
549
+ * 2. Primary public key
550
+ * 3. Primary private key (last so a partial write leaves us in a known
551
+ * "no identity yet" state — easier to retry than a half-written one)
552
+ * 4. Read back + sign/verify to confirm the storage round-trip works
553
+ *
554
+ * If any step throws, the caller sees the error AND any partial state is
555
+ * cleaned up so the device is left either fully consistent or fully empty.
556
+ * It never leaves an unusable half-identity that would fool `hasIdentity()`.
557
+ *
558
+ * @internal
500
559
  */
501
- static async createIdentity() {
560
+ static async _persistIdentityAtomic(privateKey, publicKey) {
561
+ const store = await initSecureStore();
562
+ // Canonicalize BEFORE persistence so the stored value is always in
563
+ // canonical 64-hex-char lowercase form going forward. This is the single
564
+ // place all primary writes flow through, so once a value lands here all
565
+ // subsequent reads see a stable representation.
566
+ const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
567
+ const canonicalPublic = publicKey.toLowerCase();
568
+ // Step 1: Backup BEFORE touching primary storage so we always have a
569
+ // recoverable copy even if the device crashes mid-write. Store the
570
+ // backup in canonical form too so a backup-restore cycle preserves
571
+ // canonicalization.
572
+ try {
573
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
574
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
575
+ });
576
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
577
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
578
+ }
579
+ catch (error) {
580
+ logger.error('Failed to write identity backup before primary', error, { component: 'KeyManager' });
581
+ throw new IdentityPersistError('Failed to write identity backup', error);
582
+ }
583
+ // Step 2 + 3: Write primary keys. Public first so that if private write
584
+ // fails we are still missing the most critical bit.
585
+ try {
586
+ await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, canonicalPublic);
587
+ await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, canonicalPrivate, {
588
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
589
+ });
590
+ }
591
+ catch (error) {
592
+ logger.error('Failed to write primary identity to secure store', error, { component: 'KeyManager' });
593
+ // Roll back the public-key half-write so hasIdentity() doesn't lie later.
594
+ try {
595
+ await store.deleteItemAsync(STORAGE_KEYS.PUBLIC_KEY);
596
+ }
597
+ catch { /* best effort */ }
598
+ try {
599
+ await store.deleteItemAsync(STORAGE_KEYS.PRIVATE_KEY);
600
+ }
601
+ catch { /* best effort */ }
602
+ throw new IdentityPersistError('Failed to write identity to secure store', error);
603
+ }
604
+ // Step 4: Verify round-trip. If the store silently drops our writes
605
+ // (e.g., a misconfigured keychain access group), we MUST surface it
606
+ // before declaring success — otherwise the caller will think the
607
+ // identity was saved and discard the in-memory copy.
608
+ let readBackPrivate;
609
+ let readBackPublic;
610
+ try {
611
+ readBackPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
612
+ readBackPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
613
+ }
614
+ catch (error) {
615
+ logger.error('Failed to read identity back after write', error, { component: 'KeyManager' });
616
+ throw new IdentityPersistError('Failed to verify identity after write', error);
617
+ }
618
+ // Hex comparisons are case-insensitive — normalize on both sides so a
619
+ // store that uppercases on round-trip (some keychain backends) doesn't
620
+ // trigger a spurious mismatch.
621
+ if (readBackPrivate?.toLowerCase() !== canonicalPrivate ||
622
+ readBackPublic?.toLowerCase() !== canonicalPublic) {
623
+ logger.error('Identity round-trip mismatch after write', undefined, { component: 'KeyManager' });
624
+ throw new IdentityPersistError('Identity write was not persisted correctly (round-trip mismatch).');
625
+ }
626
+ // Final sanity: derive public from the stored private and confirm the
627
+ // pair signs/verifies cleanly. Catches a (theoretical) elliptic library
628
+ // corruption immediately rather than the next time the user tries to
629
+ // sign in.
630
+ try {
631
+ const keyPair = ec.keyFromPrivate(KeyManager.canonicalPrivateKey(readBackPrivate));
632
+ const derived = keyPair.getPublic('hex');
633
+ if (derived.toLowerCase() !== readBackPublic.toLowerCase()) {
634
+ throw new IdentityPersistError('Stored public key does not match derived public key.');
635
+ }
636
+ // Sign/verify roundtrip using a known test vector
637
+ const probeHash = '0'.repeat(64);
638
+ const signature = keyPair.sign(probeHash);
639
+ if (!keyPair.verify(probeHash, signature)) {
640
+ throw new IdentityPersistError('Sign/verify roundtrip failed for newly stored identity.');
641
+ }
642
+ }
643
+ catch (error) {
644
+ if (error instanceof IdentityPersistError)
645
+ throw error;
646
+ logger.error('Identity sign/verify probe failed', error, { component: 'KeyManager' });
647
+ throw new IdentityPersistError('Stored identity failed crypto self-test', error);
648
+ }
649
+ // Update cache only after we are certain the identity is durable.
650
+ KeyManager.cachedPublicKey = canonicalPublic;
651
+ KeyManager.cachedHasIdentity = true;
652
+ }
653
+ /**
654
+ * Generate and securely store a new key pair on the device.
655
+ *
656
+ * Refuses to overwrite an existing identity unless `options.overwrite === true`.
657
+ * Returns the public key. The private key never leaves secure storage.
658
+ *
659
+ * @throws IdentityAlreadyExistsError if an identity already exists and overwrite is not set
660
+ * @throws IdentityPersistError if the key cannot be durably written
661
+ */
662
+ static async createIdentity(options) {
502
663
  if (isWebPlatform()) {
503
664
  throw new Error('Identity creation is only available on native platforms (iOS/Android). Please use the native app to create your identity.');
504
665
  }
505
- const store = await initSecureStore();
666
+ // CRITICAL SAFEGUARD: never silently overwrite an existing identity.
667
+ // The local key IS the account — clobbering it without consent is
668
+ // catastrophic. Callers must opt in explicitly when they have already
669
+ // confirmed (via UI) that the user has saved their recovery phrase.
670
+ if (!options?.overwrite) {
671
+ const existing = await KeyManager.getPublicKey();
672
+ if (existing) {
673
+ throw new IdentityAlreadyExistsError(existing);
674
+ }
675
+ }
506
676
  const { privateKey, publicKey } = await KeyManager.generateKeyPair();
507
- await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, privateKey, {
508
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
509
- });
510
- await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, publicKey);
511
- // Update cache
512
- KeyManager.cachedPublicKey = publicKey;
513
- KeyManager.cachedHasIdentity = true;
677
+ await KeyManager._persistIdentityAtomic(privateKey, publicKey);
514
678
  return publicKey;
515
679
  }
516
680
  /**
517
- * Import an existing key pair (e.g., from recovery phrase)
681
+ * Import an existing key pair (e.g., from recovery phrase).
682
+ *
683
+ * Refuses to overwrite an existing identity unless `options.overwrite === true`.
684
+ *
685
+ * @throws IdentityAlreadyExistsError if an identity already exists and overwrite is not set
686
+ * @throws IdentityPersistError if the key cannot be durably written
518
687
  */
519
- static async importKeyPair(privateKey) {
688
+ static async importKeyPair(privateKey, options) {
520
689
  if (isWebPlatform()) {
521
690
  throw new Error('Identity import is only available on native platforms (iOS/Android). Please use the native app to import your identity.');
522
691
  }
523
- const store = await initSecureStore();
524
- const keyPair = ec.keyFromPrivate(privateKey);
692
+ if (!KeyManager.isValidPrivateKey(privateKey)) {
693
+ throw new Error('Invalid private key supplied to importKeyPair.');
694
+ }
695
+ // Canonicalize the incoming private key so the stored value (and the
696
+ // derived public key) are always in canonical form. Without this, an
697
+ // externally-imported short or uppercase key would derive one public
698
+ // key here and a different one when later read back unpadded.
699
+ const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
700
+ const keyPair = ec.keyFromPrivate(canonicalPrivate);
525
701
  const publicKey = keyPair.getPublic('hex');
526
- await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, privateKey, {
527
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
528
- });
529
- await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, publicKey);
530
- // Update cache
531
- KeyManager.cachedPublicKey = publicKey;
532
- KeyManager.cachedHasIdentity = true;
702
+ // Refuse silent overwrite — see createIdentity() for rationale.
703
+ if (!options?.overwrite) {
704
+ const existing = await KeyManager.getPublicKey();
705
+ if (existing && existing.toLowerCase() !== publicKey.toLowerCase()) {
706
+ throw new IdentityAlreadyExistsError(existing);
707
+ }
708
+ // If existing === publicKey, the device already has this exact identity;
709
+ // re-persisting is a no-op but harmless. Fall through to ensure backup
710
+ // is up to date.
711
+ }
712
+ await KeyManager._persistIdentityAtomic(canonicalPrivate, publicKey);
533
713
  return publicKey;
534
714
  }
535
715
  /**
@@ -581,7 +761,15 @@ export class KeyManager {
581
761
  }
582
762
  }
583
763
  /**
584
- * Check if an identity (key pair) exists on this device (cached for performance)
764
+ * Check if a complete, parseable identity exists on this device.
765
+ *
766
+ * Returns `true` only when BOTH the private and public keys are present,
767
+ * both are well-formed, AND the public key derives from the private key.
768
+ * A partially-written or corrupted identity returns `false` so that
769
+ * downstream code can resume the create / restore flow correctly.
770
+ *
771
+ * Note: this does NOT perform the full sign/verify roundtrip — call
772
+ * `verifyIdentityIntegrity()` for that.
585
773
  */
586
774
  static async hasIdentity() {
587
775
  if (isWebPlatform()) {
@@ -590,22 +778,75 @@ export class KeyManager {
590
778
  if (KeyManager.cachedHasIdentity !== null) {
591
779
  return KeyManager.cachedHasIdentity;
592
780
  }
781
+ let privateKey;
782
+ let publicKey;
593
783
  try {
594
- const privateKey = await KeyManager.getPrivateKey();
595
- const hasIdentity = privateKey !== null;
596
- // Cache result
597
- KeyManager.cachedHasIdentity = hasIdentity;
598
- return hasIdentity;
784
+ const store = await initSecureStore();
785
+ [privateKey, publicKey] = await Promise.all([
786
+ store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY),
787
+ store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY),
788
+ ]);
599
789
  }
600
790
  catch (error) {
601
- // If we can't check, assume no identity (safer default)
602
- // Cache false to avoid repeated failed attempts
603
- KeyManager.cachedHasIdentity = false;
604
- if (isDev()) {
605
- logger.warn('Failed to check identity', { component: 'KeyManager' }, error);
606
- }
791
+ // Storage threw could be a transient keychain lock (e.g., background
792
+ // fetch before the device is unlocked). Do NOT cache `false`: if we
793
+ // did, the next call would skip storage entirely and return false even
794
+ // after the device is unlocked. Just return false and let the next
795
+ // call retry from storage.
796
+ logger.error('Failed to read identity from secure storage', error, { component: 'KeyManager' });
607
797
  return false;
608
798
  }
799
+ // Storage succeeded. Now classify the result. From here onward, any
800
+ // outcome is stable and safe to cache (the bytes won't change between
801
+ // calls).
802
+ let hasIdentity = false;
803
+ if (privateKey && publicKey) {
804
+ // Require BOTH bytes-present AND parseable AND matching. Any weaker
805
+ // check would let a half-written identity (private without public)
806
+ // pretend to be a real one, which then fails opaquely later in the
807
+ // sign-in flow when SignatureService.sign() can't find the keypair.
808
+ if (KeyManager.isValidPrivateKey(privateKey) && KeyManager.isValidPublicKey(publicKey)) {
809
+ try {
810
+ const derived = ec
811
+ .keyFromPrivate(KeyManager.canonicalPrivateKey(privateKey))
812
+ .getPublic('hex');
813
+ // Hex equality is case-insensitive; normalize on both sides to
814
+ // tolerate legacy uppercase-stored public keys.
815
+ hasIdentity = derived.toLowerCase() === publicKey.toLowerCase();
816
+ if (!hasIdentity) {
817
+ logger.warn('KeyManager.hasIdentity: stored public key does not match derived public key', { component: 'KeyManager' });
818
+ }
819
+ }
820
+ catch (error) {
821
+ logger.warn('KeyManager.hasIdentity: failed to derive public key from stored private key', { component: 'KeyManager' }, error);
822
+ }
823
+ }
824
+ else {
825
+ logger.warn('KeyManager.hasIdentity: stored key material is malformed', { component: 'KeyManager' });
826
+ }
827
+ }
828
+ // Cache result. Storage succeeded, so this verdict is stable:
829
+ // - true → identity exists and round-trips cleanly
830
+ // - false → storage is empty / partial / malformed (a stable result;
831
+ // callers should run integrity-recovery / restore from
832
+ // backup explicitly)
833
+ KeyManager.cachedHasIdentity = hasIdentity;
834
+ if (hasIdentity && publicKey) {
835
+ KeyManager.cachedPublicKey = publicKey;
836
+ }
837
+ // Diagnostic breadcrumb (dev only). Logs lengths + validity flags so we
838
+ // can tell from `adb logcat` exactly WHY hasIdentity returned what it
839
+ // did. Never log the key material itself.
840
+ if (isDev()) {
841
+ logger.debug('KeyManager.hasIdentity result', { component: 'KeyManager' }, {
842
+ privateLen: privateKey?.length ?? 0,
843
+ publicLen: publicKey?.length ?? 0,
844
+ privateValid: privateKey ? KeyManager.isValidPrivateKey(privateKey) : null,
845
+ publicValid: publicKey ? KeyManager.isValidPublicKey(publicKey) : null,
846
+ derived: hasIdentity,
847
+ });
848
+ }
849
+ return hasIdentity;
609
850
  }
610
851
  /**
611
852
  * Delete the stored identity (both keys)
@@ -691,7 +932,12 @@ export class KeyManager {
691
932
  }
692
933
  }
693
934
  /**
694
- * Verify identity integrity - checks if keys are valid and accessible
935
+ * Verify identity integrity checks keys are valid, accessible, derive
936
+ * consistently, AND can sign + verify a probe message.
937
+ *
938
+ * Returns true only when the full sign/verify roundtrip succeeds. Use
939
+ * this on app start to detect silent corruption before the user finds
940
+ * out by failing to sign in.
695
941
  */
696
942
  static async verifyIdentityIntegrity() {
697
943
  if (isWebPlatform()) {
@@ -703,35 +949,49 @@ export class KeyManager {
703
949
  if (!privateKey || !publicKey) {
704
950
  return false;
705
951
  }
706
- // Validate private key format
952
+ // Validate formats
707
953
  if (!KeyManager.isValidPrivateKey(privateKey)) {
708
954
  return false;
709
955
  }
710
- // Validate public key format
711
956
  if (!KeyManager.isValidPublicKey(publicKey)) {
712
957
  return false;
713
958
  }
714
- // Verify public key can be derived from private key
959
+ // Verify public key derives from private key (case-insensitive
960
+ // because hex is case-insensitive — legacy uppercase stored values
961
+ // must still validate).
715
962
  const derivedPublicKey = KeyManager.derivePublicKey(privateKey);
716
- if (derivedPublicKey !== publicKey) {
963
+ if (derivedPublicKey.toLowerCase() !== publicKey.toLowerCase()) {
717
964
  return false; // Keys don't match
718
965
  }
719
- // Verify we can create a key pair object (tests elliptic curve operations)
720
- const keyPair = await KeyManager.getKeyPairObject();
721
- if (!keyPair) {
966
+ // Full sign/verify probe proves the keypair is functional, not just
967
+ // bytewise parseable. A previous version of this method would return
968
+ // true even when the underlying elliptic curve state was wedged.
969
+ const keyPair = ec.keyFromPrivate(KeyManager.canonicalPrivateKey(privateKey));
970
+ const probeHash = '0'.repeat(64);
971
+ const signature = keyPair.sign(probeHash);
972
+ if (!keyPair.verify(probeHash, signature)) {
973
+ logger.error('Identity sign/verify probe failed during integrity check', undefined, { component: 'KeyManager' });
722
974
  return false;
723
975
  }
724
976
  return true;
725
977
  }
726
978
  catch (error) {
727
- if (isDev()) {
728
- logger.error('Identity integrity check failed', error, { component: 'KeyManager' });
729
- }
979
+ logger.error('Identity integrity check failed', error, { component: 'KeyManager' });
730
980
  return false;
731
981
  }
732
982
  }
733
983
  /**
734
- * Restore identity from backup if primary storage is corrupted
984
+ * Restore identity from backup if primary storage is corrupted.
985
+ *
986
+ * SAFETY: this method will NEVER overwrite a verifying primary identity.
987
+ * If the primary passes a sign/verify probe, the backup is left untouched
988
+ * and `false` is returned — this protects against a transient
989
+ * `verifyIdentityIntegrity()` blip clobbering valid keys with stale
990
+ * backup keys (e.g., from a previous account before an import).
991
+ *
992
+ * Additionally, if the backup public key does NOT match the (still-
993
+ * present-but-failing) primary public key, we refuse to overwrite — the
994
+ * backup may belong to a different identity entirely.
735
995
  */
736
996
  static async restoreIdentityFromBackup() {
737
997
  if (isWebPlatform()) {
@@ -739,6 +999,13 @@ export class KeyManager {
739
999
  }
740
1000
  try {
741
1001
  const store = await initSecureStore();
1002
+ // First: if the primary still works, do nothing. Returning true here
1003
+ // would be misleading; returning false (no restore needed) is the
1004
+ // honest answer.
1005
+ const primaryOk = await KeyManager.verifyIdentityIntegrity();
1006
+ if (primaryOk) {
1007
+ return false;
1008
+ }
742
1009
  // Check if backup exists
743
1010
  const backupPrivateKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
744
1011
  const backupPublicKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
@@ -746,35 +1013,42 @@ export class KeyManager {
746
1013
  return false; // No backup available
747
1014
  }
748
1015
  // Verify backup integrity
749
- if (!KeyManager.isValidPrivateKey(backupPrivateKey)) {
1016
+ if (!KeyManager.isValidPrivateKey(backupPrivateKey) || !KeyManager.isValidPublicKey(backupPublicKey)) {
1017
+ logger.warn('Backup identity is malformed; refusing to restore', { component: 'KeyManager' });
750
1018
  return false;
751
1019
  }
752
- if (!KeyManager.isValidPublicKey(backupPublicKey)) {
1020
+ // Verify backup keys derive consistently. Hex is case-insensitive so
1021
+ // normalize both sides — a legacy uppercase-stored backup must still
1022
+ // be considered valid.
1023
+ const derivedPublicKey = KeyManager.derivePublicKey(backupPrivateKey);
1024
+ if (derivedPublicKey.toLowerCase() !== backupPublicKey.toLowerCase()) {
1025
+ logger.warn('Backup public key does not match derived; refusing to restore', { component: 'KeyManager' });
753
1026
  return false;
754
1027
  }
755
- // Verify keys match
756
- const derivedPublicKey = KeyManager.derivePublicKey(backupPrivateKey);
757
- if (derivedPublicKey !== backupPublicKey) {
758
- return false; // Backup keys don't match
1028
+ // CRITICAL: if there is still a (broken) primary public key present
1029
+ // that does NOT match the backup, the backup may be from a completely
1030
+ // different identity. Better to surface a corrupted state than
1031
+ // silently switch the user to a different account.
1032
+ const currentPrimaryPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY).catch(() => null);
1033
+ if (currentPrimaryPublic &&
1034
+ currentPrimaryPublic.toLowerCase() !== backupPublicKey.toLowerCase()) {
1035
+ logger.error('Primary identity is corrupted AND does not match the backup. Refusing to restore to avoid switching accounts.', undefined, { component: 'KeyManager' });
1036
+ return false;
759
1037
  }
760
- await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, backupPrivateKey, {
761
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
762
- });
763
- await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, backupPublicKey);
764
- const restored = await KeyManager.verifyIdentityIntegrity();
765
- if (restored) {
766
- // Update cache
767
- KeyManager.cachedPublicKey = backupPublicKey;
768
- KeyManager.cachedHasIdentity = true;
769
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
770
- return true;
1038
+ // Safe to restore: rebuild the primary using the same atomic write
1039
+ // path createIdentity uses, including verification.
1040
+ try {
1041
+ await KeyManager._persistIdentityAtomic(backupPrivateKey, backupPublicKey);
771
1042
  }
772
- return false;
1043
+ catch (error) {
1044
+ logger.error('Failed to persist identity restored from backup', error, { component: 'KeyManager' });
1045
+ return false;
1046
+ }
1047
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
1048
+ return true;
773
1049
  }
774
1050
  catch (error) {
775
- if (isDev()) {
776
- logger.error('Failed to restore identity from backup', error, { component: 'KeyManager' });
777
- }
1051
+ logger.error('Failed to restore identity from backup', error, { component: 'KeyManager' });
778
1052
  return false;
779
1053
  }
780
1054
  }
@@ -789,38 +1063,99 @@ export class KeyManager {
789
1063
  const privateKey = await KeyManager.getPrivateKey();
790
1064
  if (!privateKey)
791
1065
  return null;
792
- return ec.keyFromPrivate(privateKey);
1066
+ return ec.keyFromPrivate(KeyManager.canonicalPrivateKey(privateKey));
793
1067
  }
794
1068
  /**
795
1069
  * Derive public key from a private key (without storing)
796
1070
  */
797
1071
  static derivePublicKey(privateKey) {
798
- const keyPair = ec.keyFromPrivate(privateKey);
1072
+ const keyPair = ec.keyFromPrivate(KeyManager.canonicalPrivateKey(privateKey));
799
1073
  return keyPair.getPublic('hex');
800
1074
  }
801
1075
  /**
802
1076
  * Validate that a string is a valid public key
1077
+ *
1078
+ * Returns false on parse errors (invalid input is the expected fail mode here).
1079
+ * Errors are logged at debug level so they're available when troubleshooting
1080
+ * but don't pollute production logs.
803
1081
  */
804
1082
  static isValidPublicKey(publicKey) {
1083
+ if (typeof publicKey !== 'string' || publicKey.length === 0) {
1084
+ return false;
1085
+ }
1086
+ // secp256k1 public keys are either uncompressed (130 hex chars, starts with 04)
1087
+ // or compressed (66 hex chars, starts with 02 or 03). Anything else is
1088
+ // clearly bogus; reject up front so we never silently widen the trust
1089
+ // boundary by accepting whatever BN(...) parses out of junk input.
1090
+ if (!/^[0-9a-fA-F]+$/.test(publicKey)) {
1091
+ return false;
1092
+ }
1093
+ if (publicKey.length !== 130 && publicKey.length !== 66) {
1094
+ return false;
1095
+ }
805
1096
  try {
806
1097
  ec.keyFromPublic(publicKey, 'hex');
807
1098
  return true;
808
1099
  }
809
- catch {
1100
+ catch (error) {
1101
+ if (isDev()) {
1102
+ logger.debug('[oxy.crypto] isValidPublicKey rejected input', { component: 'KeyManager' }, error);
1103
+ }
810
1104
  return false;
811
1105
  }
812
1106
  }
813
1107
  /**
814
- * Validate that a string is a valid private key
1108
+ * Validate that a string is a valid private key.
1109
+ *
1110
+ * secp256k1 private keys are 256-bit, so 64 hex chars. We require strict
1111
+ * hex-only input because `elliptic`'s underlying `BN(input, 16)` happily
1112
+ * accepts non-hex characters (treating them as zero), which would let
1113
+ * "not-hex" pass through as a valid (but compromised, near-zero) key.
815
1114
  */
816
1115
  static isValidPrivateKey(privateKey) {
1116
+ if (typeof privateKey !== 'string' || privateKey.length === 0) {
1117
+ return false;
1118
+ }
1119
+ if (!/^[0-9a-fA-F]+$/.test(privateKey)) {
1120
+ return false;
1121
+ }
1122
+ // secp256k1 private keys are 32 bytes (64 hex chars). `elliptic`'s
1123
+ // `getPrivate('hex')` strips leading zero bytes, so a valid key whose
1124
+ // leading byte is 0 ends up as 62 hex chars in storage. Accept any
1125
+ // length from 1..64 here — we re-pad before deriving below — and
1126
+ // reject longer than 64.
1127
+ if (privateKey.length > 64) {
1128
+ return false;
1129
+ }
1130
+ const padded = privateKey.padStart(64, '0').toLowerCase();
1131
+ // After padding, require minimum entropy: reject obvious low-scalar
1132
+ // keys. A scalar that fits in 8 hex chars (~32 bits of entropy) is a
1133
+ // degenerate / accidental key, not a real one. The existing isZero()
1134
+ // check below covers literal 0; this also rejects trivially small
1135
+ // scalars like '1', '2', etc. that would otherwise pad to a valid but
1136
+ // weak key whose public point is trivially derivable.
1137
+ if (/^0{56}/.test(padded)) {
1138
+ return false;
1139
+ }
817
1140
  try {
818
- const keyPair = ec.keyFromPrivate(privateKey);
1141
+ const keyPair = ec.keyFromPrivate(padded);
1142
+ const priv = keyPair.getPrivate();
1143
+ // Private key must be > 0 and < curve order n. elliptic doesn't
1144
+ // enforce this on keyFromPrivate, so we do it here.
1145
+ if (priv.isZero() || priv.cmp(ec.curve.n) >= 0) {
1146
+ return false;
1147
+ }
819
1148
  // Verify it can derive a public key
820
- keyPair.getPublic('hex');
1149
+ const pub = keyPair.getPublic('hex');
1150
+ if (!pub || pub.length === 0) {
1151
+ return false;
1152
+ }
821
1153
  return true;
822
1154
  }
823
- catch {
1155
+ catch (error) {
1156
+ if (isDev()) {
1157
+ logger.debug('[oxy.crypto] isValidPrivateKey rejected input', { component: 'KeyManager' }, error);
1158
+ }
824
1159
  return false;
825
1160
  }
826
1161
  }