@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/CrossDomainAuth.js +3 -1
- package/dist/cjs/HttpService.js +214 -33
- package/dist/cjs/OxyServices.base.js +9 -0
- package/dist/cjs/OxyServices.js +8 -3
- package/dist/cjs/crypto/index.js +3 -1
- package/dist/cjs/crypto/keyManager.js +476 -172
- package/dist/cjs/crypto/polyfill.js +14 -65
- package/dist/cjs/crypto/recoveryPhrase.js +30 -11
- package/dist/cjs/crypto/signatureService.js +25 -60
- package/dist/cjs/i18n/locales/en-US.json +46 -1
- package/dist/cjs/i18n/locales/es-ES.json +46 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +46 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +46 -1
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/mixins/OxyServices.assets.js +9 -4
- package/dist/cjs/mixins/OxyServices.auth.js +147 -14
- package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
- package/dist/cjs/mixins/OxyServices.features.js +0 -11
- package/dist/cjs/mixins/OxyServices.fedcm.js +4 -3
- package/dist/cjs/mixins/OxyServices.language.js +5 -36
- package/dist/cjs/mixins/OxyServices.redirect.js +6 -2
- package/dist/cjs/mixins/OxyServices.security.js +13 -2
- package/dist/cjs/mixins/OxyServices.user.js +59 -38
- package/dist/cjs/mixins/OxyServices.utility.js +416 -110
- package/dist/cjs/mixins/index.js +11 -3
- package/dist/cjs/utils/accountUtils.js +71 -2
- package/dist/cjs/utils/deviceManager.js +5 -36
- package/dist/cjs/utils/languageUtils.js +22 -0
- package/dist/cjs/utils/platformCrypto.js +165 -0
- package/dist/cjs/utils/platformCrypto.native.js +123 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/CrossDomainAuth.js +3 -1
- package/dist/esm/HttpService.js +215 -34
- package/dist/esm/OxyServices.base.js +9 -0
- package/dist/esm/OxyServices.js +8 -3
- package/dist/esm/crypto/index.js +1 -1
- package/dist/esm/crypto/keyManager.js +473 -138
- package/dist/esm/crypto/polyfill.js +14 -32
- package/dist/esm/crypto/recoveryPhrase.js +30 -11
- package/dist/esm/crypto/signatureService.js +25 -27
- package/dist/esm/i18n/locales/en-US.json +46 -1
- package/dist/esm/i18n/locales/es-ES.json +46 -1
- package/dist/esm/i18n/locales/locales/en-US.json +46 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +46 -1
- package/dist/esm/index.js +4 -3
- package/dist/esm/mixins/OxyServices.assets.js +9 -4
- package/dist/esm/mixins/OxyServices.auth.js +145 -14
- package/dist/esm/mixins/OxyServices.contacts.js +47 -0
- package/dist/esm/mixins/OxyServices.features.js +0 -11
- package/dist/esm/mixins/OxyServices.fedcm.js +4 -3
- package/dist/esm/mixins/OxyServices.language.js +5 -3
- package/dist/esm/mixins/OxyServices.redirect.js +6 -2
- package/dist/esm/mixins/OxyServices.security.js +13 -2
- package/dist/esm/mixins/OxyServices.user.js +59 -38
- package/dist/esm/mixins/OxyServices.utility.js +416 -77
- package/dist/esm/mixins/index.js +11 -3
- package/dist/esm/utils/accountUtils.js +67 -1
- package/dist/esm/utils/deviceManager.js +5 -3
- package/dist/esm/utils/languageUtils.js +21 -0
- package/dist/esm/utils/platformCrypto.js +125 -0
- package/dist/esm/utils/platformCrypto.native.js +80 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +47 -3
- package/dist/types/OxyServices.base.d.ts +7 -0
- package/dist/types/OxyServices.d.ts +50 -7
- package/dist/types/crypto/index.d.ts +1 -1
- package/dist/types/crypto/keyManager.d.ts +110 -9
- package/dist/types/crypto/polyfill.d.ts +3 -1
- package/dist/types/crypto/recoveryPhrase.d.ts +31 -7
- package/dist/types/crypto/signatureService.d.ts +4 -0
- package/dist/types/index.d.ts +7 -5
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +6 -10
- package/dist/types/mixins/OxyServices.auth.d.ts +82 -5
- package/dist/types/mixins/OxyServices.contacts.d.ts +99 -0
- package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +2 -7
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +28 -11
- package/dist/types/mixins/OxyServices.utility.d.ts +145 -10
- package/dist/types/mixins/index.d.ts +52 -4
- package/dist/types/models/interfaces.d.ts +62 -3
- package/dist/types/utils/accountUtils.d.ts +41 -1
- package/dist/types/utils/languageUtils.d.ts +1 -0
- package/dist/types/utils/platformCrypto.d.ts +87 -0
- package/dist/types/utils/platformCrypto.native.d.ts +54 -0
- package/package.json +45 -2
- package/src/CrossDomainAuth.ts +12 -10
- package/src/HttpService.ts +251 -40
- package/src/OxyServices.base.ts +10 -0
- package/src/OxyServices.ts +26 -7
- package/src/crypto/__tests__/keyManager.test.ts +336 -0
- package/src/crypto/index.ts +6 -1
- package/src/crypto/keyManager.ts +529 -151
- package/src/crypto/polyfill.ts +14 -34
- package/src/crypto/recoveryPhrase.ts +56 -17
- package/src/crypto/signatureService.ts +25 -30
- package/src/i18n/locales/en-US.json +46 -1
- package/src/i18n/locales/es-ES.json +46 -1
- package/src/index.ts +19 -4
- package/src/mixins/OxyServices.assets.ts +15 -11
- package/src/mixins/OxyServices.auth.ts +175 -15
- package/src/mixins/OxyServices.contacts.ts +73 -0
- package/src/mixins/OxyServices.features.ts +2 -12
- package/src/mixins/OxyServices.fedcm.ts +4 -3
- package/src/mixins/OxyServices.language.ts +6 -4
- package/src/mixins/OxyServices.redirect.ts +6 -2
- package/src/mixins/OxyServices.security.ts +18 -8
- package/src/mixins/OxyServices.user.ts +72 -49
- package/src/mixins/OxyServices.utility.ts +562 -89
- package/src/mixins/__tests__/serviceAuth.test.ts +623 -0
- package/src/mixins/index.ts +58 -7
- package/src/models/interfaces.ts +65 -3
- package/src/utils/accountUtils.ts +82 -2
- package/src/utils/deviceManager.ts +7 -4
- package/src/utils/languageUtils.ts +23 -2
- package/src/utils/platformCrypto.native.ts +101 -0
- 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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
218
|
+
const privateOpts = {
|
|
174
219
|
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
175
|
-
keychainAccessGroup: IOS_KEYCHAIN_GROUP, //
|
|
176
|
-
}
|
|
177
|
-
await store.setItemAsync(STORAGE_KEYS.
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
406
|
+
const sessionIdOpts = {
|
|
356
407
|
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
357
|
-
}
|
|
358
|
-
await store.setItemAsync(STORAGE_KEYS.
|
|
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
|
-
|
|
396
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
*
|
|
499
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
524
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
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
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|