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