@oxyhq/core 12.8.0 → 12.10.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.
Files changed (52) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +16 -3
  3. package/dist/cjs/crypto/identityMarker.js +255 -0
  4. package/dist/cjs/crypto/keyManager.js +933 -106
  5. package/dist/cjs/index.js +11 -4
  6. package/dist/cjs/mixins/OxyServices.auth.js +25 -7
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
  8. package/dist/cjs/mixins/OxyServices.identityBackup.js +11 -0
  9. package/dist/cjs/mixins/OxyServices.user.js +4 -0
  10. package/dist/cjs/utils/commonsApproval.js +31 -0
  11. package/dist/esm/.tsbuildinfo +1 -1
  12. package/dist/esm/boot/sessionColdBoot.js +16 -3
  13. package/dist/esm/crypto/identityMarker.js +248 -0
  14. package/dist/esm/crypto/keyManager.js +932 -106
  15. package/dist/esm/index.js +4 -2
  16. package/dist/esm/mixins/OxyServices.auth.js +22 -6
  17. package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
  18. package/dist/esm/mixins/OxyServices.identityBackup.js +11 -0
  19. package/dist/esm/mixins/OxyServices.user.js +4 -0
  20. package/dist/esm/utils/commonsApproval.js +27 -0
  21. package/dist/types/.tsbuildinfo +1 -1
  22. package/dist/types/boot/sessionColdBoot.d.ts +25 -0
  23. package/dist/types/crypto/identityMarker.d.ts +94 -0
  24. package/dist/types/crypto/keyManager.d.ts +245 -3
  25. package/dist/types/index.d.ts +6 -3
  26. package/dist/types/mixins/OxyServices.auth.d.ts +31 -6
  27. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
  28. package/dist/types/utils/commonsApproval.d.ts +13 -0
  29. package/package.json +1 -1
  30. package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
  31. package/src/boot/sessionColdBoot.ts +42 -3
  32. package/src/crypto/__tests__/identityMocks.ts +125 -0
  33. package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
  34. package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
  35. package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
  36. package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
  37. package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +138 -0
  38. package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
  39. package/src/crypto/__tests__/keyManager.test.ts +77 -87
  40. package/src/crypto/identityMarker.ts +291 -0
  41. package/src/crypto/keyManager.ts +1125 -105
  42. package/src/index.ts +14 -2
  43. package/src/mixins/OxyServices.auth.ts +40 -12
  44. package/src/mixins/OxyServices.deviceBoot.ts +9 -1
  45. package/src/mixins/OxyServices.identityBackup.ts +13 -0
  46. package/src/mixins/OxyServices.user.ts +4 -0
  47. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
  48. package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
  49. package/src/mixins/__tests__/identityBackup.test.ts +57 -0
  50. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +6 -0
  51. package/src/utils/__tests__/commonsApproval.test.ts +60 -0
  52. package/src/utils/commonsApproval.ts +39 -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,74 @@ 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
+ * Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
127
+ *
128
+ * Stored under its OWN keychain service — distinct from the v2 primary, backup,
129
+ * and shared slots — so it shares an AndroidKeyStore key with none of them
130
+ * (blast-radius isolation, same rationale as the v2 primary/backup split).
131
+ * Written `WHEN_UNLOCKED_THIS_DEVICE_ONLY` and NEVER exported off-device: it
132
+ * exists solely so the user can RE-READ their phrase from Settings on the SAME
133
+ * device that generated/imported it.
134
+ *
135
+ * This is convenience persistence, NOT a recovery mechanism — a keystore death
136
+ * wipes it alongside the keys, exactly like the private key itself. The user's
137
+ * written-down phrase remains the sole out-of-band recovery path. The mnemonic
138
+ * lives ONLY in this slot: it is never mirrored into the identity marker,
139
+ * {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
140
+ */
141
+ const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
142
+ const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
143
+ /**
144
+ * Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
145
+ * Re-derivable (its loss just re-runs the cheap slot check), so it lives in
146
+ * plain AsyncStorage rather than the keychain. It only SKIPS re-reading the
147
+ * legacy slots on an already-migrated device; it is never trusted over an actual
148
+ * v2 read (a set flag with an unhealthy v2 pair falls through to full migration).
149
+ */
150
+ const SLOTS_MIGRATED_FLAG_KEY = 'oxy_identity_slots_migrated_v2';
151
+ const V2_SLOT_LAYOUT = {
152
+ primaryService: V2_PRIMARY_KEYCHAIN_SERVICE,
153
+ primaryPrivateKeyName: V2_STORAGE_KEYS.PRIVATE_KEY,
154
+ primaryPublicKeyName: V2_STORAGE_KEYS.PUBLIC_KEY,
155
+ backupService: V2_BACKUP_KEYCHAIN_SERVICE,
156
+ backupPrivateKeyName: V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY,
157
+ backupPublicKeyName: V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY,
158
+ backupTimestampName: V2_STORAGE_KEYS.BACKUP_TIMESTAMP,
159
+ };
160
+ const LEGACY_SLOT_LAYOUT = {
161
+ primaryService: undefined,
162
+ primaryPrivateKeyName: STORAGE_KEYS.PRIVATE_KEY,
163
+ primaryPublicKeyName: STORAGE_KEYS.PUBLIC_KEY,
164
+ backupService: undefined,
165
+ backupPrivateKeyName: STORAGE_KEYS.BACKUP_PRIVATE_KEY,
166
+ backupPublicKeyName: STORAGE_KEYS.BACKUP_PUBLIC_KEY,
167
+ backupTimestampName: STORAGE_KEYS.BACKUP_TIMESTAMP,
168
+ };
80
169
  /**
81
170
  * iOS Keychain Access Group for sharing identities across Oxy apps
82
171
  * All Oxy apps must have this access group enabled in their entitlements
@@ -161,6 +250,320 @@ class KeyManager {
161
250
  static invalidateCache() {
162
251
  KeyManager.cachedPublicKey = null;
163
252
  KeyManager.cachedHasIdentity = null;
253
+ KeyManager.cachedPublicKeyResolved = false;
254
+ KeyManager.notifyIdentityChanged();
255
+ }
256
+ /**
257
+ * Subscribe to identity-verdict changes (create / import / delete / restore /
258
+ * cache invalidation). Fires synchronously; the returned function unsubscribes.
259
+ * Consumed via `useOxyEvent`-style hooks in commons to invalidate the routing
260
+ * queries the instant the identity state moves, without polling.
261
+ */
262
+ static subscribeIdentityChanged(listener) {
263
+ KeyManager.identityChangeListeners.add(listener);
264
+ return () => {
265
+ KeyManager.identityChangeListeners.delete(listener);
266
+ };
267
+ }
268
+ /** Synchronous fan-out with per-listener isolation (one throwing listener never blocks the rest). */
269
+ static notifyIdentityChanged() {
270
+ // Snapshot first — a listener may unsubscribe (mutate the Set) during fan-out.
271
+ for (const listener of Array.from(KeyManager.identityChangeListeners)) {
272
+ try {
273
+ listener();
274
+ }
275
+ catch (error) {
276
+ logger_1.logger.warn('Identity-change listener threw', { component: 'KeyManager' }, error);
277
+ }
278
+ }
279
+ }
280
+ /** Build `getItemAsync`/`deleteItemAsync` options for a given keychain service (read/delete). */
281
+ static _slotOpts(service) {
282
+ return service ? { keychainService: service } : {};
283
+ }
284
+ /** Build private-key write options (device-only accessibility) for a given keychain service. */
285
+ static _privateWriteOpts(store, service) {
286
+ const opts = { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
287
+ if (service) {
288
+ opts.keychainService = service;
289
+ }
290
+ return opts;
291
+ }
292
+ /** True only when both keys are present, well-formed, AND the public derives from the private. */
293
+ static _isHealthyPair(privateKey, publicKey) {
294
+ if (!privateKey || !publicKey) {
295
+ return false;
296
+ }
297
+ if (!KeyManager.isValidPrivateKey(privateKey) || !KeyManager.isValidPublicKey(publicKey)) {
298
+ return false;
299
+ }
300
+ try {
301
+ return KeyManager.derivePublicKey(privateKey).toLowerCase() === publicKey.toLowerCase();
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ }
307
+ /**
308
+ * Resolve the AsyncStorage-backed KV store for the advisory migration flag, or
309
+ * `null` off-RN / when unavailable. Independent of the keychain, so the flag
310
+ * cannot be taken down by the keystore event this whole subsystem defends
311
+ * against.
312
+ */
313
+ static async _advisoryStorage() {
314
+ if (!(0, protocol_1.isReactNative)()) {
315
+ return null;
316
+ }
317
+ try {
318
+ const mod = await (0, protocol_1.loadAsyncStorage)();
319
+ return mod.default;
320
+ }
321
+ catch {
322
+ // Advisory only — absence just means the slot check runs in full.
323
+ return null;
324
+ }
325
+ }
326
+ static async _readSlotsMigratedFlag() {
327
+ const storage = await KeyManager._advisoryStorage();
328
+ if (!storage) {
329
+ return false;
330
+ }
331
+ try {
332
+ return (await storage.getItem(SLOTS_MIGRATED_FLAG_KEY)) === 'true';
333
+ }
334
+ catch {
335
+ // Advisory only — treat an unreadable flag as "not yet migrated".
336
+ return false;
337
+ }
338
+ }
339
+ static async _setSlotsMigratedFlag() {
340
+ const storage = await KeyManager._advisoryStorage();
341
+ if (!storage) {
342
+ return;
343
+ }
344
+ try {
345
+ await storage.setItem(SLOTS_MIGRATED_FLAG_KEY, 'true');
346
+ }
347
+ catch (error) {
348
+ // Advisory only — a failed write just re-runs the cheap slot check next launch.
349
+ if ((0, logger_1.isDev)()) {
350
+ logger_1.logger.debug('Failed to set slots-migrated flag (advisory)', { component: 'KeyManager' }, error);
351
+ }
352
+ }
353
+ }
354
+ /**
355
+ * Ensure the identity has been migrated onto the isolated v2 slots (or that we
356
+ * know we must read legacy this session). Memoized so concurrent callers share
357
+ * ONE run; a `deferred` (read-threw) outcome is not cached so a later call
358
+ * retries after the keychain unlocks. Every identity-slot accessor awaits this
359
+ * before touching storage.
360
+ */
361
+ static async _ensureIdentitySlotsMigrated() {
362
+ if (KeyManager.slotMigrationResult && KeyManager.slotMigrationResult.mode !== 'deferred') {
363
+ return KeyManager.slotMigrationResult;
364
+ }
365
+ if (!KeyManager.slotMigrationPromise) {
366
+ const run = (async () => {
367
+ const result = await KeyManager._runSlotMigration();
368
+ KeyManager.slotMigrationResult = result;
369
+ return result;
370
+ })();
371
+ KeyManager.slotMigrationPromise = run;
372
+ // Clear the in-flight handle once settled so a deferred outcome retries.
373
+ run
374
+ .then((result) => {
375
+ if (result.mode === 'deferred') {
376
+ KeyManager.slotMigrationPromise = null;
377
+ }
378
+ })
379
+ .catch(() => {
380
+ KeyManager.slotMigrationPromise = null;
381
+ });
382
+ }
383
+ return KeyManager.slotMigrationPromise;
384
+ }
385
+ /**
386
+ * One-shot slot migration state machine. All reads are DIRECT and a thrown
387
+ * read defers everything (zero writes/deletes) so a locked keychain is never
388
+ * mistaken for an empty one. INVARIANT: at every instant ≥1 readable copy of a
389
+ * previously-existing identity remains — legacy is deleted ONLY after the v2
390
+ * copy is verified re-readable in its new (non-aliasable) location.
391
+ */
392
+ static async _runSlotMigration() {
393
+ let store;
394
+ try {
395
+ store = await initSecureStore();
396
+ }
397
+ catch (error) {
398
+ return { mode: 'deferred', cause: error };
399
+ }
400
+ const migratedFlag = await KeyManager._readSlotsMigratedFlag();
401
+ // Read the v2 primary (dedicated keychain service).
402
+ let v2Private;
403
+ let v2Public;
404
+ try {
405
+ v2Private = await store.getItemAsync(V2_STORAGE_KEYS.PRIVATE_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
406
+ v2Public = await store.getItemAsync(V2_STORAGE_KEYS.PUBLIC_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
407
+ }
408
+ catch (error) {
409
+ return { mode: 'deferred', cause: error };
410
+ }
411
+ if (KeyManager._isHealthyPair(v2Private, v2Public)) {
412
+ // v2 already owns the identity. On the first observation, clean up any
413
+ // stale legacy copy and record the fast-path flag.
414
+ if (!migratedFlag) {
415
+ await KeyManager._bestEffortDeleteLegacyPrimaryAndBackup(store);
416
+ await KeyManager._setSlotsMigratedFlag();
417
+ }
418
+ return { mode: 'v2', layout: V2_SLOT_LAYOUT };
419
+ }
420
+ // v2 primary absent/partial but the flag says migration finished → v2 is
421
+ // simply empty (identity deleted / never created). No legacy to rescue.
422
+ if (migratedFlag) {
423
+ return { mode: 'v2', layout: V2_SLOT_LAYOUT };
424
+ }
425
+ // Read the legacy primary (default keychain service = the old `key_v1`).
426
+ let legacyPrivate;
427
+ let legacyPublic;
428
+ try {
429
+ legacyPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
430
+ legacyPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
431
+ }
432
+ catch (error) {
433
+ return { mode: 'deferred', cause: error };
434
+ }
435
+ if (!KeyManager._isHealthyPair(legacyPrivate, legacyPublic)) {
436
+ // Nothing readable in either generation → v2 is the canonical (empty) home.
437
+ // The marker (not this migration) decides fresh-vs-lost.
438
+ return { mode: 'v2', layout: V2_SLOT_LAYOUT };
439
+ }
440
+ // legacy healthy, v2 absent → migrate: copy → read-back verify → only then delete legacy.
441
+ const canonicalPrivate = KeyManager.canonicalPrivateKey(legacyPrivate);
442
+ const canonicalPublic = legacyPublic.toLowerCase();
443
+ try {
444
+ await store.setItemAsync(V2_STORAGE_KEYS.PUBLIC_KEY, canonicalPublic, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
445
+ await store.setItemAsync(V2_STORAGE_KEYS.PRIVATE_KEY, canonicalPrivate, KeyManager._privateWriteOpts(store, V2_PRIMARY_KEYCHAIN_SERVICE));
446
+ const readBackPrivate = await store.getItemAsync(V2_STORAGE_KEYS.PRIVATE_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
447
+ const readBackPublic = await store.getItemAsync(V2_STORAGE_KEYS.PUBLIC_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
448
+ const verified = readBackPrivate?.toLowerCase() === canonicalPrivate &&
449
+ readBackPublic?.toLowerCase() === canonicalPublic &&
450
+ KeyManager._isHealthyPair(readBackPrivate, readBackPublic);
451
+ if (!verified) {
452
+ // v2 write did not durably land — remove the partial v2 and serve reads
453
+ // from legacy this session (legacy is UNTOUCHED). Retry next launch.
454
+ await KeyManager._bestEffortDeleteV2Primary(store);
455
+ logger_1.logger.warn('Identity slot migration verify failed; serving identity from legacy slots this session', { component: 'KeyManager' });
456
+ return { mode: 'legacy', layout: LEGACY_SLOT_LAYOUT };
457
+ }
458
+ }
459
+ catch (error) {
460
+ await KeyManager._bestEffortDeleteV2Primary(store);
461
+ logger_1.logger.warn('Identity slot migration write threw; serving identity from legacy slots this session', { component: 'KeyManager' }, error);
462
+ return { mode: 'legacy', layout: LEGACY_SLOT_LAYOUT };
463
+ }
464
+ // v2 primary is verified re-readable. Migrate the backup slot (best-effort),
465
+ // then it is finally safe to delete the legacy generation.
466
+ await KeyManager._migrateBackupSlotToV2(store, canonicalPrivate, canonicalPublic);
467
+ await KeyManager._bestEffortDeleteLegacyPrimaryAndBackup(store);
468
+ await KeyManager._setSlotsMigratedFlag();
469
+ return { mode: 'v2', layout: V2_SLOT_LAYOUT };
470
+ }
471
+ /**
472
+ * Seed the v2 backup slot during migration. Prefers a healthy legacy backup;
473
+ * otherwise mirrors the (already-verified) v2 primary material so a v2 backup
474
+ * always exists on an independent keychain key. Best-effort — a failure just
475
+ * defers backup population to the next {@link _persistIdentityAtomic}.
476
+ */
477
+ static async _migrateBackupSlotToV2(store, primaryPrivate, primaryPublic) {
478
+ try {
479
+ let backupPrivate = null;
480
+ let backupPublic = null;
481
+ try {
482
+ backupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
483
+ backupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
484
+ }
485
+ catch (error) {
486
+ if ((0, logger_1.isDev)()) {
487
+ logger_1.logger.debug('Legacy backup unreadable during migration (non-fatal)', { component: 'KeyManager' }, error);
488
+ }
489
+ backupPrivate = null;
490
+ backupPublic = null;
491
+ }
492
+ let seedPrivate;
493
+ let seedPublic;
494
+ if (KeyManager._isHealthyPair(backupPrivate, backupPublic)) {
495
+ seedPrivate = KeyManager.canonicalPrivateKey(backupPrivate);
496
+ seedPublic = backupPublic.toLowerCase();
497
+ }
498
+ else {
499
+ seedPrivate = primaryPrivate;
500
+ seedPublic = primaryPublic;
501
+ }
502
+ await store.setItemAsync(V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, seedPublic, KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE));
503
+ await store.setItemAsync(V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, seedPrivate, KeyManager._privateWriteOpts(store, V2_BACKUP_KEYCHAIN_SERVICE));
504
+ await store.setItemAsync(V2_STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString(), KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE));
505
+ }
506
+ catch (error) {
507
+ logger_1.logger.warn('Failed to migrate identity backup slot to v2 (non-fatal)', { component: 'KeyManager' }, error);
508
+ }
509
+ }
510
+ /** Best-effort single delete under an optional keychain service. Cleanup only — never surfaces. */
511
+ static async _bestEffortDelete(store, key, service) {
512
+ try {
513
+ await store.deleteItemAsync(key, KeyManager._slotOpts(service));
514
+ }
515
+ catch (error) {
516
+ if ((0, logger_1.isDev)()) {
517
+ logger_1.logger.debug('Best-effort identity delete failed', { component: 'KeyManager' }, error);
518
+ }
519
+ }
520
+ }
521
+ static async _bestEffortDeleteV2Primary(store) {
522
+ await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PRIVATE_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
523
+ await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PUBLIC_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
524
+ }
525
+ static async _bestEffortDeleteLegacyPrimaryAndBackup(store) {
526
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
527
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
528
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
529
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
530
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
531
+ }
532
+ static async _bestEffortDeleteBackupsAllGenerations(store) {
533
+ await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
534
+ await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
535
+ await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_TIMESTAMP, V2_BACKUP_KEYCHAIN_SERVICE);
536
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
537
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
538
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
539
+ }
540
+ /**
541
+ * Clear the cross-app shared identity slot (force-delete only) so a deleted
542
+ * identity cannot be resurrected via the recovery ladder's shared rung.
543
+ * Best-effort — the shared slot is a redundant convenience copy.
544
+ */
545
+ static async _clearSharedSlot(store) {
546
+ try {
547
+ if ((0, platform_1.isIOS)()) {
548
+ const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
549
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
550
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
551
+ }
552
+ else if ((0, platform_1.isAndroid)()) {
553
+ const bridge = await (0, protocol_1.loadSharedIdentityBridge)();
554
+ if (bridge) {
555
+ await bridge.clearShared();
556
+ }
557
+ else {
558
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
559
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
560
+ }
561
+ }
562
+ KeyManager.invalidateSharedCache();
563
+ }
564
+ catch (error) {
565
+ logger_1.logger.warn('Failed to clear shared identity slot during force delete', { component: 'KeyManager' }, error);
566
+ }
164
567
  }
165
568
  /**
166
569
  * Invalidate cached shared identity state
@@ -625,8 +1028,23 @@ class KeyManager {
625
1028
  *
626
1029
  * @internal
627
1030
  */
628
- static async _persistIdentityAtomic(privateKey, publicKey) {
1031
+ static async _persistIdentityAtomic(privateKey, publicKey, origin) {
629
1032
  const store = await initSecureStore();
1033
+ // Resolve the active slot layout (normally v2; legacy only in the rare
1034
+ // migration-fallback session). Reading and writing the SAME layout keeps the
1035
+ // snapshot/rollback machinery below internally consistent. A deferred
1036
+ // migration (keychain locked) must never write blind.
1037
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1038
+ if (migration.mode === 'deferred') {
1039
+ throw new IdentityUnavailableError('Identity storage is temporarily unavailable; refusing to persist an identity.', migration.cause);
1040
+ }
1041
+ const layout = migration.layout;
1042
+ const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
1043
+ const primaryPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.primaryService);
1044
+ const primaryPubWriteOpts = KeyManager._slotOpts(layout.primaryService);
1045
+ const backupReadOpts = KeyManager._slotOpts(layout.backupService);
1046
+ const backupPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.backupService);
1047
+ const backupPubWriteOpts = KeyManager._slotOpts(layout.backupService);
630
1048
  // Canonicalize BEFORE persistence so the stored value is always in
631
1049
  // canonical 64-hex-char lowercase form going forward. This is the single
632
1050
  // place all primary writes flow through, so once a value lands here all
@@ -641,8 +1059,8 @@ class KeyManager {
641
1059
  let priorPrivate;
642
1060
  let priorPublic;
643
1061
  try {
644
- priorPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
645
- priorPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
1062
+ priorPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
1063
+ priorPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
646
1064
  }
647
1065
  catch (error) {
648
1066
  logger_1.logger.error('Failed to read existing primary before persist', error, { component: 'KeyManager' });
@@ -664,18 +1082,16 @@ class KeyManager {
664
1082
  if (priorIsHealthyDifferent && priorPrivate && priorPublic) {
665
1083
  let existingBackupPublic = null;
666
1084
  try {
667
- existingBackupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
1085
+ existingBackupPublic = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
668
1086
  }
669
1087
  catch {
670
1088
  existingBackupPublic = null;
671
1089
  }
672
1090
  if (existingBackupPublic?.toLowerCase() !== priorPublic.toLowerCase()) {
673
1091
  try {
674
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, KeyManager.canonicalPrivateKey(priorPrivate), {
675
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
676
- });
677
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorPublic.toLowerCase());
678
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
1092
+ await store.setItemAsync(layout.backupPrivateKeyName, KeyManager.canonicalPrivateKey(priorPrivate), backupPrivWriteOpts);
1093
+ await store.setItemAsync(layout.backupPublicKeyName, priorPublic.toLowerCase(), backupPubWriteOpts);
1094
+ await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupPubWriteOpts);
679
1095
  }
680
1096
  catch (error) {
681
1097
  logger_1.logger.error('Failed to back up existing identity before overwrite', error, { component: 'KeyManager' });
@@ -688,14 +1104,12 @@ class KeyManager {
688
1104
  // NOT touched here — it still holds the previous good identity until the
689
1105
  // new primary is proven durable.
690
1106
  try {
691
- await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, canonicalPublic);
692
- await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, canonicalPrivate, {
693
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
694
- });
1107
+ await store.setItemAsync(layout.primaryPublicKeyName, canonicalPublic, primaryPubWriteOpts);
1108
+ await store.setItemAsync(layout.primaryPrivateKeyName, canonicalPrivate, primaryPrivWriteOpts);
695
1109
  }
696
1110
  catch (error) {
697
1111
  logger_1.logger.error('Failed to write primary identity to secure store', error, { component: 'KeyManager' });
698
- await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
1112
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
699
1113
  throw new IdentityPersistError('Failed to write identity to secure store', error);
700
1114
  }
701
1115
  // Step 2: Verify round-trip. If the store silently drops our writes
@@ -705,12 +1119,12 @@ class KeyManager {
705
1119
  let readBackPrivate;
706
1120
  let readBackPublic;
707
1121
  try {
708
- readBackPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
709
- readBackPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
1122
+ readBackPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
1123
+ readBackPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
710
1124
  }
711
1125
  catch (error) {
712
1126
  logger_1.logger.error('Failed to read identity back after write', error, { component: 'KeyManager' });
713
- await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
1127
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
714
1128
  throw new IdentityPersistError('Failed to verify identity after write', error);
715
1129
  }
716
1130
  // Hex comparisons are case-insensitive — normalize on both sides so a
@@ -719,7 +1133,7 @@ class KeyManager {
719
1133
  if (readBackPrivate?.toLowerCase() !== canonicalPrivate ||
720
1134
  readBackPublic?.toLowerCase() !== canonicalPublic) {
721
1135
  logger_1.logger.error('Identity round-trip mismatch after write', undefined, { component: 'KeyManager' });
722
- await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
1136
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
723
1137
  throw new IdentityPersistError('Identity write was not persisted correctly (round-trip mismatch).');
724
1138
  }
725
1139
  // Final sanity: derive public from the stored private and confirm the
@@ -740,7 +1154,7 @@ class KeyManager {
740
1154
  }
741
1155
  }
742
1156
  catch (error) {
743
- await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
1157
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
744
1158
  if (error instanceof IdentityPersistError)
745
1159
  throw error;
746
1160
  logger_1.logger.error('Identity sign/verify probe failed', error, { component: 'KeyManager' });
@@ -757,31 +1171,60 @@ class KeyManager {
757
1171
  let priorBackupPublic;
758
1172
  let priorBackupTimestamp;
759
1173
  try {
760
- priorBackupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
761
- priorBackupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
762
- priorBackupTimestamp = await store.getItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
1174
+ priorBackupPrivate = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
1175
+ priorBackupPublic = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
1176
+ priorBackupTimestamp = await store.getItemAsync(layout.backupTimestampName, backupReadOpts);
763
1177
  }
764
1178
  catch (error) {
765
1179
  logger_1.logger.error('Failed to snapshot identity backup before refresh', error, { component: 'KeyManager' });
766
- await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
1180
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
767
1181
  throw new IdentityPersistError('Failed to snapshot identity backup before refresh', error);
768
1182
  }
769
1183
  try {
770
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
771
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
772
- });
773
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
774
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
1184
+ await store.setItemAsync(layout.backupPrivateKeyName, canonicalPrivate, backupPrivWriteOpts);
1185
+ await store.setItemAsync(layout.backupPublicKeyName, canonicalPublic, backupPubWriteOpts);
1186
+ await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupPubWriteOpts);
775
1187
  }
776
1188
  catch (error) {
777
1189
  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);
1190
+ await KeyManager._rollbackBackup(store, layout, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
1191
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
780
1192
  throw new IdentityPersistError('Failed to refresh identity backup after primary write', error);
781
1193
  }
782
- // Update cache only after we are certain the identity is durable.
1194
+ // Update cache only after we are certain the identity is durable, then fan
1195
+ // out to identity-change subscribers.
783
1196
  KeyManager.cachedPublicKey = canonicalPublic;
784
1197
  KeyManager.cachedHasIdentity = true;
1198
+ KeyManager.cachedPublicKeyResolved = false;
1199
+ KeyManager.notifyIdentityChanged();
1200
+ // LAST step: mirror the identity into the AndroidKeyStore-independent marker
1201
+ // so a later keystore death can be told apart from a fresh install. This is
1202
+ // best-effort — a marker write failure must NEVER fail an otherwise-durable
1203
+ // persist (a subsequent healthy read re-backfills it). Rollback paths above
1204
+ // return before reaching here, so they never touch the marker.
1205
+ await KeyManager._syncMarkerAfterPersist(canonicalPublic, origin);
1206
+ }
1207
+ /**
1208
+ * Write/refresh the identity marker after a successful persist. A same-identity
1209
+ * re-persist (e.g. backup refresh, idempotent re-import) preserves `createdAt`
1210
+ * and the `onboardingComplete` milestone by only updating `origin`; a NEW or
1211
+ * switched identity writes a fresh marker. Best-effort — never throws.
1212
+ *
1213
+ * @internal
1214
+ */
1215
+ static async _syncMarkerAfterPersist(publicKey, origin) {
1216
+ try {
1217
+ const existing = await (0, identityMarker_1.readIdentityMarker)();
1218
+ if (existing && existing.publicKey.toLowerCase() === publicKey.toLowerCase()) {
1219
+ await (0, identityMarker_1.updateIdentityMarker)({ origin });
1220
+ }
1221
+ else {
1222
+ await (0, identityMarker_1.writeIdentityMarker)({ publicKey, origin });
1223
+ }
1224
+ }
1225
+ catch (error) {
1226
+ logger_1.logger.warn('Failed to sync identity marker after persist (non-fatal)', { component: 'KeyManager' }, error);
1227
+ }
785
1228
  }
786
1229
  /**
787
1230
  * Restore the backup slot to a previously-snapshotted state. Best-effort so
@@ -789,34 +1232,35 @@ class KeyManager {
789
1232
  *
790
1233
  * @internal
791
1234
  */
792
- static async _rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp) {
1235
+ static async _rollbackBackup(store, layout, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp) {
1236
+ const backupPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.backupService);
1237
+ const backupPubWriteOpts = KeyManager._slotOpts(layout.backupService);
1238
+ const backupReadOpts = KeyManager._slotOpts(layout.backupService);
793
1239
  try {
794
1240
  if (priorBackupPrivate) {
795
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, priorBackupPrivate, {
796
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
797
- });
1241
+ await store.setItemAsync(layout.backupPrivateKeyName, priorBackupPrivate, backupPrivWriteOpts);
798
1242
  }
799
1243
  else {
800
1244
  try {
801
- await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
1245
+ await store.deleteItemAsync(layout.backupPrivateKeyName, backupReadOpts);
802
1246
  }
803
1247
  catch { /* best effort */ }
804
1248
  }
805
1249
  if (priorBackupPublic) {
806
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorBackupPublic);
1250
+ await store.setItemAsync(layout.backupPublicKeyName, priorBackupPublic, backupPubWriteOpts);
807
1251
  }
808
1252
  else {
809
1253
  try {
810
- await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
1254
+ await store.deleteItemAsync(layout.backupPublicKeyName, backupReadOpts);
811
1255
  }
812
1256
  catch { /* best effort */ }
813
1257
  }
814
1258
  if (priorBackupTimestamp) {
815
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, priorBackupTimestamp);
1259
+ await store.setItemAsync(layout.backupTimestampName, priorBackupTimestamp, backupPubWriteOpts);
816
1260
  }
817
1261
  else {
818
1262
  try {
819
- await store.deleteItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
1263
+ await store.deleteItemAsync(layout.backupTimestampName, backupReadOpts);
820
1264
  }
821
1265
  catch { /* best effort */ }
822
1266
  }
@@ -834,24 +1278,25 @@ class KeyManager {
834
1278
  *
835
1279
  * @internal
836
1280
  */
837
- static async _rollbackPrimary(store, priorPrivate, priorPublic) {
1281
+ static async _rollbackPrimary(store, layout, priorPrivate, priorPublic) {
1282
+ const primaryPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.primaryService);
1283
+ const primaryPubWriteOpts = KeyManager._slotOpts(layout.primaryService);
1284
+ const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
838
1285
  try {
839
1286
  if (priorPrivate && priorPublic) {
840
1287
  // Restore exactly what was there before the failed write.
841
- await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, priorPublic, {});
842
- await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, priorPrivate, {
843
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
844
- });
1288
+ await store.setItemAsync(layout.primaryPublicKeyName, priorPublic, primaryPubWriteOpts);
1289
+ await store.setItemAsync(layout.primaryPrivateKeyName, priorPrivate, primaryPrivWriteOpts);
845
1290
  }
846
1291
  else {
847
1292
  // There was no prior identity — leave the device empty rather than
848
1293
  // half-written so hasIdentity() does not lie.
849
1294
  try {
850
- await store.deleteItemAsync(STORAGE_KEYS.PUBLIC_KEY);
1295
+ await store.deleteItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
851
1296
  }
852
1297
  catch { /* best effort */ }
853
1298
  try {
854
- await store.deleteItemAsync(STORAGE_KEYS.PRIVATE_KEY);
1299
+ await store.deleteItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
855
1300
  }
856
1301
  catch { /* best effort */ }
857
1302
  }
@@ -881,16 +1326,50 @@ class KeyManager {
881
1326
  // The local key IS the account — clobbering it without consent is
882
1327
  // catastrophic. Callers must opt in explicitly when they have already
883
1328
  // confirmed (via UI) that the user has saved their recovery phrase.
1329
+ //
1330
+ // The guard reads storage DIRECTLY (cache-bypassing) AND consults the
1331
+ // AndroidKeyStore-independent marker: either a stored key OR a marker means
1332
+ // an identity exists here → refuse. A storage THROW surfaces as
1333
+ // IdentityUnavailableError (never a blind write over a locked keystore).
884
1334
  if (!options?.overwrite) {
885
- const existing = await KeyManager.getPublicKey();
886
- if (existing) {
887
- throw new IdentityAlreadyExistsError(existing);
1335
+ const marker = await (0, identityMarker_1.readIdentityMarker)();
1336
+ const direct = await KeyManager._readPrimaryDirect();
1337
+ if (direct.publicKey) {
1338
+ throw new IdentityAlreadyExistsError(direct.publicKey);
1339
+ }
1340
+ if (marker) {
1341
+ throw new IdentityAlreadyExistsError(marker.publicKey);
888
1342
  }
889
1343
  }
890
1344
  const { privateKey, publicKey } = await KeyManager.generateKeyPair();
891
- await KeyManager._persistIdentityAtomic(privateKey, publicKey);
1345
+ await KeyManager._persistIdentityAtomic(privateKey, publicKey, 'create');
892
1346
  return publicKey;
893
1347
  }
1348
+ /**
1349
+ * Read the primary key pair DIRECTLY from storage, bypassing the in-memory
1350
+ * cache (which a prior transient failure could have poisoned). Awaits slot
1351
+ * migration first. Throws {@link IdentityUnavailableError} if storage is
1352
+ * deferred/locked or a read throws — so overwrite guards never write blind.
1353
+ *
1354
+ * @internal
1355
+ */
1356
+ static async _readPrimaryDirect() {
1357
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1358
+ if (migration.mode === 'deferred') {
1359
+ throw new IdentityUnavailableError('Identity storage is temporarily unavailable; refusing to write blind.', migration.cause);
1360
+ }
1361
+ const layout = migration.layout;
1362
+ const readOpts = KeyManager._slotOpts(layout.primaryService);
1363
+ try {
1364
+ const store = await initSecureStore();
1365
+ const privateKey = await store.getItemAsync(layout.primaryPrivateKeyName, readOpts);
1366
+ const publicKey = await store.getItemAsync(layout.primaryPublicKeyName, readOpts);
1367
+ return { privateKey, publicKey };
1368
+ }
1369
+ catch (error) {
1370
+ throw new IdentityUnavailableError('Could not read existing identity; refusing to write blind.', error);
1371
+ }
1372
+ }
894
1373
  /**
895
1374
  * Import an existing key pair (e.g., from recovery phrase).
896
1375
  *
@@ -913,30 +1392,53 @@ class KeyManager {
913
1392
  const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
914
1393
  const keyPair = ec.keyFromPrivate(canonicalPrivate);
915
1394
  const publicKey = keyPair.getPublic('hex');
916
- // Refuse silent overwrite — see createIdentity() for rationale.
1395
+ // Refuse silent overwrite — see createIdentity() for rationale. The guard
1396
+ // reads storage DIRECTLY (cache-bypassing) AND the marker, and treats
1397
+ // storage as authoritative:
1398
+ // - stored key === this import → safe idempotent refresh (fall through)
1399
+ // - stored key differs → a DIFFERENT identity is present → refuse
1400
+ // - storage empty + marker for a DIFFERENT identity (lost state) → refuse
1401
+ // - storage empty + marker matches this import (recovery) / no marker → allow
1402
+ // A storage throw surfaces as IdentityUnavailableError (never a blind write).
917
1403
  if (!options?.overwrite) {
918
- const existing = await KeyManager.getPublicKey();
919
- if (existing && existing.toLowerCase() !== publicKey.toLowerCase()) {
920
- throw new IdentityAlreadyExistsError(existing);
921
- }
922
- // If existing === publicKey, the device already has this exact identity;
923
- // re-persisting is a no-op but harmless. Fall through to ensure backup
924
- // is up to date.
925
- }
926
- await KeyManager._persistIdentityAtomic(canonicalPrivate, publicKey);
1404
+ const marker = await (0, identityMarker_1.readIdentityMarker)();
1405
+ const direct = await KeyManager._readPrimaryDirect();
1406
+ const importedPub = publicKey.toLowerCase();
1407
+ const existingPub = direct.publicKey?.toLowerCase() ?? null;
1408
+ const markerPub = marker?.publicKey.toLowerCase() ?? null;
1409
+ if (existingPub && existingPub !== importedPub) {
1410
+ throw new IdentityAlreadyExistsError(direct.publicKey);
1411
+ }
1412
+ if (!existingPub && markerPub && markerPub !== importedPub) {
1413
+ throw new IdentityAlreadyExistsError(marker?.publicKey);
1414
+ }
1415
+ // Otherwise: existing === import (idempotent refresh), or storage empty
1416
+ // with a matching/absent marker (fresh import or lost-identity recovery)
1417
+ // → fall through and (re-)persist to refresh the backup + marker.
1418
+ }
1419
+ await KeyManager._persistIdentityAtomic(canonicalPrivate, publicKey, 'import');
927
1420
  return publicKey;
928
1421
  }
929
1422
  /**
930
1423
  * Get the stored private key
931
1424
  * WARNING: Only use this for signing operations within the app
1425
+ *
1426
+ * Preserves the "return null on any storage failure" contract signing paths
1427
+ * rely on (a locked keychain simply means "cannot sign now"); unlike
1428
+ * {@link getPublicKey}, it does NOT throw {@link IdentityUnavailableError}.
932
1429
  */
933
1430
  static async getPrivateKey() {
934
1431
  if (isWebPlatform()) {
935
1432
  return null; // Identity storage is only available on native platforms
936
1433
  }
937
1434
  try {
1435
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1436
+ if (migration.mode === 'deferred') {
1437
+ // Storage unreadable right now — preserve the null contract.
1438
+ return null;
1439
+ }
938
1440
  const store = await initSecureStore();
939
- return await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
1441
+ return await store.getItemAsync(migration.layout.primaryPrivateKeyName, KeyManager._slotOpts(migration.layout.primaryService));
940
1442
  }
941
1443
  catch (error) {
942
1444
  // If secure store is not available, return null (no identity)
@@ -948,7 +1450,12 @@ class KeyManager {
948
1450
  }
949
1451
  }
950
1452
  /**
951
- * Get the stored public key (cached for performance)
1453
+ * Get the stored public key (cached for performance).
1454
+ *
1455
+ * Returns the public key, or `null` when a read SUCCEEDS and finds none.
1456
+ * THROWS {@link IdentityUnavailableError} when storage is unreadable (keychain
1457
+ * locked / module load failure) — a thrown read is NEVER flattened to `null`
1458
+ * and NEVER cached, so a poisoned "no identity" verdict can no longer stick.
952
1459
  */
953
1460
  static async getPublicKey() {
954
1461
  if (isWebPlatform()) {
@@ -957,30 +1464,113 @@ class KeyManager {
957
1464
  if (KeyManager.cachedPublicKey !== null) {
958
1465
  return KeyManager.cachedPublicKey;
959
1466
  }
1467
+ // A genuine-absent result (read succeeded, empty) is cacheable distinctly
1468
+ // from a thrown read — only the former sets this flag.
1469
+ if (KeyManager.cachedPublicKeyResolved) {
1470
+ return null;
1471
+ }
1472
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1473
+ if (migration.mode === 'deferred') {
1474
+ throw new IdentityUnavailableError('Identity storage is temporarily unavailable (keychain locked or unreadable).', migration.cause);
1475
+ }
960
1476
  try {
961
1477
  const store = await initSecureStore();
962
- const publicKey = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
963
- // Cache result (null is a valid cache value meaning no identity)
964
- KeyManager.cachedPublicKey = publicKey;
1478
+ const publicKey = await store.getItemAsync(migration.layout.primaryPublicKeyName, KeyManager._slotOpts(migration.layout.primaryService));
1479
+ if (publicKey !== null) {
1480
+ KeyManager.cachedPublicKey = publicKey;
1481
+ }
1482
+ else {
1483
+ // Genuine-absent (successful empty read) IS safe to cache.
1484
+ KeyManager.cachedPublicKeyResolved = true;
1485
+ }
965
1486
  return publicKey;
966
1487
  }
967
1488
  catch (error) {
968
- // If secure store is not available, return null (no identity)
969
- // Cache null to avoid repeated failed attempts
970
- KeyManager.cachedPublicKey = null;
1489
+ // Storage threw AFTER migration resolved transient/unavailable. Do NOT
1490
+ // cache; surface a typed error so callers never misread it as "no identity".
971
1491
  if ((0, logger_1.isDev)()) {
972
1492
  logger_1.logger.warn('Failed to access secure store', { component: 'KeyManager' }, error);
973
1493
  }
974
- return null;
1494
+ throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
1495
+ }
1496
+ }
1497
+ /**
1498
+ * Persist the recovery mnemonic (the 12-word phrase) into its dedicated,
1499
+ * device-only keychain slot so the user can re-reveal it from Settings after
1500
+ * onboarding.
1501
+ *
1502
+ * Called best-effort at identity creation/import, where the phrase is already
1503
+ * in memory: a failure to persist it must NEVER fail the identity itself, so
1504
+ * callers deliberately swallow the thrown error (logging it). Storage errors
1505
+ * throw {@link IdentityUnavailableError} — same "cannot determine" semantics as
1506
+ * the other getters — so a caller MAY observe/log the failure.
1507
+ *
1508
+ * The mnemonic is stored ONLY here — never in the marker, `getIdentityStatus`,
1509
+ * logs, or any exported bundle.
1510
+ */
1511
+ static async storeRecoveryMnemonic(mnemonic) {
1512
+ if (isWebPlatform()) {
1513
+ return; // Identity storage is only available on native platforms
1514
+ }
1515
+ try {
1516
+ const store = await initSecureStore();
1517
+ await store.setItemAsync(RECOVERY_MNEMONIC_STORAGE_KEY, mnemonic, KeyManager._privateWriteOpts(store, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE));
1518
+ }
1519
+ catch (error) {
1520
+ if ((0, logger_1.isDev)()) {
1521
+ logger_1.logger.warn('Failed to persist recovery mnemonic', { component: 'KeyManager' }, error);
1522
+ }
1523
+ throw new IdentityUnavailableError('Failed to persist recovery mnemonic.', error);
1524
+ }
1525
+ }
1526
+ /**
1527
+ * Read the stored recovery mnemonic for re-reveal in Settings.
1528
+ *
1529
+ * Returns the phrase, or `null` when a read SUCCEEDS and finds none — the
1530
+ * expected result for any identity created/imported before this feature
1531
+ * existed, since the phrase was never captured for those. THROWS
1532
+ * {@link IdentityUnavailableError} when storage is unreadable (keychain locked
1533
+ * / module load failure), matching {@link getPublicKey}'s contract — a thrown
1534
+ * read is never flattened to `null`, so a caller distinguishes "phrase was
1535
+ * never stored" from "keychain temporarily locked, retry".
1536
+ */
1537
+ static async getRecoveryMnemonic() {
1538
+ if (isWebPlatform()) {
1539
+ return null; // Identity storage is only available on native platforms
1540
+ }
1541
+ try {
1542
+ const store = await initSecureStore();
1543
+ return await store.getItemAsync(RECOVERY_MNEMONIC_STORAGE_KEY, KeyManager._slotOpts(RECOVERY_MNEMONIC_KEYCHAIN_SERVICE));
1544
+ }
1545
+ catch (error) {
1546
+ if ((0, logger_1.isDev)()) {
1547
+ logger_1.logger.warn('Failed to read recovery mnemonic', { component: 'KeyManager' }, error);
1548
+ }
1549
+ throw new IdentityUnavailableError('Failed to read recovery mnemonic from secure storage.', error);
975
1550
  }
976
1551
  }
1552
+ /**
1553
+ * Delete the stored recovery mnemonic. Best-effort: a delete failure is logged
1554
+ * and swallowed, never thrown — it runs inside the identity-deletion path where
1555
+ * an unreadable keychain must not abort the wider teardown.
1556
+ */
1557
+ static async deleteRecoveryMnemonic() {
1558
+ if (isWebPlatform()) {
1559
+ return; // Identity storage is only available on native platforms
1560
+ }
1561
+ const store = await initSecureStore();
1562
+ await KeyManager._bestEffortDelete(store, RECOVERY_MNEMONIC_STORAGE_KEY, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE);
1563
+ }
977
1564
  /**
978
1565
  * Check if a complete, parseable identity exists on this device.
979
1566
  *
980
1567
  * Returns `true` only when BOTH the private and public keys are present,
981
1568
  * both are well-formed, AND the public key derives from the private key.
982
- * A partially-written or corrupted identity returns `false` so that
983
- * downstream code can resume the create / restore flow correctly.
1569
+ * A partially-written or corrupted identity (read succeeded, bytes empty/bad)
1570
+ * returns `false` so that downstream code can resume the create / restore flow.
1571
+ * THROWS {@link IdentityUnavailableError} when storage is unreadable — a locked
1572
+ * keychain must never be mistaken for "no identity" (the old behavior that let
1573
+ * onboarding treat a transient lock as a blank device).
984
1574
  *
985
1575
  * Note: this does NOT perform the full sign/verify roundtrip — call
986
1576
  * `verifyIdentityIntegrity()` for that.
@@ -992,23 +1582,26 @@ class KeyManager {
992
1582
  if (KeyManager.cachedHasIdentity !== null) {
993
1583
  return KeyManager.cachedHasIdentity;
994
1584
  }
1585
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1586
+ if (migration.mode === 'deferred') {
1587
+ throw new IdentityUnavailableError('Identity storage is temporarily unavailable.', migration.cause);
1588
+ }
995
1589
  let privateKey;
996
1590
  let publicKey;
997
1591
  try {
998
1592
  const store = await initSecureStore();
999
1593
  [privateKey, publicKey] = await Promise.all([
1000
- store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY),
1001
- store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY),
1594
+ store.getItemAsync(migration.layout.primaryPrivateKeyName, KeyManager._slotOpts(migration.layout.primaryService)),
1595
+ store.getItemAsync(migration.layout.primaryPublicKeyName, KeyManager._slotOpts(migration.layout.primaryService)),
1002
1596
  ]);
1003
1597
  }
1004
1598
  catch (error) {
1005
1599
  // Storage threw — could be a transient keychain lock (e.g., background
1006
- // fetch before the device is unlocked). Do NOT cache `false`: if we
1007
- // did, the next call would skip storage entirely and return false even
1008
- // after the device is unlocked. Just return false and let the next
1009
- // call retry from storage.
1600
+ // fetch before the device is unlocked). Do NOT cache; throw a TYPED error
1601
+ // so callers distinguish "temporarily unavailable" from "genuinely absent"
1602
+ // instead of silently treating a locked keystore as a blank device.
1010
1603
  logger_1.logger.error('Failed to read identity from secure storage', error, { component: 'KeyManager' });
1011
- return false;
1604
+ throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
1012
1605
  }
1013
1606
  // Storage succeeded. Now classify the result. From here onward, any
1014
1607
  // outcome is stable and safe to cache (the bytes won't change between
@@ -1062,6 +1655,75 @@ class KeyManager {
1062
1655
  }
1063
1656
  return hasIdentity;
1064
1657
  }
1658
+ /**
1659
+ * Authoritative identity verdict — the corruption-vs-fresh-install
1660
+ * disambiguator that routing (commons) keys off of.
1661
+ *
1662
+ * - Healthy pair → `present` (and the marker is backfilled if missing or
1663
+ * pointing at a different key, `origin: 'backfill'`).
1664
+ * - Read succeeded but no healthy pair, WITH a marker → `lost` (keystore death
1665
+ * / corruption; route to recovery, NEVER to create).
1666
+ * - Read succeeded, no pair, NO marker → `absent` (a genuine fresh device; the
1667
+ * only state that may route to onboarding/create).
1668
+ * - A read THREW → `unavailable` (keychain locked); this verdict is NEVER
1669
+ * cached, so a later call re-reads.
1670
+ *
1671
+ * @param opts.bypassCache When true, never reads OR writes the in-memory cache
1672
+ * — a pure, fresh storage verdict for the auto-create interlock preflight.
1673
+ */
1674
+ static async getIdentityStatus(opts) {
1675
+ if (isWebPlatform()) {
1676
+ return { state: 'absent' }; // Identity storage is only available on native platforms
1677
+ }
1678
+ const bypassCache = opts?.bypassCache === true;
1679
+ // Read the marker FIRST (fail-open null) — it is the AndroidKeyStore-independent
1680
+ // signal that survives a keystore death.
1681
+ const marker = await (0, identityMarker_1.readIdentityMarker)();
1682
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1683
+ if (migration.mode === 'deferred') {
1684
+ return { state: 'unavailable', cause: migration.cause };
1685
+ }
1686
+ let privateKey;
1687
+ let publicKey;
1688
+ try {
1689
+ const store = await initSecureStore();
1690
+ const readOpts = KeyManager._slotOpts(migration.layout.primaryService);
1691
+ privateKey = await store.getItemAsync(migration.layout.primaryPrivateKeyName, readOpts);
1692
+ publicKey = await store.getItemAsync(migration.layout.primaryPublicKeyName, readOpts);
1693
+ }
1694
+ catch (error) {
1695
+ // Storage threw — NEVER cache this verdict; callers retry.
1696
+ return { state: 'unavailable', cause: error };
1697
+ }
1698
+ if (KeyManager._isHealthyPair(privateKey, publicKey) && publicKey) {
1699
+ const canonicalPublic = publicKey.toLowerCase();
1700
+ // Backfill the marker when missing or pointing at a DIFFERENT identity —
1701
+ // e.g. a loss that predates markers, healed on first healthy read.
1702
+ if (!marker || marker.publicKey.toLowerCase() !== canonicalPublic) {
1703
+ try {
1704
+ await (0, identityMarker_1.writeIdentityMarker)({ publicKey: canonicalPublic, origin: 'backfill' });
1705
+ }
1706
+ catch (error) {
1707
+ logger_1.logger.warn('Failed to backfill identity marker', { component: 'KeyManager' }, error);
1708
+ }
1709
+ }
1710
+ if (!bypassCache) {
1711
+ KeyManager.cachedPublicKey = canonicalPublic;
1712
+ KeyManager.cachedHasIdentity = true;
1713
+ KeyManager.cachedPublicKeyResolved = false;
1714
+ }
1715
+ return { state: 'present', publicKey: canonicalPublic };
1716
+ }
1717
+ // Read succeeded but no healthy pair present.
1718
+ if (!bypassCache) {
1719
+ KeyManager.cachedHasIdentity = false;
1720
+ KeyManager.cachedPublicKeyResolved = true;
1721
+ }
1722
+ if (marker) {
1723
+ return { state: 'lost', marker };
1724
+ }
1725
+ return { state: 'absent' };
1726
+ }
1065
1727
  /**
1066
1728
  * Delete the stored identity (both keys)
1067
1729
  * Use with EXTREME caution - this is irreversible without a recovery phrase
@@ -1079,6 +1741,8 @@ class KeyManager {
1079
1741
  throw new Error('Identity deletion requires explicit user confirmation. This is a safety measure to prevent accidental data loss.');
1080
1742
  }
1081
1743
  if (!force) {
1744
+ // May throw IdentityUnavailableError if storage is locked — correct: a
1745
+ // non-force delete must abort rather than run against an unreadable store.
1082
1746
  const hasIdentity = await KeyManager.hasIdentity();
1083
1747
  if (!hasIdentity) {
1084
1748
  return; // Nothing to delete
@@ -1099,21 +1763,42 @@ class KeyManager {
1099
1763
  }
1100
1764
  }
1101
1765
  }
1102
- await store.deleteItemAsync(STORAGE_KEYS.PRIVATE_KEY);
1103
- await store.deleteItemAsync(STORAGE_KEYS.PUBLIC_KEY);
1104
- // Invalidate cache
1105
- KeyManager.invalidateCache();
1106
- // Also clear backup if force deletion
1766
+ // Delete the primary from the active layout (authoritative), then best-effort
1767
+ // delete BOTH generations so a stale legacy copy can never resurrect the
1768
+ // identity after deletion.
1769
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1770
+ if (migration.mode !== 'deferred') {
1771
+ const layout = migration.layout;
1772
+ const readOpts = KeyManager._slotOpts(layout.primaryService);
1773
+ await store.deleteItemAsync(layout.primaryPrivateKeyName, readOpts);
1774
+ await store.deleteItemAsync(layout.primaryPublicKeyName, readOpts);
1775
+ }
1776
+ await KeyManager._bestEffortDeleteV2Primary(store);
1777
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
1778
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
1779
+ // Always drop the stored recovery mnemonic — it is scoped to the identity
1780
+ // being deleted, so a leftover would let Settings reveal a stale phrase for
1781
+ // an identity that no longer exists (or a DIFFERENT one after re-onboarding).
1782
+ await KeyManager.deleteRecoveryMnemonic();
1783
+ // Also clear backups + the shared slot on force deletion, so a deleted
1784
+ // identity cannot be resurrected from any recovery source.
1107
1785
  if (force) {
1108
- try {
1109
- await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
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
- }
1786
+ await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
1787
+ await KeyManager._clearSharedSlot(store);
1116
1788
  }
1789
+ // Clear the marker AFTER key deletion succeeds — a marker must never outlive
1790
+ // its identity (a leftover marker would route a truly-absent device to
1791
+ // `recovery` instead of `welcome`).
1792
+ try {
1793
+ await (0, identityMarker_1.clearIdentityMarker)();
1794
+ }
1795
+ catch (error) {
1796
+ logger_1.logger.warn('Failed to clear identity marker during delete', { component: 'KeyManager' }, error);
1797
+ }
1798
+ // Invalidate cache LAST — its subscriber fan-out fires only after both the
1799
+ // keys AND the marker are gone, so a routing subscriber that re-reads on the
1800
+ // notification observes `absent`, never a transient `lost`.
1801
+ KeyManager.invalidateCache();
1117
1802
  }
1118
1803
  /**
1119
1804
  * Backup identity to SecureStore (separate backup storage)
@@ -1125,17 +1810,23 @@ class KeyManager {
1125
1810
  }
1126
1811
  try {
1127
1812
  const store = await initSecureStore();
1128
- const privateKey = await KeyManager.getPrivateKey();
1129
- const publicKey = await KeyManager.getPublicKey();
1813
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1814
+ if (migration.mode === 'deferred') {
1815
+ return false; // Cannot read the primary safely → nothing to back up
1816
+ }
1817
+ const layout = migration.layout;
1818
+ // Read the primary DIRECTLY (raw) rather than via getPublicKey (which now
1819
+ // throws) — a locked keychain here should simply mean "nothing to back up".
1820
+ const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
1821
+ const privateKey = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
1822
+ const publicKey = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
1130
1823
  if (!privateKey || !publicKey) {
1131
1824
  return false; // Nothing to backup
1132
1825
  }
1133
1826
  // Store backup in SecureStore (still secure, but separate from primary storage)
1134
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, privateKey, {
1135
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
1136
- });
1137
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, publicKey);
1138
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
1827
+ await store.setItemAsync(layout.backupPrivateKeyName, privateKey, KeyManager._privateWriteOpts(store, layout.backupService));
1828
+ await store.setItemAsync(layout.backupPublicKeyName, publicKey, KeyManager._slotOpts(layout.backupService));
1829
+ await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), KeyManager._slotOpts(layout.backupService));
1139
1830
  return true;
1140
1831
  }
1141
1832
  catch (error) {
@@ -1219,6 +1910,15 @@ class KeyManager {
1219
1910
  }
1220
1911
  try {
1221
1912
  const store = await initSecureStore();
1913
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1914
+ if (migration.mode === 'deferred') {
1915
+ // Storage locked — refuse to restore (guard 2). Retry a later call.
1916
+ logger_1.logger.warn('restoreIdentityFromBackup: identity storage unavailable. Refusing to restore.', { component: 'KeyManager' });
1917
+ return false;
1918
+ }
1919
+ const layout = migration.layout;
1920
+ const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
1921
+ const backupReadOpts = KeyManager._slotOpts(layout.backupService);
1222
1922
  // Read the primary DIRECTLY (not via the error-swallowing getters) so
1223
1923
  // we can distinguish a transient read failure from a genuinely absent
1224
1924
  // key. A thrown read here means the keychain is locked/unavailable —
@@ -1227,8 +1927,8 @@ class KeyManager {
1227
1927
  let primaryPrivate;
1228
1928
  let primaryPublic;
1229
1929
  try {
1230
- primaryPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
1231
- primaryPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
1930
+ primaryPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
1931
+ primaryPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
1232
1932
  }
1233
1933
  catch (error) {
1234
1934
  logger_1.logger.warn('restoreIdentityFromBackup: could not read primary (transient?). Refusing to restore.', { component: 'KeyManager' }, error);
@@ -1244,8 +1944,8 @@ class KeyManager {
1244
1944
  }
1245
1945
  }
1246
1946
  // Load + validate the backup.
1247
- const backupPrivateKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
1248
- const backupPublicKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
1947
+ const backupPrivateKey = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
1948
+ const backupPublicKey = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
1249
1949
  if (!backupPrivateKey || !backupPublicKey) {
1250
1950
  return false; // No backup available
1251
1951
  }
@@ -1281,13 +1981,13 @@ class KeyManager {
1281
1981
  // Safe to restore: rebuild the primary using the same atomic write
1282
1982
  // path createIdentity uses, including verification.
1283
1983
  try {
1284
- await KeyManager._persistIdentityAtomic(backupPrivateKey, backupPublicKey);
1984
+ await KeyManager._persistIdentityAtomic(backupPrivateKey, backupPublicKey, 'restore');
1285
1985
  }
1286
1986
  catch (error) {
1287
1987
  logger_1.logger.error('Failed to persist identity restored from backup', error, { component: 'KeyManager' });
1288
1988
  return false;
1289
1989
  }
1290
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
1990
+ await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupReadOpts);
1291
1991
  return true;
1292
1992
  }
1293
1993
  catch (error) {
@@ -1295,6 +1995,116 @@ class KeyManager {
1295
1995
  return false;
1296
1996
  }
1297
1997
  }
1998
+ /**
1999
+ * Recovery ladder — restore a `lost` identity from an independent,
2000
+ * `key_v1`-surviving source WITHOUT the user re-entering their recovery phrase.
2001
+ *
2002
+ * Gated on {@link getIdentityStatus} being `lost` (marker present, keys empty):
2003
+ * - `present` / `absent` → `not-lost` (nothing to recover / nothing lost)
2004
+ * - `unavailable` → `unavailable` (keychain locked; retry later)
2005
+ *
2006
+ * Rungs, tried in order, each fully validated (well-formed + derive-match +
2007
+ * `publicKey === marker.publicKey`, so a source holding a DIFFERENT account is
2008
+ * SKIPPED, never restored):
2009
+ * 1. the v2 backup slot (independent keychain key from the primary), then
2010
+ * 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
2011
+ * group) — the copy that survives a primary+backup `key_v1` death.
2012
+ *
2013
+ * On success it re-persists via {@link _persistIdentityAtomic} (origin
2014
+ * `'restore'`) and invalidates the cache so routing re-reads `present`. When no
2015
+ * rung matches, the UI proceeds to recovery-phrase entry.
2016
+ */
2017
+ static async attemptIdentityRecovery() {
2018
+ if (isWebPlatform()) {
2019
+ return { recovered: false, reason: 'not-lost' };
2020
+ }
2021
+ const status = await KeyManager.getIdentityStatus({ bypassCache: true });
2022
+ if (status.state === 'present' || status.state === 'absent') {
2023
+ return { recovered: false, reason: 'not-lost' };
2024
+ }
2025
+ if (status.state === 'unavailable') {
2026
+ return { recovered: false, reason: 'unavailable' };
2027
+ }
2028
+ // status.state === 'lost'
2029
+ const expectedPublic = status.marker.publicKey.toLowerCase();
2030
+ let sawMismatch = false;
2031
+ // Rung 1: backup slot.
2032
+ const backupCandidate = await KeyManager._readBackupCandidate();
2033
+ if (backupCandidate) {
2034
+ if (backupCandidate.publicKey.toLowerCase() === expectedPublic) {
2035
+ if (await KeyManager._commitRecovery(backupCandidate.privateKey, backupCandidate.publicKey)) {
2036
+ return { recovered: true, source: 'backup', publicKey: backupCandidate.publicKey };
2037
+ }
2038
+ }
2039
+ else {
2040
+ sawMismatch = true;
2041
+ }
2042
+ }
2043
+ // Rung 2: cross-app shared slot.
2044
+ const sharedCandidate = await KeyManager._readSharedCandidate();
2045
+ if (sharedCandidate) {
2046
+ if (sharedCandidate.publicKey.toLowerCase() === expectedPublic) {
2047
+ if (await KeyManager._commitRecovery(sharedCandidate.privateKey, sharedCandidate.publicKey)) {
2048
+ return { recovered: true, source: 'shared', publicKey: sharedCandidate.publicKey };
2049
+ }
2050
+ }
2051
+ else {
2052
+ sawMismatch = true;
2053
+ }
2054
+ }
2055
+ // A source existed but identified a DIFFERENT account — never silently
2056
+ // switched. Report `mismatch` so the UI can require explicit confirmation.
2057
+ return { recovered: false, reason: sawMismatch ? 'mismatch' : 'no-sources' };
2058
+ }
2059
+ /** Read the active-layout backup slot as a healthy candidate, or null. @internal */
2060
+ static async _readBackupCandidate() {
2061
+ try {
2062
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
2063
+ if (migration.mode === 'deferred') {
2064
+ return null;
2065
+ }
2066
+ const layout = migration.layout;
2067
+ const backupReadOpts = KeyManager._slotOpts(layout.backupService);
2068
+ const store = await initSecureStore();
2069
+ const privateKey = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
2070
+ const publicKey = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
2071
+ if (KeyManager._isHealthyPair(privateKey, publicKey) && privateKey && publicKey) {
2072
+ return { privateKey, publicKey };
2073
+ }
2074
+ return null;
2075
+ }
2076
+ catch (error) {
2077
+ logger_1.logger.warn('Recovery: failed to read backup slot', { component: 'KeyManager' }, error);
2078
+ return null;
2079
+ }
2080
+ }
2081
+ /** Read the cross-app shared slot as a healthy candidate, or null. @internal */
2082
+ static async _readSharedCandidate() {
2083
+ try {
2084
+ const privateKey = await KeyManager.getSharedPrivateKey();
2085
+ const publicKey = await KeyManager.getSharedPublicKey();
2086
+ if (KeyManager._isHealthyPair(privateKey, publicKey) && privateKey && publicKey) {
2087
+ return { privateKey, publicKey };
2088
+ }
2089
+ return null;
2090
+ }
2091
+ catch (error) {
2092
+ logger_1.logger.warn('Recovery: failed to read shared slot', { component: 'KeyManager' }, error);
2093
+ return null;
2094
+ }
2095
+ }
2096
+ /** Persist a validated recovery candidate + refresh caches/subscribers. @internal */
2097
+ static async _commitRecovery(privateKey, publicKey) {
2098
+ try {
2099
+ await KeyManager._persistIdentityAtomic(privateKey, publicKey, 'restore');
2100
+ KeyManager.invalidateCache();
2101
+ return true;
2102
+ }
2103
+ catch (error) {
2104
+ logger_1.logger.error('Recovery: failed to persist recovered identity', error, { component: 'KeyManager' });
2105
+ return false;
2106
+ }
2107
+ }
1298
2108
  /**
1299
2109
  * Get the elliptic curve key object from the stored private key
1300
2110
  * Used internally for signing operations
@@ -1455,4 +2265,21 @@ KeyManager.cachedPublicKey = null;
1455
2265
  KeyManager.cachedHasIdentity = null;
1456
2266
  KeyManager.cachedSharedPublicKey = null;
1457
2267
  KeyManager.cachedHasSharedIdentity = null;
2268
+ /**
2269
+ * Distinguishes "public key genuinely absent (a successful empty read, safe to
2270
+ * cache)" from "never resolved / storage threw (must NOT be cached)". A `null`
2271
+ * {@link cachedPublicKey} alone is ambiguous — this flag makes the genuine
2272
+ * absence cacheable WITHOUT ever caching a null produced by a thrown read.
2273
+ */
2274
+ KeyManager.cachedPublicKeyResolved = false;
2275
+ /** Listeners notified synchronously whenever the identity verdict may have changed. */
2276
+ KeyManager.identityChangeListeners = new Set();
2277
+ /**
2278
+ * Memoized one-run-per-process slot migration. `slotMigrationResult` caches a
2279
+ * STABLE outcome (`v2`/`legacy`); a `deferred` outcome is intentionally not
2280
+ * cached (the in-flight promise is cleared) so a later call retries once the
2281
+ * keychain unlocks.
2282
+ */
2283
+ KeyManager.slotMigrationPromise = null;
2284
+ KeyManager.slotMigrationResult = null;
1458
2285
  exports.default = KeyManager;