@oxyhq/core 12.8.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.
Files changed (38) 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 +844 -106
  5. package/dist/cjs/index.js +8 -4
  6. package/dist/cjs/mixins/OxyServices.auth.js +21 -6
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
  8. package/dist/esm/.tsbuildinfo +1 -1
  9. package/dist/esm/boot/sessionColdBoot.js +16 -3
  10. package/dist/esm/crypto/identityMarker.js +248 -0
  11. package/dist/esm/crypto/keyManager.js +843 -106
  12. package/dist/esm/index.js +2 -1
  13. package/dist/esm/mixins/OxyServices.auth.js +21 -6
  14. package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
  15. package/dist/types/.tsbuildinfo +1 -1
  16. package/dist/types/boot/sessionColdBoot.d.ts +25 -0
  17. package/dist/types/crypto/identityMarker.d.ts +94 -0
  18. package/dist/types/crypto/keyManager.d.ts +212 -3
  19. package/dist/types/index.d.ts +4 -2
  20. package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
  21. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
  22. package/package.json +1 -1
  23. package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
  24. package/src/boot/sessionColdBoot.ts +42 -3
  25. package/src/crypto/__tests__/identityMocks.ts +125 -0
  26. package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
  27. package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
  28. package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
  29. package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
  30. package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
  31. package/src/crypto/__tests__/keyManager.test.ts +77 -87
  32. package/src/crypto/identityMarker.ts +291 -0
  33. package/src/crypto/keyManager.ts +1026 -105
  34. package/src/index.ts +7 -1
  35. package/src/mixins/OxyServices.auth.ts +31 -7
  36. package/src/mixins/OxyServices.deviceBoot.ts +9 -1
  37. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
  38. package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
@@ -8,9 +8,16 @@
8
8
  import { ec as EC } from 'elliptic';
9
9
  import type { ECKeyPair } from 'elliptic';
10
10
  import { isWeb, isIOS, isAndroid } from '../utils/platform';
11
- import { type ExpoCryptoLike, type ExpoSecureStoreLike, isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
11
+ import { type ExpoCryptoLike, type ExpoSecureStoreLike, isReactNative, isNodeJS, loadAsyncStorage, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
12
12
  import { isDev, logger } from '../logger';
13
13
  import { hkdfSha256 } from './kdf';
14
+ import {
15
+ type IdentityMarker,
16
+ clearIdentityMarker,
17
+ readIdentityMarker,
18
+ updateIdentityMarker,
19
+ writeIdentityMarker,
20
+ } from './identityMarker';
14
21
 
15
22
  /**
16
23
  * Options for expo-secure-store calls made by KeyManager.
@@ -78,6 +85,56 @@ export class IdentityPersistError extends Error {
78
85
  }
79
86
  }
80
87
 
88
+ /**
89
+ * Thrown when identity storage cannot be read/written right now — the keychain
90
+ * is locked, the module failed to load, or a read threw — as opposed to the
91
+ * identity being genuinely absent.
92
+ *
93
+ * This is the crux of the corruption-vs-fresh-install fix: a storage THROW must
94
+ * NEVER be flattened into "no identity" (the old behavior, which let onboarding
95
+ * treat a momentarily-locked keystore as a blank device). Callers that used to
96
+ * tolerate a `false`/`null` from `hasIdentity()`/`getPublicKey()` on error must
97
+ * now treat this typed error as "cannot determine" — retry, surface a locked
98
+ * state, or abort a destructive path — never as "safe to create/overwrite".
99
+ */
100
+ export class IdentityUnavailableError extends Error {
101
+ override readonly name = 'IdentityUnavailableError';
102
+ constructor(message: string, readonly cause?: unknown) {
103
+ super(message);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Authoritative tri-state (plus `unavailable`) verdict on the on-device
109
+ * identity, from {@link KeyManager.getIdentityStatus}.
110
+ *
111
+ * - `present` — a healthy, round-tripping key pair exists.
112
+ * - `absent` — storage read succeeded and returned nothing, AND no marker
113
+ * records a prior identity → a genuine fresh device. The ONLY
114
+ * state that may route to create/onboarding.
115
+ * - `lost` — storage read succeeded but the keys are empty/unreadable while
116
+ * the independent {@link IdentityMarker} records that an identity
117
+ * DID exist here → corruption/keystore death. Route to recovery,
118
+ * NEVER to create.
119
+ * - `unavailable` — a storage read THREW (keychain locked, module load failure).
120
+ * Transient by assumption; NEVER cached; callers retry.
121
+ */
122
+ export type IdentityStatus =
123
+ | { state: 'present'; publicKey: string }
124
+ | { state: 'absent' }
125
+ | { state: 'lost'; marker: IdentityMarker }
126
+ | { state: 'unavailable'; cause: unknown };
127
+
128
+ /**
129
+ * Result of {@link KeyManager.attemptIdentityRecovery}. On success it reports
130
+ * which independent, `key_v1`-surviving source restored the identity. On failure
131
+ * `reason` distinguishes "wasn't lost", "no surviving source", "a source held a
132
+ * DIFFERENT account" (never silently switched), and "storage unavailable".
133
+ */
134
+ export type IdentityRecoveryResult =
135
+ | { recovered: true; source: 'backup' | 'shared'; publicKey: string }
136
+ | { recovered: false; reason: 'not-lost' | 'no-sources' | 'mismatch' | 'unavailable' };
137
+
81
138
  const ec = new EC('secp256k1');
82
139
 
83
140
  /**
@@ -115,6 +172,87 @@ const STORAGE_KEYS = {
115
172
  SHARED_SESSION_ID: 'oxy_shared_session_id',
116
173
  } as const;
117
174
 
175
+ /**
176
+ * v2 identity slot layout — blast-radius isolation.
177
+ *
178
+ * The legacy keys above were written WITHOUT a `keychainService`, so on Android
179
+ * they all shared expo-secure-store's single default `key_v1` AndroidKeyStore
180
+ * key — meaning ONE keystore invalidation deleted the primary AND the backup
181
+ * together (the exact loss this hardening closes). The v2 layout gives the
182
+ * primary and the backup DISTINCT keychain services (→ independent AndroidKeyStore
183
+ * keys, independent iOS keychain items), so they can no longer die together, and
184
+ * DISTINCT key names (`_v2`) so the post-copy migration verify can only observe
185
+ * what it actually wrote (old/new locations are non-aliasable).
186
+ *
187
+ * Migration from the legacy layout is lazy + verify-before-delete — see
188
+ * {@link KeyManager._runSlotMigration}.
189
+ */
190
+ const V2_PRIMARY_KEYCHAIN_SERVICE = 'oxy_identity';
191
+ const V2_BACKUP_KEYCHAIN_SERVICE = 'oxy_identity_backup';
192
+
193
+ const V2_STORAGE_KEYS = {
194
+ PRIVATE_KEY: 'oxy_identity_private_key_v2',
195
+ PUBLIC_KEY: 'oxy_identity_public_key_v2',
196
+ BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
197
+ BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
198
+ BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
199
+ } as const;
200
+
201
+ /**
202
+ * Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
203
+ * Re-derivable (its loss just re-runs the cheap slot check), so it lives in
204
+ * plain AsyncStorage rather than the keychain. It only SKIPS re-reading the
205
+ * legacy slots on an already-migrated device; it is never trusted over an actual
206
+ * v2 read (a set flag with an unhealthy v2 pair falls through to full migration).
207
+ */
208
+ const SLOTS_MIGRATED_FLAG_KEY = 'oxy_identity_slots_migrated_v2';
209
+
210
+ /**
211
+ * The resolved set of storage key names + keychain services a session reads and
212
+ * writes. Normally {@link V2_SLOT_LAYOUT}; degrades to {@link LEGACY_SLOT_LAYOUT}
213
+ * for the current session only when a v2 migration write could not be verified
214
+ * (so the user is never locked out of a still-readable legacy identity).
215
+ */
216
+ interface ResolvedSlotLayout {
217
+ primaryService?: string;
218
+ primaryPrivateKeyName: string;
219
+ primaryPublicKeyName: string;
220
+ backupService?: string;
221
+ backupPrivateKeyName: string;
222
+ backupPublicKeyName: string;
223
+ backupTimestampName: string;
224
+ }
225
+
226
+ const V2_SLOT_LAYOUT: ResolvedSlotLayout = {
227
+ primaryService: V2_PRIMARY_KEYCHAIN_SERVICE,
228
+ primaryPrivateKeyName: V2_STORAGE_KEYS.PRIVATE_KEY,
229
+ primaryPublicKeyName: V2_STORAGE_KEYS.PUBLIC_KEY,
230
+ backupService: V2_BACKUP_KEYCHAIN_SERVICE,
231
+ backupPrivateKeyName: V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY,
232
+ backupPublicKeyName: V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY,
233
+ backupTimestampName: V2_STORAGE_KEYS.BACKUP_TIMESTAMP,
234
+ };
235
+
236
+ const LEGACY_SLOT_LAYOUT: ResolvedSlotLayout = {
237
+ primaryService: undefined,
238
+ primaryPrivateKeyName: STORAGE_KEYS.PRIVATE_KEY,
239
+ primaryPublicKeyName: STORAGE_KEYS.PUBLIC_KEY,
240
+ backupService: undefined,
241
+ backupPrivateKeyName: STORAGE_KEYS.BACKUP_PRIVATE_KEY,
242
+ backupPublicKeyName: STORAGE_KEYS.BACKUP_PUBLIC_KEY,
243
+ backupTimestampName: STORAGE_KEYS.BACKUP_TIMESTAMP,
244
+ };
245
+
246
+ /**
247
+ * Outcome of the one-time-per-process slot migration. `deferred` means a read
248
+ * threw (keychain locked) — nothing was written or deleted, and every accessor
249
+ * treats it as `unavailable` (surfaced, never cached) so a later call retries.
250
+ */
251
+ type SlotMigrationResult =
252
+ | { mode: 'v2'; layout: ResolvedSlotLayout }
253
+ | { mode: 'legacy'; layout: ResolvedSlotLayout }
254
+ | { mode: 'deferred'; cause: unknown };
255
+
118
256
  /**
119
257
  * iOS Keychain Access Group for sharing identities across Oxy apps
120
258
  * All Oxy apps must have this access group enabled in their entitlements
@@ -210,6 +348,25 @@ export class KeyManager {
210
348
  private static cachedHasIdentity: boolean | null = null;
211
349
  private static cachedSharedPublicKey: string | null = null;
212
350
  private static cachedHasSharedIdentity: boolean | null = null;
351
+ /**
352
+ * Distinguishes "public key genuinely absent (a successful empty read, safe to
353
+ * cache)" from "never resolved / storage threw (must NOT be cached)". A `null`
354
+ * {@link cachedPublicKey} alone is ambiguous — this flag makes the genuine
355
+ * absence cacheable WITHOUT ever caching a null produced by a thrown read.
356
+ */
357
+ private static cachedPublicKeyResolved = false;
358
+
359
+ /** Listeners notified synchronously whenever the identity verdict may have changed. */
360
+ private static readonly identityChangeListeners = new Set<() => void>();
361
+
362
+ /**
363
+ * Memoized one-run-per-process slot migration. `slotMigrationResult` caches a
364
+ * STABLE outcome (`v2`/`legacy`); a `deferred` outcome is intentionally not
365
+ * cached (the in-flight promise is cleared) so a later call retries once the
366
+ * keychain unlocks.
367
+ */
368
+ private static slotMigrationPromise: Promise<SlotMigrationResult> | null = null;
369
+ private static slotMigrationResult: SlotMigrationResult | null = null;
213
370
 
214
371
  /**
215
372
  * Invalidate cached identity state
@@ -218,6 +375,392 @@ export class KeyManager {
218
375
  private static invalidateCache(): void {
219
376
  KeyManager.cachedPublicKey = null;
220
377
  KeyManager.cachedHasIdentity = null;
378
+ KeyManager.cachedPublicKeyResolved = false;
379
+ KeyManager.notifyIdentityChanged();
380
+ }
381
+
382
+ /**
383
+ * Subscribe to identity-verdict changes (create / import / delete / restore /
384
+ * cache invalidation). Fires synchronously; the returned function unsubscribes.
385
+ * Consumed via `useOxyEvent`-style hooks in commons to invalidate the routing
386
+ * queries the instant the identity state moves, without polling.
387
+ */
388
+ static subscribeIdentityChanged(listener: () => void): () => void {
389
+ KeyManager.identityChangeListeners.add(listener);
390
+ return () => {
391
+ KeyManager.identityChangeListeners.delete(listener);
392
+ };
393
+ }
394
+
395
+ /** Synchronous fan-out with per-listener isolation (one throwing listener never blocks the rest). */
396
+ private static notifyIdentityChanged(): void {
397
+ // Snapshot first — a listener may unsubscribe (mutate the Set) during fan-out.
398
+ for (const listener of Array.from(KeyManager.identityChangeListeners)) {
399
+ try {
400
+ listener();
401
+ } catch (error) {
402
+ logger.warn('Identity-change listener threw', { component: 'KeyManager' }, error);
403
+ }
404
+ }
405
+ }
406
+
407
+ /** Build `getItemAsync`/`deleteItemAsync` options for a given keychain service (read/delete). */
408
+ private static _slotOpts(service?: string): OxySecureStoreOptions {
409
+ return service ? { keychainService: service } : {};
410
+ }
411
+
412
+ /** Build private-key write options (device-only accessibility) for a given keychain service. */
413
+ private static _privateWriteOpts(
414
+ store: Awaited<ReturnType<typeof initSecureStore>>,
415
+ service?: string,
416
+ ): OxySecureStoreOptions {
417
+ const opts: OxySecureStoreOptions = { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
418
+ if (service) {
419
+ opts.keychainService = service;
420
+ }
421
+ return opts;
422
+ }
423
+
424
+ /** True only when both keys are present, well-formed, AND the public derives from the private. */
425
+ private static _isHealthyPair(privateKey: string | null, publicKey: string | null): boolean {
426
+ if (!privateKey || !publicKey) {
427
+ return false;
428
+ }
429
+ if (!KeyManager.isValidPrivateKey(privateKey) || !KeyManager.isValidPublicKey(publicKey)) {
430
+ return false;
431
+ }
432
+ try {
433
+ return KeyManager.derivePublicKey(privateKey).toLowerCase() === publicKey.toLowerCase();
434
+ } catch {
435
+ return false;
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Resolve the AsyncStorage-backed KV store for the advisory migration flag, or
441
+ * `null` off-RN / when unavailable. Independent of the keychain, so the flag
442
+ * cannot be taken down by the keystore event this whole subsystem defends
443
+ * against.
444
+ */
445
+ private static async _advisoryStorage(): Promise<{
446
+ getItem(key: string): Promise<string | null>;
447
+ setItem(key: string, value: string): Promise<void>;
448
+ } | null> {
449
+ if (!isReactNative()) {
450
+ return null;
451
+ }
452
+ try {
453
+ const mod = await loadAsyncStorage();
454
+ return mod.default;
455
+ } catch {
456
+ // Advisory only — absence just means the slot check runs in full.
457
+ return null;
458
+ }
459
+ }
460
+
461
+ private static async _readSlotsMigratedFlag(): Promise<boolean> {
462
+ const storage = await KeyManager._advisoryStorage();
463
+ if (!storage) {
464
+ return false;
465
+ }
466
+ try {
467
+ return (await storage.getItem(SLOTS_MIGRATED_FLAG_KEY)) === 'true';
468
+ } catch {
469
+ // Advisory only — treat an unreadable flag as "not yet migrated".
470
+ return false;
471
+ }
472
+ }
473
+
474
+ private static async _setSlotsMigratedFlag(): Promise<void> {
475
+ const storage = await KeyManager._advisoryStorage();
476
+ if (!storage) {
477
+ return;
478
+ }
479
+ try {
480
+ await storage.setItem(SLOTS_MIGRATED_FLAG_KEY, 'true');
481
+ } catch (error) {
482
+ // Advisory only — a failed write just re-runs the cheap slot check next launch.
483
+ if (isDev()) {
484
+ logger.debug('Failed to set slots-migrated flag (advisory)', { component: 'KeyManager' }, error);
485
+ }
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Ensure the identity has been migrated onto the isolated v2 slots (or that we
491
+ * know we must read legacy this session). Memoized so concurrent callers share
492
+ * ONE run; a `deferred` (read-threw) outcome is not cached so a later call
493
+ * retries after the keychain unlocks. Every identity-slot accessor awaits this
494
+ * before touching storage.
495
+ */
496
+ private static async _ensureIdentitySlotsMigrated(): Promise<SlotMigrationResult> {
497
+ if (KeyManager.slotMigrationResult && KeyManager.slotMigrationResult.mode !== 'deferred') {
498
+ return KeyManager.slotMigrationResult;
499
+ }
500
+ if (!KeyManager.slotMigrationPromise) {
501
+ const run = (async () => {
502
+ const result = await KeyManager._runSlotMigration();
503
+ KeyManager.slotMigrationResult = result;
504
+ return result;
505
+ })();
506
+ KeyManager.slotMigrationPromise = run;
507
+ // Clear the in-flight handle once settled so a deferred outcome retries.
508
+ run
509
+ .then((result) => {
510
+ if (result.mode === 'deferred') {
511
+ KeyManager.slotMigrationPromise = null;
512
+ }
513
+ })
514
+ .catch(() => {
515
+ KeyManager.slotMigrationPromise = null;
516
+ });
517
+ }
518
+ return KeyManager.slotMigrationPromise;
519
+ }
520
+
521
+ /**
522
+ * One-shot slot migration state machine. All reads are DIRECT and a thrown
523
+ * read defers everything (zero writes/deletes) so a locked keychain is never
524
+ * mistaken for an empty one. INVARIANT: at every instant ≥1 readable copy of a
525
+ * previously-existing identity remains — legacy is deleted ONLY after the v2
526
+ * copy is verified re-readable in its new (non-aliasable) location.
527
+ */
528
+ private static async _runSlotMigration(): Promise<SlotMigrationResult> {
529
+ let store: Awaited<ReturnType<typeof initSecureStore>>;
530
+ try {
531
+ store = await initSecureStore();
532
+ } catch (error) {
533
+ return { mode: 'deferred', cause: error };
534
+ }
535
+
536
+ const migratedFlag = await KeyManager._readSlotsMigratedFlag();
537
+
538
+ // Read the v2 primary (dedicated keychain service).
539
+ let v2Private: string | null;
540
+ let v2Public: string | null;
541
+ try {
542
+ v2Private = await store.getItemAsync(
543
+ V2_STORAGE_KEYS.PRIVATE_KEY,
544
+ KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
545
+ );
546
+ v2Public = await store.getItemAsync(
547
+ V2_STORAGE_KEYS.PUBLIC_KEY,
548
+ KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
549
+ );
550
+ } catch (error) {
551
+ return { mode: 'deferred', cause: error };
552
+ }
553
+
554
+ if (KeyManager._isHealthyPair(v2Private, v2Public)) {
555
+ // v2 already owns the identity. On the first observation, clean up any
556
+ // stale legacy copy and record the fast-path flag.
557
+ if (!migratedFlag) {
558
+ await KeyManager._bestEffortDeleteLegacyPrimaryAndBackup(store);
559
+ await KeyManager._setSlotsMigratedFlag();
560
+ }
561
+ return { mode: 'v2', layout: V2_SLOT_LAYOUT };
562
+ }
563
+
564
+ // v2 primary absent/partial but the flag says migration finished → v2 is
565
+ // simply empty (identity deleted / never created). No legacy to rescue.
566
+ if (migratedFlag) {
567
+ return { mode: 'v2', layout: V2_SLOT_LAYOUT };
568
+ }
569
+
570
+ // Read the legacy primary (default keychain service = the old `key_v1`).
571
+ let legacyPrivate: string | null;
572
+ let legacyPublic: string | null;
573
+ try {
574
+ legacyPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
575
+ legacyPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
576
+ } catch (error) {
577
+ return { mode: 'deferred', cause: error };
578
+ }
579
+
580
+ if (!KeyManager._isHealthyPair(legacyPrivate, legacyPublic)) {
581
+ // Nothing readable in either generation → v2 is the canonical (empty) home.
582
+ // The marker (not this migration) decides fresh-vs-lost.
583
+ return { mode: 'v2', layout: V2_SLOT_LAYOUT };
584
+ }
585
+
586
+ // legacy healthy, v2 absent → migrate: copy → read-back verify → only then delete legacy.
587
+ const canonicalPrivate = KeyManager.canonicalPrivateKey(legacyPrivate as string);
588
+ const canonicalPublic = (legacyPublic as string).toLowerCase();
589
+ try {
590
+ await store.setItemAsync(
591
+ V2_STORAGE_KEYS.PUBLIC_KEY,
592
+ canonicalPublic,
593
+ KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
594
+ );
595
+ await store.setItemAsync(
596
+ V2_STORAGE_KEYS.PRIVATE_KEY,
597
+ canonicalPrivate,
598
+ KeyManager._privateWriteOpts(store, V2_PRIMARY_KEYCHAIN_SERVICE),
599
+ );
600
+ const readBackPrivate = await store.getItemAsync(
601
+ V2_STORAGE_KEYS.PRIVATE_KEY,
602
+ KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
603
+ );
604
+ const readBackPublic = await store.getItemAsync(
605
+ V2_STORAGE_KEYS.PUBLIC_KEY,
606
+ KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
607
+ );
608
+ const verified =
609
+ readBackPrivate?.toLowerCase() === canonicalPrivate &&
610
+ readBackPublic?.toLowerCase() === canonicalPublic &&
611
+ KeyManager._isHealthyPair(readBackPrivate, readBackPublic);
612
+ if (!verified) {
613
+ // v2 write did not durably land — remove the partial v2 and serve reads
614
+ // from legacy this session (legacy is UNTOUCHED). Retry next launch.
615
+ await KeyManager._bestEffortDeleteV2Primary(store);
616
+ logger.warn(
617
+ 'Identity slot migration verify failed; serving identity from legacy slots this session',
618
+ { component: 'KeyManager' },
619
+ );
620
+ return { mode: 'legacy', layout: LEGACY_SLOT_LAYOUT };
621
+ }
622
+ } catch (error) {
623
+ await KeyManager._bestEffortDeleteV2Primary(store);
624
+ logger.warn(
625
+ 'Identity slot migration write threw; serving identity from legacy slots this session',
626
+ { component: 'KeyManager' },
627
+ error,
628
+ );
629
+ return { mode: 'legacy', layout: LEGACY_SLOT_LAYOUT };
630
+ }
631
+
632
+ // v2 primary is verified re-readable. Migrate the backup slot (best-effort),
633
+ // then it is finally safe to delete the legacy generation.
634
+ await KeyManager._migrateBackupSlotToV2(store, canonicalPrivate, canonicalPublic);
635
+ await KeyManager._bestEffortDeleteLegacyPrimaryAndBackup(store);
636
+ await KeyManager._setSlotsMigratedFlag();
637
+ return { mode: 'v2', layout: V2_SLOT_LAYOUT };
638
+ }
639
+
640
+ /**
641
+ * Seed the v2 backup slot during migration. Prefers a healthy legacy backup;
642
+ * otherwise mirrors the (already-verified) v2 primary material so a v2 backup
643
+ * always exists on an independent keychain key. Best-effort — a failure just
644
+ * defers backup population to the next {@link _persistIdentityAtomic}.
645
+ */
646
+ private static async _migrateBackupSlotToV2(
647
+ store: Awaited<ReturnType<typeof initSecureStore>>,
648
+ primaryPrivate: string,
649
+ primaryPublic: string,
650
+ ): Promise<void> {
651
+ try {
652
+ let backupPrivate: string | null = null;
653
+ let backupPublic: string | null = null;
654
+ try {
655
+ backupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
656
+ backupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
657
+ } catch (error) {
658
+ if (isDev()) {
659
+ logger.debug('Legacy backup unreadable during migration (non-fatal)', { component: 'KeyManager' }, error);
660
+ }
661
+ backupPrivate = null;
662
+ backupPublic = null;
663
+ }
664
+
665
+ let seedPrivate: string;
666
+ let seedPublic: string;
667
+ if (KeyManager._isHealthyPair(backupPrivate, backupPublic)) {
668
+ seedPrivate = KeyManager.canonicalPrivateKey(backupPrivate as string);
669
+ seedPublic = (backupPublic as string).toLowerCase();
670
+ } else {
671
+ seedPrivate = primaryPrivate;
672
+ seedPublic = primaryPublic;
673
+ }
674
+
675
+ await store.setItemAsync(
676
+ V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY,
677
+ seedPublic,
678
+ KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE),
679
+ );
680
+ await store.setItemAsync(
681
+ V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY,
682
+ seedPrivate,
683
+ KeyManager._privateWriteOpts(store, V2_BACKUP_KEYCHAIN_SERVICE),
684
+ );
685
+ await store.setItemAsync(
686
+ V2_STORAGE_KEYS.BACKUP_TIMESTAMP,
687
+ Date.now().toString(),
688
+ KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE),
689
+ );
690
+ } catch (error) {
691
+ logger.warn('Failed to migrate identity backup slot to v2 (non-fatal)', { component: 'KeyManager' }, error);
692
+ }
693
+ }
694
+
695
+ /** Best-effort single delete under an optional keychain service. Cleanup only — never surfaces. */
696
+ private static async _bestEffortDelete(
697
+ store: Awaited<ReturnType<typeof initSecureStore>>,
698
+ key: string,
699
+ service?: string,
700
+ ): Promise<void> {
701
+ try {
702
+ await store.deleteItemAsync(key, KeyManager._slotOpts(service));
703
+ } catch (error) {
704
+ if (isDev()) {
705
+ logger.debug('Best-effort identity delete failed', { component: 'KeyManager' }, error);
706
+ }
707
+ }
708
+ }
709
+
710
+ private static async _bestEffortDeleteV2Primary(
711
+ store: Awaited<ReturnType<typeof initSecureStore>>,
712
+ ): Promise<void> {
713
+ await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PRIVATE_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
714
+ await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PUBLIC_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
715
+ }
716
+
717
+ private static async _bestEffortDeleteLegacyPrimaryAndBackup(
718
+ store: Awaited<ReturnType<typeof initSecureStore>>,
719
+ ): Promise<void> {
720
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
721
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
722
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
723
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
724
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
725
+ }
726
+
727
+ private static async _bestEffortDeleteBackupsAllGenerations(
728
+ store: Awaited<ReturnType<typeof initSecureStore>>,
729
+ ): Promise<void> {
730
+ await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
731
+ await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
732
+ await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_TIMESTAMP, V2_BACKUP_KEYCHAIN_SERVICE);
733
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
734
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
735
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
736
+ }
737
+
738
+ /**
739
+ * Clear the cross-app shared identity slot (force-delete only) so a deleted
740
+ * identity cannot be resurrected via the recovery ladder's shared rung.
741
+ * Best-effort — the shared slot is a redundant convenience copy.
742
+ */
743
+ private static async _clearSharedSlot(
744
+ store: Awaited<ReturnType<typeof initSecureStore>>,
745
+ ): Promise<void> {
746
+ try {
747
+ if (isIOS()) {
748
+ const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
749
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
750
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
751
+ } else if (isAndroid()) {
752
+ const bridge = await loadSharedIdentityBridge();
753
+ if (bridge) {
754
+ await bridge.clearShared();
755
+ } else {
756
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
757
+ await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
758
+ }
759
+ }
760
+ KeyManager.invalidateSharedCache();
761
+ } catch (error) {
762
+ logger.warn('Failed to clear shared identity slot during force delete', { component: 'KeyManager' }, error);
763
+ }
221
764
  }
222
765
 
223
766
  /**
@@ -725,9 +1268,29 @@ export class KeyManager {
725
1268
  private static async _persistIdentityAtomic(
726
1269
  privateKey: string,
727
1270
  publicKey: string,
1271
+ origin: IdentityMarker['origin'],
728
1272
  ): Promise<void> {
729
1273
  const store = await initSecureStore();
730
1274
 
1275
+ // Resolve the active slot layout (normally v2; legacy only in the rare
1276
+ // migration-fallback session). Reading and writing the SAME layout keeps the
1277
+ // snapshot/rollback machinery below internally consistent. A deferred
1278
+ // migration (keychain locked) must never write blind.
1279
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1280
+ if (migration.mode === 'deferred') {
1281
+ throw new IdentityUnavailableError(
1282
+ 'Identity storage is temporarily unavailable; refusing to persist an identity.',
1283
+ migration.cause,
1284
+ );
1285
+ }
1286
+ const layout = migration.layout;
1287
+ const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
1288
+ const primaryPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.primaryService);
1289
+ const primaryPubWriteOpts = KeyManager._slotOpts(layout.primaryService);
1290
+ const backupReadOpts = KeyManager._slotOpts(layout.backupService);
1291
+ const backupPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.backupService);
1292
+ const backupPubWriteOpts = KeyManager._slotOpts(layout.backupService);
1293
+
731
1294
  // Canonicalize BEFORE persistence so the stored value is always in
732
1295
  // canonical 64-hex-char lowercase form going forward. This is the single
733
1296
  // place all primary writes flow through, so once a value lands here all
@@ -743,8 +1306,8 @@ export class KeyManager {
743
1306
  let priorPrivate: string | null;
744
1307
  let priorPublic: string | null;
745
1308
  try {
746
- priorPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
747
- priorPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
1309
+ priorPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
1310
+ priorPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
748
1311
  } catch (error) {
749
1312
  logger.error('Failed to read existing primary before persist', error, { component: 'KeyManager' });
750
1313
  throw new IdentityPersistError(
@@ -771,17 +1334,19 @@ export class KeyManager {
771
1334
  if (priorIsHealthyDifferent && priorPrivate && priorPublic) {
772
1335
  let existingBackupPublic: string | null = null;
773
1336
  try {
774
- existingBackupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
1337
+ existingBackupPublic = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
775
1338
  } catch {
776
1339
  existingBackupPublic = null;
777
1340
  }
778
1341
  if (existingBackupPublic?.toLowerCase() !== priorPublic.toLowerCase()) {
779
1342
  try {
780
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, KeyManager.canonicalPrivateKey(priorPrivate), {
781
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
782
- });
783
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorPublic.toLowerCase());
784
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
1343
+ await store.setItemAsync(
1344
+ layout.backupPrivateKeyName,
1345
+ KeyManager.canonicalPrivateKey(priorPrivate),
1346
+ backupPrivWriteOpts,
1347
+ );
1348
+ await store.setItemAsync(layout.backupPublicKeyName, priorPublic.toLowerCase(), backupPubWriteOpts);
1349
+ await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupPubWriteOpts);
785
1350
  } catch (error) {
786
1351
  logger.error('Failed to back up existing identity before overwrite', error, { component: 'KeyManager' });
787
1352
  throw new IdentityPersistError('Failed to back up existing identity before overwrite', error);
@@ -794,13 +1359,11 @@ export class KeyManager {
794
1359
  // NOT touched here — it still holds the previous good identity until the
795
1360
  // new primary is proven durable.
796
1361
  try {
797
- await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, canonicalPublic);
798
- await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, canonicalPrivate, {
799
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
800
- });
1362
+ await store.setItemAsync(layout.primaryPublicKeyName, canonicalPublic, primaryPubWriteOpts);
1363
+ await store.setItemAsync(layout.primaryPrivateKeyName, canonicalPrivate, primaryPrivWriteOpts);
801
1364
  } catch (error) {
802
1365
  logger.error('Failed to write primary identity to secure store', error, { component: 'KeyManager' });
803
- await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
1366
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
804
1367
  throw new IdentityPersistError('Failed to write identity to secure store', error);
805
1368
  }
806
1369
 
@@ -811,11 +1374,11 @@ export class KeyManager {
811
1374
  let readBackPrivate: string | null;
812
1375
  let readBackPublic: string | null;
813
1376
  try {
814
- readBackPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
815
- readBackPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
1377
+ readBackPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
1378
+ readBackPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
816
1379
  } catch (error) {
817
1380
  logger.error('Failed to read identity back after write', error, { component: 'KeyManager' });
818
- await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
1381
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
819
1382
  throw new IdentityPersistError('Failed to verify identity after write', error);
820
1383
  }
821
1384
 
@@ -827,7 +1390,7 @@ export class KeyManager {
827
1390
  readBackPublic?.toLowerCase() !== canonicalPublic
828
1391
  ) {
829
1392
  logger.error('Identity round-trip mismatch after write', undefined, { component: 'KeyManager' });
830
- await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
1393
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
831
1394
  throw new IdentityPersistError('Identity write was not persisted correctly (round-trip mismatch).');
832
1395
  }
833
1396
 
@@ -848,7 +1411,7 @@ export class KeyManager {
848
1411
  throw new IdentityPersistError('Sign/verify roundtrip failed for newly stored identity.');
849
1412
  }
850
1413
  } catch (error) {
851
- await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
1414
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
852
1415
  if (error instanceof IdentityPersistError) throw error;
853
1416
  logger.error('Identity sign/verify probe failed', error, { component: 'KeyManager' });
854
1417
  throw new IdentityPersistError('Stored identity failed crypto self-test', error);
@@ -865,31 +1428,63 @@ export class KeyManager {
865
1428
  let priorBackupPublic: string | null;
866
1429
  let priorBackupTimestamp: string | null;
867
1430
  try {
868
- priorBackupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
869
- priorBackupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
870
- priorBackupTimestamp = await store.getItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
1431
+ priorBackupPrivate = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
1432
+ priorBackupPublic = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
1433
+ priorBackupTimestamp = await store.getItemAsync(layout.backupTimestampName, backupReadOpts);
871
1434
  } catch (error) {
872
1435
  logger.error('Failed to snapshot identity backup before refresh', error, { component: 'KeyManager' });
873
- await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
1436
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
874
1437
  throw new IdentityPersistError('Failed to snapshot identity backup before refresh', error);
875
1438
  }
876
1439
 
877
1440
  try {
878
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
879
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
880
- });
881
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
882
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
1441
+ await store.setItemAsync(layout.backupPrivateKeyName, canonicalPrivate, backupPrivWriteOpts);
1442
+ await store.setItemAsync(layout.backupPublicKeyName, canonicalPublic, backupPubWriteOpts);
1443
+ await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupPubWriteOpts);
883
1444
  } catch (error) {
884
1445
  logger.error('Failed to refresh identity backup after primary write', error, { component: 'KeyManager' });
885
- await KeyManager._rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
886
- await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
1446
+ await KeyManager._rollbackBackup(store, layout, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
1447
+ await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
887
1448
  throw new IdentityPersistError('Failed to refresh identity backup after primary write', error);
888
1449
  }
889
1450
 
890
- // Update cache only after we are certain the identity is durable.
1451
+ // Update cache only after we are certain the identity is durable, then fan
1452
+ // out to identity-change subscribers.
891
1453
  KeyManager.cachedPublicKey = canonicalPublic;
892
1454
  KeyManager.cachedHasIdentity = true;
1455
+ KeyManager.cachedPublicKeyResolved = false;
1456
+ KeyManager.notifyIdentityChanged();
1457
+
1458
+ // LAST step: mirror the identity into the AndroidKeyStore-independent marker
1459
+ // so a later keystore death can be told apart from a fresh install. This is
1460
+ // best-effort — a marker write failure must NEVER fail an otherwise-durable
1461
+ // persist (a subsequent healthy read re-backfills it). Rollback paths above
1462
+ // return before reaching here, so they never touch the marker.
1463
+ await KeyManager._syncMarkerAfterPersist(canonicalPublic, origin);
1464
+ }
1465
+
1466
+ /**
1467
+ * Write/refresh the identity marker after a successful persist. A same-identity
1468
+ * re-persist (e.g. backup refresh, idempotent re-import) preserves `createdAt`
1469
+ * and the `onboardingComplete` milestone by only updating `origin`; a NEW or
1470
+ * switched identity writes a fresh marker. Best-effort — never throws.
1471
+ *
1472
+ * @internal
1473
+ */
1474
+ private static async _syncMarkerAfterPersist(
1475
+ publicKey: string,
1476
+ origin: IdentityMarker['origin'],
1477
+ ): Promise<void> {
1478
+ try {
1479
+ const existing = await readIdentityMarker();
1480
+ if (existing && existing.publicKey.toLowerCase() === publicKey.toLowerCase()) {
1481
+ await updateIdentityMarker({ origin });
1482
+ } else {
1483
+ await writeIdentityMarker({ publicKey, origin });
1484
+ }
1485
+ } catch (error) {
1486
+ logger.warn('Failed to sync identity marker after persist (non-fatal)', { component: 'KeyManager' }, error);
1487
+ }
893
1488
  }
894
1489
 
895
1490
  /**
@@ -900,29 +1495,31 @@ export class KeyManager {
900
1495
  */
901
1496
  private static async _rollbackBackup(
902
1497
  store: Awaited<ReturnType<typeof initSecureStore>>,
1498
+ layout: ResolvedSlotLayout,
903
1499
  priorBackupPrivate: string | null,
904
1500
  priorBackupPublic: string | null,
905
1501
  priorBackupTimestamp: string | null,
906
1502
  ): Promise<void> {
1503
+ const backupPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.backupService);
1504
+ const backupPubWriteOpts = KeyManager._slotOpts(layout.backupService);
1505
+ const backupReadOpts = KeyManager._slotOpts(layout.backupService);
907
1506
  try {
908
1507
  if (priorBackupPrivate) {
909
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, priorBackupPrivate, {
910
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
911
- });
1508
+ await store.setItemAsync(layout.backupPrivateKeyName, priorBackupPrivate, backupPrivWriteOpts);
912
1509
  } else {
913
- try { await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY); } catch { /* best effort */ }
1510
+ try { await store.deleteItemAsync(layout.backupPrivateKeyName, backupReadOpts); } catch { /* best effort */ }
914
1511
  }
915
1512
 
916
1513
  if (priorBackupPublic) {
917
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorBackupPublic);
1514
+ await store.setItemAsync(layout.backupPublicKeyName, priorBackupPublic, backupPubWriteOpts);
918
1515
  } else {
919
- try { await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY); } catch { /* best effort */ }
1516
+ try { await store.deleteItemAsync(layout.backupPublicKeyName, backupReadOpts); } catch { /* best effort */ }
920
1517
  }
921
1518
 
922
1519
  if (priorBackupTimestamp) {
923
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, priorBackupTimestamp);
1520
+ await store.setItemAsync(layout.backupTimestampName, priorBackupTimestamp, backupPubWriteOpts);
924
1521
  } else {
925
- try { await store.deleteItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP); } catch { /* best effort */ }
1522
+ try { await store.deleteItemAsync(layout.backupTimestampName, backupReadOpts); } catch { /* best effort */ }
926
1523
  }
927
1524
  } catch (rollbackError) {
928
1525
  logger.error('Failed to roll back identity backup after a failed refresh', rollbackError, { component: 'KeyManager' });
@@ -940,21 +1537,23 @@ export class KeyManager {
940
1537
  */
941
1538
  private static async _rollbackPrimary(
942
1539
  store: Awaited<ReturnType<typeof initSecureStore>>,
1540
+ layout: ResolvedSlotLayout,
943
1541
  priorPrivate: string | null,
944
1542
  priorPublic: string | null,
945
1543
  ): Promise<void> {
1544
+ const primaryPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.primaryService);
1545
+ const primaryPubWriteOpts = KeyManager._slotOpts(layout.primaryService);
1546
+ const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
946
1547
  try {
947
1548
  if (priorPrivate && priorPublic) {
948
1549
  // Restore exactly what was there before the failed write.
949
- await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, priorPublic, {});
950
- await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, priorPrivate, {
951
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
952
- });
1550
+ await store.setItemAsync(layout.primaryPublicKeyName, priorPublic, primaryPubWriteOpts);
1551
+ await store.setItemAsync(layout.primaryPrivateKeyName, priorPrivate, primaryPrivWriteOpts);
953
1552
  } else {
954
1553
  // There was no prior identity — leave the device empty rather than
955
1554
  // half-written so hasIdentity() does not lie.
956
- try { await store.deleteItemAsync(STORAGE_KEYS.PUBLIC_KEY); } catch { /* best effort */ }
957
- try { await store.deleteItemAsync(STORAGE_KEYS.PRIVATE_KEY); } catch { /* best effort */ }
1555
+ try { await store.deleteItemAsync(layout.primaryPublicKeyName, primaryReadOpts); } catch { /* best effort */ }
1556
+ try { await store.deleteItemAsync(layout.primaryPrivateKeyName, primaryReadOpts); } catch { /* best effort */ }
958
1557
  }
959
1558
  } catch (rollbackError) {
960
1559
  logger.error('Failed to roll back primary identity after a failed write', rollbackError, { component: 'KeyManager' });
@@ -982,18 +1581,55 @@ export class KeyManager {
982
1581
  // The local key IS the account — clobbering it without consent is
983
1582
  // catastrophic. Callers must opt in explicitly when they have already
984
1583
  // confirmed (via UI) that the user has saved their recovery phrase.
1584
+ //
1585
+ // The guard reads storage DIRECTLY (cache-bypassing) AND consults the
1586
+ // AndroidKeyStore-independent marker: either a stored key OR a marker means
1587
+ // an identity exists here → refuse. A storage THROW surfaces as
1588
+ // IdentityUnavailableError (never a blind write over a locked keystore).
985
1589
  if (!options?.overwrite) {
986
- const existing = await KeyManager.getPublicKey();
987
- if (existing) {
988
- throw new IdentityAlreadyExistsError(existing);
1590
+ const marker = await readIdentityMarker();
1591
+ const direct = await KeyManager._readPrimaryDirect();
1592
+ if (direct.publicKey) {
1593
+ throw new IdentityAlreadyExistsError(direct.publicKey);
1594
+ }
1595
+ if (marker) {
1596
+ throw new IdentityAlreadyExistsError(marker.publicKey);
989
1597
  }
990
1598
  }
991
1599
 
992
1600
  const { privateKey, publicKey } = await KeyManager.generateKeyPair();
993
- await KeyManager._persistIdentityAtomic(privateKey, publicKey);
1601
+ await KeyManager._persistIdentityAtomic(privateKey, publicKey, 'create');
994
1602
  return publicKey;
995
1603
  }
996
1604
 
1605
+ /**
1606
+ * Read the primary key pair DIRECTLY from storage, bypassing the in-memory
1607
+ * cache (which a prior transient failure could have poisoned). Awaits slot
1608
+ * migration first. Throws {@link IdentityUnavailableError} if storage is
1609
+ * deferred/locked or a read throws — so overwrite guards never write blind.
1610
+ *
1611
+ * @internal
1612
+ */
1613
+ private static async _readPrimaryDirect(): Promise<{ privateKey: string | null; publicKey: string | null }> {
1614
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1615
+ if (migration.mode === 'deferred') {
1616
+ throw new IdentityUnavailableError(
1617
+ 'Identity storage is temporarily unavailable; refusing to write blind.',
1618
+ migration.cause,
1619
+ );
1620
+ }
1621
+ const layout = migration.layout;
1622
+ const readOpts = KeyManager._slotOpts(layout.primaryService);
1623
+ try {
1624
+ const store = await initSecureStore();
1625
+ const privateKey = await store.getItemAsync(layout.primaryPrivateKeyName, readOpts);
1626
+ const publicKey = await store.getItemAsync(layout.primaryPublicKeyName, readOpts);
1627
+ return { privateKey, publicKey };
1628
+ } catch (error) {
1629
+ throw new IdentityUnavailableError('Could not read existing identity; refusing to write blind.', error);
1630
+ }
1631
+ }
1632
+
997
1633
  /**
998
1634
  * Import an existing key pair (e.g., from recovery phrase).
999
1635
  *
@@ -1022,32 +1658,59 @@ export class KeyManager {
1022
1658
  const keyPair = ec.keyFromPrivate(canonicalPrivate);
1023
1659
  const publicKey = keyPair.getPublic('hex');
1024
1660
 
1025
- // Refuse silent overwrite — see createIdentity() for rationale.
1661
+ // Refuse silent overwrite — see createIdentity() for rationale. The guard
1662
+ // reads storage DIRECTLY (cache-bypassing) AND the marker, and treats
1663
+ // storage as authoritative:
1664
+ // - stored key === this import → safe idempotent refresh (fall through)
1665
+ // - stored key differs → a DIFFERENT identity is present → refuse
1666
+ // - storage empty + marker for a DIFFERENT identity (lost state) → refuse
1667
+ // - storage empty + marker matches this import (recovery) / no marker → allow
1668
+ // A storage throw surfaces as IdentityUnavailableError (never a blind write).
1026
1669
  if (!options?.overwrite) {
1027
- const existing = await KeyManager.getPublicKey();
1028
- if (existing && existing.toLowerCase() !== publicKey.toLowerCase()) {
1029
- throw new IdentityAlreadyExistsError(existing);
1670
+ const marker = await readIdentityMarker();
1671
+ const direct = await KeyManager._readPrimaryDirect();
1672
+ const importedPub = publicKey.toLowerCase();
1673
+ const existingPub = direct.publicKey?.toLowerCase() ?? null;
1674
+ const markerPub = marker?.publicKey.toLowerCase() ?? null;
1675
+
1676
+ if (existingPub && existingPub !== importedPub) {
1677
+ throw new IdentityAlreadyExistsError(direct.publicKey as string);
1030
1678
  }
1031
- // If existing === publicKey, the device already has this exact identity;
1032
- // re-persisting is a no-op but harmless. Fall through to ensure backup
1033
- // is up to date.
1679
+ if (!existingPub && markerPub && markerPub !== importedPub) {
1680
+ throw new IdentityAlreadyExistsError(marker?.publicKey as string);
1681
+ }
1682
+ // Otherwise: existing === import (idempotent refresh), or storage empty
1683
+ // with a matching/absent marker (fresh import or lost-identity recovery)
1684
+ // → fall through and (re-)persist to refresh the backup + marker.
1034
1685
  }
1035
1686
 
1036
- await KeyManager._persistIdentityAtomic(canonicalPrivate, publicKey);
1687
+ await KeyManager._persistIdentityAtomic(canonicalPrivate, publicKey, 'import');
1037
1688
  return publicKey;
1038
1689
  }
1039
1690
 
1040
1691
  /**
1041
1692
  * Get the stored private key
1042
1693
  * WARNING: Only use this for signing operations within the app
1694
+ *
1695
+ * Preserves the "return null on any storage failure" contract signing paths
1696
+ * rely on (a locked keychain simply means "cannot sign now"); unlike
1697
+ * {@link getPublicKey}, it does NOT throw {@link IdentityUnavailableError}.
1043
1698
  */
1044
1699
  static async getPrivateKey(): Promise<string | null> {
1045
1700
  if (isWebPlatform()) {
1046
1701
  return null; // Identity storage is only available on native platforms
1047
1702
  }
1048
1703
  try {
1704
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1705
+ if (migration.mode === 'deferred') {
1706
+ // Storage unreadable right now — preserve the null contract.
1707
+ return null;
1708
+ }
1049
1709
  const store = await initSecureStore();
1050
- return await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
1710
+ return await store.getItemAsync(
1711
+ migration.layout.primaryPrivateKeyName,
1712
+ KeyManager._slotOpts(migration.layout.primaryService),
1713
+ );
1051
1714
  } catch (error) {
1052
1715
  // If secure store is not available, return null (no identity)
1053
1716
  // This allows the app to continue functioning even if secure store fails to load
@@ -1059,7 +1722,12 @@ export class KeyManager {
1059
1722
  }
1060
1723
 
1061
1724
  /**
1062
- * Get the stored public key (cached for performance)
1725
+ * Get the stored public key (cached for performance).
1726
+ *
1727
+ * Returns the public key, or `null` when a read SUCCEEDS and finds none.
1728
+ * THROWS {@link IdentityUnavailableError} when storage is unreadable (keychain
1729
+ * locked / module load failure) — a thrown read is NEVER flattened to `null`
1730
+ * and NEVER cached, so a poisoned "no identity" verdict can no longer stick.
1063
1731
  */
1064
1732
  static async getPublicKey(): Promise<string | null> {
1065
1733
  if (isWebPlatform()) {
@@ -1068,23 +1736,40 @@ export class KeyManager {
1068
1736
  if (KeyManager.cachedPublicKey !== null) {
1069
1737
  return KeyManager.cachedPublicKey;
1070
1738
  }
1739
+ // A genuine-absent result (read succeeded, empty) is cacheable distinctly
1740
+ // from a thrown read — only the former sets this flag.
1741
+ if (KeyManager.cachedPublicKeyResolved) {
1742
+ return null;
1743
+ }
1744
+
1745
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1746
+ if (migration.mode === 'deferred') {
1747
+ throw new IdentityUnavailableError(
1748
+ 'Identity storage is temporarily unavailable (keychain locked or unreadable).',
1749
+ migration.cause,
1750
+ );
1751
+ }
1071
1752
 
1072
1753
  try {
1073
1754
  const store = await initSecureStore();
1074
- const publicKey = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
1075
-
1076
- // Cache result (null is a valid cache value meaning no identity)
1077
- KeyManager.cachedPublicKey = publicKey;
1078
-
1755
+ const publicKey = await store.getItemAsync(
1756
+ migration.layout.primaryPublicKeyName,
1757
+ KeyManager._slotOpts(migration.layout.primaryService),
1758
+ );
1759
+ if (publicKey !== null) {
1760
+ KeyManager.cachedPublicKey = publicKey;
1761
+ } else {
1762
+ // Genuine-absent (successful empty read) IS safe to cache.
1763
+ KeyManager.cachedPublicKeyResolved = true;
1764
+ }
1079
1765
  return publicKey;
1080
1766
  } catch (error) {
1081
- // If secure store is not available, return null (no identity)
1082
- // Cache null to avoid repeated failed attempts
1083
- KeyManager.cachedPublicKey = null;
1767
+ // Storage threw AFTER migration resolved transient/unavailable. Do NOT
1768
+ // cache; surface a typed error so callers never misread it as "no identity".
1084
1769
  if (isDev()) {
1085
1770
  logger.warn('Failed to access secure store', { component: 'KeyManager' }, error);
1086
1771
  }
1087
- return null;
1772
+ throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
1088
1773
  }
1089
1774
  }
1090
1775
 
@@ -1093,8 +1778,11 @@ export class KeyManager {
1093
1778
  *
1094
1779
  * Returns `true` only when BOTH the private and public keys are present,
1095
1780
  * both are well-formed, AND the public key derives from the private key.
1096
- * A partially-written or corrupted identity returns `false` so that
1097
- * downstream code can resume the create / restore flow correctly.
1781
+ * A partially-written or corrupted identity (read succeeded, bytes empty/bad)
1782
+ * returns `false` so that downstream code can resume the create / restore flow.
1783
+ * THROWS {@link IdentityUnavailableError} when storage is unreadable — a locked
1784
+ * keychain must never be mistaken for "no identity" (the old behavior that let
1785
+ * onboarding treat a transient lock as a blank device).
1098
1786
  *
1099
1787
  * Note: this does NOT perform the full sign/verify roundtrip — call
1100
1788
  * `verifyIdentityIntegrity()` for that.
@@ -1107,22 +1795,26 @@ export class KeyManager {
1107
1795
  return KeyManager.cachedHasIdentity;
1108
1796
  }
1109
1797
 
1798
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1799
+ if (migration.mode === 'deferred') {
1800
+ throw new IdentityUnavailableError('Identity storage is temporarily unavailable.', migration.cause);
1801
+ }
1802
+
1110
1803
  let privateKey: string | null;
1111
1804
  let publicKey: string | null;
1112
1805
  try {
1113
1806
  const store = await initSecureStore();
1114
1807
  [privateKey, publicKey] = await Promise.all([
1115
- store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY),
1116
- store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY),
1808
+ store.getItemAsync(migration.layout.primaryPrivateKeyName, KeyManager._slotOpts(migration.layout.primaryService)),
1809
+ store.getItemAsync(migration.layout.primaryPublicKeyName, KeyManager._slotOpts(migration.layout.primaryService)),
1117
1810
  ]);
1118
1811
  } catch (error) {
1119
1812
  // Storage threw — could be a transient keychain lock (e.g., background
1120
- // fetch before the device is unlocked). Do NOT cache `false`: if we
1121
- // did, the next call would skip storage entirely and return false even
1122
- // after the device is unlocked. Just return false and let the next
1123
- // call retry from storage.
1813
+ // fetch before the device is unlocked). Do NOT cache; throw a TYPED error
1814
+ // so callers distinguish "temporarily unavailable" from "genuinely absent"
1815
+ // instead of silently treating a locked keystore as a blank device.
1124
1816
  logger.error('Failed to read identity from secure storage', error, { component: 'KeyManager' });
1125
- return false;
1817
+ throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
1126
1818
  }
1127
1819
 
1128
1820
  // Storage succeeded. Now classify the result. From here onward, any
@@ -1191,6 +1883,79 @@ export class KeyManager {
1191
1883
  return hasIdentity;
1192
1884
  }
1193
1885
 
1886
+ /**
1887
+ * Authoritative identity verdict — the corruption-vs-fresh-install
1888
+ * disambiguator that routing (commons) keys off of.
1889
+ *
1890
+ * - Healthy pair → `present` (and the marker is backfilled if missing or
1891
+ * pointing at a different key, `origin: 'backfill'`).
1892
+ * - Read succeeded but no healthy pair, WITH a marker → `lost` (keystore death
1893
+ * / corruption; route to recovery, NEVER to create).
1894
+ * - Read succeeded, no pair, NO marker → `absent` (a genuine fresh device; the
1895
+ * only state that may route to onboarding/create).
1896
+ * - A read THREW → `unavailable` (keychain locked); this verdict is NEVER
1897
+ * cached, so a later call re-reads.
1898
+ *
1899
+ * @param opts.bypassCache When true, never reads OR writes the in-memory cache
1900
+ * — a pure, fresh storage verdict for the auto-create interlock preflight.
1901
+ */
1902
+ static async getIdentityStatus(opts?: { bypassCache?: boolean }): Promise<IdentityStatus> {
1903
+ if (isWebPlatform()) {
1904
+ return { state: 'absent' }; // Identity storage is only available on native platforms
1905
+ }
1906
+ const bypassCache = opts?.bypassCache === true;
1907
+
1908
+ // Read the marker FIRST (fail-open null) — it is the AndroidKeyStore-independent
1909
+ // signal that survives a keystore death.
1910
+ const marker = await readIdentityMarker();
1911
+
1912
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
1913
+ if (migration.mode === 'deferred') {
1914
+ return { state: 'unavailable', cause: migration.cause };
1915
+ }
1916
+
1917
+ let privateKey: string | null;
1918
+ let publicKey: string | null;
1919
+ try {
1920
+ const store = await initSecureStore();
1921
+ const readOpts = KeyManager._slotOpts(migration.layout.primaryService);
1922
+ privateKey = await store.getItemAsync(migration.layout.primaryPrivateKeyName, readOpts);
1923
+ publicKey = await store.getItemAsync(migration.layout.primaryPublicKeyName, readOpts);
1924
+ } catch (error) {
1925
+ // Storage threw — NEVER cache this verdict; callers retry.
1926
+ return { state: 'unavailable', cause: error };
1927
+ }
1928
+
1929
+ if (KeyManager._isHealthyPair(privateKey, publicKey) && publicKey) {
1930
+ const canonicalPublic = publicKey.toLowerCase();
1931
+ // Backfill the marker when missing or pointing at a DIFFERENT identity —
1932
+ // e.g. a loss that predates markers, healed on first healthy read.
1933
+ if (!marker || marker.publicKey.toLowerCase() !== canonicalPublic) {
1934
+ try {
1935
+ await writeIdentityMarker({ publicKey: canonicalPublic, origin: 'backfill' });
1936
+ } catch (error) {
1937
+ logger.warn('Failed to backfill identity marker', { component: 'KeyManager' }, error);
1938
+ }
1939
+ }
1940
+ if (!bypassCache) {
1941
+ KeyManager.cachedPublicKey = canonicalPublic;
1942
+ KeyManager.cachedHasIdentity = true;
1943
+ KeyManager.cachedPublicKeyResolved = false;
1944
+ }
1945
+ return { state: 'present', publicKey: canonicalPublic };
1946
+ }
1947
+
1948
+ // Read succeeded but no healthy pair present.
1949
+ if (!bypassCache) {
1950
+ KeyManager.cachedHasIdentity = false;
1951
+ KeyManager.cachedPublicKeyResolved = true;
1952
+ }
1953
+ if (marker) {
1954
+ return { state: 'lost', marker };
1955
+ }
1956
+ return { state: 'absent' };
1957
+ }
1958
+
1194
1959
  /**
1195
1960
  * Delete the stored identity (both keys)
1196
1961
  * Use with EXTREME caution - this is irreversible without a recovery phrase
@@ -1213,6 +1978,8 @@ export class KeyManager {
1213
1978
  }
1214
1979
 
1215
1980
  if (!force) {
1981
+ // May throw IdentityUnavailableError if storage is locked — correct: a
1982
+ // non-force delete must abort rather than run against an unreadable store.
1216
1983
  const hasIdentity = await KeyManager.hasIdentity();
1217
1984
  if (!hasIdentity) {
1218
1985
  return; // Nothing to delete
@@ -1220,7 +1987,7 @@ export class KeyManager {
1220
1987
  }
1221
1988
 
1222
1989
  const store = await initSecureStore();
1223
-
1990
+
1224
1991
  // ALWAYS create backup before deletion unless explicitly skipped
1225
1992
  if (!skipBackup) {
1226
1993
  try {
@@ -1235,22 +2002,40 @@ export class KeyManager {
1235
2002
  }
1236
2003
  }
1237
2004
 
1238
- await store.deleteItemAsync(STORAGE_KEYS.PRIVATE_KEY);
1239
- await store.deleteItemAsync(STORAGE_KEYS.PUBLIC_KEY);
1240
-
1241
- // Invalidate cache
1242
- KeyManager.invalidateCache();
1243
-
1244
- // Also clear backup if force deletion
2005
+ // Delete the primary from the active layout (authoritative), then best-effort
2006
+ // delete BOTH generations so a stale legacy copy can never resurrect the
2007
+ // identity after deletion.
2008
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
2009
+ if (migration.mode !== 'deferred') {
2010
+ const layout = migration.layout;
2011
+ const readOpts = KeyManager._slotOpts(layout.primaryService);
2012
+ await store.deleteItemAsync(layout.primaryPrivateKeyName, readOpts);
2013
+ await store.deleteItemAsync(layout.primaryPublicKeyName, readOpts);
2014
+ }
2015
+ await KeyManager._bestEffortDeleteV2Primary(store);
2016
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
2017
+ await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
2018
+
2019
+ // Also clear backups + the shared slot on force deletion, so a deleted
2020
+ // identity cannot be resurrected from any recovery source.
1245
2021
  if (force) {
1246
- try {
1247
- await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
1248
- await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
1249
- await store.deleteItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
1250
- } catch (error) {
1251
- // Ignore backup deletion errors
1252
- }
2022
+ await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
2023
+ await KeyManager._clearSharedSlot(store);
2024
+ }
2025
+
2026
+ // Clear the marker AFTER key deletion succeeds — a marker must never outlive
2027
+ // its identity (a leftover marker would route a truly-absent device to
2028
+ // `recovery` instead of `welcome`).
2029
+ try {
2030
+ await clearIdentityMarker();
2031
+ } catch (error) {
2032
+ logger.warn('Failed to clear identity marker during delete', { component: 'KeyManager' }, error);
1253
2033
  }
2034
+
2035
+ // Invalidate cache LAST — its subscriber fan-out fires only after both the
2036
+ // keys AND the marker are gone, so a routing subscriber that re-reads on the
2037
+ // notification observes `absent`, never a transient `lost`.
2038
+ KeyManager.invalidateCache();
1254
2039
  }
1255
2040
 
1256
2041
  /**
@@ -1263,19 +2048,29 @@ export class KeyManager {
1263
2048
  }
1264
2049
  try {
1265
2050
  const store = await initSecureStore();
1266
- const privateKey = await KeyManager.getPrivateKey();
1267
- const publicKey = await KeyManager.getPublicKey();
2051
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
2052
+ if (migration.mode === 'deferred') {
2053
+ return false; // Cannot read the primary safely → nothing to back up
2054
+ }
2055
+ const layout = migration.layout;
2056
+ // Read the primary DIRECTLY (raw) rather than via getPublicKey (which now
2057
+ // throws) — a locked keychain here should simply mean "nothing to back up".
2058
+ const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
2059
+ const privateKey = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
2060
+ const publicKey = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
1268
2061
 
1269
2062
  if (!privateKey || !publicKey) {
1270
2063
  return false; // Nothing to backup
1271
2064
  }
1272
2065
 
1273
2066
  // Store backup in SecureStore (still secure, but separate from primary storage)
1274
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, privateKey, {
1275
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
1276
- });
1277
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, publicKey);
1278
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
2067
+ await store.setItemAsync(
2068
+ layout.backupPrivateKeyName,
2069
+ privateKey,
2070
+ KeyManager._privateWriteOpts(store, layout.backupService),
2071
+ );
2072
+ await store.setItemAsync(layout.backupPublicKeyName, publicKey, KeyManager._slotOpts(layout.backupService));
2073
+ await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), KeyManager._slotOpts(layout.backupService));
1279
2074
 
1280
2075
  return true;
1281
2076
  } catch (error) {
@@ -1365,6 +2160,18 @@ export class KeyManager {
1365
2160
  }
1366
2161
  try {
1367
2162
  const store = await initSecureStore();
2163
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
2164
+ if (migration.mode === 'deferred') {
2165
+ // Storage locked — refuse to restore (guard 2). Retry a later call.
2166
+ logger.warn(
2167
+ 'restoreIdentityFromBackup: identity storage unavailable. Refusing to restore.',
2168
+ { component: 'KeyManager' },
2169
+ );
2170
+ return false;
2171
+ }
2172
+ const layout = migration.layout;
2173
+ const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
2174
+ const backupReadOpts = KeyManager._slotOpts(layout.backupService);
1368
2175
 
1369
2176
  // Read the primary DIRECTLY (not via the error-swallowing getters) so
1370
2177
  // we can distinguish a transient read failure from a genuinely absent
@@ -1374,8 +2181,8 @@ export class KeyManager {
1374
2181
  let primaryPrivate: string | null;
1375
2182
  let primaryPublic: string | null;
1376
2183
  try {
1377
- primaryPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
1378
- primaryPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
2184
+ primaryPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
2185
+ primaryPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
1379
2186
  } catch (error) {
1380
2187
  logger.warn(
1381
2188
  'restoreIdentityFromBackup: could not read primary (transient?). Refusing to restore.',
@@ -1398,8 +2205,8 @@ export class KeyManager {
1398
2205
  }
1399
2206
 
1400
2207
  // Load + validate the backup.
1401
- const backupPrivateKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
1402
- const backupPublicKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
2208
+ const backupPrivateKey = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
2209
+ const backupPublicKey = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
1403
2210
 
1404
2211
  if (!backupPrivateKey || !backupPublicKey) {
1405
2212
  return false; // No backup available
@@ -1452,13 +2259,13 @@ export class KeyManager {
1452
2259
  // Safe to restore: rebuild the primary using the same atomic write
1453
2260
  // path createIdentity uses, including verification.
1454
2261
  try {
1455
- await KeyManager._persistIdentityAtomic(backupPrivateKey, backupPublicKey);
2262
+ await KeyManager._persistIdentityAtomic(backupPrivateKey, backupPublicKey, 'restore');
1456
2263
  } catch (error) {
1457
2264
  logger.error('Failed to persist identity restored from backup', error, { component: 'KeyManager' });
1458
2265
  return false;
1459
2266
  }
1460
2267
 
1461
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
2268
+ await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupReadOpts);
1462
2269
  return true;
1463
2270
  } catch (error) {
1464
2271
  logger.error('Failed to restore identity from backup', error, { component: 'KeyManager' });
@@ -1466,6 +2273,120 @@ export class KeyManager {
1466
2273
  }
1467
2274
  }
1468
2275
 
2276
+ /**
2277
+ * Recovery ladder — restore a `lost` identity from an independent,
2278
+ * `key_v1`-surviving source WITHOUT the user re-entering their recovery phrase.
2279
+ *
2280
+ * Gated on {@link getIdentityStatus} being `lost` (marker present, keys empty):
2281
+ * - `present` / `absent` → `not-lost` (nothing to recover / nothing lost)
2282
+ * - `unavailable` → `unavailable` (keychain locked; retry later)
2283
+ *
2284
+ * Rungs, tried in order, each fully validated (well-formed + derive-match +
2285
+ * `publicKey === marker.publicKey`, so a source holding a DIFFERENT account is
2286
+ * SKIPPED, never restored):
2287
+ * 1. the v2 backup slot (independent keychain key from the primary), then
2288
+ * 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
2289
+ * group) — the copy that survives a primary+backup `key_v1` death.
2290
+ *
2291
+ * On success it re-persists via {@link _persistIdentityAtomic} (origin
2292
+ * `'restore'`) and invalidates the cache so routing re-reads `present`. When no
2293
+ * rung matches, the UI proceeds to recovery-phrase entry.
2294
+ */
2295
+ static async attemptIdentityRecovery(): Promise<IdentityRecoveryResult> {
2296
+ if (isWebPlatform()) {
2297
+ return { recovered: false, reason: 'not-lost' };
2298
+ }
2299
+
2300
+ const status = await KeyManager.getIdentityStatus({ bypassCache: true });
2301
+ if (status.state === 'present' || status.state === 'absent') {
2302
+ return { recovered: false, reason: 'not-lost' };
2303
+ }
2304
+ if (status.state === 'unavailable') {
2305
+ return { recovered: false, reason: 'unavailable' };
2306
+ }
2307
+
2308
+ // status.state === 'lost'
2309
+ const expectedPublic = status.marker.publicKey.toLowerCase();
2310
+ let sawMismatch = false;
2311
+
2312
+ // Rung 1: backup slot.
2313
+ const backupCandidate = await KeyManager._readBackupCandidate();
2314
+ if (backupCandidate) {
2315
+ if (backupCandidate.publicKey.toLowerCase() === expectedPublic) {
2316
+ if (await KeyManager._commitRecovery(backupCandidate.privateKey, backupCandidate.publicKey)) {
2317
+ return { recovered: true, source: 'backup', publicKey: backupCandidate.publicKey };
2318
+ }
2319
+ } else {
2320
+ sawMismatch = true;
2321
+ }
2322
+ }
2323
+
2324
+ // Rung 2: cross-app shared slot.
2325
+ const sharedCandidate = await KeyManager._readSharedCandidate();
2326
+ if (sharedCandidate) {
2327
+ if (sharedCandidate.publicKey.toLowerCase() === expectedPublic) {
2328
+ if (await KeyManager._commitRecovery(sharedCandidate.privateKey, sharedCandidate.publicKey)) {
2329
+ return { recovered: true, source: 'shared', publicKey: sharedCandidate.publicKey };
2330
+ }
2331
+ } else {
2332
+ sawMismatch = true;
2333
+ }
2334
+ }
2335
+
2336
+ // A source existed but identified a DIFFERENT account — never silently
2337
+ // switched. Report `mismatch` so the UI can require explicit confirmation.
2338
+ return { recovered: false, reason: sawMismatch ? 'mismatch' : 'no-sources' };
2339
+ }
2340
+
2341
+ /** Read the active-layout backup slot as a healthy candidate, or null. @internal */
2342
+ private static async _readBackupCandidate(): Promise<{ privateKey: string; publicKey: string } | null> {
2343
+ try {
2344
+ const migration = await KeyManager._ensureIdentitySlotsMigrated();
2345
+ if (migration.mode === 'deferred') {
2346
+ return null;
2347
+ }
2348
+ const layout = migration.layout;
2349
+ const backupReadOpts = KeyManager._slotOpts(layout.backupService);
2350
+ const store = await initSecureStore();
2351
+ const privateKey = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
2352
+ const publicKey = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
2353
+ if (KeyManager._isHealthyPair(privateKey, publicKey) && privateKey && publicKey) {
2354
+ return { privateKey, publicKey };
2355
+ }
2356
+ return null;
2357
+ } catch (error) {
2358
+ logger.warn('Recovery: failed to read backup slot', { component: 'KeyManager' }, error);
2359
+ return null;
2360
+ }
2361
+ }
2362
+
2363
+ /** Read the cross-app shared slot as a healthy candidate, or null. @internal */
2364
+ private static async _readSharedCandidate(): Promise<{ privateKey: string; publicKey: string } | null> {
2365
+ try {
2366
+ const privateKey = await KeyManager.getSharedPrivateKey();
2367
+ const publicKey = await KeyManager.getSharedPublicKey();
2368
+ if (KeyManager._isHealthyPair(privateKey, publicKey) && privateKey && publicKey) {
2369
+ return { privateKey, publicKey };
2370
+ }
2371
+ return null;
2372
+ } catch (error) {
2373
+ logger.warn('Recovery: failed to read shared slot', { component: 'KeyManager' }, error);
2374
+ return null;
2375
+ }
2376
+ }
2377
+
2378
+ /** Persist a validated recovery candidate + refresh caches/subscribers. @internal */
2379
+ private static async _commitRecovery(privateKey: string, publicKey: string): Promise<boolean> {
2380
+ try {
2381
+ await KeyManager._persistIdentityAtomic(privateKey, publicKey, 'restore');
2382
+ KeyManager.invalidateCache();
2383
+ return true;
2384
+ } catch (error) {
2385
+ logger.error('Recovery: failed to persist recovered identity', error, { component: 'KeyManager' });
2386
+ return false;
2387
+ }
2388
+ }
2389
+
1469
2390
  /**
1470
2391
  * Get the elliptic curve key object from the stored private key
1471
2392
  * Used internally for signing operations