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