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