@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.
@@ -43,15 +43,19 @@
43
43
  * This module imports nothing from `./keyManager` (which imports it).
44
44
  */
45
45
  Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.RetiredKeyRingUnreadableError = void 0;
46
47
  exports.retiredKeyScope = retiredKeyScope;
47
48
  exports.retireIdentityKey = retireIdentityKey;
48
49
  exports.retireIdentityKeyDuringMigration = retireIdentityKeyDuringMigration;
50
+ exports.claimRetiredIdentityKeys = claimRetiredIdentityKeys;
51
+ exports.countQuarantinedRetiredKeys = countQuarantinedRetiredKeys;
49
52
  exports.listRetiredIdentityKeys = listRetiredIdentityKeys;
50
53
  exports.getRetiredPrivateKey = getRetiredPrivateKey;
51
54
  exports.forgetRetiredIdentityKey = forgetRetiredIdentityKey;
52
55
  exports.forgetRetiredIdentityKeys = forgetRetiredIdentityKeys;
53
56
  exports.syncRetiredKeys = syncRetiredKeys;
54
57
  exports.exportRetiredIdentityKeys = exportRetiredIdentityKeys;
58
+ exports.unmirrorPersonalRetiredKeys = unmirrorPersonalRetiredKeys;
55
59
  exports.retiredKeyFromPrivateKey = retiredKeyFromPrivateKey;
56
60
  exports.retiredKeyFromPhrase = retiredKeyFromPhrase;
57
61
  const bip39_1 = require("@scure/bip39");
@@ -64,6 +68,14 @@ const platform_1 = require("../utils/platform");
64
68
  const identitySigner_1 = require("./identitySigner");
65
69
  const keychainItems_1 = require("./keychainItems");
66
70
  const keychainGroupMigration_1 = require("./keychainGroupMigration");
71
+ /** Thrown when the ring's index cannot be read as the ring: never treated as empty (R5-9). */
72
+ class RetiredKeyRingUnreadableError extends Error {
73
+ constructor(reason) {
74
+ super(`retired key ring: ${reason}`);
75
+ this.name = 'RetiredKeyRingUnreadableError';
76
+ }
77
+ }
78
+ exports.RetiredKeyRingUnreadableError = RetiredKeyRingUnreadableError;
67
79
  const PERSONAL_SCOPE = 'personal';
68
80
  /** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
69
81
  const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
@@ -132,6 +144,24 @@ function privateOptions(store, forWrite) {
132
144
  function indexOptions() {
133
145
  return (0, platform_1.isIOS)() ? {} : { keychainService: INDEX_KEYCHAIN_SERVICE };
134
146
  }
147
+ const ACCOUNT_ID = /^(?:[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;
148
+ /** A well-formed Oxy account id (24-hex or uuid), else `null`. */
149
+ function ownerOf(ownerAccountId) {
150
+ return ownerAccountId !== undefined && ACCOUNT_ID.test(ownerAccountId)
151
+ ? ownerAccountId.toLowerCase()
152
+ : null;
153
+ }
154
+ /**
155
+ * Whether `entry` may be reached by a reader of `scope` acting for `owner`: an
156
+ * organization's entries by its own scope, a personal entry only by its owner.
157
+ */
158
+ function isVisible(entry, scope, owner) {
159
+ if (entry.scope !== scope)
160
+ return false;
161
+ if (scope !== PERSONAL_SCOPE)
162
+ return true;
163
+ return owner !== null && entry.owner === owner;
164
+ }
135
165
  function isIndexEntry(value) {
136
166
  if (typeof value !== 'object' || value === null)
137
167
  return false;
@@ -141,23 +171,31 @@ function isIndexEntry(value) {
141
171
  typeof entry.publicKey === 'string' &&
142
172
  PUBLIC_KEY.test(entry.publicKey) &&
143
173
  typeof entry.retiredAt === 'number' &&
144
- Number.isFinite(entry.retiredAt));
174
+ Number.isFinite(entry.retiredAt) &&
175
+ (entry.owner === undefined || (typeof entry.owner === 'string' && ACCOUNT_ID.test(entry.owner))));
145
176
  }
177
+ /**
178
+ * The ring's index. STRICT (R5-6, R5-9): an index that does not parse, or that
179
+ * holds an entry this version cannot read, THROWS
180
+ * {@link RetiredKeyRingUnreadableError} instead of reading as empty, so no
181
+ * writer overwrites it (losing every entry) and no backup is sealed without
182
+ * the keys it names. A thrown keychain read propagates.
183
+ */
146
184
  async function readIndex(store) {
147
185
  const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
148
186
  if (!raw)
149
187
  return [];
188
+ let parsed;
150
189
  try {
151
- const parsed = JSON.parse(raw);
152
- return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
190
+ parsed = JSON.parse(raw);
153
191
  }
154
- catch (error) {
155
- logger_1.logger.warn('Retired key index unreadable; treating as empty', {
156
- component: 'RetiredKeys',
157
- error: error instanceof Error ? error.name : 'unknown',
158
- });
159
- return [];
192
+ catch {
193
+ throw new RetiredKeyRingUnreadableError('the index does not parse');
194
+ }
195
+ if (!Array.isArray(parsed) || !parsed.every(isIndexEntry)) {
196
+ throw new RetiredKeyRingUnreadableError('the index holds an entry this version cannot read');
160
197
  }
198
+ return parsed.map((entry) => entry.owner === undefined ? entry : { ...entry, owner: entry.owner.toLowerCase() });
161
199
  }
162
200
  async function writeIndex(store, entries) {
163
201
  const unique = new Map();
@@ -170,6 +208,7 @@ async function writeIndex(store, entries) {
170
208
  await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify(sorted), indexOptions());
171
209
  }
172
210
  function toListed(entry) {
211
+ // The owner is the reader's own id; it is not repeated.
173
212
  return entry.scope === PERSONAL_SCOPE
174
213
  ? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
175
214
  : { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
@@ -194,19 +233,29 @@ async function assertMayWrite() {
194
233
  }
195
234
  }
196
235
  /**
197
- * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
198
- * key already kept stays, with its first date. Refuses a pair whose public key
199
- * does not derive from its private key. Resolves once every copy confirmed it
236
+ * Keep `key` in the ring of `selector`'s account. Commons only. A PERSONAL key
237
+ * needs `selector.ownerAccountId`, the person whose key it was (R5-2): the
238
+ * signed-in account at a rotation, the account whose key history or backup
239
+ * named it. Idempotent: a key already kept stays, with its first date; a
240
+ * quarantined entry of the same key is bound to that owner; an entry bound to
241
+ * a DIFFERENT owner is refused. Refuses a pair whose public key does not
242
+ * derive from its private key. Resolves once every copy confirmed it
200
243
  * (Android: the signer store's read-back too), and throws otherwise, so a
201
244
  * caller about to replace the key can stop before anything is lost.
202
245
  */
203
246
  async function retireIdentityKey(key, selector) {
247
+ const scope = retiredKeyScope(selector);
248
+ if (scope === PERSONAL_SCOPE && ownerOf(selector?.ownerAccountId) === null) {
249
+ throw new Error('retired key: a personal key needs ownerAccountId, the account it belongs to');
250
+ }
204
251
  await keepRetired(key, selector, true);
205
252
  }
206
253
  /**
207
254
  * {@link retireIdentityKey} from inside the iOS access-group migration, which
208
- * keeps a replaced pre-split copy before deleting it from the shared group and
209
- * cannot await itself. @internal
255
+ * keeps a pre-split copy before deleting it from the shared group and cannot
256
+ * await itself. A personal key without `ownerAccountId` is kept QUARANTINED:
257
+ * held (it may be money) but reached by nobody until
258
+ * {@link claimRetiredIdentityKeys} binds it. @internal
210
259
  */
211
260
  async function retireIdentityKeyDuringMigration(key, selector) {
212
261
  await keepRetired(key, selector, false);
@@ -215,6 +264,7 @@ async function keepRetired(key, selector, gate) {
215
264
  const scope = retiredKeyScope(selector);
216
265
  if (!scope)
217
266
  throw new Error('retired key: accountId must be a 24-hex or uuid account id');
267
+ const owner = scope === PERSONAL_SCOPE ? ownerOf(selector?.ownerAccountId) : null;
218
268
  await assertMayWrite();
219
269
  if (!isHealthyPair(key.privateKey, key.publicKey)) {
220
270
  throw new Error('retired key: the public key does not derive from the private key');
@@ -222,51 +272,114 @@ async function keepRetired(key, selector, gate) {
222
272
  const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(key.privateKey);
223
273
  const publicKey = (0, secp256k1_1.normalizeSecp256k1PublicKey)(key.publicKey);
224
274
  const store = await ringStore(gate);
275
+ // Strict: an unreadable index stops the write before the key is touched.
276
+ const before = await readIndex(store);
277
+ const existing = before.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
278
+ if (existing?.owner !== undefined && owner !== null && existing.owner !== owner) {
279
+ throw new Error('retired key: this key is kept for a different account');
280
+ }
225
281
  await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
226
282
  const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
227
283
  if (readBack !== privateKey) {
228
284
  throw new Error('retired key: the keychain did not confirm the key');
229
285
  }
286
+ const index = await readIndex(store);
287
+ const kept = index.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
288
+ if (!kept) {
289
+ await writeIndex(store, [
290
+ ...index,
291
+ owner === null
292
+ ? { scope, publicKey, retiredAt: Date.now() }
293
+ : { scope, publicKey, retiredAt: Date.now(), owner },
294
+ ]);
295
+ }
296
+ else if (kept.owner === undefined && owner !== null) {
297
+ await writeIndex(store, index.map((entry) => (entry === kept ? { ...entry, owner } : entry)));
298
+ }
299
+ // The identity host answers only from the signer store, which holds the
300
+ // organizations' rings and the CURRENT person's personal entries; a
301
+ // quarantined entry is never mirrored.
230
302
  const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
231
- if (signer?.writeRetired && !(await signer.writeRetired(scope, privateKey, publicKey))) {
303
+ if (signer?.writeRetired &&
304
+ (scope !== PERSONAL_SCOPE || owner !== null) &&
305
+ !(await signer.writeRetired(scope, privateKey, publicKey))) {
232
306
  throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
233
307
  }
308
+ }
309
+ /**
310
+ * Bind the QUARANTINED personal entries whose public key `publicKeys` names to
311
+ * `ownerAccountId` (R5-2, R5-3). Commons only, with the signed-in person's
312
+ * account id and that account's key history from Oxy
313
+ * (`oxy.identity.keyHistory()`: retired and current public keys): a key the
314
+ * account's own history names is that account's. Entries bound to anyone, and
315
+ * keys the history does not name, are left as they are. Resolves how many it
316
+ * bound; call {@link syncRetiredKeys} after it on Android.
317
+ */
318
+ async function claimRetiredIdentityKeys(ownerAccountId, publicKeys) {
319
+ const owner = ownerOf(ownerAccountId);
320
+ if (owner === null)
321
+ throw new Error('retired key: ownerAccountId must be an account id');
322
+ if ((0, platform_1.isWeb)())
323
+ return 0;
324
+ await assertMayWrite();
325
+ const named = new Set(publicKeys.map((key) => canonicalPublicKey(key)).filter((key) => key !== null));
326
+ const store = await ringStore();
234
327
  const index = await readIndex(store);
235
- if (!index.some((entry) => entry.scope === scope && entry.publicKey === publicKey)) {
236
- await writeIndex(store, [...index, { scope, publicKey, retiredAt: Date.now() }]);
237
- }
328
+ let bound = 0;
329
+ const next = index.map((entry) => {
330
+ if (entry.scope !== PERSONAL_SCOPE || entry.owner !== undefined)
331
+ return entry;
332
+ if (!named.has(entry.publicKey))
333
+ return entry;
334
+ bound += 1;
335
+ return { ...entry, owner };
336
+ });
337
+ if (bound > 0)
338
+ await writeIndex(store, next);
339
+ return bound;
340
+ }
341
+ /**
342
+ * How many personal entries are quarantined (no known owner). Commons shows
343
+ * them as "keys from before" the person can claim by adding the phrase, and
344
+ * {@link claimRetiredIdentityKeys} binds the ones the account's history names.
345
+ */
346
+ async function countQuarantinedRetiredKeys() {
347
+ if ((0, platform_1.isWeb)() || ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()))
348
+ return 0;
349
+ const store = await ringStore();
350
+ return (await readIndex(store)).filter((entry) => entry.scope === PERSONAL_SCOPE && entry.owner === undefined).length;
238
351
  }
239
352
  /**
240
353
  * The retired keys of `selector`'s account this app can reach, oldest first.
241
- * iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
242
- * (the wallet only), which leaves out the scope's current key. After an
243
- * interrupted rotation the local list can include the current key: compare
244
- * with the DID's `#key-1` before treating an entry as retired.
354
+ * The personal ring answers only `selector.ownerAccountId`'s entries (none
355
+ * without it; R5-2). iOS and Commons: the ring's index. Android elsewhere:
356
+ * Commons' identity host (the wallet only), which holds only the personal
357
+ * entries of the person Commons is signed in as and leaves out the scope's
358
+ * current key. After an interrupted rotation the local list can include the
359
+ * current key: compare with the DID's `#key-1` before treating an entry as
360
+ * retired, and check every entry against the account's key history.
361
+ *
362
+ * STRICT (R5-6): a ring that cannot be read throws (the keychain's error, or
363
+ * {@link RetiredKeyRingUnreadableError}); it never reads as empty.
245
364
  */
246
365
  async function listRetiredIdentityKeys(selector) {
247
366
  const scope = retiredKeyScope(selector);
248
367
  if (!scope || (0, platform_1.isWeb)())
249
368
  return [];
250
- try {
251
- if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()) {
252
- const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
253
- const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
254
- return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
255
- ? { publicKey, retiredAt: 0 }
256
- : { publicKey, accountId: scope, retiredAt: 0 });
257
- }
258
- if (!(0, platform_1.isIOS)() && !(0, platform_1.isAndroid)())
259
- return [];
260
- const store = await ringStore();
261
- return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
262
- }
263
- catch (error) {
264
- logger_1.logger.warn('Failed to list retired keys', {
265
- component: 'RetiredKeys',
266
- error: error instanceof Error ? error.name : 'unknown',
267
- });
369
+ const owner = ownerOf(selector?.ownerAccountId);
370
+ if (scope === PERSONAL_SCOPE && owner === null)
268
371
  return [];
372
+ if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()) {
373
+ const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
374
+ const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
375
+ return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
376
+ ? { publicKey, retiredAt: 0 }
377
+ : { publicKey, accountId: scope, retiredAt: 0 });
269
378
  }
379
+ if (!(0, platform_1.isIOS)() && !(0, platform_1.isAndroid)())
380
+ return [];
381
+ const store = await ringStore();
382
+ return (await readIndex(store)).filter((entry) => isVisible(entry, scope, owner)).map(toListed);
270
383
  }
271
384
  /**
272
385
  * The private half of a retired key, where this app may hold it: Commons (both
@@ -280,12 +393,18 @@ async function getRetiredPrivateKey(publicKey, selector) {
280
393
  const canonical = canonicalPublicKey(publicKey);
281
394
  if (!scope || !canonical || (0, platform_1.isWeb)())
282
395
  return null;
396
+ const owner = ownerOf(selector?.ownerAccountId);
397
+ if (scope === PERSONAL_SCOPE && owner === null)
398
+ return null;
283
399
  try {
284
400
  if ((0, platform_1.isAndroid)()) {
285
401
  const signer = (0, identitySigner_1.registeredIdentitySignerStore)();
286
402
  if (!signer)
287
403
  return null;
288
404
  const store = await ringStore();
405
+ if (!(await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical)) {
406
+ return null;
407
+ }
289
408
  const local = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
290
409
  if (local && isHealthyPair(local, canonical))
291
410
  return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
@@ -298,6 +417,11 @@ async function getRetiredPrivateKey(publicKey, selector) {
298
417
  if (!(0, platform_1.isIOS)())
299
418
  return null;
300
419
  const store = await ringStore();
420
+ // The index decides who may reach a key (R5-2); a key it does not list for
421
+ // this reader is not theirs, whatever item exists.
422
+ if (!(await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical)) {
423
+ return null;
424
+ }
301
425
  const value = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
302
426
  return value && isHealthyPair(value, canonical) ? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(value) : null;
303
427
  }
@@ -319,8 +443,12 @@ async function forgetRetiredIdentityKey(publicKey, selector) {
319
443
  const canonical = canonicalPublicKey(publicKey);
320
444
  if (!scope || !canonical)
321
445
  throw new Error('retired key: invalid selector or public key');
446
+ const owner = ownerOf(selector?.ownerAccountId);
322
447
  await assertMayWrite();
323
448
  const store = await ringStore();
449
+ const visible = (await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical);
450
+ if (!visible)
451
+ throw new Error('retired key: not a key of this account');
324
452
  await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
325
453
  const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
326
454
  await signer?.clearRetired?.(scope, canonical);
@@ -340,33 +468,47 @@ async function forgetRetiredIdentityKeys(selector) {
340
468
  return 0;
341
469
  if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)())
342
470
  return 0;
471
+ const owner = ownerOf(selector?.ownerAccountId);
472
+ if (scope === PERSONAL_SCOPE && owner === null)
473
+ return 0;
343
474
  const store = await ringStore();
344
475
  const index = await readIndex(store);
345
476
  const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
346
477
  let removed = 0;
347
478
  for (const entry of index) {
348
- if (entry.scope !== scope)
479
+ if (!isVisible(entry, scope, owner))
349
480
  continue;
350
481
  await store.deleteItemAsync(privateKeyName(scope, entry.publicKey), privateOptions(store, false));
351
482
  await signer?.clearRetired?.(scope, entry.publicKey);
352
483
  removed += 1;
353
484
  }
354
- await writeIndex(store, index.filter((entry) => entry.scope !== scope));
485
+ await writeIndex(store, index.filter((entry) => !isVisible(entry, scope, owner)));
355
486
  return removed;
356
487
  }
357
488
  /**
358
- * Commons on Android: copy every retired key into the identity signer store
359
- * again, so the identity host answers for it after that file was rebuilt (its
360
- * self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
361
- * Resolves how many it mirrored. Elsewhere 0.
489
+ * Commons on Android: make the identity signer store hold exactly what the
490
+ * identity host may answer — every organization ring, and the personal entries
491
+ * of `ownerAccountId` (the person Commons is signed in as) — so the host
492
+ * answers for them after that file was rebuilt (its self-heal leaves it
493
+ * empty), and never for a previous person's keys (R5-2): every other personal
494
+ * entry is removed from the signer store (its own slot keeps it). Without
495
+ * `ownerAccountId` no personal entry is mirrored. Call on every boot and after
496
+ * every sign-in, beside `syncOrganizationKeys`. Resolves how many it mirrored.
497
+ * Elsewhere 0.
362
498
  */
363
- async function syncRetiredKeys() {
499
+ async function syncRetiredKeys(ownerAccountId) {
364
500
  const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
365
501
  if (!signer?.writeRetired || !signer.readRetired)
366
502
  return 0;
503
+ const owner = ownerOf(ownerAccountId);
367
504
  const store = await ringStore();
505
+ const index = await readIndex(store);
506
+ const allowed = new Set(index.filter((entry) => isVisible(entry, PERSONAL_SCOPE, owner)).map((e) => e.publicKey));
507
+ await clearPersonalMirror(signer, index, allowed);
368
508
  let mirrored = 0;
369
- for (const entry of await readIndex(store)) {
509
+ for (const entry of index) {
510
+ if (entry.scope === PERSONAL_SCOPE && !isVisible(entry, PERSONAL_SCOPE, owner))
511
+ continue;
370
512
  const privateKey = await store.getItemAsync(privateKeyName(entry.scope, entry.publicKey), privateOptions(store, false));
371
513
  if (!privateKey || !isHealthyPair(privateKey, entry.publicKey))
372
514
  continue;
@@ -380,12 +522,14 @@ async function syncRetiredKeys() {
380
522
  return mirrored;
381
523
  }
382
524
  /**
383
- * Every retired key of `selector`'s ring WITH its private half, for the
384
- * encrypted identity backup (`oxy.identity.backup.create`), which carries them
385
- * under the CURRENT phrase so a lost phone costs no more than the current
386
- * phrase does (R4-4). Commons only; an entry whose private half is missing or
387
- * does not derive its public key is left out. Never log or transmit the result
388
- * except inside that encrypted envelope.
525
+ * Every retired key of `selector`'s ring (personal: `ownerAccountId`'s entries)
526
+ * WITH its private half, for the encrypted identity backup
527
+ * (`oxy.identity.backup.create`), which carries them under the CURRENT phrase
528
+ * so a lost phone costs no more than the current phrase does (R4-4). Commons
529
+ * only. STRICT (R5-6): an unreadable ring, or a listed entry whose private
530
+ * half is missing or does not derive its public key, THROWS — a backup is
531
+ * never sealed without a key the ring says it holds. Never log or transmit the
532
+ * result except inside that encrypted envelope.
389
533
  */
390
534
  async function exportRetiredIdentityKeys(selector) {
391
535
  const scope = retiredKeyScope(selector);
@@ -394,12 +538,57 @@ async function exportRetiredIdentityKeys(selector) {
394
538
  await assertMayWrite();
395
539
  const out = [];
396
540
  for (const entry of await listRetiredIdentityKeys(selector)) {
397
- const privateKey = await getRetiredPrivateKey(entry.publicKey, selector);
398
- if (privateKey)
399
- out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
541
+ const privateKey = await readRetiredStrict(entry.publicKey, scope);
542
+ out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
400
543
  }
401
544
  return out;
402
545
  }
546
+ /** A listed entry's private half, or a throw: Commons' own slot, then (Android) the signer copy. */
547
+ async function readRetiredStrict(publicKey, scope) {
548
+ const store = await ringStore();
549
+ const local = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
550
+ if (local && isHealthyPair(local, publicKey))
551
+ return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
552
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
553
+ const mirrored = (await signer?.readRetired?.(scope, publicKey)) ?? null;
554
+ if (mirrored && isHealthyPair(mirrored.privateKey, publicKey)) {
555
+ return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(mirrored.privateKey);
556
+ }
557
+ throw new RetiredKeyRingUnreadableError('a listed key has no readable private half');
558
+ }
559
+ /**
560
+ * Commons on Android, when the identity leaves this device ("Start over", a
561
+ * forced delete): remove every PERSONAL entry from the identity signer store,
562
+ * so the identity host answers no other app with them before the next person
563
+ * signs in (R5-2). Commons' own slot and index keep them (money, bound to
564
+ * their owner). Elsewhere a no-op.
565
+ */
566
+ async function unmirrorPersonalRetiredKeys() {
567
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
568
+ if (!signer?.clearRetired)
569
+ return;
570
+ let index = [];
571
+ try {
572
+ index = await readIndex(await ringStore());
573
+ }
574
+ catch {
575
+ // The signer store's own list below still reaches every mirrored entry.
576
+ }
577
+ await clearPersonalMirror(signer, index, new Set());
578
+ }
579
+ /** Remove from the signer store every personal entry not in `allowed`: the index's and the store's own list. */
580
+ async function clearPersonalMirror(signer, index, allowed) {
581
+ if (!signer.clearRetired)
582
+ return;
583
+ const mirrored = new Set(index.filter((entry) => entry.scope === PERSONAL_SCOPE).map((entry) => entry.publicKey));
584
+ for (const publicKey of (await signer.listRetired?.(PERSONAL_SCOPE)) ?? []) {
585
+ mirrored.add(publicKey.toLowerCase());
586
+ }
587
+ for (const publicKey of mirrored) {
588
+ if (!allowed.has(publicKey))
589
+ await signer.clearRetired(PERSONAL_SCOPE, publicKey);
590
+ }
591
+ }
403
592
  /**
404
593
  * The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
405
594
  * for a person whose old identity was imported as a private key and so has no
@@ -199,7 +199,7 @@ export class IdentityApi {
199
199
  try {
200
200
  if (!oldPrivateKeyForRing)
201
201
  throw new Error('no readable private key');
202
- await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
202
+ await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey }, { ownerAccountId: userId });
203
203
  }
204
204
  catch (retainError) {
205
205
  logger.warn('Could not keep the current key as a retired key; not rotating.', { component: 'oxy.identity', method: 'rotateKey' }, retainError);
@@ -629,9 +629,12 @@ export class IdentityBackupApi {
629
629
  if (isWeb())
630
630
  return [];
631
631
  const { exportRetiredIdentityKeys } = await import('../crypto/index.js');
632
+ const userId = this.ctx.oxy.session.userId;
633
+ if (!userId)
634
+ throw new Error('No authenticated user — sign in before backing up.');
632
635
  let held;
633
636
  try {
634
- held = await exportRetiredIdentityKeys();
637
+ held = await exportRetiredIdentityKeys({ ownerAccountId: userId });
635
638
  }
636
639
  catch {
637
640
  return []; // Not Commons: this app holds no ring to carry.
@@ -690,6 +693,11 @@ export class IdentityBackupApi {
690
693
  */
691
694
  async keepCarriedRetiredKeys(payload) {
692
695
  const { KeyManager, retireIdentityKey } = await import('../crypto/index.js');
696
+ // The backup is the signed-in account's (its phrase opened it): its keys
697
+ // are kept for that account only (R5-2).
698
+ const ownerAccountId = this.ctx.oxy.session.userId;
699
+ if (!ownerAccountId)
700
+ throw new Error('No authenticated user — sign in before restoring.');
693
701
  let kept = 0;
694
702
  for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
695
703
  try {
@@ -700,7 +708,7 @@ export class IdentityBackupApi {
700
708
  if (KeyManager.derivePublicKey(key.privateKey).toLowerCase() !== key.publicKey.toLowerCase()) {
701
709
  continue;
702
710
  }
703
- await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey });
711
+ await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey }, { ownerAccountId });
704
712
  kept += 1;
705
713
  }
706
714
  catch (error) {
@@ -22,7 +22,7 @@ export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFo
22
22
  export { ensureKeychainGroupsMigrated, getPreSplitExposure, } from './keychainGroupMigration.js';
23
23
  // Retired identity keys: kept for spending after a rotation or replacement
24
24
  // (docs/adr/0061-organization-identity-key.md D7 as amended 2026-11-06)
25
- export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, } from './retiredKeys.js';
25
+ export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, claimRetiredIdentityKeys, countQuarantinedRetiredKeys, unmirrorPersonalRetiredKeys, RetiredKeyRingUnreadableError, } from './retiredKeys.js';
26
26
  export { SignatureService } from './signatureService.js';
27
27
  export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase.js';
28
28
  // Low-level primitives (encrypted backup, device transfer)