@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +16 -3
- package/dist/cjs/crypto/identityMarker.js +255 -0
- package/dist/cjs/crypto/keyManager.js +933 -106
- package/dist/cjs/index.js +11 -4
- package/dist/cjs/mixins/OxyServices.auth.js +25 -7
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/cjs/mixins/OxyServices.identityBackup.js +11 -0
- package/dist/cjs/mixins/OxyServices.user.js +4 -0
- package/dist/cjs/utils/commonsApproval.js +31 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +16 -3
- package/dist/esm/crypto/identityMarker.js +248 -0
- package/dist/esm/crypto/keyManager.js +932 -106
- package/dist/esm/index.js +4 -2
- package/dist/esm/mixins/OxyServices.auth.js +22 -6
- package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/esm/mixins/OxyServices.identityBackup.js +11 -0
- package/dist/esm/mixins/OxyServices.user.js +4 -0
- package/dist/esm/utils/commonsApproval.js +27 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +25 -0
- package/dist/types/crypto/identityMarker.d.ts +94 -0
- package/dist/types/crypto/keyManager.d.ts +245 -3
- package/dist/types/index.d.ts +6 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +31 -6
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
- package/dist/types/utils/commonsApproval.d.ts +13 -0
- package/package.json +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
- package/src/boot/sessionColdBoot.ts +42 -3
- package/src/crypto/__tests__/identityMocks.ts +125 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
- package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
- package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
- package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
- package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +138 -0
- package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
- package/src/crypto/__tests__/keyManager.test.ts +77 -87
- package/src/crypto/identityMarker.ts +291 -0
- package/src/crypto/keyManager.ts +1125 -105
- package/src/index.ts +14 -2
- package/src/mixins/OxyServices.auth.ts +40 -12
- package/src/mixins/OxyServices.deviceBoot.ts +9 -1
- package/src/mixins/OxyServices.identityBackup.ts +13 -0
- package/src/mixins/OxyServices.user.ts +4 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
- package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
- package/src/mixins/__tests__/identityBackup.test.ts +57 -0
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +6 -0
- package/src/utils/__tests__/commonsApproval.test.ts +60 -0
- package/src/utils/commonsApproval.ts +39 -0
package/src/crypto/keyManager.ts
CHANGED
|
@@ -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,106 @@ 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
|
+
* Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
|
|
203
|
+
*
|
|
204
|
+
* Stored under its OWN keychain service — distinct from the v2 primary, backup,
|
|
205
|
+
* and shared slots — so it shares an AndroidKeyStore key with none of them
|
|
206
|
+
* (blast-radius isolation, same rationale as the v2 primary/backup split).
|
|
207
|
+
* Written `WHEN_UNLOCKED_THIS_DEVICE_ONLY` and NEVER exported off-device: it
|
|
208
|
+
* exists solely so the user can RE-READ their phrase from Settings on the SAME
|
|
209
|
+
* device that generated/imported it.
|
|
210
|
+
*
|
|
211
|
+
* This is convenience persistence, NOT a recovery mechanism — a keystore death
|
|
212
|
+
* wipes it alongside the keys, exactly like the private key itself. The user's
|
|
213
|
+
* written-down phrase remains the sole out-of-band recovery path. The mnemonic
|
|
214
|
+
* lives ONLY in this slot: it is never mirrored into the identity marker,
|
|
215
|
+
* {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
|
|
216
|
+
*/
|
|
217
|
+
const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
|
|
218
|
+
const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
|
|
222
|
+
* Re-derivable (its loss just re-runs the cheap slot check), so it lives in
|
|
223
|
+
* plain AsyncStorage rather than the keychain. It only SKIPS re-reading the
|
|
224
|
+
* legacy slots on an already-migrated device; it is never trusted over an actual
|
|
225
|
+
* v2 read (a set flag with an unhealthy v2 pair falls through to full migration).
|
|
226
|
+
*/
|
|
227
|
+
const SLOTS_MIGRATED_FLAG_KEY = 'oxy_identity_slots_migrated_v2';
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The resolved set of storage key names + keychain services a session reads and
|
|
231
|
+
* writes. Normally {@link V2_SLOT_LAYOUT}; degrades to {@link LEGACY_SLOT_LAYOUT}
|
|
232
|
+
* for the current session only when a v2 migration write could not be verified
|
|
233
|
+
* (so the user is never locked out of a still-readable legacy identity).
|
|
234
|
+
*/
|
|
235
|
+
interface ResolvedSlotLayout {
|
|
236
|
+
primaryService?: string;
|
|
237
|
+
primaryPrivateKeyName: string;
|
|
238
|
+
primaryPublicKeyName: string;
|
|
239
|
+
backupService?: string;
|
|
240
|
+
backupPrivateKeyName: string;
|
|
241
|
+
backupPublicKeyName: string;
|
|
242
|
+
backupTimestampName: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const V2_SLOT_LAYOUT: ResolvedSlotLayout = {
|
|
246
|
+
primaryService: V2_PRIMARY_KEYCHAIN_SERVICE,
|
|
247
|
+
primaryPrivateKeyName: V2_STORAGE_KEYS.PRIVATE_KEY,
|
|
248
|
+
primaryPublicKeyName: V2_STORAGE_KEYS.PUBLIC_KEY,
|
|
249
|
+
backupService: V2_BACKUP_KEYCHAIN_SERVICE,
|
|
250
|
+
backupPrivateKeyName: V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY,
|
|
251
|
+
backupPublicKeyName: V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY,
|
|
252
|
+
backupTimestampName: V2_STORAGE_KEYS.BACKUP_TIMESTAMP,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const LEGACY_SLOT_LAYOUT: ResolvedSlotLayout = {
|
|
256
|
+
primaryService: undefined,
|
|
257
|
+
primaryPrivateKeyName: STORAGE_KEYS.PRIVATE_KEY,
|
|
258
|
+
primaryPublicKeyName: STORAGE_KEYS.PUBLIC_KEY,
|
|
259
|
+
backupService: undefined,
|
|
260
|
+
backupPrivateKeyName: STORAGE_KEYS.BACKUP_PRIVATE_KEY,
|
|
261
|
+
backupPublicKeyName: STORAGE_KEYS.BACKUP_PUBLIC_KEY,
|
|
262
|
+
backupTimestampName: STORAGE_KEYS.BACKUP_TIMESTAMP,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Outcome of the one-time-per-process slot migration. `deferred` means a read
|
|
267
|
+
* threw (keychain locked) — nothing was written or deleted, and every accessor
|
|
268
|
+
* treats it as `unavailable` (surfaced, never cached) so a later call retries.
|
|
269
|
+
*/
|
|
270
|
+
type SlotMigrationResult =
|
|
271
|
+
| { mode: 'v2'; layout: ResolvedSlotLayout }
|
|
272
|
+
| { mode: 'legacy'; layout: ResolvedSlotLayout }
|
|
273
|
+
| { mode: 'deferred'; cause: unknown };
|
|
274
|
+
|
|
118
275
|
/**
|
|
119
276
|
* iOS Keychain Access Group for sharing identities across Oxy apps
|
|
120
277
|
* All Oxy apps must have this access group enabled in their entitlements
|
|
@@ -210,6 +367,25 @@ export class KeyManager {
|
|
|
210
367
|
private static cachedHasIdentity: boolean | null = null;
|
|
211
368
|
private static cachedSharedPublicKey: string | null = null;
|
|
212
369
|
private static cachedHasSharedIdentity: boolean | null = null;
|
|
370
|
+
/**
|
|
371
|
+
* Distinguishes "public key genuinely absent (a successful empty read, safe to
|
|
372
|
+
* cache)" from "never resolved / storage threw (must NOT be cached)". A `null`
|
|
373
|
+
* {@link cachedPublicKey} alone is ambiguous — this flag makes the genuine
|
|
374
|
+
* absence cacheable WITHOUT ever caching a null produced by a thrown read.
|
|
375
|
+
*/
|
|
376
|
+
private static cachedPublicKeyResolved = false;
|
|
377
|
+
|
|
378
|
+
/** Listeners notified synchronously whenever the identity verdict may have changed. */
|
|
379
|
+
private static readonly identityChangeListeners = new Set<() => void>();
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Memoized one-run-per-process slot migration. `slotMigrationResult` caches a
|
|
383
|
+
* STABLE outcome (`v2`/`legacy`); a `deferred` outcome is intentionally not
|
|
384
|
+
* cached (the in-flight promise is cleared) so a later call retries once the
|
|
385
|
+
* keychain unlocks.
|
|
386
|
+
*/
|
|
387
|
+
private static slotMigrationPromise: Promise<SlotMigrationResult> | null = null;
|
|
388
|
+
private static slotMigrationResult: SlotMigrationResult | null = null;
|
|
213
389
|
|
|
214
390
|
/**
|
|
215
391
|
* Invalidate cached identity state
|
|
@@ -218,6 +394,392 @@ export class KeyManager {
|
|
|
218
394
|
private static invalidateCache(): void {
|
|
219
395
|
KeyManager.cachedPublicKey = null;
|
|
220
396
|
KeyManager.cachedHasIdentity = null;
|
|
397
|
+
KeyManager.cachedPublicKeyResolved = false;
|
|
398
|
+
KeyManager.notifyIdentityChanged();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Subscribe to identity-verdict changes (create / import / delete / restore /
|
|
403
|
+
* cache invalidation). Fires synchronously; the returned function unsubscribes.
|
|
404
|
+
* Consumed via `useOxyEvent`-style hooks in commons to invalidate the routing
|
|
405
|
+
* queries the instant the identity state moves, without polling.
|
|
406
|
+
*/
|
|
407
|
+
static subscribeIdentityChanged(listener: () => void): () => void {
|
|
408
|
+
KeyManager.identityChangeListeners.add(listener);
|
|
409
|
+
return () => {
|
|
410
|
+
KeyManager.identityChangeListeners.delete(listener);
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Synchronous fan-out with per-listener isolation (one throwing listener never blocks the rest). */
|
|
415
|
+
private static notifyIdentityChanged(): void {
|
|
416
|
+
// Snapshot first — a listener may unsubscribe (mutate the Set) during fan-out.
|
|
417
|
+
for (const listener of Array.from(KeyManager.identityChangeListeners)) {
|
|
418
|
+
try {
|
|
419
|
+
listener();
|
|
420
|
+
} catch (error) {
|
|
421
|
+
logger.warn('Identity-change listener threw', { component: 'KeyManager' }, error);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Build `getItemAsync`/`deleteItemAsync` options for a given keychain service (read/delete). */
|
|
427
|
+
private static _slotOpts(service?: string): OxySecureStoreOptions {
|
|
428
|
+
return service ? { keychainService: service } : {};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Build private-key write options (device-only accessibility) for a given keychain service. */
|
|
432
|
+
private static _privateWriteOpts(
|
|
433
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
434
|
+
service?: string,
|
|
435
|
+
): OxySecureStoreOptions {
|
|
436
|
+
const opts: OxySecureStoreOptions = { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
|
|
437
|
+
if (service) {
|
|
438
|
+
opts.keychainService = service;
|
|
439
|
+
}
|
|
440
|
+
return opts;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** True only when both keys are present, well-formed, AND the public derives from the private. */
|
|
444
|
+
private static _isHealthyPair(privateKey: string | null, publicKey: string | null): boolean {
|
|
445
|
+
if (!privateKey || !publicKey) {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
if (!KeyManager.isValidPrivateKey(privateKey) || !KeyManager.isValidPublicKey(publicKey)) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
return KeyManager.derivePublicKey(privateKey).toLowerCase() === publicKey.toLowerCase();
|
|
453
|
+
} catch {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Resolve the AsyncStorage-backed KV store for the advisory migration flag, or
|
|
460
|
+
* `null` off-RN / when unavailable. Independent of the keychain, so the flag
|
|
461
|
+
* cannot be taken down by the keystore event this whole subsystem defends
|
|
462
|
+
* against.
|
|
463
|
+
*/
|
|
464
|
+
private static async _advisoryStorage(): Promise<{
|
|
465
|
+
getItem(key: string): Promise<string | null>;
|
|
466
|
+
setItem(key: string, value: string): Promise<void>;
|
|
467
|
+
} | null> {
|
|
468
|
+
if (!isReactNative()) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const mod = await loadAsyncStorage();
|
|
473
|
+
return mod.default;
|
|
474
|
+
} catch {
|
|
475
|
+
// Advisory only — absence just means the slot check runs in full.
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private static async _readSlotsMigratedFlag(): Promise<boolean> {
|
|
481
|
+
const storage = await KeyManager._advisoryStorage();
|
|
482
|
+
if (!storage) {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
return (await storage.getItem(SLOTS_MIGRATED_FLAG_KEY)) === 'true';
|
|
487
|
+
} catch {
|
|
488
|
+
// Advisory only — treat an unreadable flag as "not yet migrated".
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private static async _setSlotsMigratedFlag(): Promise<void> {
|
|
494
|
+
const storage = await KeyManager._advisoryStorage();
|
|
495
|
+
if (!storage) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
await storage.setItem(SLOTS_MIGRATED_FLAG_KEY, 'true');
|
|
500
|
+
} catch (error) {
|
|
501
|
+
// Advisory only — a failed write just re-runs the cheap slot check next launch.
|
|
502
|
+
if (isDev()) {
|
|
503
|
+
logger.debug('Failed to set slots-migrated flag (advisory)', { component: 'KeyManager' }, error);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Ensure the identity has been migrated onto the isolated v2 slots (or that we
|
|
510
|
+
* know we must read legacy this session). Memoized so concurrent callers share
|
|
511
|
+
* ONE run; a `deferred` (read-threw) outcome is not cached so a later call
|
|
512
|
+
* retries after the keychain unlocks. Every identity-slot accessor awaits this
|
|
513
|
+
* before touching storage.
|
|
514
|
+
*/
|
|
515
|
+
private static async _ensureIdentitySlotsMigrated(): Promise<SlotMigrationResult> {
|
|
516
|
+
if (KeyManager.slotMigrationResult && KeyManager.slotMigrationResult.mode !== 'deferred') {
|
|
517
|
+
return KeyManager.slotMigrationResult;
|
|
518
|
+
}
|
|
519
|
+
if (!KeyManager.slotMigrationPromise) {
|
|
520
|
+
const run = (async () => {
|
|
521
|
+
const result = await KeyManager._runSlotMigration();
|
|
522
|
+
KeyManager.slotMigrationResult = result;
|
|
523
|
+
return result;
|
|
524
|
+
})();
|
|
525
|
+
KeyManager.slotMigrationPromise = run;
|
|
526
|
+
// Clear the in-flight handle once settled so a deferred outcome retries.
|
|
527
|
+
run
|
|
528
|
+
.then((result) => {
|
|
529
|
+
if (result.mode === 'deferred') {
|
|
530
|
+
KeyManager.slotMigrationPromise = null;
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
.catch(() => {
|
|
534
|
+
KeyManager.slotMigrationPromise = null;
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
return KeyManager.slotMigrationPromise;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* One-shot slot migration state machine. All reads are DIRECT and a thrown
|
|
542
|
+
* read defers everything (zero writes/deletes) so a locked keychain is never
|
|
543
|
+
* mistaken for an empty one. INVARIANT: at every instant ≥1 readable copy of a
|
|
544
|
+
* previously-existing identity remains — legacy is deleted ONLY after the v2
|
|
545
|
+
* copy is verified re-readable in its new (non-aliasable) location.
|
|
546
|
+
*/
|
|
547
|
+
private static async _runSlotMigration(): Promise<SlotMigrationResult> {
|
|
548
|
+
let store: Awaited<ReturnType<typeof initSecureStore>>;
|
|
549
|
+
try {
|
|
550
|
+
store = await initSecureStore();
|
|
551
|
+
} catch (error) {
|
|
552
|
+
return { mode: 'deferred', cause: error };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const migratedFlag = await KeyManager._readSlotsMigratedFlag();
|
|
556
|
+
|
|
557
|
+
// Read the v2 primary (dedicated keychain service).
|
|
558
|
+
let v2Private: string | null;
|
|
559
|
+
let v2Public: string | null;
|
|
560
|
+
try {
|
|
561
|
+
v2Private = await store.getItemAsync(
|
|
562
|
+
V2_STORAGE_KEYS.PRIVATE_KEY,
|
|
563
|
+
KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
564
|
+
);
|
|
565
|
+
v2Public = await store.getItemAsync(
|
|
566
|
+
V2_STORAGE_KEYS.PUBLIC_KEY,
|
|
567
|
+
KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
568
|
+
);
|
|
569
|
+
} catch (error) {
|
|
570
|
+
return { mode: 'deferred', cause: error };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (KeyManager._isHealthyPair(v2Private, v2Public)) {
|
|
574
|
+
// v2 already owns the identity. On the first observation, clean up any
|
|
575
|
+
// stale legacy copy and record the fast-path flag.
|
|
576
|
+
if (!migratedFlag) {
|
|
577
|
+
await KeyManager._bestEffortDeleteLegacyPrimaryAndBackup(store);
|
|
578
|
+
await KeyManager._setSlotsMigratedFlag();
|
|
579
|
+
}
|
|
580
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// v2 primary absent/partial but the flag says migration finished → v2 is
|
|
584
|
+
// simply empty (identity deleted / never created). No legacy to rescue.
|
|
585
|
+
if (migratedFlag) {
|
|
586
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Read the legacy primary (default keychain service = the old `key_v1`).
|
|
590
|
+
let legacyPrivate: string | null;
|
|
591
|
+
let legacyPublic: string | null;
|
|
592
|
+
try {
|
|
593
|
+
legacyPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
|
|
594
|
+
legacyPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
return { mode: 'deferred', cause: error };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (!KeyManager._isHealthyPair(legacyPrivate, legacyPublic)) {
|
|
600
|
+
// Nothing readable in either generation → v2 is the canonical (empty) home.
|
|
601
|
+
// The marker (not this migration) decides fresh-vs-lost.
|
|
602
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// legacy healthy, v2 absent → migrate: copy → read-back verify → only then delete legacy.
|
|
606
|
+
const canonicalPrivate = KeyManager.canonicalPrivateKey(legacyPrivate as string);
|
|
607
|
+
const canonicalPublic = (legacyPublic as string).toLowerCase();
|
|
608
|
+
try {
|
|
609
|
+
await store.setItemAsync(
|
|
610
|
+
V2_STORAGE_KEYS.PUBLIC_KEY,
|
|
611
|
+
canonicalPublic,
|
|
612
|
+
KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
613
|
+
);
|
|
614
|
+
await store.setItemAsync(
|
|
615
|
+
V2_STORAGE_KEYS.PRIVATE_KEY,
|
|
616
|
+
canonicalPrivate,
|
|
617
|
+
KeyManager._privateWriteOpts(store, V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
618
|
+
);
|
|
619
|
+
const readBackPrivate = await store.getItemAsync(
|
|
620
|
+
V2_STORAGE_KEYS.PRIVATE_KEY,
|
|
621
|
+
KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
622
|
+
);
|
|
623
|
+
const readBackPublic = await store.getItemAsync(
|
|
624
|
+
V2_STORAGE_KEYS.PUBLIC_KEY,
|
|
625
|
+
KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
626
|
+
);
|
|
627
|
+
const verified =
|
|
628
|
+
readBackPrivate?.toLowerCase() === canonicalPrivate &&
|
|
629
|
+
readBackPublic?.toLowerCase() === canonicalPublic &&
|
|
630
|
+
KeyManager._isHealthyPair(readBackPrivate, readBackPublic);
|
|
631
|
+
if (!verified) {
|
|
632
|
+
// v2 write did not durably land — remove the partial v2 and serve reads
|
|
633
|
+
// from legacy this session (legacy is UNTOUCHED). Retry next launch.
|
|
634
|
+
await KeyManager._bestEffortDeleteV2Primary(store);
|
|
635
|
+
logger.warn(
|
|
636
|
+
'Identity slot migration verify failed; serving identity from legacy slots this session',
|
|
637
|
+
{ component: 'KeyManager' },
|
|
638
|
+
);
|
|
639
|
+
return { mode: 'legacy', layout: LEGACY_SLOT_LAYOUT };
|
|
640
|
+
}
|
|
641
|
+
} catch (error) {
|
|
642
|
+
await KeyManager._bestEffortDeleteV2Primary(store);
|
|
643
|
+
logger.warn(
|
|
644
|
+
'Identity slot migration write threw; serving identity from legacy slots this session',
|
|
645
|
+
{ component: 'KeyManager' },
|
|
646
|
+
error,
|
|
647
|
+
);
|
|
648
|
+
return { mode: 'legacy', layout: LEGACY_SLOT_LAYOUT };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// v2 primary is verified re-readable. Migrate the backup slot (best-effort),
|
|
652
|
+
// then it is finally safe to delete the legacy generation.
|
|
653
|
+
await KeyManager._migrateBackupSlotToV2(store, canonicalPrivate, canonicalPublic);
|
|
654
|
+
await KeyManager._bestEffortDeleteLegacyPrimaryAndBackup(store);
|
|
655
|
+
await KeyManager._setSlotsMigratedFlag();
|
|
656
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Seed the v2 backup slot during migration. Prefers a healthy legacy backup;
|
|
661
|
+
* otherwise mirrors the (already-verified) v2 primary material so a v2 backup
|
|
662
|
+
* always exists on an independent keychain key. Best-effort — a failure just
|
|
663
|
+
* defers backup population to the next {@link _persistIdentityAtomic}.
|
|
664
|
+
*/
|
|
665
|
+
private static async _migrateBackupSlotToV2(
|
|
666
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
667
|
+
primaryPrivate: string,
|
|
668
|
+
primaryPublic: string,
|
|
669
|
+
): Promise<void> {
|
|
670
|
+
try {
|
|
671
|
+
let backupPrivate: string | null = null;
|
|
672
|
+
let backupPublic: string | null = null;
|
|
673
|
+
try {
|
|
674
|
+
backupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
675
|
+
backupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
if (isDev()) {
|
|
678
|
+
logger.debug('Legacy backup unreadable during migration (non-fatal)', { component: 'KeyManager' }, error);
|
|
679
|
+
}
|
|
680
|
+
backupPrivate = null;
|
|
681
|
+
backupPublic = null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
let seedPrivate: string;
|
|
685
|
+
let seedPublic: string;
|
|
686
|
+
if (KeyManager._isHealthyPair(backupPrivate, backupPublic)) {
|
|
687
|
+
seedPrivate = KeyManager.canonicalPrivateKey(backupPrivate as string);
|
|
688
|
+
seedPublic = (backupPublic as string).toLowerCase();
|
|
689
|
+
} else {
|
|
690
|
+
seedPrivate = primaryPrivate;
|
|
691
|
+
seedPublic = primaryPublic;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
await store.setItemAsync(
|
|
695
|
+
V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY,
|
|
696
|
+
seedPublic,
|
|
697
|
+
KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE),
|
|
698
|
+
);
|
|
699
|
+
await store.setItemAsync(
|
|
700
|
+
V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY,
|
|
701
|
+
seedPrivate,
|
|
702
|
+
KeyManager._privateWriteOpts(store, V2_BACKUP_KEYCHAIN_SERVICE),
|
|
703
|
+
);
|
|
704
|
+
await store.setItemAsync(
|
|
705
|
+
V2_STORAGE_KEYS.BACKUP_TIMESTAMP,
|
|
706
|
+
Date.now().toString(),
|
|
707
|
+
KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE),
|
|
708
|
+
);
|
|
709
|
+
} catch (error) {
|
|
710
|
+
logger.warn('Failed to migrate identity backup slot to v2 (non-fatal)', { component: 'KeyManager' }, error);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Best-effort single delete under an optional keychain service. Cleanup only — never surfaces. */
|
|
715
|
+
private static async _bestEffortDelete(
|
|
716
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
717
|
+
key: string,
|
|
718
|
+
service?: string,
|
|
719
|
+
): Promise<void> {
|
|
720
|
+
try {
|
|
721
|
+
await store.deleteItemAsync(key, KeyManager._slotOpts(service));
|
|
722
|
+
} catch (error) {
|
|
723
|
+
if (isDev()) {
|
|
724
|
+
logger.debug('Best-effort identity delete failed', { component: 'KeyManager' }, error);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private static async _bestEffortDeleteV2Primary(
|
|
730
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
731
|
+
): Promise<void> {
|
|
732
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PRIVATE_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
|
|
733
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PUBLIC_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private static async _bestEffortDeleteLegacyPrimaryAndBackup(
|
|
737
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
738
|
+
): Promise<void> {
|
|
739
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
|
|
740
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
|
|
741
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
742
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
743
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private static async _bestEffortDeleteBackupsAllGenerations(
|
|
747
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
748
|
+
): Promise<void> {
|
|
749
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
|
|
750
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
|
|
751
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_TIMESTAMP, V2_BACKUP_KEYCHAIN_SERVICE);
|
|
752
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
753
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
754
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Clear the cross-app shared identity slot (force-delete only) so a deleted
|
|
759
|
+
* identity cannot be resurrected via the recovery ladder's shared rung.
|
|
760
|
+
* Best-effort — the shared slot is a redundant convenience copy.
|
|
761
|
+
*/
|
|
762
|
+
private static async _clearSharedSlot(
|
|
763
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
764
|
+
): Promise<void> {
|
|
765
|
+
try {
|
|
766
|
+
if (isIOS()) {
|
|
767
|
+
const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
768
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
|
|
769
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
|
|
770
|
+
} else if (isAndroid()) {
|
|
771
|
+
const bridge = await loadSharedIdentityBridge();
|
|
772
|
+
if (bridge) {
|
|
773
|
+
await bridge.clearShared();
|
|
774
|
+
} else {
|
|
775
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
|
|
776
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
KeyManager.invalidateSharedCache();
|
|
780
|
+
} catch (error) {
|
|
781
|
+
logger.warn('Failed to clear shared identity slot during force delete', { component: 'KeyManager' }, error);
|
|
782
|
+
}
|
|
221
783
|
}
|
|
222
784
|
|
|
223
785
|
/**
|
|
@@ -725,9 +1287,29 @@ export class KeyManager {
|
|
|
725
1287
|
private static async _persistIdentityAtomic(
|
|
726
1288
|
privateKey: string,
|
|
727
1289
|
publicKey: string,
|
|
1290
|
+
origin: IdentityMarker['origin'],
|
|
728
1291
|
): Promise<void> {
|
|
729
1292
|
const store = await initSecureStore();
|
|
730
1293
|
|
|
1294
|
+
// Resolve the active slot layout (normally v2; legacy only in the rare
|
|
1295
|
+
// migration-fallback session). Reading and writing the SAME layout keeps the
|
|
1296
|
+
// snapshot/rollback machinery below internally consistent. A deferred
|
|
1297
|
+
// migration (keychain locked) must never write blind.
|
|
1298
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1299
|
+
if (migration.mode === 'deferred') {
|
|
1300
|
+
throw new IdentityUnavailableError(
|
|
1301
|
+
'Identity storage is temporarily unavailable; refusing to persist an identity.',
|
|
1302
|
+
migration.cause,
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
const layout = migration.layout;
|
|
1306
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1307
|
+
const primaryPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.primaryService);
|
|
1308
|
+
const primaryPubWriteOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1309
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
1310
|
+
const backupPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.backupService);
|
|
1311
|
+
const backupPubWriteOpts = KeyManager._slotOpts(layout.backupService);
|
|
1312
|
+
|
|
731
1313
|
// Canonicalize BEFORE persistence so the stored value is always in
|
|
732
1314
|
// canonical 64-hex-char lowercase form going forward. This is the single
|
|
733
1315
|
// place all primary writes flow through, so once a value lands here all
|
|
@@ -743,8 +1325,8 @@ export class KeyManager {
|
|
|
743
1325
|
let priorPrivate: string | null;
|
|
744
1326
|
let priorPublic: string | null;
|
|
745
1327
|
try {
|
|
746
|
-
priorPrivate = await store.getItemAsync(
|
|
747
|
-
priorPublic = await store.getItemAsync(
|
|
1328
|
+
priorPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
1329
|
+
priorPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
748
1330
|
} catch (error) {
|
|
749
1331
|
logger.error('Failed to read existing primary before persist', error, { component: 'KeyManager' });
|
|
750
1332
|
throw new IdentityPersistError(
|
|
@@ -771,17 +1353,19 @@ export class KeyManager {
|
|
|
771
1353
|
if (priorIsHealthyDifferent && priorPrivate && priorPublic) {
|
|
772
1354
|
let existingBackupPublic: string | null = null;
|
|
773
1355
|
try {
|
|
774
|
-
existingBackupPublic = await store.getItemAsync(
|
|
1356
|
+
existingBackupPublic = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
775
1357
|
} catch {
|
|
776
1358
|
existingBackupPublic = null;
|
|
777
1359
|
}
|
|
778
1360
|
if (existingBackupPublic?.toLowerCase() !== priorPublic.toLowerCase()) {
|
|
779
1361
|
try {
|
|
780
|
-
await store.setItemAsync(
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1362
|
+
await store.setItemAsync(
|
|
1363
|
+
layout.backupPrivateKeyName,
|
|
1364
|
+
KeyManager.canonicalPrivateKey(priorPrivate),
|
|
1365
|
+
backupPrivWriteOpts,
|
|
1366
|
+
);
|
|
1367
|
+
await store.setItemAsync(layout.backupPublicKeyName, priorPublic.toLowerCase(), backupPubWriteOpts);
|
|
1368
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupPubWriteOpts);
|
|
785
1369
|
} catch (error) {
|
|
786
1370
|
logger.error('Failed to back up existing identity before overwrite', error, { component: 'KeyManager' });
|
|
787
1371
|
throw new IdentityPersistError('Failed to back up existing identity before overwrite', error);
|
|
@@ -794,13 +1378,11 @@ export class KeyManager {
|
|
|
794
1378
|
// NOT touched here — it still holds the previous good identity until the
|
|
795
1379
|
// new primary is proven durable.
|
|
796
1380
|
try {
|
|
797
|
-
await store.setItemAsync(
|
|
798
|
-
await store.setItemAsync(
|
|
799
|
-
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
800
|
-
});
|
|
1381
|
+
await store.setItemAsync(layout.primaryPublicKeyName, canonicalPublic, primaryPubWriteOpts);
|
|
1382
|
+
await store.setItemAsync(layout.primaryPrivateKeyName, canonicalPrivate, primaryPrivWriteOpts);
|
|
801
1383
|
} catch (error) {
|
|
802
1384
|
logger.error('Failed to write primary identity to secure store', error, { component: 'KeyManager' });
|
|
803
|
-
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
1385
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
804
1386
|
throw new IdentityPersistError('Failed to write identity to secure store', error);
|
|
805
1387
|
}
|
|
806
1388
|
|
|
@@ -811,11 +1393,11 @@ export class KeyManager {
|
|
|
811
1393
|
let readBackPrivate: string | null;
|
|
812
1394
|
let readBackPublic: string | null;
|
|
813
1395
|
try {
|
|
814
|
-
readBackPrivate = await store.getItemAsync(
|
|
815
|
-
readBackPublic = await store.getItemAsync(
|
|
1396
|
+
readBackPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
1397
|
+
readBackPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
816
1398
|
} catch (error) {
|
|
817
1399
|
logger.error('Failed to read identity back after write', error, { component: 'KeyManager' });
|
|
818
|
-
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
1400
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
819
1401
|
throw new IdentityPersistError('Failed to verify identity after write', error);
|
|
820
1402
|
}
|
|
821
1403
|
|
|
@@ -827,7 +1409,7 @@ export class KeyManager {
|
|
|
827
1409
|
readBackPublic?.toLowerCase() !== canonicalPublic
|
|
828
1410
|
) {
|
|
829
1411
|
logger.error('Identity round-trip mismatch after write', undefined, { component: 'KeyManager' });
|
|
830
|
-
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
1412
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
831
1413
|
throw new IdentityPersistError('Identity write was not persisted correctly (round-trip mismatch).');
|
|
832
1414
|
}
|
|
833
1415
|
|
|
@@ -848,7 +1430,7 @@ export class KeyManager {
|
|
|
848
1430
|
throw new IdentityPersistError('Sign/verify roundtrip failed for newly stored identity.');
|
|
849
1431
|
}
|
|
850
1432
|
} catch (error) {
|
|
851
|
-
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
1433
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
852
1434
|
if (error instanceof IdentityPersistError) throw error;
|
|
853
1435
|
logger.error('Identity sign/verify probe failed', error, { component: 'KeyManager' });
|
|
854
1436
|
throw new IdentityPersistError('Stored identity failed crypto self-test', error);
|
|
@@ -865,31 +1447,63 @@ export class KeyManager {
|
|
|
865
1447
|
let priorBackupPublic: string | null;
|
|
866
1448
|
let priorBackupTimestamp: string | null;
|
|
867
1449
|
try {
|
|
868
|
-
priorBackupPrivate = await store.getItemAsync(
|
|
869
|
-
priorBackupPublic = await store.getItemAsync(
|
|
870
|
-
priorBackupTimestamp = await store.getItemAsync(
|
|
1450
|
+
priorBackupPrivate = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
|
|
1451
|
+
priorBackupPublic = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
1452
|
+
priorBackupTimestamp = await store.getItemAsync(layout.backupTimestampName, backupReadOpts);
|
|
871
1453
|
} catch (error) {
|
|
872
1454
|
logger.error('Failed to snapshot identity backup before refresh', error, { component: 'KeyManager' });
|
|
873
|
-
await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
|
|
1455
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
874
1456
|
throw new IdentityPersistError('Failed to snapshot identity backup before refresh', error);
|
|
875
1457
|
}
|
|
876
1458
|
|
|
877
1459
|
try {
|
|
878
|
-
await store.setItemAsync(
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
|
|
882
|
-
await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
|
|
1460
|
+
await store.setItemAsync(layout.backupPrivateKeyName, canonicalPrivate, backupPrivWriteOpts);
|
|
1461
|
+
await store.setItemAsync(layout.backupPublicKeyName, canonicalPublic, backupPubWriteOpts);
|
|
1462
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupPubWriteOpts);
|
|
883
1463
|
} catch (error) {
|
|
884
1464
|
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);
|
|
1465
|
+
await KeyManager._rollbackBackup(store, layout, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
|
|
1466
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
887
1467
|
throw new IdentityPersistError('Failed to refresh identity backup after primary write', error);
|
|
888
1468
|
}
|
|
889
1469
|
|
|
890
|
-
// Update cache only after we are certain the identity is durable
|
|
1470
|
+
// Update cache only after we are certain the identity is durable, then fan
|
|
1471
|
+
// out to identity-change subscribers.
|
|
891
1472
|
KeyManager.cachedPublicKey = canonicalPublic;
|
|
892
1473
|
KeyManager.cachedHasIdentity = true;
|
|
1474
|
+
KeyManager.cachedPublicKeyResolved = false;
|
|
1475
|
+
KeyManager.notifyIdentityChanged();
|
|
1476
|
+
|
|
1477
|
+
// LAST step: mirror the identity into the AndroidKeyStore-independent marker
|
|
1478
|
+
// so a later keystore death can be told apart from a fresh install. This is
|
|
1479
|
+
// best-effort — a marker write failure must NEVER fail an otherwise-durable
|
|
1480
|
+
// persist (a subsequent healthy read re-backfills it). Rollback paths above
|
|
1481
|
+
// return before reaching here, so they never touch the marker.
|
|
1482
|
+
await KeyManager._syncMarkerAfterPersist(canonicalPublic, origin);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Write/refresh the identity marker after a successful persist. A same-identity
|
|
1487
|
+
* re-persist (e.g. backup refresh, idempotent re-import) preserves `createdAt`
|
|
1488
|
+
* and the `onboardingComplete` milestone by only updating `origin`; a NEW or
|
|
1489
|
+
* switched identity writes a fresh marker. Best-effort — never throws.
|
|
1490
|
+
*
|
|
1491
|
+
* @internal
|
|
1492
|
+
*/
|
|
1493
|
+
private static async _syncMarkerAfterPersist(
|
|
1494
|
+
publicKey: string,
|
|
1495
|
+
origin: IdentityMarker['origin'],
|
|
1496
|
+
): Promise<void> {
|
|
1497
|
+
try {
|
|
1498
|
+
const existing = await readIdentityMarker();
|
|
1499
|
+
if (existing && existing.publicKey.toLowerCase() === publicKey.toLowerCase()) {
|
|
1500
|
+
await updateIdentityMarker({ origin });
|
|
1501
|
+
} else {
|
|
1502
|
+
await writeIdentityMarker({ publicKey, origin });
|
|
1503
|
+
}
|
|
1504
|
+
} catch (error) {
|
|
1505
|
+
logger.warn('Failed to sync identity marker after persist (non-fatal)', { component: 'KeyManager' }, error);
|
|
1506
|
+
}
|
|
893
1507
|
}
|
|
894
1508
|
|
|
895
1509
|
/**
|
|
@@ -900,29 +1514,31 @@ export class KeyManager {
|
|
|
900
1514
|
*/
|
|
901
1515
|
private static async _rollbackBackup(
|
|
902
1516
|
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
1517
|
+
layout: ResolvedSlotLayout,
|
|
903
1518
|
priorBackupPrivate: string | null,
|
|
904
1519
|
priorBackupPublic: string | null,
|
|
905
1520
|
priorBackupTimestamp: string | null,
|
|
906
1521
|
): Promise<void> {
|
|
1522
|
+
const backupPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.backupService);
|
|
1523
|
+
const backupPubWriteOpts = KeyManager._slotOpts(layout.backupService);
|
|
1524
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
907
1525
|
try {
|
|
908
1526
|
if (priorBackupPrivate) {
|
|
909
|
-
await store.setItemAsync(
|
|
910
|
-
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
911
|
-
});
|
|
1527
|
+
await store.setItemAsync(layout.backupPrivateKeyName, priorBackupPrivate, backupPrivWriteOpts);
|
|
912
1528
|
} else {
|
|
913
|
-
try { await store.deleteItemAsync(
|
|
1529
|
+
try { await store.deleteItemAsync(layout.backupPrivateKeyName, backupReadOpts); } catch { /* best effort */ }
|
|
914
1530
|
}
|
|
915
1531
|
|
|
916
1532
|
if (priorBackupPublic) {
|
|
917
|
-
await store.setItemAsync(
|
|
1533
|
+
await store.setItemAsync(layout.backupPublicKeyName, priorBackupPublic, backupPubWriteOpts);
|
|
918
1534
|
} else {
|
|
919
|
-
try { await store.deleteItemAsync(
|
|
1535
|
+
try { await store.deleteItemAsync(layout.backupPublicKeyName, backupReadOpts); } catch { /* best effort */ }
|
|
920
1536
|
}
|
|
921
1537
|
|
|
922
1538
|
if (priorBackupTimestamp) {
|
|
923
|
-
await store.setItemAsync(
|
|
1539
|
+
await store.setItemAsync(layout.backupTimestampName, priorBackupTimestamp, backupPubWriteOpts);
|
|
924
1540
|
} else {
|
|
925
|
-
try { await store.deleteItemAsync(
|
|
1541
|
+
try { await store.deleteItemAsync(layout.backupTimestampName, backupReadOpts); } catch { /* best effort */ }
|
|
926
1542
|
}
|
|
927
1543
|
} catch (rollbackError) {
|
|
928
1544
|
logger.error('Failed to roll back identity backup after a failed refresh', rollbackError, { component: 'KeyManager' });
|
|
@@ -940,21 +1556,23 @@ export class KeyManager {
|
|
|
940
1556
|
*/
|
|
941
1557
|
private static async _rollbackPrimary(
|
|
942
1558
|
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
1559
|
+
layout: ResolvedSlotLayout,
|
|
943
1560
|
priorPrivate: string | null,
|
|
944
1561
|
priorPublic: string | null,
|
|
945
1562
|
): Promise<void> {
|
|
1563
|
+
const primaryPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.primaryService);
|
|
1564
|
+
const primaryPubWriteOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1565
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
946
1566
|
try {
|
|
947
1567
|
if (priorPrivate && priorPublic) {
|
|
948
1568
|
// Restore exactly what was there before the failed write.
|
|
949
|
-
await store.setItemAsync(
|
|
950
|
-
await store.setItemAsync(
|
|
951
|
-
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
952
|
-
});
|
|
1569
|
+
await store.setItemAsync(layout.primaryPublicKeyName, priorPublic, primaryPubWriteOpts);
|
|
1570
|
+
await store.setItemAsync(layout.primaryPrivateKeyName, priorPrivate, primaryPrivWriteOpts);
|
|
953
1571
|
} else {
|
|
954
1572
|
// There was no prior identity — leave the device empty rather than
|
|
955
1573
|
// half-written so hasIdentity() does not lie.
|
|
956
|
-
try { await store.deleteItemAsync(
|
|
957
|
-
try { await store.deleteItemAsync(
|
|
1574
|
+
try { await store.deleteItemAsync(layout.primaryPublicKeyName, primaryReadOpts); } catch { /* best effort */ }
|
|
1575
|
+
try { await store.deleteItemAsync(layout.primaryPrivateKeyName, primaryReadOpts); } catch { /* best effort */ }
|
|
958
1576
|
}
|
|
959
1577
|
} catch (rollbackError) {
|
|
960
1578
|
logger.error('Failed to roll back primary identity after a failed write', rollbackError, { component: 'KeyManager' });
|
|
@@ -982,18 +1600,55 @@ export class KeyManager {
|
|
|
982
1600
|
// The local key IS the account — clobbering it without consent is
|
|
983
1601
|
// catastrophic. Callers must opt in explicitly when they have already
|
|
984
1602
|
// confirmed (via UI) that the user has saved their recovery phrase.
|
|
1603
|
+
//
|
|
1604
|
+
// The guard reads storage DIRECTLY (cache-bypassing) AND consults the
|
|
1605
|
+
// AndroidKeyStore-independent marker: either a stored key OR a marker means
|
|
1606
|
+
// an identity exists here → refuse. A storage THROW surfaces as
|
|
1607
|
+
// IdentityUnavailableError (never a blind write over a locked keystore).
|
|
985
1608
|
if (!options?.overwrite) {
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
|
|
1609
|
+
const marker = await readIdentityMarker();
|
|
1610
|
+
const direct = await KeyManager._readPrimaryDirect();
|
|
1611
|
+
if (direct.publicKey) {
|
|
1612
|
+
throw new IdentityAlreadyExistsError(direct.publicKey);
|
|
1613
|
+
}
|
|
1614
|
+
if (marker) {
|
|
1615
|
+
throw new IdentityAlreadyExistsError(marker.publicKey);
|
|
989
1616
|
}
|
|
990
1617
|
}
|
|
991
1618
|
|
|
992
1619
|
const { privateKey, publicKey } = await KeyManager.generateKeyPair();
|
|
993
|
-
await KeyManager._persistIdentityAtomic(privateKey, publicKey);
|
|
1620
|
+
await KeyManager._persistIdentityAtomic(privateKey, publicKey, 'create');
|
|
994
1621
|
return publicKey;
|
|
995
1622
|
}
|
|
996
1623
|
|
|
1624
|
+
/**
|
|
1625
|
+
* Read the primary key pair DIRECTLY from storage, bypassing the in-memory
|
|
1626
|
+
* cache (which a prior transient failure could have poisoned). Awaits slot
|
|
1627
|
+
* migration first. Throws {@link IdentityUnavailableError} if storage is
|
|
1628
|
+
* deferred/locked or a read throws — so overwrite guards never write blind.
|
|
1629
|
+
*
|
|
1630
|
+
* @internal
|
|
1631
|
+
*/
|
|
1632
|
+
private static async _readPrimaryDirect(): Promise<{ privateKey: string | null; publicKey: string | null }> {
|
|
1633
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1634
|
+
if (migration.mode === 'deferred') {
|
|
1635
|
+
throw new IdentityUnavailableError(
|
|
1636
|
+
'Identity storage is temporarily unavailable; refusing to write blind.',
|
|
1637
|
+
migration.cause,
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
const layout = migration.layout;
|
|
1641
|
+
const readOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1642
|
+
try {
|
|
1643
|
+
const store = await initSecureStore();
|
|
1644
|
+
const privateKey = await store.getItemAsync(layout.primaryPrivateKeyName, readOpts);
|
|
1645
|
+
const publicKey = await store.getItemAsync(layout.primaryPublicKeyName, readOpts);
|
|
1646
|
+
return { privateKey, publicKey };
|
|
1647
|
+
} catch (error) {
|
|
1648
|
+
throw new IdentityUnavailableError('Could not read existing identity; refusing to write blind.', error);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
997
1652
|
/**
|
|
998
1653
|
* Import an existing key pair (e.g., from recovery phrase).
|
|
999
1654
|
*
|
|
@@ -1022,32 +1677,59 @@ export class KeyManager {
|
|
|
1022
1677
|
const keyPair = ec.keyFromPrivate(canonicalPrivate);
|
|
1023
1678
|
const publicKey = keyPair.getPublic('hex');
|
|
1024
1679
|
|
|
1025
|
-
// Refuse silent overwrite — see createIdentity() for rationale.
|
|
1680
|
+
// Refuse silent overwrite — see createIdentity() for rationale. The guard
|
|
1681
|
+
// reads storage DIRECTLY (cache-bypassing) AND the marker, and treats
|
|
1682
|
+
// storage as authoritative:
|
|
1683
|
+
// - stored key === this import → safe idempotent refresh (fall through)
|
|
1684
|
+
// - stored key differs → a DIFFERENT identity is present → refuse
|
|
1685
|
+
// - storage empty + marker for a DIFFERENT identity (lost state) → refuse
|
|
1686
|
+
// - storage empty + marker matches this import (recovery) / no marker → allow
|
|
1687
|
+
// A storage throw surfaces as IdentityUnavailableError (never a blind write).
|
|
1026
1688
|
if (!options?.overwrite) {
|
|
1027
|
-
const
|
|
1028
|
-
|
|
1029
|
-
|
|
1689
|
+
const marker = await readIdentityMarker();
|
|
1690
|
+
const direct = await KeyManager._readPrimaryDirect();
|
|
1691
|
+
const importedPub = publicKey.toLowerCase();
|
|
1692
|
+
const existingPub = direct.publicKey?.toLowerCase() ?? null;
|
|
1693
|
+
const markerPub = marker?.publicKey.toLowerCase() ?? null;
|
|
1694
|
+
|
|
1695
|
+
if (existingPub && existingPub !== importedPub) {
|
|
1696
|
+
throw new IdentityAlreadyExistsError(direct.publicKey as string);
|
|
1030
1697
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1698
|
+
if (!existingPub && markerPub && markerPub !== importedPub) {
|
|
1699
|
+
throw new IdentityAlreadyExistsError(marker?.publicKey as string);
|
|
1700
|
+
}
|
|
1701
|
+
// Otherwise: existing === import (idempotent refresh), or storage empty
|
|
1702
|
+
// with a matching/absent marker (fresh import or lost-identity recovery)
|
|
1703
|
+
// → fall through and (re-)persist to refresh the backup + marker.
|
|
1034
1704
|
}
|
|
1035
1705
|
|
|
1036
|
-
await KeyManager._persistIdentityAtomic(canonicalPrivate, publicKey);
|
|
1706
|
+
await KeyManager._persistIdentityAtomic(canonicalPrivate, publicKey, 'import');
|
|
1037
1707
|
return publicKey;
|
|
1038
1708
|
}
|
|
1039
1709
|
|
|
1040
1710
|
/**
|
|
1041
1711
|
* Get the stored private key
|
|
1042
1712
|
* WARNING: Only use this for signing operations within the app
|
|
1713
|
+
*
|
|
1714
|
+
* Preserves the "return null on any storage failure" contract signing paths
|
|
1715
|
+
* rely on (a locked keychain simply means "cannot sign now"); unlike
|
|
1716
|
+
* {@link getPublicKey}, it does NOT throw {@link IdentityUnavailableError}.
|
|
1043
1717
|
*/
|
|
1044
1718
|
static async getPrivateKey(): Promise<string | null> {
|
|
1045
1719
|
if (isWebPlatform()) {
|
|
1046
1720
|
return null; // Identity storage is only available on native platforms
|
|
1047
1721
|
}
|
|
1048
1722
|
try {
|
|
1723
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1724
|
+
if (migration.mode === 'deferred') {
|
|
1725
|
+
// Storage unreadable right now — preserve the null contract.
|
|
1726
|
+
return null;
|
|
1727
|
+
}
|
|
1049
1728
|
const store = await initSecureStore();
|
|
1050
|
-
return await store.getItemAsync(
|
|
1729
|
+
return await store.getItemAsync(
|
|
1730
|
+
migration.layout.primaryPrivateKeyName,
|
|
1731
|
+
KeyManager._slotOpts(migration.layout.primaryService),
|
|
1732
|
+
);
|
|
1051
1733
|
} catch (error) {
|
|
1052
1734
|
// If secure store is not available, return null (no identity)
|
|
1053
1735
|
// This allows the app to continue functioning even if secure store fails to load
|
|
@@ -1059,7 +1741,12 @@ export class KeyManager {
|
|
|
1059
1741
|
}
|
|
1060
1742
|
|
|
1061
1743
|
/**
|
|
1062
|
-
* Get the stored public key (cached for performance)
|
|
1744
|
+
* Get the stored public key (cached for performance).
|
|
1745
|
+
*
|
|
1746
|
+
* Returns the public key, or `null` when a read SUCCEEDS and finds none.
|
|
1747
|
+
* THROWS {@link IdentityUnavailableError} when storage is unreadable (keychain
|
|
1748
|
+
* locked / module load failure) — a thrown read is NEVER flattened to `null`
|
|
1749
|
+
* and NEVER cached, so a poisoned "no identity" verdict can no longer stick.
|
|
1063
1750
|
*/
|
|
1064
1751
|
static async getPublicKey(): Promise<string | null> {
|
|
1065
1752
|
if (isWebPlatform()) {
|
|
@@ -1068,33 +1755,128 @@ export class KeyManager {
|
|
|
1068
1755
|
if (KeyManager.cachedPublicKey !== null) {
|
|
1069
1756
|
return KeyManager.cachedPublicKey;
|
|
1070
1757
|
}
|
|
1758
|
+
// A genuine-absent result (read succeeded, empty) is cacheable distinctly
|
|
1759
|
+
// from a thrown read — only the former sets this flag.
|
|
1760
|
+
if (KeyManager.cachedPublicKeyResolved) {
|
|
1761
|
+
return null;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1765
|
+
if (migration.mode === 'deferred') {
|
|
1766
|
+
throw new IdentityUnavailableError(
|
|
1767
|
+
'Identity storage is temporarily unavailable (keychain locked or unreadable).',
|
|
1768
|
+
migration.cause,
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1071
1771
|
|
|
1072
1772
|
try {
|
|
1073
1773
|
const store = await initSecureStore();
|
|
1074
|
-
const publicKey = await store.getItemAsync(
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1774
|
+
const publicKey = await store.getItemAsync(
|
|
1775
|
+
migration.layout.primaryPublicKeyName,
|
|
1776
|
+
KeyManager._slotOpts(migration.layout.primaryService),
|
|
1777
|
+
);
|
|
1778
|
+
if (publicKey !== null) {
|
|
1779
|
+
KeyManager.cachedPublicKey = publicKey;
|
|
1780
|
+
} else {
|
|
1781
|
+
// Genuine-absent (successful empty read) IS safe to cache.
|
|
1782
|
+
KeyManager.cachedPublicKeyResolved = true;
|
|
1783
|
+
}
|
|
1079
1784
|
return publicKey;
|
|
1080
1785
|
} catch (error) {
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
KeyManager.cachedPublicKey = null;
|
|
1786
|
+
// Storage threw AFTER migration resolved — transient/unavailable. Do NOT
|
|
1787
|
+
// cache; surface a typed error so callers never misread it as "no identity".
|
|
1084
1788
|
if (isDev()) {
|
|
1085
1789
|
logger.warn('Failed to access secure store', { component: 'KeyManager' }, error);
|
|
1086
1790
|
}
|
|
1087
|
-
|
|
1791
|
+
throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Persist the recovery mnemonic (the 12-word phrase) into its dedicated,
|
|
1797
|
+
* device-only keychain slot so the user can re-reveal it from Settings after
|
|
1798
|
+
* onboarding.
|
|
1799
|
+
*
|
|
1800
|
+
* Called best-effort at identity creation/import, where the phrase is already
|
|
1801
|
+
* in memory: a failure to persist it must NEVER fail the identity itself, so
|
|
1802
|
+
* callers deliberately swallow the thrown error (logging it). Storage errors
|
|
1803
|
+
* throw {@link IdentityUnavailableError} — same "cannot determine" semantics as
|
|
1804
|
+
* the other getters — so a caller MAY observe/log the failure.
|
|
1805
|
+
*
|
|
1806
|
+
* The mnemonic is stored ONLY here — never in the marker, `getIdentityStatus`,
|
|
1807
|
+
* logs, or any exported bundle.
|
|
1808
|
+
*/
|
|
1809
|
+
static async storeRecoveryMnemonic(mnemonic: string): Promise<void> {
|
|
1810
|
+
if (isWebPlatform()) {
|
|
1811
|
+
return; // Identity storage is only available on native platforms
|
|
1812
|
+
}
|
|
1813
|
+
try {
|
|
1814
|
+
const store = await initSecureStore();
|
|
1815
|
+
await store.setItemAsync(
|
|
1816
|
+
RECOVERY_MNEMONIC_STORAGE_KEY,
|
|
1817
|
+
mnemonic,
|
|
1818
|
+
KeyManager._privateWriteOpts(store, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE),
|
|
1819
|
+
);
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
if (isDev()) {
|
|
1822
|
+
logger.warn('Failed to persist recovery mnemonic', { component: 'KeyManager' }, error);
|
|
1823
|
+
}
|
|
1824
|
+
throw new IdentityUnavailableError('Failed to persist recovery mnemonic.', error);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* Read the stored recovery mnemonic for re-reveal in Settings.
|
|
1830
|
+
*
|
|
1831
|
+
* Returns the phrase, or `null` when a read SUCCEEDS and finds none — the
|
|
1832
|
+
* expected result for any identity created/imported before this feature
|
|
1833
|
+
* existed, since the phrase was never captured for those. THROWS
|
|
1834
|
+
* {@link IdentityUnavailableError} when storage is unreadable (keychain locked
|
|
1835
|
+
* / module load failure), matching {@link getPublicKey}'s contract — a thrown
|
|
1836
|
+
* read is never flattened to `null`, so a caller distinguishes "phrase was
|
|
1837
|
+
* never stored" from "keychain temporarily locked, retry".
|
|
1838
|
+
*/
|
|
1839
|
+
static async getRecoveryMnemonic(): Promise<string | null> {
|
|
1840
|
+
if (isWebPlatform()) {
|
|
1841
|
+
return null; // Identity storage is only available on native platforms
|
|
1842
|
+
}
|
|
1843
|
+
try {
|
|
1844
|
+
const store = await initSecureStore();
|
|
1845
|
+
return await store.getItemAsync(
|
|
1846
|
+
RECOVERY_MNEMONIC_STORAGE_KEY,
|
|
1847
|
+
KeyManager._slotOpts(RECOVERY_MNEMONIC_KEYCHAIN_SERVICE),
|
|
1848
|
+
);
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
if (isDev()) {
|
|
1851
|
+
logger.warn('Failed to read recovery mnemonic', { component: 'KeyManager' }, error);
|
|
1852
|
+
}
|
|
1853
|
+
throw new IdentityUnavailableError('Failed to read recovery mnemonic from secure storage.', error);
|
|
1088
1854
|
}
|
|
1089
1855
|
}
|
|
1090
1856
|
|
|
1857
|
+
/**
|
|
1858
|
+
* Delete the stored recovery mnemonic. Best-effort: a delete failure is logged
|
|
1859
|
+
* and swallowed, never thrown — it runs inside the identity-deletion path where
|
|
1860
|
+
* an unreadable keychain must not abort the wider teardown.
|
|
1861
|
+
*/
|
|
1862
|
+
static async deleteRecoveryMnemonic(): Promise<void> {
|
|
1863
|
+
if (isWebPlatform()) {
|
|
1864
|
+
return; // Identity storage is only available on native platforms
|
|
1865
|
+
}
|
|
1866
|
+
const store = await initSecureStore();
|
|
1867
|
+
await KeyManager._bestEffortDelete(store, RECOVERY_MNEMONIC_STORAGE_KEY, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1091
1870
|
/**
|
|
1092
1871
|
* Check if a complete, parseable identity exists on this device.
|
|
1093
1872
|
*
|
|
1094
1873
|
* Returns `true` only when BOTH the private and public keys are present,
|
|
1095
1874
|
* both are well-formed, AND the public key derives from the private key.
|
|
1096
|
-
* A partially-written or corrupted identity
|
|
1097
|
-
* downstream code can resume the create / restore flow
|
|
1875
|
+
* A partially-written or corrupted identity (read succeeded, bytes empty/bad)
|
|
1876
|
+
* returns `false` so that downstream code can resume the create / restore flow.
|
|
1877
|
+
* THROWS {@link IdentityUnavailableError} when storage is unreadable — a locked
|
|
1878
|
+
* keychain must never be mistaken for "no identity" (the old behavior that let
|
|
1879
|
+
* onboarding treat a transient lock as a blank device).
|
|
1098
1880
|
*
|
|
1099
1881
|
* Note: this does NOT perform the full sign/verify roundtrip — call
|
|
1100
1882
|
* `verifyIdentityIntegrity()` for that.
|
|
@@ -1107,22 +1889,26 @@ export class KeyManager {
|
|
|
1107
1889
|
return KeyManager.cachedHasIdentity;
|
|
1108
1890
|
}
|
|
1109
1891
|
|
|
1892
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1893
|
+
if (migration.mode === 'deferred') {
|
|
1894
|
+
throw new IdentityUnavailableError('Identity storage is temporarily unavailable.', migration.cause);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1110
1897
|
let privateKey: string | null;
|
|
1111
1898
|
let publicKey: string | null;
|
|
1112
1899
|
try {
|
|
1113
1900
|
const store = await initSecureStore();
|
|
1114
1901
|
[privateKey, publicKey] = await Promise.all([
|
|
1115
|
-
store.getItemAsync(
|
|
1116
|
-
store.getItemAsync(
|
|
1902
|
+
store.getItemAsync(migration.layout.primaryPrivateKeyName, KeyManager._slotOpts(migration.layout.primaryService)),
|
|
1903
|
+
store.getItemAsync(migration.layout.primaryPublicKeyName, KeyManager._slotOpts(migration.layout.primaryService)),
|
|
1117
1904
|
]);
|
|
1118
1905
|
} catch (error) {
|
|
1119
1906
|
// Storage threw — could be a transient keychain lock (e.g., background
|
|
1120
|
-
// fetch before the device is unlocked). Do NOT cache
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
// call retry from storage.
|
|
1907
|
+
// fetch before the device is unlocked). Do NOT cache; throw a TYPED error
|
|
1908
|
+
// so callers distinguish "temporarily unavailable" from "genuinely absent"
|
|
1909
|
+
// instead of silently treating a locked keystore as a blank device.
|
|
1124
1910
|
logger.error('Failed to read identity from secure storage', error, { component: 'KeyManager' });
|
|
1125
|
-
|
|
1911
|
+
throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
|
|
1126
1912
|
}
|
|
1127
1913
|
|
|
1128
1914
|
// Storage succeeded. Now classify the result. From here onward, any
|
|
@@ -1191,6 +1977,79 @@ export class KeyManager {
|
|
|
1191
1977
|
return hasIdentity;
|
|
1192
1978
|
}
|
|
1193
1979
|
|
|
1980
|
+
/**
|
|
1981
|
+
* Authoritative identity verdict — the corruption-vs-fresh-install
|
|
1982
|
+
* disambiguator that routing (commons) keys off of.
|
|
1983
|
+
*
|
|
1984
|
+
* - Healthy pair → `present` (and the marker is backfilled if missing or
|
|
1985
|
+
* pointing at a different key, `origin: 'backfill'`).
|
|
1986
|
+
* - Read succeeded but no healthy pair, WITH a marker → `lost` (keystore death
|
|
1987
|
+
* / corruption; route to recovery, NEVER to create).
|
|
1988
|
+
* - Read succeeded, no pair, NO marker → `absent` (a genuine fresh device; the
|
|
1989
|
+
* only state that may route to onboarding/create).
|
|
1990
|
+
* - A read THREW → `unavailable` (keychain locked); this verdict is NEVER
|
|
1991
|
+
* cached, so a later call re-reads.
|
|
1992
|
+
*
|
|
1993
|
+
* @param opts.bypassCache When true, never reads OR writes the in-memory cache
|
|
1994
|
+
* — a pure, fresh storage verdict for the auto-create interlock preflight.
|
|
1995
|
+
*/
|
|
1996
|
+
static async getIdentityStatus(opts?: { bypassCache?: boolean }): Promise<IdentityStatus> {
|
|
1997
|
+
if (isWebPlatform()) {
|
|
1998
|
+
return { state: 'absent' }; // Identity storage is only available on native platforms
|
|
1999
|
+
}
|
|
2000
|
+
const bypassCache = opts?.bypassCache === true;
|
|
2001
|
+
|
|
2002
|
+
// Read the marker FIRST (fail-open null) — it is the AndroidKeyStore-independent
|
|
2003
|
+
// signal that survives a keystore death.
|
|
2004
|
+
const marker = await readIdentityMarker();
|
|
2005
|
+
|
|
2006
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
2007
|
+
if (migration.mode === 'deferred') {
|
|
2008
|
+
return { state: 'unavailable', cause: migration.cause };
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
let privateKey: string | null;
|
|
2012
|
+
let publicKey: string | null;
|
|
2013
|
+
try {
|
|
2014
|
+
const store = await initSecureStore();
|
|
2015
|
+
const readOpts = KeyManager._slotOpts(migration.layout.primaryService);
|
|
2016
|
+
privateKey = await store.getItemAsync(migration.layout.primaryPrivateKeyName, readOpts);
|
|
2017
|
+
publicKey = await store.getItemAsync(migration.layout.primaryPublicKeyName, readOpts);
|
|
2018
|
+
} catch (error) {
|
|
2019
|
+
// Storage threw — NEVER cache this verdict; callers retry.
|
|
2020
|
+
return { state: 'unavailable', cause: error };
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
if (KeyManager._isHealthyPair(privateKey, publicKey) && publicKey) {
|
|
2024
|
+
const canonicalPublic = publicKey.toLowerCase();
|
|
2025
|
+
// Backfill the marker when missing or pointing at a DIFFERENT identity —
|
|
2026
|
+
// e.g. a loss that predates markers, healed on first healthy read.
|
|
2027
|
+
if (!marker || marker.publicKey.toLowerCase() !== canonicalPublic) {
|
|
2028
|
+
try {
|
|
2029
|
+
await writeIdentityMarker({ publicKey: canonicalPublic, origin: 'backfill' });
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
logger.warn('Failed to backfill identity marker', { component: 'KeyManager' }, error);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
if (!bypassCache) {
|
|
2035
|
+
KeyManager.cachedPublicKey = canonicalPublic;
|
|
2036
|
+
KeyManager.cachedHasIdentity = true;
|
|
2037
|
+
KeyManager.cachedPublicKeyResolved = false;
|
|
2038
|
+
}
|
|
2039
|
+
return { state: 'present', publicKey: canonicalPublic };
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// Read succeeded but no healthy pair present.
|
|
2043
|
+
if (!bypassCache) {
|
|
2044
|
+
KeyManager.cachedHasIdentity = false;
|
|
2045
|
+
KeyManager.cachedPublicKeyResolved = true;
|
|
2046
|
+
}
|
|
2047
|
+
if (marker) {
|
|
2048
|
+
return { state: 'lost', marker };
|
|
2049
|
+
}
|
|
2050
|
+
return { state: 'absent' };
|
|
2051
|
+
}
|
|
2052
|
+
|
|
1194
2053
|
/**
|
|
1195
2054
|
* Delete the stored identity (both keys)
|
|
1196
2055
|
* Use with EXTREME caution - this is irreversible without a recovery phrase
|
|
@@ -1213,6 +2072,8 @@ export class KeyManager {
|
|
|
1213
2072
|
}
|
|
1214
2073
|
|
|
1215
2074
|
if (!force) {
|
|
2075
|
+
// May throw IdentityUnavailableError if storage is locked — correct: a
|
|
2076
|
+
// non-force delete must abort rather than run against an unreadable store.
|
|
1216
2077
|
const hasIdentity = await KeyManager.hasIdentity();
|
|
1217
2078
|
if (!hasIdentity) {
|
|
1218
2079
|
return; // Nothing to delete
|
|
@@ -1220,7 +2081,7 @@ export class KeyManager {
|
|
|
1220
2081
|
}
|
|
1221
2082
|
|
|
1222
2083
|
const store = await initSecureStore();
|
|
1223
|
-
|
|
2084
|
+
|
|
1224
2085
|
// ALWAYS create backup before deletion unless explicitly skipped
|
|
1225
2086
|
if (!skipBackup) {
|
|
1226
2087
|
try {
|
|
@@ -1235,22 +2096,45 @@ export class KeyManager {
|
|
|
1235
2096
|
}
|
|
1236
2097
|
}
|
|
1237
2098
|
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
2099
|
+
// Delete the primary from the active layout (authoritative), then best-effort
|
|
2100
|
+
// delete BOTH generations so a stale legacy copy can never resurrect the
|
|
2101
|
+
// identity after deletion.
|
|
2102
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
2103
|
+
if (migration.mode !== 'deferred') {
|
|
2104
|
+
const layout = migration.layout;
|
|
2105
|
+
const readOpts = KeyManager._slotOpts(layout.primaryService);
|
|
2106
|
+
await store.deleteItemAsync(layout.primaryPrivateKeyName, readOpts);
|
|
2107
|
+
await store.deleteItemAsync(layout.primaryPublicKeyName, readOpts);
|
|
2108
|
+
}
|
|
2109
|
+
await KeyManager._bestEffortDeleteV2Primary(store);
|
|
2110
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
|
|
2111
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
|
|
2112
|
+
|
|
2113
|
+
// Always drop the stored recovery mnemonic — it is scoped to the identity
|
|
2114
|
+
// being deleted, so a leftover would let Settings reveal a stale phrase for
|
|
2115
|
+
// an identity that no longer exists (or a DIFFERENT one after re-onboarding).
|
|
2116
|
+
await KeyManager.deleteRecoveryMnemonic();
|
|
2117
|
+
|
|
2118
|
+
// Also clear backups + the shared slot on force deletion, so a deleted
|
|
2119
|
+
// identity cannot be resurrected from any recovery source.
|
|
1245
2120
|
if (force) {
|
|
1246
|
-
|
|
1247
|
-
|
|
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
|
-
}
|
|
2121
|
+
await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
|
|
2122
|
+
await KeyManager._clearSharedSlot(store);
|
|
1253
2123
|
}
|
|
2124
|
+
|
|
2125
|
+
// Clear the marker AFTER key deletion succeeds — a marker must never outlive
|
|
2126
|
+
// its identity (a leftover marker would route a truly-absent device to
|
|
2127
|
+
// `recovery` instead of `welcome`).
|
|
2128
|
+
try {
|
|
2129
|
+
await clearIdentityMarker();
|
|
2130
|
+
} catch (error) {
|
|
2131
|
+
logger.warn('Failed to clear identity marker during delete', { component: 'KeyManager' }, error);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// Invalidate cache LAST — its subscriber fan-out fires only after both the
|
|
2135
|
+
// keys AND the marker are gone, so a routing subscriber that re-reads on the
|
|
2136
|
+
// notification observes `absent`, never a transient `lost`.
|
|
2137
|
+
KeyManager.invalidateCache();
|
|
1254
2138
|
}
|
|
1255
2139
|
|
|
1256
2140
|
/**
|
|
@@ -1263,19 +2147,29 @@ export class KeyManager {
|
|
|
1263
2147
|
}
|
|
1264
2148
|
try {
|
|
1265
2149
|
const store = await initSecureStore();
|
|
1266
|
-
const
|
|
1267
|
-
|
|
2150
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
2151
|
+
if (migration.mode === 'deferred') {
|
|
2152
|
+
return false; // Cannot read the primary safely → nothing to back up
|
|
2153
|
+
}
|
|
2154
|
+
const layout = migration.layout;
|
|
2155
|
+
// Read the primary DIRECTLY (raw) rather than via getPublicKey (which now
|
|
2156
|
+
// throws) — a locked keychain here should simply mean "nothing to back up".
|
|
2157
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
2158
|
+
const privateKey = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
2159
|
+
const publicKey = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
1268
2160
|
|
|
1269
2161
|
if (!privateKey || !publicKey) {
|
|
1270
2162
|
return false; // Nothing to backup
|
|
1271
2163
|
}
|
|
1272
2164
|
|
|
1273
2165
|
// Store backup in SecureStore (still secure, but separate from primary storage)
|
|
1274
|
-
await store.setItemAsync(
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
2166
|
+
await store.setItemAsync(
|
|
2167
|
+
layout.backupPrivateKeyName,
|
|
2168
|
+
privateKey,
|
|
2169
|
+
KeyManager._privateWriteOpts(store, layout.backupService),
|
|
2170
|
+
);
|
|
2171
|
+
await store.setItemAsync(layout.backupPublicKeyName, publicKey, KeyManager._slotOpts(layout.backupService));
|
|
2172
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), KeyManager._slotOpts(layout.backupService));
|
|
1279
2173
|
|
|
1280
2174
|
return true;
|
|
1281
2175
|
} catch (error) {
|
|
@@ -1365,6 +2259,18 @@ export class KeyManager {
|
|
|
1365
2259
|
}
|
|
1366
2260
|
try {
|
|
1367
2261
|
const store = await initSecureStore();
|
|
2262
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
2263
|
+
if (migration.mode === 'deferred') {
|
|
2264
|
+
// Storage locked — refuse to restore (guard 2). Retry a later call.
|
|
2265
|
+
logger.warn(
|
|
2266
|
+
'restoreIdentityFromBackup: identity storage unavailable. Refusing to restore.',
|
|
2267
|
+
{ component: 'KeyManager' },
|
|
2268
|
+
);
|
|
2269
|
+
return false;
|
|
2270
|
+
}
|
|
2271
|
+
const layout = migration.layout;
|
|
2272
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
2273
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
1368
2274
|
|
|
1369
2275
|
// Read the primary DIRECTLY (not via the error-swallowing getters) so
|
|
1370
2276
|
// we can distinguish a transient read failure from a genuinely absent
|
|
@@ -1374,8 +2280,8 @@ export class KeyManager {
|
|
|
1374
2280
|
let primaryPrivate: string | null;
|
|
1375
2281
|
let primaryPublic: string | null;
|
|
1376
2282
|
try {
|
|
1377
|
-
primaryPrivate = await store.getItemAsync(
|
|
1378
|
-
primaryPublic = await store.getItemAsync(
|
|
2283
|
+
primaryPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
2284
|
+
primaryPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
1379
2285
|
} catch (error) {
|
|
1380
2286
|
logger.warn(
|
|
1381
2287
|
'restoreIdentityFromBackup: could not read primary (transient?). Refusing to restore.',
|
|
@@ -1398,8 +2304,8 @@ export class KeyManager {
|
|
|
1398
2304
|
}
|
|
1399
2305
|
|
|
1400
2306
|
// Load + validate the backup.
|
|
1401
|
-
const backupPrivateKey = await store.getItemAsync(
|
|
1402
|
-
const backupPublicKey = await store.getItemAsync(
|
|
2307
|
+
const backupPrivateKey = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
|
|
2308
|
+
const backupPublicKey = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
1403
2309
|
|
|
1404
2310
|
if (!backupPrivateKey || !backupPublicKey) {
|
|
1405
2311
|
return false; // No backup available
|
|
@@ -1452,13 +2358,13 @@ export class KeyManager {
|
|
|
1452
2358
|
// Safe to restore: rebuild the primary using the same atomic write
|
|
1453
2359
|
// path createIdentity uses, including verification.
|
|
1454
2360
|
try {
|
|
1455
|
-
await KeyManager._persistIdentityAtomic(backupPrivateKey, backupPublicKey);
|
|
2361
|
+
await KeyManager._persistIdentityAtomic(backupPrivateKey, backupPublicKey, 'restore');
|
|
1456
2362
|
} catch (error) {
|
|
1457
2363
|
logger.error('Failed to persist identity restored from backup', error, { component: 'KeyManager' });
|
|
1458
2364
|
return false;
|
|
1459
2365
|
}
|
|
1460
2366
|
|
|
1461
|
-
await store.setItemAsync(
|
|
2367
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupReadOpts);
|
|
1462
2368
|
return true;
|
|
1463
2369
|
} catch (error) {
|
|
1464
2370
|
logger.error('Failed to restore identity from backup', error, { component: 'KeyManager' });
|
|
@@ -1466,6 +2372,120 @@ export class KeyManager {
|
|
|
1466
2372
|
}
|
|
1467
2373
|
}
|
|
1468
2374
|
|
|
2375
|
+
/**
|
|
2376
|
+
* Recovery ladder — restore a `lost` identity from an independent,
|
|
2377
|
+
* `key_v1`-surviving source WITHOUT the user re-entering their recovery phrase.
|
|
2378
|
+
*
|
|
2379
|
+
* Gated on {@link getIdentityStatus} being `lost` (marker present, keys empty):
|
|
2380
|
+
* - `present` / `absent` → `not-lost` (nothing to recover / nothing lost)
|
|
2381
|
+
* - `unavailable` → `unavailable` (keychain locked; retry later)
|
|
2382
|
+
*
|
|
2383
|
+
* Rungs, tried in order, each fully validated (well-formed + derive-match +
|
|
2384
|
+
* `publicKey === marker.publicKey`, so a source holding a DIFFERENT account is
|
|
2385
|
+
* SKIPPED, never restored):
|
|
2386
|
+
* 1. the v2 backup slot (independent keychain key from the primary), then
|
|
2387
|
+
* 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
|
|
2388
|
+
* group) — the copy that survives a primary+backup `key_v1` death.
|
|
2389
|
+
*
|
|
2390
|
+
* On success it re-persists via {@link _persistIdentityAtomic} (origin
|
|
2391
|
+
* `'restore'`) and invalidates the cache so routing re-reads `present`. When no
|
|
2392
|
+
* rung matches, the UI proceeds to recovery-phrase entry.
|
|
2393
|
+
*/
|
|
2394
|
+
static async attemptIdentityRecovery(): Promise<IdentityRecoveryResult> {
|
|
2395
|
+
if (isWebPlatform()) {
|
|
2396
|
+
return { recovered: false, reason: 'not-lost' };
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
const status = await KeyManager.getIdentityStatus({ bypassCache: true });
|
|
2400
|
+
if (status.state === 'present' || status.state === 'absent') {
|
|
2401
|
+
return { recovered: false, reason: 'not-lost' };
|
|
2402
|
+
}
|
|
2403
|
+
if (status.state === 'unavailable') {
|
|
2404
|
+
return { recovered: false, reason: 'unavailable' };
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
// status.state === 'lost'
|
|
2408
|
+
const expectedPublic = status.marker.publicKey.toLowerCase();
|
|
2409
|
+
let sawMismatch = false;
|
|
2410
|
+
|
|
2411
|
+
// Rung 1: backup slot.
|
|
2412
|
+
const backupCandidate = await KeyManager._readBackupCandidate();
|
|
2413
|
+
if (backupCandidate) {
|
|
2414
|
+
if (backupCandidate.publicKey.toLowerCase() === expectedPublic) {
|
|
2415
|
+
if (await KeyManager._commitRecovery(backupCandidate.privateKey, backupCandidate.publicKey)) {
|
|
2416
|
+
return { recovered: true, source: 'backup', publicKey: backupCandidate.publicKey };
|
|
2417
|
+
}
|
|
2418
|
+
} else {
|
|
2419
|
+
sawMismatch = true;
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
// Rung 2: cross-app shared slot.
|
|
2424
|
+
const sharedCandidate = await KeyManager._readSharedCandidate();
|
|
2425
|
+
if (sharedCandidate) {
|
|
2426
|
+
if (sharedCandidate.publicKey.toLowerCase() === expectedPublic) {
|
|
2427
|
+
if (await KeyManager._commitRecovery(sharedCandidate.privateKey, sharedCandidate.publicKey)) {
|
|
2428
|
+
return { recovered: true, source: 'shared', publicKey: sharedCandidate.publicKey };
|
|
2429
|
+
}
|
|
2430
|
+
} else {
|
|
2431
|
+
sawMismatch = true;
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
// A source existed but identified a DIFFERENT account — never silently
|
|
2436
|
+
// switched. Report `mismatch` so the UI can require explicit confirmation.
|
|
2437
|
+
return { recovered: false, reason: sawMismatch ? 'mismatch' : 'no-sources' };
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/** Read the active-layout backup slot as a healthy candidate, or null. @internal */
|
|
2441
|
+
private static async _readBackupCandidate(): Promise<{ privateKey: string; publicKey: string } | null> {
|
|
2442
|
+
try {
|
|
2443
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
2444
|
+
if (migration.mode === 'deferred') {
|
|
2445
|
+
return null;
|
|
2446
|
+
}
|
|
2447
|
+
const layout = migration.layout;
|
|
2448
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
2449
|
+
const store = await initSecureStore();
|
|
2450
|
+
const privateKey = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
|
|
2451
|
+
const publicKey = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
2452
|
+
if (KeyManager._isHealthyPair(privateKey, publicKey) && privateKey && publicKey) {
|
|
2453
|
+
return { privateKey, publicKey };
|
|
2454
|
+
}
|
|
2455
|
+
return null;
|
|
2456
|
+
} catch (error) {
|
|
2457
|
+
logger.warn('Recovery: failed to read backup slot', { component: 'KeyManager' }, error);
|
|
2458
|
+
return null;
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
/** Read the cross-app shared slot as a healthy candidate, or null. @internal */
|
|
2463
|
+
private static async _readSharedCandidate(): Promise<{ privateKey: string; publicKey: string } | null> {
|
|
2464
|
+
try {
|
|
2465
|
+
const privateKey = await KeyManager.getSharedPrivateKey();
|
|
2466
|
+
const publicKey = await KeyManager.getSharedPublicKey();
|
|
2467
|
+
if (KeyManager._isHealthyPair(privateKey, publicKey) && privateKey && publicKey) {
|
|
2468
|
+
return { privateKey, publicKey };
|
|
2469
|
+
}
|
|
2470
|
+
return null;
|
|
2471
|
+
} catch (error) {
|
|
2472
|
+
logger.warn('Recovery: failed to read shared slot', { component: 'KeyManager' }, error);
|
|
2473
|
+
return null;
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
/** Persist a validated recovery candidate + refresh caches/subscribers. @internal */
|
|
2478
|
+
private static async _commitRecovery(privateKey: string, publicKey: string): Promise<boolean> {
|
|
2479
|
+
try {
|
|
2480
|
+
await KeyManager._persistIdentityAtomic(privateKey, publicKey, 'restore');
|
|
2481
|
+
KeyManager.invalidateCache();
|
|
2482
|
+
return true;
|
|
2483
|
+
} catch (error) {
|
|
2484
|
+
logger.error('Recovery: failed to persist recovered identity', error, { component: 'KeyManager' });
|
|
2485
|
+
return false;
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
|
|
1469
2489
|
/**
|
|
1470
2490
|
* Get the elliptic curve key object from the stored private key
|
|
1471
2491
|
* Used internally for signing operations
|