@oxy.so/core 7.31.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.
Files changed (33) hide show
  1. package/dist/cjs/api/identity.js +155 -24
  2. package/dist/cjs/crypto/identityMarker.js +15 -0
  3. package/dist/cjs/crypto/index.js +11 -2
  4. package/dist/cjs/crypto/keyManager.js +111 -16
  5. package/dist/cjs/crypto/keychainGroupMigration.js +327 -160
  6. package/dist/cjs/crypto/organizationKeys.js +33 -5
  7. package/dist/cjs/crypto/retiredKeys.js +315 -64
  8. package/dist/cjs/utils/keychainAccessGroups.js +111 -25
  9. package/dist/esm/api/identity.js +155 -24
  10. package/dist/esm/crypto/identityMarker.js +14 -0
  11. package/dist/esm/crypto/index.js +3 -3
  12. package/dist/esm/crypto/keyManager.js +113 -18
  13. package/dist/esm/crypto/keychainGroupMigration.js +327 -162
  14. package/dist/esm/crypto/organizationKeys.js +34 -6
  15. package/dist/esm/crypto/retiredKeys.js +306 -63
  16. package/dist/esm/utils/keychainAccessGroups.js +107 -24
  17. package/dist/types/api/identity.d.ts +36 -0
  18. package/dist/types/crypto/identityMarker.d.ts +8 -0
  19. package/dist/types/crypto/index.d.ts +3 -3
  20. package/dist/types/crypto/keyManager.d.ts +45 -1
  21. package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
  22. package/dist/types/crypto/organizationKeys.d.ts +4 -2
  23. package/dist/types/crypto/retiredKeys.d.ts +108 -17
  24. package/dist/types/utils/keychainAccessGroups.d.ts +41 -11
  25. package/package.json +1 -1
  26. package/src/api/identity.ts +198 -41
  27. package/src/crypto/identityMarker.ts +15 -0
  28. package/src/crypto/index.ts +12 -1
  29. package/src/crypto/keyManager.ts +127 -26
  30. package/src/crypto/keychainGroupMigration.ts +390 -185
  31. package/src/crypto/organizationKeys.ts +38 -6
  32. package/src/crypto/retiredKeys.ts +369 -65
  33. package/src/utils/keychainAccessGroups.ts +121 -24
@@ -60,14 +60,40 @@ import { logger } from '../logger';
60
60
  import { groupedKeychainStore, resolveKeychainAccess } from '../utils/keychainAccessGroups';
61
61
  import { isAndroid, isIOS, isWeb } from '../utils/platform';
62
62
  import { registeredIdentitySignerStore } from './identitySigner';
63
- import { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
64
- import { isOrganizationAccountId } from './organizationKeys';
63
+ import { isOrganizationAccountId } from './keychainItems';
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. */
@@ -97,15 +123,14 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
97
123
  * The secure store for the ring. On iOS every item is a `spending-key`
98
124
  * (ADR 0062): the private keys AND the index live in the wallet group, which
99
125
  * only Commons and the Peable wallet can read, never the shared one. In
100
- * Commons every access first awaits the access-group migration. On Android
101
- * the grouping is a no-op and Commons' own services below apply.
126
+ * Commons every access first awaits the access-group migration (`gate: false`
127
+ * is the migration itself, keeping a pre-split copy). On Android the grouping
128
+ * is a no-op and Commons' own services below apply.
102
129
  */
103
- async function ringStore(): Promise<ExpoSecureStoreLike> {
130
+ async function ringStore(gate = true): Promise<ExpoSecureStoreLike> {
104
131
  const raw = await loadSecureStore();
105
132
  const access = await resolveKeychainAccess();
106
- if (access.mode === 'grouped' && access.role === 'holder') {
107
- await ensureKeychainGroupsMigrated();
108
- }
133
+ if (gate) await prepareKeychainAccess(access);
109
134
  return groupedKeychainStore(raw, access, 'spending-key');
110
135
  }
111
136
 
@@ -165,6 +190,31 @@ interface IndexEntry {
165
190
  scope: string;
166
191
  publicKey: string;
167
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;
168
218
  }
169
219
 
170
220
  function isIndexEntry(value: unknown): value is IndexEntry {
@@ -176,23 +226,33 @@ function isIndexEntry(value: unknown): value is IndexEntry {
176
226
  typeof entry.publicKey === 'string' &&
177
227
  PUBLIC_KEY.test(entry.publicKey) &&
178
228
  typeof entry.retiredAt === 'number' &&
179
- Number.isFinite(entry.retiredAt)
229
+ Number.isFinite(entry.retiredAt) &&
230
+ (entry.owner === undefined || (typeof entry.owner === 'string' && ACCOUNT_ID.test(entry.owner)))
180
231
  );
181
232
  }
182
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
+ */
183
241
  async function readIndex(store: ExpoSecureStoreLike): Promise<IndexEntry[]> {
184
242
  const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
185
243
  if (!raw) return [];
244
+ let parsed: unknown;
186
245
  try {
187
- const parsed: unknown = JSON.parse(raw);
188
- return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
189
- } catch (error) {
190
- logger.warn('Retired key index unreadable; treating as empty', {
191
- component: 'RetiredKeys',
192
- error: error instanceof Error ? error.name : 'unknown',
193
- });
194
- 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');
195
252
  }
253
+ return parsed.map((entry: IndexEntry) =>
254
+ entry.owner === undefined ? entry : { ...entry, owner: entry.owner.toLowerCase() },
255
+ );
196
256
  }
197
257
 
198
258
  async function writeIndex(store: ExpoSecureStoreLike, entries: IndexEntry[]): Promise<void> {
@@ -208,41 +268,90 @@ async function writeIndex(store: ExpoSecureStoreLike, entries: IndexEntry[]): Pr
208
268
  }
209
269
 
210
270
  function toListed(entry: IndexEntry): RetiredIdentityKey {
271
+ // The owner is the reader's own id; it is not repeated.
211
272
  return entry.scope === PERSONAL_SCOPE
212
273
  ? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
213
274
  : { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
214
275
  }
215
276
 
216
- /** Whether this app writes the ring: Commons (Android: the signer store it registers; iOS: native). */
217
- function assertMayWrite(): void {
277
+ /**
278
+ * Whether this app writes the ring: Commons only (Android: the signer store it
279
+ * registers; iOS: the keychain role `holder`). The Peable wallet holds the
280
+ * wallet group too, but only reads, derives and signs: a key the wallet could
281
+ * add to the ring is a key it could choose (R4-14).
282
+ */
283
+ async function assertMayWrite(): Promise<void> {
218
284
  if (isWeb()) throw new Error('retired key: only a native device holds one');
219
285
  if (isAndroid() && !registeredIdentitySignerStore()?.writeRetired) {
220
286
  throw new Error(
221
287
  'On Android only Commons holds retired keys; this app has no identity signer store for them.',
222
288
  );
223
289
  }
290
+ if (isIOS()) {
291
+ const access = await resolveKeychainAccess();
292
+ if (access.mode === 'grouped' && access.role !== 'holder') {
293
+ throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
294
+ }
295
+ }
224
296
  }
225
297
 
226
298
  /**
227
- * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
228
- * key already kept stays, with its first date. Refuses a pair whose public key
229
- * 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
230
306
  * (Android: the signer store's read-back too), and throws otherwise, so a
231
307
  * caller about to replace the key can stop before anything is lost.
232
308
  */
233
309
  export async function retireIdentityKey(
234
310
  key: RetiredKeyPair,
235
311
  selector?: RetiredKeySelector,
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
+ }
317
+ await keepRetired(key, selector, true);
318
+ }
319
+
320
+ /**
321
+ * {@link retireIdentityKey} from inside the iOS access-group migration, which
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
326
+ */
327
+ export async function retireIdentityKeyDuringMigration(
328
+ key: RetiredKeyPair,
329
+ selector?: RetiredKeySelector,
330
+ ): Promise<void> {
331
+ await keepRetired(key, selector, false);
332
+ }
333
+
334
+ async function keepRetired(
335
+ key: RetiredKeyPair,
336
+ selector: RetiredKeySelector | undefined,
337
+ gate: boolean,
236
338
  ): Promise<void> {
237
339
  const scope = retiredKeyScope(selector);
238
340
  if (!scope) throw new Error('retired key: accountId must be a 24-hex or uuid account id');
239
- assertMayWrite();
341
+ const owner = scope === PERSONAL_SCOPE ? ownerOf(selector?.ownerAccountId) : null;
342
+ await assertMayWrite();
240
343
  if (!isHealthyPair(key.privateKey, key.publicKey)) {
241
344
  throw new Error('retired key: the public key does not derive from the private key');
242
345
  }
243
346
  const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
244
347
  const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
245
- const store = await ringStore();
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
+ }
246
355
  await store.setItemAsync(
247
356
  privateKeyName(scope, publicKey),
248
357
  privateKey,
@@ -255,48 +364,112 @@ export async function retireIdentityKey(
255
364
  if (readBack !== privateKey) {
256
365
  throw new Error('retired key: the keychain did not confirm the key');
257
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.
258
385
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
259
- 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
+ ) {
260
391
  throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
261
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();
262
416
  const index = await readIndex(store);
263
- if (!index.some((entry) => entry.scope === scope && entry.publicKey === publicKey)) {
264
- await writeIndex(store, [...index, { scope, publicKey, retiredAt: Date.now() }]);
265
- }
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;
266
439
  }
267
440
 
268
441
  /**
269
442
  * The retired keys of `selector`'s account this app can reach, oldest first.
270
- * iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
271
- * (the wallet only), which leaves out the scope's current key. After an
272
- * interrupted rotation the local list can include the current key: compare
273
- * 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.
274
453
  */
275
454
  export async function listRetiredIdentityKeys(
276
455
  selector?: RetiredKeySelector,
277
456
  ): Promise<RetiredIdentityKey[]> {
278
457
  const scope = retiredKeyScope(selector);
279
458
  if (!scope || isWeb()) return [];
280
- try {
281
- if (isAndroid() && !registeredIdentitySignerStore()) {
282
- const bridge = await loadCommonsIdentityBridge();
283
- const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
284
- return (keys ?? []).map((publicKey) =>
285
- scope === PERSONAL_SCOPE
286
- ? { publicKey, retiredAt: 0 }
287
- : { publicKey, accountId: scope, retiredAt: 0 },
288
- );
289
- }
290
- if (!isIOS() && !isAndroid()) return [];
291
- const store = await ringStore();
292
- return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
293
- } catch (error) {
294
- logger.warn('Failed to list retired keys', {
295
- component: 'RetiredKeys',
296
- error: error instanceof Error ? error.name : 'unknown',
297
- });
298
- 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
+ );
299
469
  }
470
+ if (!isIOS() && !isAndroid()) return [];
471
+ const store = await ringStore();
472
+ return (await readIndex(store)).filter((entry) => isVisible(entry, scope, owner)).map(toListed);
300
473
  }
301
474
 
302
475
  /**
@@ -313,11 +486,20 @@ export async function getRetiredPrivateKey(
313
486
  const scope = retiredKeyScope(selector);
314
487
  const canonical = canonicalPublicKey(publicKey);
315
488
  if (!scope || !canonical || isWeb()) return null;
489
+ const owner = ownerOf(selector?.ownerAccountId);
490
+ if (scope === PERSONAL_SCOPE && owner === null) return null;
316
491
  try {
317
492
  if (isAndroid()) {
318
493
  const signer = registeredIdentitySignerStore();
319
494
  if (!signer) return null;
320
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
+ }
321
503
  const local = await store.getItemAsync(
322
504
  privateKeyName(scope, canonical),
323
505
  privateOptions(store, false),
@@ -331,6 +513,15 @@ export async function getRetiredPrivateKey(
331
513
  }
332
514
  if (!isIOS()) return null;
333
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
+ }
334
525
  const value = await store.getItemAsync(
335
526
  privateKeyName(scope, canonical),
336
527
  privateOptions(store, false),
@@ -357,8 +548,13 @@ export async function forgetRetiredIdentityKey(
357
548
  const scope = retiredKeyScope(selector);
358
549
  const canonical = canonicalPublicKey(publicKey);
359
550
  if (!scope || !canonical) throw new Error('retired key: invalid selector or public key');
360
- assertMayWrite();
551
+ const owner = ownerOf(selector?.ownerAccountId);
552
+ await assertMayWrite();
361
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');
362
558
  await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
363
559
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
364
560
  await signer?.clearRetired?.(scope, canonical);
@@ -371,21 +567,25 @@ export async function forgetRetiredIdentityKey(
371
567
  }
372
568
 
373
569
  /**
374
- * Forget every retired key of one ring (the person's, or an organization's):
375
- * called when the identity itself is deleted from this device, so a phone
376
- * handed on keeps none of the person's old keys. Commons only. Resolves how
377
- * many it removed.
570
+ * Forget every retired key of one ring (the person's, or an organization's).
571
+ * Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
572
+ * delete-account flow), which tells them the coins go with it. Deleting the
573
+ * identity from this device — "Start over", a forced delete — never calls it:
574
+ * the ring is money, and the same person restoring their identity later must
575
+ * find it (R4-4). Resolves how many it removed.
378
576
  */
379
577
  export async function forgetRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<number> {
380
578
  const scope = retiredKeyScope(selector);
381
579
  if (!scope || isWeb()) return 0;
382
580
  if (isAndroid() && !registeredIdentitySignerStore()) return 0;
581
+ const owner = ownerOf(selector?.ownerAccountId);
582
+ if (scope === PERSONAL_SCOPE && owner === null) return 0;
383
583
  const store = await ringStore();
384
584
  const index = await readIndex(store);
385
585
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
386
586
  let removed = 0;
387
587
  for (const entry of index) {
388
- if (entry.scope !== scope) continue;
588
+ if (!isVisible(entry, scope, owner)) continue;
389
589
  await store.deleteItemAsync(
390
590
  privateKeyName(scope, entry.publicKey),
391
591
  privateOptions(store, false),
@@ -395,23 +595,35 @@ export async function forgetRetiredIdentityKeys(selector?: RetiredKeySelector):
395
595
  }
396
596
  await writeIndex(
397
597
  store,
398
- index.filter((entry) => entry.scope !== scope),
598
+ index.filter((entry) => !isVisible(entry, scope, owner)),
399
599
  );
400
600
  return removed;
401
601
  }
402
602
 
403
603
  /**
404
- * Commons on Android: copy every retired key into the identity signer store
405
- * again, so the identity host answers for it after that file was rebuilt (its
406
- * self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
407
- * 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.
408
613
  */
409
- export async function syncRetiredKeys(): Promise<number> {
614
+ export async function syncRetiredKeys(ownerAccountId?: string): Promise<number> {
410
615
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
411
616
  if (!signer?.writeRetired || !signer.readRetired) return 0;
617
+ const owner = ownerOf(ownerAccountId);
412
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);
413
624
  let mirrored = 0;
414
- 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;
415
627
  const privateKey = await store.getItemAsync(
416
628
  privateKeyName(entry.scope, entry.publicKey),
417
629
  privateOptions(store, false),
@@ -432,6 +644,98 @@ export async function syncRetiredKeys(): Promise<number> {
432
644
  return mirrored;
433
645
  }
434
646
 
647
+ /**
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.
656
+ */
657
+ export async function exportRetiredIdentityKeys(
658
+ selector?: RetiredKeySelector,
659
+ ): Promise<Array<RetiredKeyPair & { retiredAt: number }>> {
660
+ const scope = retiredKeyScope(selector);
661
+ if (!scope || isWeb()) return [];
662
+ await assertMayWrite();
663
+ const out: Array<RetiredKeyPair & { retiredAt: number }> = [];
664
+ for (const entry of await listRetiredIdentityKeys(selector)) {
665
+ const privateKey = await readRetiredStrict(entry.publicKey, scope);
666
+ out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
667
+ }
668
+ return out;
669
+ }
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
+
724
+ /**
725
+ * The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
726
+ * for a person whose old identity was imported as a private key and so has no
727
+ * phrase to re-enter. Stores nothing: the caller checks the public key against
728
+ * the account's key history and only then calls {@link retireIdentityKey}.
729
+ */
730
+ export function retiredKeyFromPrivateKey(privateKeyHex: string): RetiredKeyPair {
731
+ const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
732
+ if (!/^[0-9a-f]{64}$/.test(trimmed) || !isValidSecp256k1PrivateKey(trimmed)) {
733
+ throw new Error('retired key: not a valid private key');
734
+ }
735
+ const privateKey = normalizeSecp256k1PrivateKey(trimmed);
736
+ return { privateKey, publicKey: publicKeyOf(privateKey) };
737
+ }
738
+
435
739
  /**
436
740
  * The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
437
741
  * and organization-key derivation; 12–24 words). Stores nothing: the caller