@oxyhq/core 12.7.0 → 12.9.0
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/boot/sessionColdBoot.js +16 -3
- package/dist/cjs/crypto/identityMarker.js +255 -0
- package/dist/cjs/crypto/keyManager.js +844 -106
- package/dist/cjs/index.js +8 -4
- package/dist/cjs/mixins/OxyServices.auth.js +21 -6
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/cjs/mixins/OxyServices.utility.js +11 -1
- package/dist/cjs/server/auth.js +3 -0
- package/dist/cjs/server/index.js +2 -1
- package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +16 -3
- package/dist/esm/crypto/identityMarker.js +248 -0
- package/dist/esm/crypto/keyManager.js +843 -106
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.auth.js +21 -6
- package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/esm/mixins/OxyServices.utility.js +11 -1
- package/dist/esm/server/auth.js +2 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +25 -0
- package/dist/types/crypto/identityMarker.d.ts +94 -0
- package/dist/types/crypto/keyManager.d.ts +212 -3
- package/dist/types/index.d.ts +4 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
- package/dist/types/server/auth.d.ts +4 -0
- package/dist/types/server/index.d.ts +2 -2
- package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
- package/package.json +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
- package/src/boot/sessionColdBoot.ts +42 -3
- package/src/crypto/__tests__/identityMocks.ts +125 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
- package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
- package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
- package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
- package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
- package/src/crypto/__tests__/keyManager.test.ts +77 -87
- package/src/crypto/identityMarker.ts +291 -0
- package/src/crypto/keyManager.ts +1026 -105
- package/src/index.ts +7 -1
- package/src/mixins/OxyServices.auth.ts +31 -7
- package/src/mixins/OxyServices.deviceBoot.ts +9 -1
- package/src/mixins/OxyServices.utility.ts +19 -1
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
- package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
- package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
- package/src/server/auth.ts +5 -0
- package/src/server/index.ts +2 -0
- package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
- package/src/utils/oxyServiceEnvironment.ts +17 -0
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* Private keys are stored securely using expo-secure-store and never leave the device.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.KeyManager = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = void 0;
|
|
9
|
+
exports.KeyManager = exports.IdentityUnavailableError = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = void 0;
|
|
10
10
|
const elliptic_1 = require("elliptic");
|
|
11
11
|
const platform_1 = require("../utils/platform");
|
|
12
12
|
const protocol_1 = require("@oxyhq/protocol");
|
|
13
13
|
const logger_1 = require("../logger");
|
|
14
14
|
const kdf_1 = require("./kdf");
|
|
15
|
+
const identityMarker_1 = require("./identityMarker");
|
|
15
16
|
/**
|
|
16
17
|
* Thrown when an identity-mutating operation (createIdentity / importKeyPair)
|
|
17
18
|
* is invoked while a valid identity already exists on the device.
|
|
@@ -45,6 +46,26 @@ class IdentityPersistError extends Error {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
exports.IdentityPersistError = IdentityPersistError;
|
|
49
|
+
/**
|
|
50
|
+
* Thrown when identity storage cannot be read/written right now — the keychain
|
|
51
|
+
* is locked, the module failed to load, or a read threw — as opposed to the
|
|
52
|
+
* identity being genuinely absent.
|
|
53
|
+
*
|
|
54
|
+
* This is the crux of the corruption-vs-fresh-install fix: a storage THROW must
|
|
55
|
+
* NEVER be flattened into "no identity" (the old behavior, which let onboarding
|
|
56
|
+
* treat a momentarily-locked keystore as a blank device). Callers that used to
|
|
57
|
+
* tolerate a `false`/`null` from `hasIdentity()`/`getPublicKey()` on error must
|
|
58
|
+
* now treat this typed error as "cannot determine" — retry, surface a locked
|
|
59
|
+
* state, or abort a destructive path — never as "safe to create/overwrite".
|
|
60
|
+
*/
|
|
61
|
+
class IdentityUnavailableError extends Error {
|
|
62
|
+
constructor(message, cause) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.cause = cause;
|
|
65
|
+
this.name = 'IdentityUnavailableError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
exports.IdentityUnavailableError = IdentityUnavailableError;
|
|
48
69
|
const ec = new elliptic_1.ec('secp256k1');
|
|
49
70
|
/**
|
|
50
71
|
* HKDF salt that domain-separates every identity-scoped seed produced by
|
|
@@ -77,6 +98,56 @@ const STORAGE_KEYS = {
|
|
|
77
98
|
SHARED_SESSION_TOKEN: 'oxy_shared_session_token',
|
|
78
99
|
SHARED_SESSION_ID: 'oxy_shared_session_id',
|
|
79
100
|
};
|
|
101
|
+
/**
|
|
102
|
+
* v2 identity slot layout — blast-radius isolation.
|
|
103
|
+
*
|
|
104
|
+
* The legacy keys above were written WITHOUT a `keychainService`, so on Android
|
|
105
|
+
* they all shared expo-secure-store's single default `key_v1` AndroidKeyStore
|
|
106
|
+
* key — meaning ONE keystore invalidation deleted the primary AND the backup
|
|
107
|
+
* together (the exact loss this hardening closes). The v2 layout gives the
|
|
108
|
+
* primary and the backup DISTINCT keychain services (→ independent AndroidKeyStore
|
|
109
|
+
* keys, independent iOS keychain items), so they can no longer die together, and
|
|
110
|
+
* DISTINCT key names (`_v2`) so the post-copy migration verify can only observe
|
|
111
|
+
* what it actually wrote (old/new locations are non-aliasable).
|
|
112
|
+
*
|
|
113
|
+
* Migration from the legacy layout is lazy + verify-before-delete — see
|
|
114
|
+
* {@link KeyManager._runSlotMigration}.
|
|
115
|
+
*/
|
|
116
|
+
const V2_PRIMARY_KEYCHAIN_SERVICE = 'oxy_identity';
|
|
117
|
+
const V2_BACKUP_KEYCHAIN_SERVICE = 'oxy_identity_backup';
|
|
118
|
+
const V2_STORAGE_KEYS = {
|
|
119
|
+
PRIVATE_KEY: 'oxy_identity_private_key_v2',
|
|
120
|
+
PUBLIC_KEY: 'oxy_identity_public_key_v2',
|
|
121
|
+
BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
|
|
122
|
+
BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
|
|
123
|
+
BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
|
|
127
|
+
* Re-derivable (its loss just re-runs the cheap slot check), so it lives in
|
|
128
|
+
* plain AsyncStorage rather than the keychain. It only SKIPS re-reading the
|
|
129
|
+
* legacy slots on an already-migrated device; it is never trusted over an actual
|
|
130
|
+
* v2 read (a set flag with an unhealthy v2 pair falls through to full migration).
|
|
131
|
+
*/
|
|
132
|
+
const SLOTS_MIGRATED_FLAG_KEY = 'oxy_identity_slots_migrated_v2';
|
|
133
|
+
const V2_SLOT_LAYOUT = {
|
|
134
|
+
primaryService: V2_PRIMARY_KEYCHAIN_SERVICE,
|
|
135
|
+
primaryPrivateKeyName: V2_STORAGE_KEYS.PRIVATE_KEY,
|
|
136
|
+
primaryPublicKeyName: V2_STORAGE_KEYS.PUBLIC_KEY,
|
|
137
|
+
backupService: V2_BACKUP_KEYCHAIN_SERVICE,
|
|
138
|
+
backupPrivateKeyName: V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY,
|
|
139
|
+
backupPublicKeyName: V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY,
|
|
140
|
+
backupTimestampName: V2_STORAGE_KEYS.BACKUP_TIMESTAMP,
|
|
141
|
+
};
|
|
142
|
+
const LEGACY_SLOT_LAYOUT = {
|
|
143
|
+
primaryService: undefined,
|
|
144
|
+
primaryPrivateKeyName: STORAGE_KEYS.PRIVATE_KEY,
|
|
145
|
+
primaryPublicKeyName: STORAGE_KEYS.PUBLIC_KEY,
|
|
146
|
+
backupService: undefined,
|
|
147
|
+
backupPrivateKeyName: STORAGE_KEYS.BACKUP_PRIVATE_KEY,
|
|
148
|
+
backupPublicKeyName: STORAGE_KEYS.BACKUP_PUBLIC_KEY,
|
|
149
|
+
backupTimestampName: STORAGE_KEYS.BACKUP_TIMESTAMP,
|
|
150
|
+
};
|
|
80
151
|
/**
|
|
81
152
|
* iOS Keychain Access Group for sharing identities across Oxy apps
|
|
82
153
|
* All Oxy apps must have this access group enabled in their entitlements
|
|
@@ -161,6 +232,320 @@ class KeyManager {
|
|
|
161
232
|
static invalidateCache() {
|
|
162
233
|
KeyManager.cachedPublicKey = null;
|
|
163
234
|
KeyManager.cachedHasIdentity = null;
|
|
235
|
+
KeyManager.cachedPublicKeyResolved = false;
|
|
236
|
+
KeyManager.notifyIdentityChanged();
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Subscribe to identity-verdict changes (create / import / delete / restore /
|
|
240
|
+
* cache invalidation). Fires synchronously; the returned function unsubscribes.
|
|
241
|
+
* Consumed via `useOxyEvent`-style hooks in commons to invalidate the routing
|
|
242
|
+
* queries the instant the identity state moves, without polling.
|
|
243
|
+
*/
|
|
244
|
+
static subscribeIdentityChanged(listener) {
|
|
245
|
+
KeyManager.identityChangeListeners.add(listener);
|
|
246
|
+
return () => {
|
|
247
|
+
KeyManager.identityChangeListeners.delete(listener);
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/** Synchronous fan-out with per-listener isolation (one throwing listener never blocks the rest). */
|
|
251
|
+
static notifyIdentityChanged() {
|
|
252
|
+
// Snapshot first — a listener may unsubscribe (mutate the Set) during fan-out.
|
|
253
|
+
for (const listener of Array.from(KeyManager.identityChangeListeners)) {
|
|
254
|
+
try {
|
|
255
|
+
listener();
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
logger_1.logger.warn('Identity-change listener threw', { component: 'KeyManager' }, error);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Build `getItemAsync`/`deleteItemAsync` options for a given keychain service (read/delete). */
|
|
263
|
+
static _slotOpts(service) {
|
|
264
|
+
return service ? { keychainService: service } : {};
|
|
265
|
+
}
|
|
266
|
+
/** Build private-key write options (device-only accessibility) for a given keychain service. */
|
|
267
|
+
static _privateWriteOpts(store, service) {
|
|
268
|
+
const opts = { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
|
|
269
|
+
if (service) {
|
|
270
|
+
opts.keychainService = service;
|
|
271
|
+
}
|
|
272
|
+
return opts;
|
|
273
|
+
}
|
|
274
|
+
/** True only when both keys are present, well-formed, AND the public derives from the private. */
|
|
275
|
+
static _isHealthyPair(privateKey, publicKey) {
|
|
276
|
+
if (!privateKey || !publicKey) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
if (!KeyManager.isValidPrivateKey(privateKey) || !KeyManager.isValidPublicKey(publicKey)) {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
return KeyManager.derivePublicKey(privateKey).toLowerCase() === publicKey.toLowerCase();
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Resolve the AsyncStorage-backed KV store for the advisory migration flag, or
|
|
291
|
+
* `null` off-RN / when unavailable. Independent of the keychain, so the flag
|
|
292
|
+
* cannot be taken down by the keystore event this whole subsystem defends
|
|
293
|
+
* against.
|
|
294
|
+
*/
|
|
295
|
+
static async _advisoryStorage() {
|
|
296
|
+
if (!(0, protocol_1.isReactNative)()) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const mod = await (0, protocol_1.loadAsyncStorage)();
|
|
301
|
+
return mod.default;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Advisory only — absence just means the slot check runs in full.
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
static async _readSlotsMigratedFlag() {
|
|
309
|
+
const storage = await KeyManager._advisoryStorage();
|
|
310
|
+
if (!storage) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
return (await storage.getItem(SLOTS_MIGRATED_FLAG_KEY)) === 'true';
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// Advisory only — treat an unreadable flag as "not yet migrated".
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
static async _setSlotsMigratedFlag() {
|
|
322
|
+
const storage = await KeyManager._advisoryStorage();
|
|
323
|
+
if (!storage) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
await storage.setItem(SLOTS_MIGRATED_FLAG_KEY, 'true');
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
// Advisory only — a failed write just re-runs the cheap slot check next launch.
|
|
331
|
+
if ((0, logger_1.isDev)()) {
|
|
332
|
+
logger_1.logger.debug('Failed to set slots-migrated flag (advisory)', { component: 'KeyManager' }, error);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Ensure the identity has been migrated onto the isolated v2 slots (or that we
|
|
338
|
+
* know we must read legacy this session). Memoized so concurrent callers share
|
|
339
|
+
* ONE run; a `deferred` (read-threw) outcome is not cached so a later call
|
|
340
|
+
* retries after the keychain unlocks. Every identity-slot accessor awaits this
|
|
341
|
+
* before touching storage.
|
|
342
|
+
*/
|
|
343
|
+
static async _ensureIdentitySlotsMigrated() {
|
|
344
|
+
if (KeyManager.slotMigrationResult && KeyManager.slotMigrationResult.mode !== 'deferred') {
|
|
345
|
+
return KeyManager.slotMigrationResult;
|
|
346
|
+
}
|
|
347
|
+
if (!KeyManager.slotMigrationPromise) {
|
|
348
|
+
const run = (async () => {
|
|
349
|
+
const result = await KeyManager._runSlotMigration();
|
|
350
|
+
KeyManager.slotMigrationResult = result;
|
|
351
|
+
return result;
|
|
352
|
+
})();
|
|
353
|
+
KeyManager.slotMigrationPromise = run;
|
|
354
|
+
// Clear the in-flight handle once settled so a deferred outcome retries.
|
|
355
|
+
run
|
|
356
|
+
.then((result) => {
|
|
357
|
+
if (result.mode === 'deferred') {
|
|
358
|
+
KeyManager.slotMigrationPromise = null;
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
.catch(() => {
|
|
362
|
+
KeyManager.slotMigrationPromise = null;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return KeyManager.slotMigrationPromise;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* One-shot slot migration state machine. All reads are DIRECT and a thrown
|
|
369
|
+
* read defers everything (zero writes/deletes) so a locked keychain is never
|
|
370
|
+
* mistaken for an empty one. INVARIANT: at every instant ≥1 readable copy of a
|
|
371
|
+
* previously-existing identity remains — legacy is deleted ONLY after the v2
|
|
372
|
+
* copy is verified re-readable in its new (non-aliasable) location.
|
|
373
|
+
*/
|
|
374
|
+
static async _runSlotMigration() {
|
|
375
|
+
let store;
|
|
376
|
+
try {
|
|
377
|
+
store = await initSecureStore();
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
return { mode: 'deferred', cause: error };
|
|
381
|
+
}
|
|
382
|
+
const migratedFlag = await KeyManager._readSlotsMigratedFlag();
|
|
383
|
+
// Read the v2 primary (dedicated keychain service).
|
|
384
|
+
let v2Private;
|
|
385
|
+
let v2Public;
|
|
386
|
+
try {
|
|
387
|
+
v2Private = await store.getItemAsync(V2_STORAGE_KEYS.PRIVATE_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
|
|
388
|
+
v2Public = await store.getItemAsync(V2_STORAGE_KEYS.PUBLIC_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
return { mode: 'deferred', cause: error };
|
|
392
|
+
}
|
|
393
|
+
if (KeyManager._isHealthyPair(v2Private, v2Public)) {
|
|
394
|
+
// v2 already owns the identity. On the first observation, clean up any
|
|
395
|
+
// stale legacy copy and record the fast-path flag.
|
|
396
|
+
if (!migratedFlag) {
|
|
397
|
+
await KeyManager._bestEffortDeleteLegacyPrimaryAndBackup(store);
|
|
398
|
+
await KeyManager._setSlotsMigratedFlag();
|
|
399
|
+
}
|
|
400
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
401
|
+
}
|
|
402
|
+
// v2 primary absent/partial but the flag says migration finished → v2 is
|
|
403
|
+
// simply empty (identity deleted / never created). No legacy to rescue.
|
|
404
|
+
if (migratedFlag) {
|
|
405
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
406
|
+
}
|
|
407
|
+
// Read the legacy primary (default keychain service = the old `key_v1`).
|
|
408
|
+
let legacyPrivate;
|
|
409
|
+
let legacyPublic;
|
|
410
|
+
try {
|
|
411
|
+
legacyPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
|
|
412
|
+
legacyPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
return { mode: 'deferred', cause: error };
|
|
416
|
+
}
|
|
417
|
+
if (!KeyManager._isHealthyPair(legacyPrivate, legacyPublic)) {
|
|
418
|
+
// Nothing readable in either generation → v2 is the canonical (empty) home.
|
|
419
|
+
// The marker (not this migration) decides fresh-vs-lost.
|
|
420
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
421
|
+
}
|
|
422
|
+
// legacy healthy, v2 absent → migrate: copy → read-back verify → only then delete legacy.
|
|
423
|
+
const canonicalPrivate = KeyManager.canonicalPrivateKey(legacyPrivate);
|
|
424
|
+
const canonicalPublic = legacyPublic.toLowerCase();
|
|
425
|
+
try {
|
|
426
|
+
await store.setItemAsync(V2_STORAGE_KEYS.PUBLIC_KEY, canonicalPublic, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
|
|
427
|
+
await store.setItemAsync(V2_STORAGE_KEYS.PRIVATE_KEY, canonicalPrivate, KeyManager._privateWriteOpts(store, V2_PRIMARY_KEYCHAIN_SERVICE));
|
|
428
|
+
const readBackPrivate = await store.getItemAsync(V2_STORAGE_KEYS.PRIVATE_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
|
|
429
|
+
const readBackPublic = await store.getItemAsync(V2_STORAGE_KEYS.PUBLIC_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
|
|
430
|
+
const verified = readBackPrivate?.toLowerCase() === canonicalPrivate &&
|
|
431
|
+
readBackPublic?.toLowerCase() === canonicalPublic &&
|
|
432
|
+
KeyManager._isHealthyPair(readBackPrivate, readBackPublic);
|
|
433
|
+
if (!verified) {
|
|
434
|
+
// v2 write did not durably land — remove the partial v2 and serve reads
|
|
435
|
+
// from legacy this session (legacy is UNTOUCHED). Retry next launch.
|
|
436
|
+
await KeyManager._bestEffortDeleteV2Primary(store);
|
|
437
|
+
logger_1.logger.warn('Identity slot migration verify failed; serving identity from legacy slots this session', { component: 'KeyManager' });
|
|
438
|
+
return { mode: 'legacy', layout: LEGACY_SLOT_LAYOUT };
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
await KeyManager._bestEffortDeleteV2Primary(store);
|
|
443
|
+
logger_1.logger.warn('Identity slot migration write threw; serving identity from legacy slots this session', { component: 'KeyManager' }, error);
|
|
444
|
+
return { mode: 'legacy', layout: LEGACY_SLOT_LAYOUT };
|
|
445
|
+
}
|
|
446
|
+
// v2 primary is verified re-readable. Migrate the backup slot (best-effort),
|
|
447
|
+
// then it is finally safe to delete the legacy generation.
|
|
448
|
+
await KeyManager._migrateBackupSlotToV2(store, canonicalPrivate, canonicalPublic);
|
|
449
|
+
await KeyManager._bestEffortDeleteLegacyPrimaryAndBackup(store);
|
|
450
|
+
await KeyManager._setSlotsMigratedFlag();
|
|
451
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Seed the v2 backup slot during migration. Prefers a healthy legacy backup;
|
|
455
|
+
* otherwise mirrors the (already-verified) v2 primary material so a v2 backup
|
|
456
|
+
* always exists on an independent keychain key. Best-effort — a failure just
|
|
457
|
+
* defers backup population to the next {@link _persistIdentityAtomic}.
|
|
458
|
+
*/
|
|
459
|
+
static async _migrateBackupSlotToV2(store, primaryPrivate, primaryPublic) {
|
|
460
|
+
try {
|
|
461
|
+
let backupPrivate = null;
|
|
462
|
+
let backupPublic = null;
|
|
463
|
+
try {
|
|
464
|
+
backupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
465
|
+
backupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
if ((0, logger_1.isDev)()) {
|
|
469
|
+
logger_1.logger.debug('Legacy backup unreadable during migration (non-fatal)', { component: 'KeyManager' }, error);
|
|
470
|
+
}
|
|
471
|
+
backupPrivate = null;
|
|
472
|
+
backupPublic = null;
|
|
473
|
+
}
|
|
474
|
+
let seedPrivate;
|
|
475
|
+
let seedPublic;
|
|
476
|
+
if (KeyManager._isHealthyPair(backupPrivate, backupPublic)) {
|
|
477
|
+
seedPrivate = KeyManager.canonicalPrivateKey(backupPrivate);
|
|
478
|
+
seedPublic = backupPublic.toLowerCase();
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
seedPrivate = primaryPrivate;
|
|
482
|
+
seedPublic = primaryPublic;
|
|
483
|
+
}
|
|
484
|
+
await store.setItemAsync(V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, seedPublic, KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE));
|
|
485
|
+
await store.setItemAsync(V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, seedPrivate, KeyManager._privateWriteOpts(store, V2_BACKUP_KEYCHAIN_SERVICE));
|
|
486
|
+
await store.setItemAsync(V2_STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString(), KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE));
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
logger_1.logger.warn('Failed to migrate identity backup slot to v2 (non-fatal)', { component: 'KeyManager' }, error);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** Best-effort single delete under an optional keychain service. Cleanup only — never surfaces. */
|
|
493
|
+
static async _bestEffortDelete(store, key, service) {
|
|
494
|
+
try {
|
|
495
|
+
await store.deleteItemAsync(key, KeyManager._slotOpts(service));
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
if ((0, logger_1.isDev)()) {
|
|
499
|
+
logger_1.logger.debug('Best-effort identity delete failed', { component: 'KeyManager' }, error);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
static async _bestEffortDeleteV2Primary(store) {
|
|
504
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PRIVATE_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
|
|
505
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PUBLIC_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
|
|
506
|
+
}
|
|
507
|
+
static async _bestEffortDeleteLegacyPrimaryAndBackup(store) {
|
|
508
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
|
|
509
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
|
|
510
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
511
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
512
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
|
|
513
|
+
}
|
|
514
|
+
static async _bestEffortDeleteBackupsAllGenerations(store) {
|
|
515
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
|
|
516
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
|
|
517
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_TIMESTAMP, V2_BACKUP_KEYCHAIN_SERVICE);
|
|
518
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
519
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
520
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Clear the cross-app shared identity slot (force-delete only) so a deleted
|
|
524
|
+
* identity cannot be resurrected via the recovery ladder's shared rung.
|
|
525
|
+
* Best-effort — the shared slot is a redundant convenience copy.
|
|
526
|
+
*/
|
|
527
|
+
static async _clearSharedSlot(store) {
|
|
528
|
+
try {
|
|
529
|
+
if ((0, platform_1.isIOS)()) {
|
|
530
|
+
const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
531
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
|
|
532
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
|
|
533
|
+
}
|
|
534
|
+
else if ((0, platform_1.isAndroid)()) {
|
|
535
|
+
const bridge = await (0, protocol_1.loadSharedIdentityBridge)();
|
|
536
|
+
if (bridge) {
|
|
537
|
+
await bridge.clearShared();
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
|
|
541
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
KeyManager.invalidateSharedCache();
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
logger_1.logger.warn('Failed to clear shared identity slot during force delete', { component: 'KeyManager' }, error);
|
|
548
|
+
}
|
|
164
549
|
}
|
|
165
550
|
/**
|
|
166
551
|
* Invalidate cached shared identity state
|
|
@@ -625,8 +1010,23 @@ class KeyManager {
|
|
|
625
1010
|
*
|
|
626
1011
|
* @internal
|
|
627
1012
|
*/
|
|
628
|
-
static async _persistIdentityAtomic(privateKey, publicKey) {
|
|
1013
|
+
static async _persistIdentityAtomic(privateKey, publicKey, origin) {
|
|
629
1014
|
const store = await initSecureStore();
|
|
1015
|
+
// Resolve the active slot layout (normally v2; legacy only in the rare
|
|
1016
|
+
// migration-fallback session). Reading and writing the SAME layout keeps the
|
|
1017
|
+
// snapshot/rollback machinery below internally consistent. A deferred
|
|
1018
|
+
// migration (keychain locked) must never write blind.
|
|
1019
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1020
|
+
if (migration.mode === 'deferred') {
|
|
1021
|
+
throw new IdentityUnavailableError('Identity storage is temporarily unavailable; refusing to persist an identity.', migration.cause);
|
|
1022
|
+
}
|
|
1023
|
+
const layout = migration.layout;
|
|
1024
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1025
|
+
const primaryPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.primaryService);
|
|
1026
|
+
const primaryPubWriteOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1027
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
1028
|
+
const backupPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.backupService);
|
|
1029
|
+
const backupPubWriteOpts = KeyManager._slotOpts(layout.backupService);
|
|
630
1030
|
// Canonicalize BEFORE persistence so the stored value is always in
|
|
631
1031
|
// canonical 64-hex-char lowercase form going forward. This is the single
|
|
632
1032
|
// place all primary writes flow through, so once a value lands here all
|
|
@@ -641,8 +1041,8 @@ class KeyManager {
|
|
|
641
1041
|
let priorPrivate;
|
|
642
1042
|
let priorPublic;
|
|
643
1043
|
try {
|
|
644
|
-
priorPrivate = await store.getItemAsync(
|
|
645
|
-
priorPublic = await store.getItemAsync(
|
|
1044
|
+
priorPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
1045
|
+
priorPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
646
1046
|
}
|
|
647
1047
|
catch (error) {
|
|
648
1048
|
logger_1.logger.error('Failed to read existing primary before persist', error, { component: 'KeyManager' });
|
|
@@ -664,18 +1064,16 @@ class KeyManager {
|
|
|
664
1064
|
if (priorIsHealthyDifferent && priorPrivate && priorPublic) {
|
|
665
1065
|
let existingBackupPublic = null;
|
|
666
1066
|
try {
|
|
667
|
-
existingBackupPublic = await store.getItemAsync(
|
|
1067
|
+
existingBackupPublic = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
668
1068
|
}
|
|
669
1069
|
catch {
|
|
670
1070
|
existingBackupPublic = null;
|
|
671
1071
|
}
|
|
672
1072
|
if (existingBackupPublic?.toLowerCase() !== priorPublic.toLowerCase()) {
|
|
673
1073
|
try {
|
|
674
|
-
await store.setItemAsync(
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorPublic.toLowerCase());
|
|
678
|
-
await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
|
|
1074
|
+
await store.setItemAsync(layout.backupPrivateKeyName, KeyManager.canonicalPrivateKey(priorPrivate), backupPrivWriteOpts);
|
|
1075
|
+
await store.setItemAsync(layout.backupPublicKeyName, priorPublic.toLowerCase(), backupPubWriteOpts);
|
|
1076
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupPubWriteOpts);
|
|
679
1077
|
}
|
|
680
1078
|
catch (error) {
|
|
681
1079
|
logger_1.logger.error('Failed to back up existing identity before overwrite', error, { component: 'KeyManager' });
|
|
@@ -688,14 +1086,12 @@ class KeyManager {
|
|
|
688
1086
|
// NOT touched here — it still holds the previous good identity until the
|
|
689
1087
|
// new primary is proven durable.
|
|
690
1088
|
try {
|
|
691
|
-
await store.setItemAsync(
|
|
692
|
-
await store.setItemAsync(
|
|
693
|
-
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
694
|
-
});
|
|
1089
|
+
await store.setItemAsync(layout.primaryPublicKeyName, canonicalPublic, primaryPubWriteOpts);
|
|
1090
|
+
await store.setItemAsync(layout.primaryPrivateKeyName, canonicalPrivate, primaryPrivWriteOpts);
|
|
695
1091
|
}
|
|
696
1092
|
catch (error) {
|
|
697
1093
|
logger_1.logger.error('Failed to write primary identity to secure store', error, { component: 'KeyManager' });
|
|
698
|
-
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
1094
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
699
1095
|
throw new IdentityPersistError('Failed to write identity to secure store', error);
|
|
700
1096
|
}
|
|
701
1097
|
// Step 2: Verify round-trip. If the store silently drops our writes
|
|
@@ -705,12 +1101,12 @@ class KeyManager {
|
|
|
705
1101
|
let readBackPrivate;
|
|
706
1102
|
let readBackPublic;
|
|
707
1103
|
try {
|
|
708
|
-
readBackPrivate = await store.getItemAsync(
|
|
709
|
-
readBackPublic = await store.getItemAsync(
|
|
1104
|
+
readBackPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
1105
|
+
readBackPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
710
1106
|
}
|
|
711
1107
|
catch (error) {
|
|
712
1108
|
logger_1.logger.error('Failed to read identity back after write', error, { component: 'KeyManager' });
|
|
713
|
-
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
1109
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
714
1110
|
throw new IdentityPersistError('Failed to verify identity after write', error);
|
|
715
1111
|
}
|
|
716
1112
|
// Hex comparisons are case-insensitive — normalize on both sides so a
|
|
@@ -719,7 +1115,7 @@ class KeyManager {
|
|
|
719
1115
|
if (readBackPrivate?.toLowerCase() !== canonicalPrivate ||
|
|
720
1116
|
readBackPublic?.toLowerCase() !== canonicalPublic) {
|
|
721
1117
|
logger_1.logger.error('Identity round-trip mismatch after write', undefined, { component: 'KeyManager' });
|
|
722
|
-
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
1118
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
723
1119
|
throw new IdentityPersistError('Identity write was not persisted correctly (round-trip mismatch).');
|
|
724
1120
|
}
|
|
725
1121
|
// Final sanity: derive public from the stored private and confirm the
|
|
@@ -740,7 +1136,7 @@ class KeyManager {
|
|
|
740
1136
|
}
|
|
741
1137
|
}
|
|
742
1138
|
catch (error) {
|
|
743
|
-
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
1139
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
744
1140
|
if (error instanceof IdentityPersistError)
|
|
745
1141
|
throw error;
|
|
746
1142
|
logger_1.logger.error('Identity sign/verify probe failed', error, { component: 'KeyManager' });
|
|
@@ -757,31 +1153,60 @@ class KeyManager {
|
|
|
757
1153
|
let priorBackupPublic;
|
|
758
1154
|
let priorBackupTimestamp;
|
|
759
1155
|
try {
|
|
760
|
-
priorBackupPrivate = await store.getItemAsync(
|
|
761
|
-
priorBackupPublic = await store.getItemAsync(
|
|
762
|
-
priorBackupTimestamp = await store.getItemAsync(
|
|
1156
|
+
priorBackupPrivate = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
|
|
1157
|
+
priorBackupPublic = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
1158
|
+
priorBackupTimestamp = await store.getItemAsync(layout.backupTimestampName, backupReadOpts);
|
|
763
1159
|
}
|
|
764
1160
|
catch (error) {
|
|
765
1161
|
logger_1.logger.error('Failed to snapshot identity backup before refresh', error, { component: 'KeyManager' });
|
|
766
|
-
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
1162
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
767
1163
|
throw new IdentityPersistError('Failed to snapshot identity backup before refresh', error);
|
|
768
1164
|
}
|
|
769
1165
|
try {
|
|
770
|
-
await store.setItemAsync(
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
|
|
774
|
-
await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
|
|
1166
|
+
await store.setItemAsync(layout.backupPrivateKeyName, canonicalPrivate, backupPrivWriteOpts);
|
|
1167
|
+
await store.setItemAsync(layout.backupPublicKeyName, canonicalPublic, backupPubWriteOpts);
|
|
1168
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupPubWriteOpts);
|
|
775
1169
|
}
|
|
776
1170
|
catch (error) {
|
|
777
1171
|
logger_1.logger.error('Failed to refresh identity backup after primary write', error, { component: 'KeyManager' });
|
|
778
|
-
await KeyManager._rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
|
|
779
|
-
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
1172
|
+
await KeyManager._rollbackBackup(store, layout, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
|
|
1173
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
780
1174
|
throw new IdentityPersistError('Failed to refresh identity backup after primary write', error);
|
|
781
1175
|
}
|
|
782
|
-
// Update cache only after we are certain the identity is durable
|
|
1176
|
+
// Update cache only after we are certain the identity is durable, then fan
|
|
1177
|
+
// out to identity-change subscribers.
|
|
783
1178
|
KeyManager.cachedPublicKey = canonicalPublic;
|
|
784
1179
|
KeyManager.cachedHasIdentity = true;
|
|
1180
|
+
KeyManager.cachedPublicKeyResolved = false;
|
|
1181
|
+
KeyManager.notifyIdentityChanged();
|
|
1182
|
+
// LAST step: mirror the identity into the AndroidKeyStore-independent marker
|
|
1183
|
+
// so a later keystore death can be told apart from a fresh install. This is
|
|
1184
|
+
// best-effort — a marker write failure must NEVER fail an otherwise-durable
|
|
1185
|
+
// persist (a subsequent healthy read re-backfills it). Rollback paths above
|
|
1186
|
+
// return before reaching here, so they never touch the marker.
|
|
1187
|
+
await KeyManager._syncMarkerAfterPersist(canonicalPublic, origin);
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Write/refresh the identity marker after a successful persist. A same-identity
|
|
1191
|
+
* re-persist (e.g. backup refresh, idempotent re-import) preserves `createdAt`
|
|
1192
|
+
* and the `onboardingComplete` milestone by only updating `origin`; a NEW or
|
|
1193
|
+
* switched identity writes a fresh marker. Best-effort — never throws.
|
|
1194
|
+
*
|
|
1195
|
+
* @internal
|
|
1196
|
+
*/
|
|
1197
|
+
static async _syncMarkerAfterPersist(publicKey, origin) {
|
|
1198
|
+
try {
|
|
1199
|
+
const existing = await (0, identityMarker_1.readIdentityMarker)();
|
|
1200
|
+
if (existing && existing.publicKey.toLowerCase() === publicKey.toLowerCase()) {
|
|
1201
|
+
await (0, identityMarker_1.updateIdentityMarker)({ origin });
|
|
1202
|
+
}
|
|
1203
|
+
else {
|
|
1204
|
+
await (0, identityMarker_1.writeIdentityMarker)({ publicKey, origin });
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
catch (error) {
|
|
1208
|
+
logger_1.logger.warn('Failed to sync identity marker after persist (non-fatal)', { component: 'KeyManager' }, error);
|
|
1209
|
+
}
|
|
785
1210
|
}
|
|
786
1211
|
/**
|
|
787
1212
|
* Restore the backup slot to a previously-snapshotted state. Best-effort so
|
|
@@ -789,34 +1214,35 @@ class KeyManager {
|
|
|
789
1214
|
*
|
|
790
1215
|
* @internal
|
|
791
1216
|
*/
|
|
792
|
-
static async _rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp) {
|
|
1217
|
+
static async _rollbackBackup(store, layout, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp) {
|
|
1218
|
+
const backupPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.backupService);
|
|
1219
|
+
const backupPubWriteOpts = KeyManager._slotOpts(layout.backupService);
|
|
1220
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
793
1221
|
try {
|
|
794
1222
|
if (priorBackupPrivate) {
|
|
795
|
-
await store.setItemAsync(
|
|
796
|
-
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
797
|
-
});
|
|
1223
|
+
await store.setItemAsync(layout.backupPrivateKeyName, priorBackupPrivate, backupPrivWriteOpts);
|
|
798
1224
|
}
|
|
799
1225
|
else {
|
|
800
1226
|
try {
|
|
801
|
-
await store.deleteItemAsync(
|
|
1227
|
+
await store.deleteItemAsync(layout.backupPrivateKeyName, backupReadOpts);
|
|
802
1228
|
}
|
|
803
1229
|
catch { /* best effort */ }
|
|
804
1230
|
}
|
|
805
1231
|
if (priorBackupPublic) {
|
|
806
|
-
await store.setItemAsync(
|
|
1232
|
+
await store.setItemAsync(layout.backupPublicKeyName, priorBackupPublic, backupPubWriteOpts);
|
|
807
1233
|
}
|
|
808
1234
|
else {
|
|
809
1235
|
try {
|
|
810
|
-
await store.deleteItemAsync(
|
|
1236
|
+
await store.deleteItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
811
1237
|
}
|
|
812
1238
|
catch { /* best effort */ }
|
|
813
1239
|
}
|
|
814
1240
|
if (priorBackupTimestamp) {
|
|
815
|
-
await store.setItemAsync(
|
|
1241
|
+
await store.setItemAsync(layout.backupTimestampName, priorBackupTimestamp, backupPubWriteOpts);
|
|
816
1242
|
}
|
|
817
1243
|
else {
|
|
818
1244
|
try {
|
|
819
|
-
await store.deleteItemAsync(
|
|
1245
|
+
await store.deleteItemAsync(layout.backupTimestampName, backupReadOpts);
|
|
820
1246
|
}
|
|
821
1247
|
catch { /* best effort */ }
|
|
822
1248
|
}
|
|
@@ -834,24 +1260,25 @@ class KeyManager {
|
|
|
834
1260
|
*
|
|
835
1261
|
* @internal
|
|
836
1262
|
*/
|
|
837
|
-
static async _rollbackPrimary(store, priorPrivate, priorPublic) {
|
|
1263
|
+
static async _rollbackPrimary(store, layout, priorPrivate, priorPublic) {
|
|
1264
|
+
const primaryPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.primaryService);
|
|
1265
|
+
const primaryPubWriteOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1266
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
838
1267
|
try {
|
|
839
1268
|
if (priorPrivate && priorPublic) {
|
|
840
1269
|
// Restore exactly what was there before the failed write.
|
|
841
|
-
await store.setItemAsync(
|
|
842
|
-
await store.setItemAsync(
|
|
843
|
-
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
844
|
-
});
|
|
1270
|
+
await store.setItemAsync(layout.primaryPublicKeyName, priorPublic, primaryPubWriteOpts);
|
|
1271
|
+
await store.setItemAsync(layout.primaryPrivateKeyName, priorPrivate, primaryPrivWriteOpts);
|
|
845
1272
|
}
|
|
846
1273
|
else {
|
|
847
1274
|
// There was no prior identity — leave the device empty rather than
|
|
848
1275
|
// half-written so hasIdentity() does not lie.
|
|
849
1276
|
try {
|
|
850
|
-
await store.deleteItemAsync(
|
|
1277
|
+
await store.deleteItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
851
1278
|
}
|
|
852
1279
|
catch { /* best effort */ }
|
|
853
1280
|
try {
|
|
854
|
-
await store.deleteItemAsync(
|
|
1281
|
+
await store.deleteItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
855
1282
|
}
|
|
856
1283
|
catch { /* best effort */ }
|
|
857
1284
|
}
|
|
@@ -881,16 +1308,50 @@ class KeyManager {
|
|
|
881
1308
|
// The local key IS the account — clobbering it without consent is
|
|
882
1309
|
// catastrophic. Callers must opt in explicitly when they have already
|
|
883
1310
|
// confirmed (via UI) that the user has saved their recovery phrase.
|
|
1311
|
+
//
|
|
1312
|
+
// The guard reads storage DIRECTLY (cache-bypassing) AND consults the
|
|
1313
|
+
// AndroidKeyStore-independent marker: either a stored key OR a marker means
|
|
1314
|
+
// an identity exists here → refuse. A storage THROW surfaces as
|
|
1315
|
+
// IdentityUnavailableError (never a blind write over a locked keystore).
|
|
884
1316
|
if (!options?.overwrite) {
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
1317
|
+
const marker = await (0, identityMarker_1.readIdentityMarker)();
|
|
1318
|
+
const direct = await KeyManager._readPrimaryDirect();
|
|
1319
|
+
if (direct.publicKey) {
|
|
1320
|
+
throw new IdentityAlreadyExistsError(direct.publicKey);
|
|
1321
|
+
}
|
|
1322
|
+
if (marker) {
|
|
1323
|
+
throw new IdentityAlreadyExistsError(marker.publicKey);
|
|
888
1324
|
}
|
|
889
1325
|
}
|
|
890
1326
|
const { privateKey, publicKey } = await KeyManager.generateKeyPair();
|
|
891
|
-
await KeyManager._persistIdentityAtomic(privateKey, publicKey);
|
|
1327
|
+
await KeyManager._persistIdentityAtomic(privateKey, publicKey, 'create');
|
|
892
1328
|
return publicKey;
|
|
893
1329
|
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Read the primary key pair DIRECTLY from storage, bypassing the in-memory
|
|
1332
|
+
* cache (which a prior transient failure could have poisoned). Awaits slot
|
|
1333
|
+
* migration first. Throws {@link IdentityUnavailableError} if storage is
|
|
1334
|
+
* deferred/locked or a read throws — so overwrite guards never write blind.
|
|
1335
|
+
*
|
|
1336
|
+
* @internal
|
|
1337
|
+
*/
|
|
1338
|
+
static async _readPrimaryDirect() {
|
|
1339
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1340
|
+
if (migration.mode === 'deferred') {
|
|
1341
|
+
throw new IdentityUnavailableError('Identity storage is temporarily unavailable; refusing to write blind.', migration.cause);
|
|
1342
|
+
}
|
|
1343
|
+
const layout = migration.layout;
|
|
1344
|
+
const readOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1345
|
+
try {
|
|
1346
|
+
const store = await initSecureStore();
|
|
1347
|
+
const privateKey = await store.getItemAsync(layout.primaryPrivateKeyName, readOpts);
|
|
1348
|
+
const publicKey = await store.getItemAsync(layout.primaryPublicKeyName, readOpts);
|
|
1349
|
+
return { privateKey, publicKey };
|
|
1350
|
+
}
|
|
1351
|
+
catch (error) {
|
|
1352
|
+
throw new IdentityUnavailableError('Could not read existing identity; refusing to write blind.', error);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
894
1355
|
/**
|
|
895
1356
|
* Import an existing key pair (e.g., from recovery phrase).
|
|
896
1357
|
*
|
|
@@ -913,30 +1374,53 @@ class KeyManager {
|
|
|
913
1374
|
const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
|
|
914
1375
|
const keyPair = ec.keyFromPrivate(canonicalPrivate);
|
|
915
1376
|
const publicKey = keyPair.getPublic('hex');
|
|
916
|
-
// Refuse silent overwrite — see createIdentity() for rationale.
|
|
1377
|
+
// Refuse silent overwrite — see createIdentity() for rationale. The guard
|
|
1378
|
+
// reads storage DIRECTLY (cache-bypassing) AND the marker, and treats
|
|
1379
|
+
// storage as authoritative:
|
|
1380
|
+
// - stored key === this import → safe idempotent refresh (fall through)
|
|
1381
|
+
// - stored key differs → a DIFFERENT identity is present → refuse
|
|
1382
|
+
// - storage empty + marker for a DIFFERENT identity (lost state) → refuse
|
|
1383
|
+
// - storage empty + marker matches this import (recovery) / no marker → allow
|
|
1384
|
+
// A storage throw surfaces as IdentityUnavailableError (never a blind write).
|
|
917
1385
|
if (!options?.overwrite) {
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1386
|
+
const marker = await (0, identityMarker_1.readIdentityMarker)();
|
|
1387
|
+
const direct = await KeyManager._readPrimaryDirect();
|
|
1388
|
+
const importedPub = publicKey.toLowerCase();
|
|
1389
|
+
const existingPub = direct.publicKey?.toLowerCase() ?? null;
|
|
1390
|
+
const markerPub = marker?.publicKey.toLowerCase() ?? null;
|
|
1391
|
+
if (existingPub && existingPub !== importedPub) {
|
|
1392
|
+
throw new IdentityAlreadyExistsError(direct.publicKey);
|
|
1393
|
+
}
|
|
1394
|
+
if (!existingPub && markerPub && markerPub !== importedPub) {
|
|
1395
|
+
throw new IdentityAlreadyExistsError(marker?.publicKey);
|
|
1396
|
+
}
|
|
1397
|
+
// Otherwise: existing === import (idempotent refresh), or storage empty
|
|
1398
|
+
// with a matching/absent marker (fresh import or lost-identity recovery)
|
|
1399
|
+
// → fall through and (re-)persist to refresh the backup + marker.
|
|
1400
|
+
}
|
|
1401
|
+
await KeyManager._persistIdentityAtomic(canonicalPrivate, publicKey, 'import');
|
|
927
1402
|
return publicKey;
|
|
928
1403
|
}
|
|
929
1404
|
/**
|
|
930
1405
|
* Get the stored private key
|
|
931
1406
|
* WARNING: Only use this for signing operations within the app
|
|
1407
|
+
*
|
|
1408
|
+
* Preserves the "return null on any storage failure" contract signing paths
|
|
1409
|
+
* rely on (a locked keychain simply means "cannot sign now"); unlike
|
|
1410
|
+
* {@link getPublicKey}, it does NOT throw {@link IdentityUnavailableError}.
|
|
932
1411
|
*/
|
|
933
1412
|
static async getPrivateKey() {
|
|
934
1413
|
if (isWebPlatform()) {
|
|
935
1414
|
return null; // Identity storage is only available on native platforms
|
|
936
1415
|
}
|
|
937
1416
|
try {
|
|
1417
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1418
|
+
if (migration.mode === 'deferred') {
|
|
1419
|
+
// Storage unreadable right now — preserve the null contract.
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
938
1422
|
const store = await initSecureStore();
|
|
939
|
-
return await store.getItemAsync(
|
|
1423
|
+
return await store.getItemAsync(migration.layout.primaryPrivateKeyName, KeyManager._slotOpts(migration.layout.primaryService));
|
|
940
1424
|
}
|
|
941
1425
|
catch (error) {
|
|
942
1426
|
// If secure store is not available, return null (no identity)
|
|
@@ -948,7 +1432,12 @@ class KeyManager {
|
|
|
948
1432
|
}
|
|
949
1433
|
}
|
|
950
1434
|
/**
|
|
951
|
-
* Get the stored public key (cached for performance)
|
|
1435
|
+
* Get the stored public key (cached for performance).
|
|
1436
|
+
*
|
|
1437
|
+
* Returns the public key, or `null` when a read SUCCEEDS and finds none.
|
|
1438
|
+
* THROWS {@link IdentityUnavailableError} when storage is unreadable (keychain
|
|
1439
|
+
* locked / module load failure) — a thrown read is NEVER flattened to `null`
|
|
1440
|
+
* and NEVER cached, so a poisoned "no identity" verdict can no longer stick.
|
|
952
1441
|
*/
|
|
953
1442
|
static async getPublicKey() {
|
|
954
1443
|
if (isWebPlatform()) {
|
|
@@ -957,21 +1446,34 @@ class KeyManager {
|
|
|
957
1446
|
if (KeyManager.cachedPublicKey !== null) {
|
|
958
1447
|
return KeyManager.cachedPublicKey;
|
|
959
1448
|
}
|
|
1449
|
+
// A genuine-absent result (read succeeded, empty) is cacheable distinctly
|
|
1450
|
+
// from a thrown read — only the former sets this flag.
|
|
1451
|
+
if (KeyManager.cachedPublicKeyResolved) {
|
|
1452
|
+
return null;
|
|
1453
|
+
}
|
|
1454
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1455
|
+
if (migration.mode === 'deferred') {
|
|
1456
|
+
throw new IdentityUnavailableError('Identity storage is temporarily unavailable (keychain locked or unreadable).', migration.cause);
|
|
1457
|
+
}
|
|
960
1458
|
try {
|
|
961
1459
|
const store = await initSecureStore();
|
|
962
|
-
const publicKey = await store.getItemAsync(
|
|
963
|
-
|
|
964
|
-
|
|
1460
|
+
const publicKey = await store.getItemAsync(migration.layout.primaryPublicKeyName, KeyManager._slotOpts(migration.layout.primaryService));
|
|
1461
|
+
if (publicKey !== null) {
|
|
1462
|
+
KeyManager.cachedPublicKey = publicKey;
|
|
1463
|
+
}
|
|
1464
|
+
else {
|
|
1465
|
+
// Genuine-absent (successful empty read) IS safe to cache.
|
|
1466
|
+
KeyManager.cachedPublicKeyResolved = true;
|
|
1467
|
+
}
|
|
965
1468
|
return publicKey;
|
|
966
1469
|
}
|
|
967
1470
|
catch (error) {
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
KeyManager.cachedPublicKey = null;
|
|
1471
|
+
// Storage threw AFTER migration resolved — transient/unavailable. Do NOT
|
|
1472
|
+
// cache; surface a typed error so callers never misread it as "no identity".
|
|
971
1473
|
if ((0, logger_1.isDev)()) {
|
|
972
1474
|
logger_1.logger.warn('Failed to access secure store', { component: 'KeyManager' }, error);
|
|
973
1475
|
}
|
|
974
|
-
|
|
1476
|
+
throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
|
|
975
1477
|
}
|
|
976
1478
|
}
|
|
977
1479
|
/**
|
|
@@ -979,8 +1481,11 @@ class KeyManager {
|
|
|
979
1481
|
*
|
|
980
1482
|
* Returns `true` only when BOTH the private and public keys are present,
|
|
981
1483
|
* both are well-formed, AND the public key derives from the private key.
|
|
982
|
-
* A partially-written or corrupted identity
|
|
983
|
-
* downstream code can resume the create / restore flow
|
|
1484
|
+
* A partially-written or corrupted identity (read succeeded, bytes empty/bad)
|
|
1485
|
+
* returns `false` so that downstream code can resume the create / restore flow.
|
|
1486
|
+
* THROWS {@link IdentityUnavailableError} when storage is unreadable — a locked
|
|
1487
|
+
* keychain must never be mistaken for "no identity" (the old behavior that let
|
|
1488
|
+
* onboarding treat a transient lock as a blank device).
|
|
984
1489
|
*
|
|
985
1490
|
* Note: this does NOT perform the full sign/verify roundtrip — call
|
|
986
1491
|
* `verifyIdentityIntegrity()` for that.
|
|
@@ -992,23 +1497,26 @@ class KeyManager {
|
|
|
992
1497
|
if (KeyManager.cachedHasIdentity !== null) {
|
|
993
1498
|
return KeyManager.cachedHasIdentity;
|
|
994
1499
|
}
|
|
1500
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1501
|
+
if (migration.mode === 'deferred') {
|
|
1502
|
+
throw new IdentityUnavailableError('Identity storage is temporarily unavailable.', migration.cause);
|
|
1503
|
+
}
|
|
995
1504
|
let privateKey;
|
|
996
1505
|
let publicKey;
|
|
997
1506
|
try {
|
|
998
1507
|
const store = await initSecureStore();
|
|
999
1508
|
[privateKey, publicKey] = await Promise.all([
|
|
1000
|
-
store.getItemAsync(
|
|
1001
|
-
store.getItemAsync(
|
|
1509
|
+
store.getItemAsync(migration.layout.primaryPrivateKeyName, KeyManager._slotOpts(migration.layout.primaryService)),
|
|
1510
|
+
store.getItemAsync(migration.layout.primaryPublicKeyName, KeyManager._slotOpts(migration.layout.primaryService)),
|
|
1002
1511
|
]);
|
|
1003
1512
|
}
|
|
1004
1513
|
catch (error) {
|
|
1005
1514
|
// Storage threw — could be a transient keychain lock (e.g., background
|
|
1006
|
-
// fetch before the device is unlocked). Do NOT cache
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
// call retry from storage.
|
|
1515
|
+
// fetch before the device is unlocked). Do NOT cache; throw a TYPED error
|
|
1516
|
+
// so callers distinguish "temporarily unavailable" from "genuinely absent"
|
|
1517
|
+
// instead of silently treating a locked keystore as a blank device.
|
|
1010
1518
|
logger_1.logger.error('Failed to read identity from secure storage', error, { component: 'KeyManager' });
|
|
1011
|
-
|
|
1519
|
+
throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
|
|
1012
1520
|
}
|
|
1013
1521
|
// Storage succeeded. Now classify the result. From here onward, any
|
|
1014
1522
|
// outcome is stable and safe to cache (the bytes won't change between
|
|
@@ -1062,6 +1570,75 @@ class KeyManager {
|
|
|
1062
1570
|
}
|
|
1063
1571
|
return hasIdentity;
|
|
1064
1572
|
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Authoritative identity verdict — the corruption-vs-fresh-install
|
|
1575
|
+
* disambiguator that routing (commons) keys off of.
|
|
1576
|
+
*
|
|
1577
|
+
* - Healthy pair → `present` (and the marker is backfilled if missing or
|
|
1578
|
+
* pointing at a different key, `origin: 'backfill'`).
|
|
1579
|
+
* - Read succeeded but no healthy pair, WITH a marker → `lost` (keystore death
|
|
1580
|
+
* / corruption; route to recovery, NEVER to create).
|
|
1581
|
+
* - Read succeeded, no pair, NO marker → `absent` (a genuine fresh device; the
|
|
1582
|
+
* only state that may route to onboarding/create).
|
|
1583
|
+
* - A read THREW → `unavailable` (keychain locked); this verdict is NEVER
|
|
1584
|
+
* cached, so a later call re-reads.
|
|
1585
|
+
*
|
|
1586
|
+
* @param opts.bypassCache When true, never reads OR writes the in-memory cache
|
|
1587
|
+
* — a pure, fresh storage verdict for the auto-create interlock preflight.
|
|
1588
|
+
*/
|
|
1589
|
+
static async getIdentityStatus(opts) {
|
|
1590
|
+
if (isWebPlatform()) {
|
|
1591
|
+
return { state: 'absent' }; // Identity storage is only available on native platforms
|
|
1592
|
+
}
|
|
1593
|
+
const bypassCache = opts?.bypassCache === true;
|
|
1594
|
+
// Read the marker FIRST (fail-open null) — it is the AndroidKeyStore-independent
|
|
1595
|
+
// signal that survives a keystore death.
|
|
1596
|
+
const marker = await (0, identityMarker_1.readIdentityMarker)();
|
|
1597
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1598
|
+
if (migration.mode === 'deferred') {
|
|
1599
|
+
return { state: 'unavailable', cause: migration.cause };
|
|
1600
|
+
}
|
|
1601
|
+
let privateKey;
|
|
1602
|
+
let publicKey;
|
|
1603
|
+
try {
|
|
1604
|
+
const store = await initSecureStore();
|
|
1605
|
+
const readOpts = KeyManager._slotOpts(migration.layout.primaryService);
|
|
1606
|
+
privateKey = await store.getItemAsync(migration.layout.primaryPrivateKeyName, readOpts);
|
|
1607
|
+
publicKey = await store.getItemAsync(migration.layout.primaryPublicKeyName, readOpts);
|
|
1608
|
+
}
|
|
1609
|
+
catch (error) {
|
|
1610
|
+
// Storage threw — NEVER cache this verdict; callers retry.
|
|
1611
|
+
return { state: 'unavailable', cause: error };
|
|
1612
|
+
}
|
|
1613
|
+
if (KeyManager._isHealthyPair(privateKey, publicKey) && publicKey) {
|
|
1614
|
+
const canonicalPublic = publicKey.toLowerCase();
|
|
1615
|
+
// Backfill the marker when missing or pointing at a DIFFERENT identity —
|
|
1616
|
+
// e.g. a loss that predates markers, healed on first healthy read.
|
|
1617
|
+
if (!marker || marker.publicKey.toLowerCase() !== canonicalPublic) {
|
|
1618
|
+
try {
|
|
1619
|
+
await (0, identityMarker_1.writeIdentityMarker)({ publicKey: canonicalPublic, origin: 'backfill' });
|
|
1620
|
+
}
|
|
1621
|
+
catch (error) {
|
|
1622
|
+
logger_1.logger.warn('Failed to backfill identity marker', { component: 'KeyManager' }, error);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
if (!bypassCache) {
|
|
1626
|
+
KeyManager.cachedPublicKey = canonicalPublic;
|
|
1627
|
+
KeyManager.cachedHasIdentity = true;
|
|
1628
|
+
KeyManager.cachedPublicKeyResolved = false;
|
|
1629
|
+
}
|
|
1630
|
+
return { state: 'present', publicKey: canonicalPublic };
|
|
1631
|
+
}
|
|
1632
|
+
// Read succeeded but no healthy pair present.
|
|
1633
|
+
if (!bypassCache) {
|
|
1634
|
+
KeyManager.cachedHasIdentity = false;
|
|
1635
|
+
KeyManager.cachedPublicKeyResolved = true;
|
|
1636
|
+
}
|
|
1637
|
+
if (marker) {
|
|
1638
|
+
return { state: 'lost', marker };
|
|
1639
|
+
}
|
|
1640
|
+
return { state: 'absent' };
|
|
1641
|
+
}
|
|
1065
1642
|
/**
|
|
1066
1643
|
* Delete the stored identity (both keys)
|
|
1067
1644
|
* Use with EXTREME caution - this is irreversible without a recovery phrase
|
|
@@ -1079,6 +1656,8 @@ class KeyManager {
|
|
|
1079
1656
|
throw new Error('Identity deletion requires explicit user confirmation. This is a safety measure to prevent accidental data loss.');
|
|
1080
1657
|
}
|
|
1081
1658
|
if (!force) {
|
|
1659
|
+
// May throw IdentityUnavailableError if storage is locked — correct: a
|
|
1660
|
+
// non-force delete must abort rather than run against an unreadable store.
|
|
1082
1661
|
const hasIdentity = await KeyManager.hasIdentity();
|
|
1083
1662
|
if (!hasIdentity) {
|
|
1084
1663
|
return; // Nothing to delete
|
|
@@ -1099,21 +1678,38 @@ class KeyManager {
|
|
|
1099
1678
|
}
|
|
1100
1679
|
}
|
|
1101
1680
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
//
|
|
1105
|
-
KeyManager.
|
|
1106
|
-
|
|
1681
|
+
// Delete the primary from the active layout (authoritative), then best-effort
|
|
1682
|
+
// delete BOTH generations so a stale legacy copy can never resurrect the
|
|
1683
|
+
// identity after deletion.
|
|
1684
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1685
|
+
if (migration.mode !== 'deferred') {
|
|
1686
|
+
const layout = migration.layout;
|
|
1687
|
+
const readOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1688
|
+
await store.deleteItemAsync(layout.primaryPrivateKeyName, readOpts);
|
|
1689
|
+
await store.deleteItemAsync(layout.primaryPublicKeyName, readOpts);
|
|
1690
|
+
}
|
|
1691
|
+
await KeyManager._bestEffortDeleteV2Primary(store);
|
|
1692
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
|
|
1693
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
|
|
1694
|
+
// Also clear backups + the shared slot on force deletion, so a deleted
|
|
1695
|
+
// identity cannot be resurrected from any recovery source.
|
|
1107
1696
|
if (force) {
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
1111
|
-
await store.deleteItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
|
|
1112
|
-
}
|
|
1113
|
-
catch (error) {
|
|
1114
|
-
// Ignore backup deletion errors
|
|
1115
|
-
}
|
|
1697
|
+
await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
|
|
1698
|
+
await KeyManager._clearSharedSlot(store);
|
|
1116
1699
|
}
|
|
1700
|
+
// Clear the marker AFTER key deletion succeeds — a marker must never outlive
|
|
1701
|
+
// its identity (a leftover marker would route a truly-absent device to
|
|
1702
|
+
// `recovery` instead of `welcome`).
|
|
1703
|
+
try {
|
|
1704
|
+
await (0, identityMarker_1.clearIdentityMarker)();
|
|
1705
|
+
}
|
|
1706
|
+
catch (error) {
|
|
1707
|
+
logger_1.logger.warn('Failed to clear identity marker during delete', { component: 'KeyManager' }, error);
|
|
1708
|
+
}
|
|
1709
|
+
// Invalidate cache LAST — its subscriber fan-out fires only after both the
|
|
1710
|
+
// keys AND the marker are gone, so a routing subscriber that re-reads on the
|
|
1711
|
+
// notification observes `absent`, never a transient `lost`.
|
|
1712
|
+
KeyManager.invalidateCache();
|
|
1117
1713
|
}
|
|
1118
1714
|
/**
|
|
1119
1715
|
* Backup identity to SecureStore (separate backup storage)
|
|
@@ -1125,17 +1721,23 @@ class KeyManager {
|
|
|
1125
1721
|
}
|
|
1126
1722
|
try {
|
|
1127
1723
|
const store = await initSecureStore();
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1724
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1725
|
+
if (migration.mode === 'deferred') {
|
|
1726
|
+
return false; // Cannot read the primary safely → nothing to back up
|
|
1727
|
+
}
|
|
1728
|
+
const layout = migration.layout;
|
|
1729
|
+
// Read the primary DIRECTLY (raw) rather than via getPublicKey (which now
|
|
1730
|
+
// throws) — a locked keychain here should simply mean "nothing to back up".
|
|
1731
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1732
|
+
const privateKey = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
1733
|
+
const publicKey = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
1130
1734
|
if (!privateKey || !publicKey) {
|
|
1131
1735
|
return false; // Nothing to backup
|
|
1132
1736
|
}
|
|
1133
1737
|
// Store backup in SecureStore (still secure, but separate from primary storage)
|
|
1134
|
-
await store.setItemAsync(
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, publicKey);
|
|
1138
|
-
await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
|
|
1738
|
+
await store.setItemAsync(layout.backupPrivateKeyName, privateKey, KeyManager._privateWriteOpts(store, layout.backupService));
|
|
1739
|
+
await store.setItemAsync(layout.backupPublicKeyName, publicKey, KeyManager._slotOpts(layout.backupService));
|
|
1740
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), KeyManager._slotOpts(layout.backupService));
|
|
1139
1741
|
return true;
|
|
1140
1742
|
}
|
|
1141
1743
|
catch (error) {
|
|
@@ -1219,6 +1821,15 @@ class KeyManager {
|
|
|
1219
1821
|
}
|
|
1220
1822
|
try {
|
|
1221
1823
|
const store = await initSecureStore();
|
|
1824
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1825
|
+
if (migration.mode === 'deferred') {
|
|
1826
|
+
// Storage locked — refuse to restore (guard 2). Retry a later call.
|
|
1827
|
+
logger_1.logger.warn('restoreIdentityFromBackup: identity storage unavailable. Refusing to restore.', { component: 'KeyManager' });
|
|
1828
|
+
return false;
|
|
1829
|
+
}
|
|
1830
|
+
const layout = migration.layout;
|
|
1831
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1832
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
1222
1833
|
// Read the primary DIRECTLY (not via the error-swallowing getters) so
|
|
1223
1834
|
// we can distinguish a transient read failure from a genuinely absent
|
|
1224
1835
|
// key. A thrown read here means the keychain is locked/unavailable —
|
|
@@ -1227,8 +1838,8 @@ class KeyManager {
|
|
|
1227
1838
|
let primaryPrivate;
|
|
1228
1839
|
let primaryPublic;
|
|
1229
1840
|
try {
|
|
1230
|
-
primaryPrivate = await store.getItemAsync(
|
|
1231
|
-
primaryPublic = await store.getItemAsync(
|
|
1841
|
+
primaryPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
1842
|
+
primaryPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
1232
1843
|
}
|
|
1233
1844
|
catch (error) {
|
|
1234
1845
|
logger_1.logger.warn('restoreIdentityFromBackup: could not read primary (transient?). Refusing to restore.', { component: 'KeyManager' }, error);
|
|
@@ -1244,8 +1855,8 @@ class KeyManager {
|
|
|
1244
1855
|
}
|
|
1245
1856
|
}
|
|
1246
1857
|
// Load + validate the backup.
|
|
1247
|
-
const backupPrivateKey = await store.getItemAsync(
|
|
1248
|
-
const backupPublicKey = await store.getItemAsync(
|
|
1858
|
+
const backupPrivateKey = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
|
|
1859
|
+
const backupPublicKey = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
1249
1860
|
if (!backupPrivateKey || !backupPublicKey) {
|
|
1250
1861
|
return false; // No backup available
|
|
1251
1862
|
}
|
|
@@ -1281,13 +1892,13 @@ class KeyManager {
|
|
|
1281
1892
|
// Safe to restore: rebuild the primary using the same atomic write
|
|
1282
1893
|
// path createIdentity uses, including verification.
|
|
1283
1894
|
try {
|
|
1284
|
-
await KeyManager._persistIdentityAtomic(backupPrivateKey, backupPublicKey);
|
|
1895
|
+
await KeyManager._persistIdentityAtomic(backupPrivateKey, backupPublicKey, 'restore');
|
|
1285
1896
|
}
|
|
1286
1897
|
catch (error) {
|
|
1287
1898
|
logger_1.logger.error('Failed to persist identity restored from backup', error, { component: 'KeyManager' });
|
|
1288
1899
|
return false;
|
|
1289
1900
|
}
|
|
1290
|
-
await store.setItemAsync(
|
|
1901
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupReadOpts);
|
|
1291
1902
|
return true;
|
|
1292
1903
|
}
|
|
1293
1904
|
catch (error) {
|
|
@@ -1295,6 +1906,116 @@ class KeyManager {
|
|
|
1295
1906
|
return false;
|
|
1296
1907
|
}
|
|
1297
1908
|
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Recovery ladder — restore a `lost` identity from an independent,
|
|
1911
|
+
* `key_v1`-surviving source WITHOUT the user re-entering their recovery phrase.
|
|
1912
|
+
*
|
|
1913
|
+
* Gated on {@link getIdentityStatus} being `lost` (marker present, keys empty):
|
|
1914
|
+
* - `present` / `absent` → `not-lost` (nothing to recover / nothing lost)
|
|
1915
|
+
* - `unavailable` → `unavailable` (keychain locked; retry later)
|
|
1916
|
+
*
|
|
1917
|
+
* Rungs, tried in order, each fully validated (well-formed + derive-match +
|
|
1918
|
+
* `publicKey === marker.publicKey`, so a source holding a DIFFERENT account is
|
|
1919
|
+
* SKIPPED, never restored):
|
|
1920
|
+
* 1. the v2 backup slot (independent keychain key from the primary), then
|
|
1921
|
+
* 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
|
|
1922
|
+
* group) — the copy that survives a primary+backup `key_v1` death.
|
|
1923
|
+
*
|
|
1924
|
+
* On success it re-persists via {@link _persistIdentityAtomic} (origin
|
|
1925
|
+
* `'restore'`) and invalidates the cache so routing re-reads `present`. When no
|
|
1926
|
+
* rung matches, the UI proceeds to recovery-phrase entry.
|
|
1927
|
+
*/
|
|
1928
|
+
static async attemptIdentityRecovery() {
|
|
1929
|
+
if (isWebPlatform()) {
|
|
1930
|
+
return { recovered: false, reason: 'not-lost' };
|
|
1931
|
+
}
|
|
1932
|
+
const status = await KeyManager.getIdentityStatus({ bypassCache: true });
|
|
1933
|
+
if (status.state === 'present' || status.state === 'absent') {
|
|
1934
|
+
return { recovered: false, reason: 'not-lost' };
|
|
1935
|
+
}
|
|
1936
|
+
if (status.state === 'unavailable') {
|
|
1937
|
+
return { recovered: false, reason: 'unavailable' };
|
|
1938
|
+
}
|
|
1939
|
+
// status.state === 'lost'
|
|
1940
|
+
const expectedPublic = status.marker.publicKey.toLowerCase();
|
|
1941
|
+
let sawMismatch = false;
|
|
1942
|
+
// Rung 1: backup slot.
|
|
1943
|
+
const backupCandidate = await KeyManager._readBackupCandidate();
|
|
1944
|
+
if (backupCandidate) {
|
|
1945
|
+
if (backupCandidate.publicKey.toLowerCase() === expectedPublic) {
|
|
1946
|
+
if (await KeyManager._commitRecovery(backupCandidate.privateKey, backupCandidate.publicKey)) {
|
|
1947
|
+
return { recovered: true, source: 'backup', publicKey: backupCandidate.publicKey };
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
else {
|
|
1951
|
+
sawMismatch = true;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
// Rung 2: cross-app shared slot.
|
|
1955
|
+
const sharedCandidate = await KeyManager._readSharedCandidate();
|
|
1956
|
+
if (sharedCandidate) {
|
|
1957
|
+
if (sharedCandidate.publicKey.toLowerCase() === expectedPublic) {
|
|
1958
|
+
if (await KeyManager._commitRecovery(sharedCandidate.privateKey, sharedCandidate.publicKey)) {
|
|
1959
|
+
return { recovered: true, source: 'shared', publicKey: sharedCandidate.publicKey };
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
else {
|
|
1963
|
+
sawMismatch = true;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
// A source existed but identified a DIFFERENT account — never silently
|
|
1967
|
+
// switched. Report `mismatch` so the UI can require explicit confirmation.
|
|
1968
|
+
return { recovered: false, reason: sawMismatch ? 'mismatch' : 'no-sources' };
|
|
1969
|
+
}
|
|
1970
|
+
/** Read the active-layout backup slot as a healthy candidate, or null. @internal */
|
|
1971
|
+
static async _readBackupCandidate() {
|
|
1972
|
+
try {
|
|
1973
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1974
|
+
if (migration.mode === 'deferred') {
|
|
1975
|
+
return null;
|
|
1976
|
+
}
|
|
1977
|
+
const layout = migration.layout;
|
|
1978
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
1979
|
+
const store = await initSecureStore();
|
|
1980
|
+
const privateKey = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
|
|
1981
|
+
const publicKey = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
1982
|
+
if (KeyManager._isHealthyPair(privateKey, publicKey) && privateKey && publicKey) {
|
|
1983
|
+
return { privateKey, publicKey };
|
|
1984
|
+
}
|
|
1985
|
+
return null;
|
|
1986
|
+
}
|
|
1987
|
+
catch (error) {
|
|
1988
|
+
logger_1.logger.warn('Recovery: failed to read backup slot', { component: 'KeyManager' }, error);
|
|
1989
|
+
return null;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
/** Read the cross-app shared slot as a healthy candidate, or null. @internal */
|
|
1993
|
+
static async _readSharedCandidate() {
|
|
1994
|
+
try {
|
|
1995
|
+
const privateKey = await KeyManager.getSharedPrivateKey();
|
|
1996
|
+
const publicKey = await KeyManager.getSharedPublicKey();
|
|
1997
|
+
if (KeyManager._isHealthyPair(privateKey, publicKey) && privateKey && publicKey) {
|
|
1998
|
+
return { privateKey, publicKey };
|
|
1999
|
+
}
|
|
2000
|
+
return null;
|
|
2001
|
+
}
|
|
2002
|
+
catch (error) {
|
|
2003
|
+
logger_1.logger.warn('Recovery: failed to read shared slot', { component: 'KeyManager' }, error);
|
|
2004
|
+
return null;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
/** Persist a validated recovery candidate + refresh caches/subscribers. @internal */
|
|
2008
|
+
static async _commitRecovery(privateKey, publicKey) {
|
|
2009
|
+
try {
|
|
2010
|
+
await KeyManager._persistIdentityAtomic(privateKey, publicKey, 'restore');
|
|
2011
|
+
KeyManager.invalidateCache();
|
|
2012
|
+
return true;
|
|
2013
|
+
}
|
|
2014
|
+
catch (error) {
|
|
2015
|
+
logger_1.logger.error('Recovery: failed to persist recovered identity', error, { component: 'KeyManager' });
|
|
2016
|
+
return false;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
1298
2019
|
/**
|
|
1299
2020
|
* Get the elliptic curve key object from the stored private key
|
|
1300
2021
|
* Used internally for signing operations
|
|
@@ -1455,4 +2176,21 @@ KeyManager.cachedPublicKey = null;
|
|
|
1455
2176
|
KeyManager.cachedHasIdentity = null;
|
|
1456
2177
|
KeyManager.cachedSharedPublicKey = null;
|
|
1457
2178
|
KeyManager.cachedHasSharedIdentity = null;
|
|
2179
|
+
/**
|
|
2180
|
+
* Distinguishes "public key genuinely absent (a successful empty read, safe to
|
|
2181
|
+
* cache)" from "never resolved / storage threw (must NOT be cached)". A `null`
|
|
2182
|
+
* {@link cachedPublicKey} alone is ambiguous — this flag makes the genuine
|
|
2183
|
+
* absence cacheable WITHOUT ever caching a null produced by a thrown read.
|
|
2184
|
+
*/
|
|
2185
|
+
KeyManager.cachedPublicKeyResolved = false;
|
|
2186
|
+
/** Listeners notified synchronously whenever the identity verdict may have changed. */
|
|
2187
|
+
KeyManager.identityChangeListeners = new Set();
|
|
2188
|
+
/**
|
|
2189
|
+
* Memoized one-run-per-process slot migration. `slotMigrationResult` caches a
|
|
2190
|
+
* STABLE outcome (`v2`/`legacy`); a `deferred` outcome is intentionally not
|
|
2191
|
+
* cached (the in-flight promise is cleared) so a later call retries once the
|
|
2192
|
+
* keychain unlocks.
|
|
2193
|
+
*/
|
|
2194
|
+
KeyManager.slotMigrationPromise = null;
|
|
2195
|
+
KeyManager.slotMigrationResult = null;
|
|
1458
2196
|
exports.default = KeyManager;
|