@oxy.so/core 7.32.0 → 7.33.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.
@@ -64,10 +64,36 @@ import { isOrganizationAccountId } from './keychainItems';
64
64
  import { prepareKeychainAccess } from './keychainGroupMigration';
65
65
  import type { OxySecureStoreOptions as SecureStoreOptions } from './secureStoreOptions';
66
66
 
67
- /** Whose ring: the person's (`accountId` absent) or an organization's. */
67
+ /**
68
+ * Whose ring: the person's (`accountId` absent) or an organization's.
69
+ *
70
+ * The personal ring is BOUND TO ITS OWNER (R5-2): every personal entry records
71
+ * the Oxy account id of the person whose key it was, and a personal read
72
+ * reaches only the entries of `ownerAccountId`. A phone outlives the person on
73
+ * it ("Start over" keeps the ring: it is money, recoverable from that account's
74
+ * backup), so the next person on the phone must never list, derive from or
75
+ * sign with a previous person's keys. A personal read without `ownerAccountId`
76
+ * reaches nothing. An entry whose owner is not known (a key the access-group
77
+ * migration kept, an entry written before 7.33.0) is QUARANTINED: invisible to
78
+ * every reader until Commons binds it with {@link claimRetiredIdentityKeys},
79
+ * which requires the account's key history to name it.
80
+ */
68
81
  export interface RetiredKeySelector {
69
82
  /** An organization's account id; absent for the personal ring. */
70
83
  accountId?: string;
84
+ /**
85
+ * The person's Oxy account id, for the PERSONAL ring: whose entries are
86
+ * reached. Ignored for an organization's ring (its owner is `accountId`).
87
+ */
88
+ ownerAccountId?: string;
89
+ }
90
+
91
+ /** Thrown when the ring's index cannot be read as the ring: never treated as empty (R5-9). */
92
+ export class RetiredKeyRingUnreadableError extends Error {
93
+ constructor(reason: string) {
94
+ super(`retired key ring: ${reason}`);
95
+ this.name = 'RetiredKeyRingUnreadableError';
96
+ }
71
97
  }
72
98
 
73
99
  /** One retired key as the ring lists it: public data only. */
@@ -164,6 +190,31 @@ interface IndexEntry {
164
190
  scope: string;
165
191
  publicKey: string;
166
192
  retiredAt: number;
193
+ /**
194
+ * Personal entries: the Oxy account id of the person whose key it was.
195
+ * Absent: quarantined (never listed, derived from or signed with).
196
+ */
197
+ owner?: string;
198
+ }
199
+
200
+ const ACCOUNT_ID =
201
+ /^(?:[0-9a-f]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
202
+
203
+ /** A well-formed Oxy account id (24-hex or uuid), else `null`. */
204
+ function ownerOf(ownerAccountId: string | undefined): string | null {
205
+ return ownerAccountId !== undefined && ACCOUNT_ID.test(ownerAccountId)
206
+ ? ownerAccountId.toLowerCase()
207
+ : null;
208
+ }
209
+
210
+ /**
211
+ * Whether `entry` may be reached by a reader of `scope` acting for `owner`: an
212
+ * organization's entries by its own scope, a personal entry only by its owner.
213
+ */
214
+ function isVisible(entry: IndexEntry, scope: string, owner: string | null): boolean {
215
+ if (entry.scope !== scope) return false;
216
+ if (scope !== PERSONAL_SCOPE) return true;
217
+ return owner !== null && entry.owner === owner;
167
218
  }
168
219
 
169
220
  function isIndexEntry(value: unknown): value is IndexEntry {
@@ -175,23 +226,33 @@ function isIndexEntry(value: unknown): value is IndexEntry {
175
226
  typeof entry.publicKey === 'string' &&
176
227
  PUBLIC_KEY.test(entry.publicKey) &&
177
228
  typeof entry.retiredAt === 'number' &&
178
- Number.isFinite(entry.retiredAt)
229
+ Number.isFinite(entry.retiredAt) &&
230
+ (entry.owner === undefined || (typeof entry.owner === 'string' && ACCOUNT_ID.test(entry.owner)))
179
231
  );
180
232
  }
181
233
 
234
+ /**
235
+ * The ring's index. STRICT (R5-6, R5-9): an index that does not parse, or that
236
+ * holds an entry this version cannot read, THROWS
237
+ * {@link RetiredKeyRingUnreadableError} instead of reading as empty, so no
238
+ * writer overwrites it (losing every entry) and no backup is sealed without
239
+ * the keys it names. A thrown keychain read propagates.
240
+ */
182
241
  async function readIndex(store: ExpoSecureStoreLike): Promise<IndexEntry[]> {
183
242
  const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
184
243
  if (!raw) return [];
244
+ let parsed: unknown;
185
245
  try {
186
- const parsed: unknown = JSON.parse(raw);
187
- return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
188
- } catch (error) {
189
- logger.warn('Retired key index unreadable; treating as empty', {
190
- component: 'RetiredKeys',
191
- error: error instanceof Error ? error.name : 'unknown',
192
- });
193
- return [];
246
+ parsed = JSON.parse(raw);
247
+ } catch {
248
+ throw new RetiredKeyRingUnreadableError('the index does not parse');
249
+ }
250
+ if (!Array.isArray(parsed) || !parsed.every(isIndexEntry)) {
251
+ throw new RetiredKeyRingUnreadableError('the index holds an entry this version cannot read');
194
252
  }
253
+ return parsed.map((entry: IndexEntry) =>
254
+ entry.owner === undefined ? entry : { ...entry, owner: entry.owner.toLowerCase() },
255
+ );
195
256
  }
196
257
 
197
258
  async function writeIndex(store: ExpoSecureStoreLike, entries: IndexEntry[]): Promise<void> {
@@ -207,6 +268,7 @@ async function writeIndex(store: ExpoSecureStoreLike, entries: IndexEntry[]): Pr
207
268
  }
208
269
 
209
270
  function toListed(entry: IndexEntry): RetiredIdentityKey {
271
+ // The owner is the reader's own id; it is not repeated.
210
272
  return entry.scope === PERSONAL_SCOPE
211
273
  ? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
212
274
  : { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
@@ -234,9 +296,13 @@ async function assertMayWrite(): Promise<void> {
234
296
  }
235
297
 
236
298
  /**
237
- * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
238
- * key already kept stays, with its first date. Refuses a pair whose public key
239
- * does not derive from its private key. Resolves once every copy confirmed it
299
+ * Keep `key` in the ring of `selector`'s account. Commons only. A PERSONAL key
300
+ * needs `selector.ownerAccountId`, the person whose key it was (R5-2): the
301
+ * signed-in account at a rotation, the account whose key history or backup
302
+ * named it. Idempotent: a key already kept stays, with its first date; a
303
+ * quarantined entry of the same key is bound to that owner; an entry bound to
304
+ * a DIFFERENT owner is refused. Refuses a pair whose public key does not
305
+ * derive from its private key. Resolves once every copy confirmed it
240
306
  * (Android: the signer store's read-back too), and throws otherwise, so a
241
307
  * caller about to replace the key can stop before anything is lost.
242
308
  */
@@ -244,13 +310,19 @@ export async function retireIdentityKey(
244
310
  key: RetiredKeyPair,
245
311
  selector?: RetiredKeySelector,
246
312
  ): Promise<void> {
313
+ const scope = retiredKeyScope(selector);
314
+ if (scope === PERSONAL_SCOPE && ownerOf(selector?.ownerAccountId) === null) {
315
+ throw new Error('retired key: a personal key needs ownerAccountId, the account it belongs to');
316
+ }
247
317
  await keepRetired(key, selector, true);
248
318
  }
249
319
 
250
320
  /**
251
321
  * {@link retireIdentityKey} from inside the iOS access-group migration, which
252
- * keeps a replaced pre-split copy before deleting it from the shared group and
253
- * cannot await itself. @internal
322
+ * keeps a pre-split copy before deleting it from the shared group and cannot
323
+ * await itself. A personal key without `ownerAccountId` is kept QUARANTINED:
324
+ * held (it may be money) but reached by nobody until
325
+ * {@link claimRetiredIdentityKeys} binds it. @internal
254
326
  */
255
327
  export async function retireIdentityKeyDuringMigration(
256
328
  key: RetiredKeyPair,
@@ -266,6 +338,7 @@ async function keepRetired(
266
338
  ): Promise<void> {
267
339
  const scope = retiredKeyScope(selector);
268
340
  if (!scope) throw new Error('retired key: accountId must be a 24-hex or uuid account id');
341
+ const owner = scope === PERSONAL_SCOPE ? ownerOf(selector?.ownerAccountId) : null;
269
342
  await assertMayWrite();
270
343
  if (!isHealthyPair(key.privateKey, key.publicKey)) {
271
344
  throw new Error('retired key: the public key does not derive from the private key');
@@ -273,6 +346,12 @@ async function keepRetired(
273
346
  const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
274
347
  const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
275
348
  const store = await ringStore(gate);
349
+ // Strict: an unreadable index stops the write before the key is touched.
350
+ const before = await readIndex(store);
351
+ const existing = before.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
352
+ if (existing?.owner !== undefined && owner !== null && existing.owner !== owner) {
353
+ throw new Error('retired key: this key is kept for a different account');
354
+ }
276
355
  await store.setItemAsync(
277
356
  privateKeyName(scope, publicKey),
278
357
  privateKey,
@@ -285,48 +364,112 @@ async function keepRetired(
285
364
  if (readBack !== privateKey) {
286
365
  throw new Error('retired key: the keychain did not confirm the key');
287
366
  }
367
+ const index = await readIndex(store);
368
+ const kept = index.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
369
+ if (!kept) {
370
+ await writeIndex(store, [
371
+ ...index,
372
+ owner === null
373
+ ? { scope, publicKey, retiredAt: Date.now() }
374
+ : { scope, publicKey, retiredAt: Date.now(), owner },
375
+ ]);
376
+ } else if (kept.owner === undefined && owner !== null) {
377
+ await writeIndex(
378
+ store,
379
+ index.map((entry) => (entry === kept ? { ...entry, owner } : entry)),
380
+ );
381
+ }
382
+ // The identity host answers only from the signer store, which holds the
383
+ // organizations' rings and the CURRENT person's personal entries; a
384
+ // quarantined entry is never mirrored.
288
385
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
289
- if (signer?.writeRetired && !(await signer.writeRetired(scope, privateKey, publicKey))) {
386
+ if (
387
+ signer?.writeRetired &&
388
+ (scope !== PERSONAL_SCOPE || owner !== null) &&
389
+ !(await signer.writeRetired(scope, privateKey, publicKey))
390
+ ) {
290
391
  throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
291
392
  }
393
+ }
394
+
395
+ /**
396
+ * Bind the QUARANTINED personal entries whose public key `publicKeys` names to
397
+ * `ownerAccountId` (R5-2, R5-3). Commons only, with the signed-in person's
398
+ * account id and that account's key history from Oxy
399
+ * (`oxy.identity.keyHistory()`: retired and current public keys): a key the
400
+ * account's own history names is that account's. Entries bound to anyone, and
401
+ * keys the history does not name, are left as they are. Resolves how many it
402
+ * bound; call {@link syncRetiredKeys} after it on Android.
403
+ */
404
+ export async function claimRetiredIdentityKeys(
405
+ ownerAccountId: string,
406
+ publicKeys: readonly string[],
407
+ ): Promise<number> {
408
+ const owner = ownerOf(ownerAccountId);
409
+ if (owner === null) throw new Error('retired key: ownerAccountId must be an account id');
410
+ if (isWeb()) return 0;
411
+ await assertMayWrite();
412
+ const named = new Set(
413
+ publicKeys.map((key) => canonicalPublicKey(key)).filter((key): key is string => key !== null),
414
+ );
415
+ const store = await ringStore();
292
416
  const index = await readIndex(store);
293
- if (!index.some((entry) => entry.scope === scope && entry.publicKey === publicKey)) {
294
- await writeIndex(store, [...index, { scope, publicKey, retiredAt: Date.now() }]);
295
- }
417
+ let bound = 0;
418
+ const next = index.map((entry) => {
419
+ if (entry.scope !== PERSONAL_SCOPE || entry.owner !== undefined) return entry;
420
+ if (!named.has(entry.publicKey)) return entry;
421
+ bound += 1;
422
+ return { ...entry, owner };
423
+ });
424
+ if (bound > 0) await writeIndex(store, next);
425
+ return bound;
426
+ }
427
+
428
+ /**
429
+ * How many personal entries are quarantined (no known owner). Commons shows
430
+ * them as "keys from before" the person can claim by adding the phrase, and
431
+ * {@link claimRetiredIdentityKeys} binds the ones the account's history names.
432
+ */
433
+ export async function countQuarantinedRetiredKeys(): Promise<number> {
434
+ if (isWeb() || (isAndroid() && !registeredIdentitySignerStore())) return 0;
435
+ const store = await ringStore();
436
+ return (await readIndex(store)).filter(
437
+ (entry) => entry.scope === PERSONAL_SCOPE && entry.owner === undefined,
438
+ ).length;
296
439
  }
297
440
 
298
441
  /**
299
442
  * The retired keys of `selector`'s account this app can reach, oldest first.
300
- * iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
301
- * (the wallet only), which leaves out the scope's current key. After an
302
- * interrupted rotation the local list can include the current key: compare
303
- * with the DID's `#key-1` before treating an entry as retired.
443
+ * The personal ring answers only `selector.ownerAccountId`'s entries (none
444
+ * without it; R5-2). iOS and Commons: the ring's index. Android elsewhere:
445
+ * Commons' identity host (the wallet only), which holds only the personal
446
+ * entries of the person Commons is signed in as and leaves out the scope's
447
+ * current key. After an interrupted rotation the local list can include the
448
+ * current key: compare with the DID's `#key-1` before treating an entry as
449
+ * retired, and check every entry against the account's key history.
450
+ *
451
+ * STRICT (R5-6): a ring that cannot be read throws (the keychain's error, or
452
+ * {@link RetiredKeyRingUnreadableError}); it never reads as empty.
304
453
  */
305
454
  export async function listRetiredIdentityKeys(
306
455
  selector?: RetiredKeySelector,
307
456
  ): Promise<RetiredIdentityKey[]> {
308
457
  const scope = retiredKeyScope(selector);
309
458
  if (!scope || isWeb()) return [];
310
- try {
311
- if (isAndroid() && !registeredIdentitySignerStore()) {
312
- const bridge = await loadCommonsIdentityBridge();
313
- const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
314
- return (keys ?? []).map((publicKey) =>
315
- scope === PERSONAL_SCOPE
316
- ? { publicKey, retiredAt: 0 }
317
- : { publicKey, accountId: scope, retiredAt: 0 },
318
- );
319
- }
320
- if (!isIOS() && !isAndroid()) return [];
321
- const store = await ringStore();
322
- return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
323
- } catch (error) {
324
- logger.warn('Failed to list retired keys', {
325
- component: 'RetiredKeys',
326
- error: error instanceof Error ? error.name : 'unknown',
327
- });
328
- return [];
459
+ const owner = ownerOf(selector?.ownerAccountId);
460
+ if (scope === PERSONAL_SCOPE && owner === null) return [];
461
+ if (isAndroid() && !registeredIdentitySignerStore()) {
462
+ const bridge = await loadCommonsIdentityBridge();
463
+ const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
464
+ return (keys ?? []).map((publicKey) =>
465
+ scope === PERSONAL_SCOPE
466
+ ? { publicKey, retiredAt: 0 }
467
+ : { publicKey, accountId: scope, retiredAt: 0 },
468
+ );
329
469
  }
470
+ if (!isIOS() && !isAndroid()) return [];
471
+ const store = await ringStore();
472
+ return (await readIndex(store)).filter((entry) => isVisible(entry, scope, owner)).map(toListed);
330
473
  }
331
474
 
332
475
  /**
@@ -343,11 +486,20 @@ export async function getRetiredPrivateKey(
343
486
  const scope = retiredKeyScope(selector);
344
487
  const canonical = canonicalPublicKey(publicKey);
345
488
  if (!scope || !canonical || isWeb()) return null;
489
+ const owner = ownerOf(selector?.ownerAccountId);
490
+ if (scope === PERSONAL_SCOPE && owner === null) return null;
346
491
  try {
347
492
  if (isAndroid()) {
348
493
  const signer = registeredIdentitySignerStore();
349
494
  if (!signer) return null;
350
495
  const store = await ringStore();
496
+ if (
497
+ !(await readIndex(store)).some(
498
+ (entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical,
499
+ )
500
+ ) {
501
+ return null;
502
+ }
351
503
  const local = await store.getItemAsync(
352
504
  privateKeyName(scope, canonical),
353
505
  privateOptions(store, false),
@@ -361,6 +513,15 @@ export async function getRetiredPrivateKey(
361
513
  }
362
514
  if (!isIOS()) return null;
363
515
  const store = await ringStore();
516
+ // The index decides who may reach a key (R5-2); a key it does not list for
517
+ // this reader is not theirs, whatever item exists.
518
+ if (
519
+ !(await readIndex(store)).some(
520
+ (entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical,
521
+ )
522
+ ) {
523
+ return null;
524
+ }
364
525
  const value = await store.getItemAsync(
365
526
  privateKeyName(scope, canonical),
366
527
  privateOptions(store, false),
@@ -387,8 +548,13 @@ export async function forgetRetiredIdentityKey(
387
548
  const scope = retiredKeyScope(selector);
388
549
  const canonical = canonicalPublicKey(publicKey);
389
550
  if (!scope || !canonical) throw new Error('retired key: invalid selector or public key');
551
+ const owner = ownerOf(selector?.ownerAccountId);
390
552
  await assertMayWrite();
391
553
  const store = await ringStore();
554
+ const visible = (await readIndex(store)).some(
555
+ (entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical,
556
+ );
557
+ if (!visible) throw new Error('retired key: not a key of this account');
392
558
  await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
393
559
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
394
560
  await signer?.clearRetired?.(scope, canonical);
@@ -412,12 +578,14 @@ export async function forgetRetiredIdentityKeys(selector?: RetiredKeySelector):
412
578
  const scope = retiredKeyScope(selector);
413
579
  if (!scope || isWeb()) return 0;
414
580
  if (isAndroid() && !registeredIdentitySignerStore()) return 0;
581
+ const owner = ownerOf(selector?.ownerAccountId);
582
+ if (scope === PERSONAL_SCOPE && owner === null) return 0;
415
583
  const store = await ringStore();
416
584
  const index = await readIndex(store);
417
585
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
418
586
  let removed = 0;
419
587
  for (const entry of index) {
420
- if (entry.scope !== scope) continue;
588
+ if (!isVisible(entry, scope, owner)) continue;
421
589
  await store.deleteItemAsync(
422
590
  privateKeyName(scope, entry.publicKey),
423
591
  privateOptions(store, false),
@@ -427,23 +595,35 @@ export async function forgetRetiredIdentityKeys(selector?: RetiredKeySelector):
427
595
  }
428
596
  await writeIndex(
429
597
  store,
430
- index.filter((entry) => entry.scope !== scope),
598
+ index.filter((entry) => !isVisible(entry, scope, owner)),
431
599
  );
432
600
  return removed;
433
601
  }
434
602
 
435
603
  /**
436
- * Commons on Android: copy every retired key into the identity signer store
437
- * again, so the identity host answers for it after that file was rebuilt (its
438
- * self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
439
- * Resolves how many it mirrored. Elsewhere 0.
604
+ * Commons on Android: make the identity signer store hold exactly what the
605
+ * identity host may answer — every organization ring, and the personal entries
606
+ * of `ownerAccountId` (the person Commons is signed in as) — so the host
607
+ * answers for them after that file was rebuilt (its self-heal leaves it
608
+ * empty), and never for a previous person's keys (R5-2): every other personal
609
+ * entry is removed from the signer store (its own slot keeps it). Without
610
+ * `ownerAccountId` no personal entry is mirrored. Call on every boot and after
611
+ * every sign-in, beside `syncOrganizationKeys`. Resolves how many it mirrored.
612
+ * Elsewhere 0.
440
613
  */
441
- export async function syncRetiredKeys(): Promise<number> {
614
+ export async function syncRetiredKeys(ownerAccountId?: string): Promise<number> {
442
615
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
443
616
  if (!signer?.writeRetired || !signer.readRetired) return 0;
617
+ const owner = ownerOf(ownerAccountId);
444
618
  const store = await ringStore();
619
+ const index = await readIndex(store);
620
+ const allowed = new Set(
621
+ index.filter((entry) => isVisible(entry, PERSONAL_SCOPE, owner)).map((e) => e.publicKey),
622
+ );
623
+ await clearPersonalMirror(signer, index, allowed);
445
624
  let mirrored = 0;
446
- for (const entry of await readIndex(store)) {
625
+ for (const entry of index) {
626
+ if (entry.scope === PERSONAL_SCOPE && !isVisible(entry, PERSONAL_SCOPE, owner)) continue;
447
627
  const privateKey = await store.getItemAsync(
448
628
  privateKeyName(entry.scope, entry.publicKey),
449
629
  privateOptions(store, false),
@@ -465,12 +645,14 @@ export async function syncRetiredKeys(): Promise<number> {
465
645
  }
466
646
 
467
647
  /**
468
- * Every retired key of `selector`'s ring WITH its private half, for the
469
- * encrypted identity backup (`oxy.identity.backup.create`), which carries them
470
- * under the CURRENT phrase so a lost phone costs no more than the current
471
- * phrase does (R4-4). Commons only; an entry whose private half is missing or
472
- * does not derive its public key is left out. Never log or transmit the result
473
- * except inside that encrypted envelope.
648
+ * Every retired key of `selector`'s ring (personal: `ownerAccountId`'s entries)
649
+ * WITH its private half, for the encrypted identity backup
650
+ * (`oxy.identity.backup.create`), which carries them under the CURRENT phrase
651
+ * so a lost phone costs no more than the current phrase does (R4-4). Commons
652
+ * only. STRICT (R5-6): an unreadable ring, or a listed entry whose private
653
+ * half is missing or does not derive its public key, THROWS — a backup is
654
+ * never sealed without a key the ring says it holds. Never log or transmit the
655
+ * result except inside that encrypted envelope.
474
656
  */
475
657
  export async function exportRetiredIdentityKeys(
476
658
  selector?: RetiredKeySelector,
@@ -480,13 +662,65 @@ export async function exportRetiredIdentityKeys(
480
662
  await assertMayWrite();
481
663
  const out: Array<RetiredKeyPair & { retiredAt: number }> = [];
482
664
  for (const entry of await listRetiredIdentityKeys(selector)) {
483
- const privateKey = await getRetiredPrivateKey(entry.publicKey, selector);
484
- if (privateKey)
485
- out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
665
+ const privateKey = await readRetiredStrict(entry.publicKey, scope);
666
+ out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
486
667
  }
487
668
  return out;
488
669
  }
489
670
 
671
+ /** A listed entry's private half, or a throw: Commons' own slot, then (Android) the signer copy. */
672
+ async function readRetiredStrict(publicKey: string, scope: string): Promise<string> {
673
+ const store = await ringStore();
674
+ const local = await store.getItemAsync(
675
+ privateKeyName(scope, publicKey),
676
+ privateOptions(store, false),
677
+ );
678
+ if (local && isHealthyPair(local, publicKey)) return normalizeSecp256k1PrivateKey(local);
679
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
680
+ const mirrored = (await signer?.readRetired?.(scope, publicKey)) ?? null;
681
+ if (mirrored && isHealthyPair(mirrored.privateKey, publicKey)) {
682
+ return normalizeSecp256k1PrivateKey(mirrored.privateKey);
683
+ }
684
+ throw new RetiredKeyRingUnreadableError('a listed key has no readable private half');
685
+ }
686
+
687
+ /**
688
+ * Commons on Android, when the identity leaves this device ("Start over", a
689
+ * forced delete): remove every PERSONAL entry from the identity signer store,
690
+ * so the identity host answers no other app with them before the next person
691
+ * signs in (R5-2). Commons' own slot and index keep them (money, bound to
692
+ * their owner). Elsewhere a no-op.
693
+ */
694
+ export async function unmirrorPersonalRetiredKeys(): Promise<void> {
695
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
696
+ if (!signer?.clearRetired) return;
697
+ let index: IndexEntry[] = [];
698
+ try {
699
+ index = await readIndex(await ringStore());
700
+ } catch {
701
+ // The signer store's own list below still reaches every mirrored entry.
702
+ }
703
+ await clearPersonalMirror(signer, index, new Set());
704
+ }
705
+
706
+ /** Remove from the signer store every personal entry not in `allowed`: the index's and the store's own list. */
707
+ async function clearPersonalMirror(
708
+ signer: NonNullable<ReturnType<typeof registeredIdentitySignerStore>>,
709
+ index: readonly IndexEntry[],
710
+ allowed: ReadonlySet<string>,
711
+ ): Promise<void> {
712
+ if (!signer.clearRetired) return;
713
+ const mirrored = new Set(
714
+ index.filter((entry) => entry.scope === PERSONAL_SCOPE).map((entry) => entry.publicKey),
715
+ );
716
+ for (const publicKey of (await signer.listRetired?.(PERSONAL_SCOPE)) ?? []) {
717
+ mirrored.add(publicKey.toLowerCase());
718
+ }
719
+ for (const publicKey of mirrored) {
720
+ if (!allowed.has(publicKey)) await signer.clearRetired(PERSONAL_SCOPE, publicKey);
721
+ }
722
+ }
723
+
490
724
  /**
491
725
  * The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
492
726
  * for a person whose old identity was imported as a private key and so has no