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