@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
@@ -32,7 +32,8 @@ import { logger } from '../logger/index.js';
32
32
  import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
33
33
  import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
34
34
  import { registeredIdentitySignerStore } from './identitySigner.js';
35
- import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
35
+ import { prepareKeychainAccess } from './keychainGroupMigration.js';
36
+ import { retireIdentityKey } from './retiredKeys.js';
36
37
  import { ORGANIZATION_ANDROID_KEYCHAIN_SERVICE as ANDROID_KEYCHAIN_SERVICE, ORGANIZATION_INDEX_KEYCHAIN_SERVICE as INDEX_KEYCHAIN_SERVICE, ORGANIZATION_INDEX_STORAGE_KEY as INDEX_STORAGE_KEY, ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE as MNEMONIC_KEYCHAIN_SERVICE, isOrganizationAccountId, organizationMnemonicName as mnemonicName, organizationPrivateKeyName as privateKeyName, organizationPublicKeyName as publicKeyName, serializeOrganizationIndex, } from './keychainItems.js';
37
38
  export { isOrganizationAccountId };
38
39
  const ORGANIZATION_PHRASE_WORDS = 24;
@@ -102,9 +103,7 @@ export async function organizationKeyFromPhrase(phrase) {
102
103
  async function secureStore(item) {
103
104
  const raw = await loadSecureStore();
104
105
  const access = await resolveKeychainAccess();
105
- if (access.mode === 'grouped' && access.role === 'holder') {
106
- await ensureKeychainGroupsMigrated();
107
- }
106
+ await prepareKeychainAccess(access);
108
107
  return groupedKeychainStore(raw, access, item);
109
108
  }
110
109
  const pairStore = () => secureStore('spending-key');
@@ -195,8 +194,10 @@ async function readLocalPair(accountId) {
195
194
  * it; on iOS only Commons holds the groups it writes (any other app throws).
196
195
  *
197
196
  * Refuses a pair whose public key does not derive from its private key, and a
198
- * phrase that does not derive the pair. Overwrites whatever this device held for
199
- * the organization (a replaced key is the caller's decision, ADR 0061 D7).
197
+ * phrase that does not derive the pair. A DIFFERENT key this device already
198
+ * held for the organization is first kept in the organization's retired ring
199
+ * (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
200
+ * cannot be confirmed nothing is overwritten and this throws.
200
201
  */
201
202
  export async function storeOrganizationKey(accountId, key) {
202
203
  assertAccountId(accountId);
@@ -218,6 +219,18 @@ export async function storeOrganizationKey(accountId, key) {
218
219
  throw new Error('On Android only Commons holds organization keys; this app has no identity signer store for them.');
219
220
  }
220
221
  const store = await pairStore();
222
+ const previous = await heldPrivateKey(store, accountId);
223
+ if (previous) {
224
+ const previousPublic = deriveSecp256k1PublicKey(previous);
225
+ if (previousPublic !== publicKey) {
226
+ try {
227
+ await retireIdentityKey({ privateKey: previous, publicKey: previousPublic }, { accountId });
228
+ }
229
+ catch (error) {
230
+ throw new Error(`organization key: the key this device held could not be kept as a previous key, so it was not replaced (${error instanceof Error ? error.message : 'unknown'})`);
231
+ }
232
+ }
233
+ }
221
234
  await store.setItemAsync(privateKeyName(accountId), privateKey, pairOptions(store, true));
222
235
  await store.setItemAsync(publicKeyName(accountId), publicKey, pairOptions(store, false));
223
236
  if (signer?.writeOrganization &&
@@ -298,6 +311,21 @@ export async function listOrganizationKeys() {
298
311
  return [];
299
312
  return readIndex(await indexStore());
300
313
  }
314
+ /**
315
+ * The private key this device holds for `accountId`, whatever its public half
316
+ * says (an unhealthy pair is still a key coins may sit under): the slot, then
317
+ * on Android the signer store's copy. `null` when there is none.
318
+ */
319
+ async function heldPrivateKey(store, accountId) {
320
+ const local = await store.getItemAsync(privateKeyName(accountId), pairOptions(store, false));
321
+ if (local && isValidSecp256k1PrivateKey(local))
322
+ return normalizeSecp256k1PrivateKey(local);
323
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
324
+ const mirrored = (await signer?.readOrganization?.(accountId)) ?? null;
325
+ return mirrored && isValidSecp256k1PrivateKey(mirrored.privateKey)
326
+ ? normalizeSecp256k1PrivateKey(mirrored.privateKey)
327
+ : null;
328
+ }
301
329
  /** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
302
330
  export async function deleteOrganizationKey(accountId) {
303
331
  assertAccountId(accountId);
@@ -49,8 +49,15 @@ import { logger } from '../logger/index.js';
49
49
  import { groupedKeychainStore, resolveKeychainAccess } from '../utils/keychainAccessGroups.js';
50
50
  import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
51
51
  import { registeredIdentitySignerStore } from './identitySigner.js';
52
- import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
53
- import { isOrganizationAccountId } from './organizationKeys.js';
52
+ import { isOrganizationAccountId } from './keychainItems.js';
53
+ import { prepareKeychainAccess } from './keychainGroupMigration.js';
54
+ /** Thrown when the ring's index cannot be read as the ring: never treated as empty (R5-9). */
55
+ export class RetiredKeyRingUnreadableError extends Error {
56
+ constructor(reason) {
57
+ super(`retired key ring: ${reason}`);
58
+ this.name = 'RetiredKeyRingUnreadableError';
59
+ }
60
+ }
54
61
  const PERSONAL_SCOPE = 'personal';
55
62
  /** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
56
63
  const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
@@ -61,15 +68,15 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
61
68
  * The secure store for the ring. On iOS every item is a `spending-key`
62
69
  * (ADR 0062): the private keys AND the index live in the wallet group, which
63
70
  * only Commons and the Peable wallet can read, never the shared one. In
64
- * Commons every access first awaits the access-group migration. On Android
65
- * the grouping is a no-op and Commons' own services below apply.
71
+ * Commons every access first awaits the access-group migration (`gate: false`
72
+ * is the migration itself, keeping a pre-split copy). On Android the grouping
73
+ * is a no-op and Commons' own services below apply.
66
74
  */
67
- async function ringStore() {
75
+ async function ringStore(gate = true) {
68
76
  const raw = await loadSecureStore();
69
77
  const access = await resolveKeychainAccess();
70
- if (access.mode === 'grouped' && access.role === 'holder') {
71
- await ensureKeychainGroupsMigrated();
72
- }
78
+ if (gate)
79
+ await prepareKeychainAccess(access);
73
80
  return groupedKeychainStore(raw, access, 'spending-key');
74
81
  }
75
82
  /** The ring a selector names: `personal`, or the organization's account id. */
@@ -119,6 +126,24 @@ function privateOptions(store, forWrite) {
119
126
  function indexOptions() {
120
127
  return isIOS() ? {} : { keychainService: INDEX_KEYCHAIN_SERVICE };
121
128
  }
129
+ 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;
130
+ /** A well-formed Oxy account id (24-hex or uuid), else `null`. */
131
+ function ownerOf(ownerAccountId) {
132
+ return ownerAccountId !== undefined && ACCOUNT_ID.test(ownerAccountId)
133
+ ? ownerAccountId.toLowerCase()
134
+ : null;
135
+ }
136
+ /**
137
+ * Whether `entry` may be reached by a reader of `scope` acting for `owner`: an
138
+ * organization's entries by its own scope, a personal entry only by its owner.
139
+ */
140
+ function isVisible(entry, scope, owner) {
141
+ if (entry.scope !== scope)
142
+ return false;
143
+ if (scope !== PERSONAL_SCOPE)
144
+ return true;
145
+ return owner !== null && entry.owner === owner;
146
+ }
122
147
  function isIndexEntry(value) {
123
148
  if (typeof value !== 'object' || value === null)
124
149
  return false;
@@ -128,23 +153,31 @@ function isIndexEntry(value) {
128
153
  typeof entry.publicKey === 'string' &&
129
154
  PUBLIC_KEY.test(entry.publicKey) &&
130
155
  typeof entry.retiredAt === 'number' &&
131
- Number.isFinite(entry.retiredAt));
156
+ Number.isFinite(entry.retiredAt) &&
157
+ (entry.owner === undefined || (typeof entry.owner === 'string' && ACCOUNT_ID.test(entry.owner))));
132
158
  }
159
+ /**
160
+ * The ring's index. STRICT (R5-6, R5-9): an index that does not parse, or that
161
+ * holds an entry this version cannot read, THROWS
162
+ * {@link RetiredKeyRingUnreadableError} instead of reading as empty, so no
163
+ * writer overwrites it (losing every entry) and no backup is sealed without
164
+ * the keys it names. A thrown keychain read propagates.
165
+ */
133
166
  async function readIndex(store) {
134
167
  const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
135
168
  if (!raw)
136
169
  return [];
170
+ let parsed;
137
171
  try {
138
- const parsed = JSON.parse(raw);
139
- return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
172
+ parsed = JSON.parse(raw);
140
173
  }
141
- catch (error) {
142
- logger.warn('Retired key index unreadable; treating as empty', {
143
- component: 'RetiredKeys',
144
- error: error instanceof Error ? error.name : 'unknown',
145
- });
146
- return [];
174
+ catch {
175
+ throw new RetiredKeyRingUnreadableError('the index does not parse');
176
+ }
177
+ if (!Array.isArray(parsed) || !parsed.every(isIndexEntry)) {
178
+ throw new RetiredKeyRingUnreadableError('the index holds an entry this version cannot read');
147
179
  }
180
+ return parsed.map((entry) => entry.owner === undefined ? entry : { ...entry, owner: entry.owner.toLowerCase() });
148
181
  }
149
182
  async function writeIndex(store, entries) {
150
183
  const unique = new Map();
@@ -157,81 +190,178 @@ async function writeIndex(store, entries) {
157
190
  await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify(sorted), indexOptions());
158
191
  }
159
192
  function toListed(entry) {
193
+ // The owner is the reader's own id; it is not repeated.
160
194
  return entry.scope === PERSONAL_SCOPE
161
195
  ? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
162
196
  : { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
163
197
  }
164
- /** Whether this app writes the ring: Commons (Android: the signer store it registers; iOS: native). */
165
- function assertMayWrite() {
198
+ /**
199
+ * Whether this app writes the ring: Commons only (Android: the signer store it
200
+ * registers; iOS: the keychain role `holder`). The Peable wallet holds the
201
+ * wallet group too, but only reads, derives and signs: a key the wallet could
202
+ * add to the ring is a key it could choose (R4-14).
203
+ */
204
+ async function assertMayWrite() {
166
205
  if (isWeb())
167
206
  throw new Error('retired key: only a native device holds one');
168
207
  if (isAndroid() && !registeredIdentitySignerStore()?.writeRetired) {
169
208
  throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
170
209
  }
210
+ if (isIOS()) {
211
+ const access = await resolveKeychainAccess();
212
+ if (access.mode === 'grouped' && access.role !== 'holder') {
213
+ throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
214
+ }
215
+ }
171
216
  }
172
217
  /**
173
- * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
174
- * key already kept stays, with its first date. Refuses a pair whose public key
175
- * does not derive from its private key. Resolves once every copy confirmed it
218
+ * Keep `key` in the ring of `selector`'s account. Commons only. A PERSONAL key
219
+ * needs `selector.ownerAccountId`, the person whose key it was (R5-2): the
220
+ * signed-in account at a rotation, the account whose key history or backup
221
+ * named it. Idempotent: a key already kept stays, with its first date; a
222
+ * quarantined entry of the same key is bound to that owner; an entry bound to
223
+ * a DIFFERENT owner is refused. Refuses a pair whose public key does not
224
+ * derive from its private key. Resolves once every copy confirmed it
176
225
  * (Android: the signer store's read-back too), and throws otherwise, so a
177
226
  * caller about to replace the key can stop before anything is lost.
178
227
  */
179
228
  export async function retireIdentityKey(key, selector) {
229
+ const scope = retiredKeyScope(selector);
230
+ if (scope === PERSONAL_SCOPE && ownerOf(selector?.ownerAccountId) === null) {
231
+ throw new Error('retired key: a personal key needs ownerAccountId, the account it belongs to');
232
+ }
233
+ await keepRetired(key, selector, true);
234
+ }
235
+ /**
236
+ * {@link retireIdentityKey} from inside the iOS access-group migration, which
237
+ * keeps a pre-split copy before deleting it from the shared group and cannot
238
+ * await itself. A personal key without `ownerAccountId` is kept QUARANTINED:
239
+ * held (it may be money) but reached by nobody until
240
+ * {@link claimRetiredIdentityKeys} binds it. @internal
241
+ */
242
+ export async function retireIdentityKeyDuringMigration(key, selector) {
243
+ await keepRetired(key, selector, false);
244
+ }
245
+ async function keepRetired(key, selector, gate) {
180
246
  const scope = retiredKeyScope(selector);
181
247
  if (!scope)
182
248
  throw new Error('retired key: accountId must be a 24-hex or uuid account id');
183
- assertMayWrite();
249
+ const owner = scope === PERSONAL_SCOPE ? ownerOf(selector?.ownerAccountId) : null;
250
+ await assertMayWrite();
184
251
  if (!isHealthyPair(key.privateKey, key.publicKey)) {
185
252
  throw new Error('retired key: the public key does not derive from the private key');
186
253
  }
187
254
  const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
188
255
  const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
189
- const store = await ringStore();
256
+ const store = await ringStore(gate);
257
+ // Strict: an unreadable index stops the write before the key is touched.
258
+ const before = await readIndex(store);
259
+ const existing = before.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
260
+ if (existing?.owner !== undefined && owner !== null && existing.owner !== owner) {
261
+ throw new Error('retired key: this key is kept for a different account');
262
+ }
190
263
  await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
191
264
  const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
192
265
  if (readBack !== privateKey) {
193
266
  throw new Error('retired key: the keychain did not confirm the key');
194
267
  }
268
+ const index = await readIndex(store);
269
+ const kept = index.find((entry) => entry.scope === scope && entry.publicKey === publicKey);
270
+ if (!kept) {
271
+ await writeIndex(store, [
272
+ ...index,
273
+ owner === null
274
+ ? { scope, publicKey, retiredAt: Date.now() }
275
+ : { scope, publicKey, retiredAt: Date.now(), owner },
276
+ ]);
277
+ }
278
+ else if (kept.owner === undefined && owner !== null) {
279
+ await writeIndex(store, index.map((entry) => (entry === kept ? { ...entry, owner } : entry)));
280
+ }
281
+ // The identity host answers only from the signer store, which holds the
282
+ // organizations' rings and the CURRENT person's personal entries; a
283
+ // quarantined entry is never mirrored.
195
284
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
196
- if (signer?.writeRetired && !(await signer.writeRetired(scope, privateKey, publicKey))) {
285
+ if (signer?.writeRetired &&
286
+ (scope !== PERSONAL_SCOPE || owner !== null) &&
287
+ !(await signer.writeRetired(scope, privateKey, publicKey))) {
197
288
  throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
198
289
  }
290
+ }
291
+ /**
292
+ * Bind the QUARANTINED personal entries whose public key `publicKeys` names to
293
+ * `ownerAccountId` (R5-2, R5-3). Commons only, with the signed-in person's
294
+ * account id and that account's key history from Oxy
295
+ * (`oxy.identity.keyHistory()`: retired and current public keys): a key the
296
+ * account's own history names is that account's. Entries bound to anyone, and
297
+ * keys the history does not name, are left as they are. Resolves how many it
298
+ * bound; call {@link syncRetiredKeys} after it on Android.
299
+ */
300
+ export async function claimRetiredIdentityKeys(ownerAccountId, publicKeys) {
301
+ const owner = ownerOf(ownerAccountId);
302
+ if (owner === null)
303
+ throw new Error('retired key: ownerAccountId must be an account id');
304
+ if (isWeb())
305
+ return 0;
306
+ await assertMayWrite();
307
+ const named = new Set(publicKeys.map((key) => canonicalPublicKey(key)).filter((key) => key !== null));
308
+ const store = await ringStore();
199
309
  const index = await readIndex(store);
200
- if (!index.some((entry) => entry.scope === scope && entry.publicKey === publicKey)) {
201
- await writeIndex(store, [...index, { scope, publicKey, retiredAt: Date.now() }]);
202
- }
310
+ let bound = 0;
311
+ const next = index.map((entry) => {
312
+ if (entry.scope !== PERSONAL_SCOPE || entry.owner !== undefined)
313
+ return entry;
314
+ if (!named.has(entry.publicKey))
315
+ return entry;
316
+ bound += 1;
317
+ return { ...entry, owner };
318
+ });
319
+ if (bound > 0)
320
+ await writeIndex(store, next);
321
+ return bound;
322
+ }
323
+ /**
324
+ * How many personal entries are quarantined (no known owner). Commons shows
325
+ * them as "keys from before" the person can claim by adding the phrase, and
326
+ * {@link claimRetiredIdentityKeys} binds the ones the account's history names.
327
+ */
328
+ export async function countQuarantinedRetiredKeys() {
329
+ if (isWeb() || (isAndroid() && !registeredIdentitySignerStore()))
330
+ return 0;
331
+ const store = await ringStore();
332
+ return (await readIndex(store)).filter((entry) => entry.scope === PERSONAL_SCOPE && entry.owner === undefined).length;
203
333
  }
204
334
  /**
205
335
  * The retired keys of `selector`'s account this app can reach, oldest first.
206
- * iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
207
- * (the wallet only), which leaves out the scope's current key. After an
208
- * interrupted rotation the local list can include the current key: compare
209
- * with the DID's `#key-1` before treating an entry as retired.
336
+ * The personal ring answers only `selector.ownerAccountId`'s entries (none
337
+ * without it; R5-2). iOS and Commons: the ring's index. Android elsewhere:
338
+ * Commons' identity host (the wallet only), which holds only the personal
339
+ * entries of the person Commons is signed in as and leaves out the scope's
340
+ * current key. After an interrupted rotation the local list can include the
341
+ * current key: compare with the DID's `#key-1` before treating an entry as
342
+ * retired, and check every entry against the account's key history.
343
+ *
344
+ * STRICT (R5-6): a ring that cannot be read throws (the keychain's error, or
345
+ * {@link RetiredKeyRingUnreadableError}); it never reads as empty.
210
346
  */
211
347
  export async function listRetiredIdentityKeys(selector) {
212
348
  const scope = retiredKeyScope(selector);
213
349
  if (!scope || isWeb())
214
350
  return [];
215
- try {
216
- if (isAndroid() && !registeredIdentitySignerStore()) {
217
- const bridge = await loadCommonsIdentityBridge();
218
- const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
219
- return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
220
- ? { publicKey, retiredAt: 0 }
221
- : { publicKey, accountId: scope, retiredAt: 0 });
222
- }
223
- if (!isIOS() && !isAndroid())
224
- return [];
225
- const store = await ringStore();
226
- return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
227
- }
228
- catch (error) {
229
- logger.warn('Failed to list retired keys', {
230
- component: 'RetiredKeys',
231
- error: error instanceof Error ? error.name : 'unknown',
232
- });
351
+ const owner = ownerOf(selector?.ownerAccountId);
352
+ if (scope === PERSONAL_SCOPE && owner === null)
233
353
  return [];
354
+ if (isAndroid() && !registeredIdentitySignerStore()) {
355
+ const bridge = await loadCommonsIdentityBridge();
356
+ const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
357
+ return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
358
+ ? { publicKey, retiredAt: 0 }
359
+ : { publicKey, accountId: scope, retiredAt: 0 });
234
360
  }
361
+ if (!isIOS() && !isAndroid())
362
+ return [];
363
+ const store = await ringStore();
364
+ return (await readIndex(store)).filter((entry) => isVisible(entry, scope, owner)).map(toListed);
235
365
  }
236
366
  /**
237
367
  * The private half of a retired key, where this app may hold it: Commons (both
@@ -245,12 +375,18 @@ export async function getRetiredPrivateKey(publicKey, selector) {
245
375
  const canonical = canonicalPublicKey(publicKey);
246
376
  if (!scope || !canonical || isWeb())
247
377
  return null;
378
+ const owner = ownerOf(selector?.ownerAccountId);
379
+ if (scope === PERSONAL_SCOPE && owner === null)
380
+ return null;
248
381
  try {
249
382
  if (isAndroid()) {
250
383
  const signer = registeredIdentitySignerStore();
251
384
  if (!signer)
252
385
  return null;
253
386
  const store = await ringStore();
387
+ if (!(await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical)) {
388
+ return null;
389
+ }
254
390
  const local = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
255
391
  if (local && isHealthyPair(local, canonical))
256
392
  return normalizeSecp256k1PrivateKey(local);
@@ -263,6 +399,11 @@ export async function getRetiredPrivateKey(publicKey, selector) {
263
399
  if (!isIOS())
264
400
  return null;
265
401
  const store = await ringStore();
402
+ // The index decides who may reach a key (R5-2); a key it does not list for
403
+ // this reader is not theirs, whatever item exists.
404
+ if (!(await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical)) {
405
+ return null;
406
+ }
266
407
  const value = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
267
408
  return value && isHealthyPair(value, canonical) ? normalizeSecp256k1PrivateKey(value) : null;
268
409
  }
@@ -284,18 +425,24 @@ export async function forgetRetiredIdentityKey(publicKey, selector) {
284
425
  const canonical = canonicalPublicKey(publicKey);
285
426
  if (!scope || !canonical)
286
427
  throw new Error('retired key: invalid selector or public key');
287
- assertMayWrite();
428
+ const owner = ownerOf(selector?.ownerAccountId);
429
+ await assertMayWrite();
288
430
  const store = await ringStore();
431
+ const visible = (await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical);
432
+ if (!visible)
433
+ throw new Error('retired key: not a key of this account');
289
434
  await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
290
435
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
291
436
  await signer?.clearRetired?.(scope, canonical);
292
437
  await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
293
438
  }
294
439
  /**
295
- * Forget every retired key of one ring (the person's, or an organization's):
296
- * called when the identity itself is deleted from this device, so a phone
297
- * handed on keeps none of the person's old keys. Commons only. Resolves how
298
- * many it removed.
440
+ * Forget every retired key of one ring (the person's, or an organization's).
441
+ * Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
442
+ * delete-account flow), which tells them the coins go with it. Deleting the
443
+ * identity from this device — "Start over", a forced delete — never calls it:
444
+ * the ring is money, and the same person restoring their identity later must
445
+ * find it (R4-4). Resolves how many it removed.
299
446
  */
300
447
  export async function forgetRetiredIdentityKeys(selector) {
301
448
  const scope = retiredKeyScope(selector);
@@ -303,33 +450,47 @@ export async function forgetRetiredIdentityKeys(selector) {
303
450
  return 0;
304
451
  if (isAndroid() && !registeredIdentitySignerStore())
305
452
  return 0;
453
+ const owner = ownerOf(selector?.ownerAccountId);
454
+ if (scope === PERSONAL_SCOPE && owner === null)
455
+ return 0;
306
456
  const store = await ringStore();
307
457
  const index = await readIndex(store);
308
458
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
309
459
  let removed = 0;
310
460
  for (const entry of index) {
311
- if (entry.scope !== scope)
461
+ if (!isVisible(entry, scope, owner))
312
462
  continue;
313
463
  await store.deleteItemAsync(privateKeyName(scope, entry.publicKey), privateOptions(store, false));
314
464
  await signer?.clearRetired?.(scope, entry.publicKey);
315
465
  removed += 1;
316
466
  }
317
- await writeIndex(store, index.filter((entry) => entry.scope !== scope));
467
+ await writeIndex(store, index.filter((entry) => !isVisible(entry, scope, owner)));
318
468
  return removed;
319
469
  }
320
470
  /**
321
- * Commons on Android: copy every retired key into the identity signer store
322
- * again, so the identity host answers for it after that file was rebuilt (its
323
- * self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
324
- * Resolves how many it mirrored. Elsewhere 0.
471
+ * Commons on Android: make the identity signer store hold exactly what the
472
+ * identity host may answer — every organization ring, and the personal entries
473
+ * of `ownerAccountId` (the person Commons is signed in as) — so the host
474
+ * answers for them after that file was rebuilt (its self-heal leaves it
475
+ * empty), and never for a previous person's keys (R5-2): every other personal
476
+ * entry is removed from the signer store (its own slot keeps it). Without
477
+ * `ownerAccountId` no personal entry is mirrored. Call on every boot and after
478
+ * every sign-in, beside `syncOrganizationKeys`. Resolves how many it mirrored.
479
+ * Elsewhere 0.
325
480
  */
326
- export async function syncRetiredKeys() {
481
+ export async function syncRetiredKeys(ownerAccountId) {
327
482
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
328
483
  if (!signer?.writeRetired || !signer.readRetired)
329
484
  return 0;
485
+ const owner = ownerOf(ownerAccountId);
330
486
  const store = await ringStore();
487
+ const index = await readIndex(store);
488
+ const allowed = new Set(index.filter((entry) => isVisible(entry, PERSONAL_SCOPE, owner)).map((e) => e.publicKey));
489
+ await clearPersonalMirror(signer, index, allowed);
331
490
  let mirrored = 0;
332
- for (const entry of await readIndex(store)) {
491
+ for (const entry of index) {
492
+ if (entry.scope === PERSONAL_SCOPE && !isVisible(entry, PERSONAL_SCOPE, owner))
493
+ continue;
333
494
  const privateKey = await store.getItemAsync(privateKeyName(entry.scope, entry.publicKey), privateOptions(store, false));
334
495
  if (!privateKey || !isHealthyPair(privateKey, entry.publicKey))
335
496
  continue;
@@ -342,6 +503,88 @@ export async function syncRetiredKeys() {
342
503
  }
343
504
  return mirrored;
344
505
  }
506
+ /**
507
+ * Every retired key of `selector`'s ring (personal: `ownerAccountId`'s entries)
508
+ * WITH its private half, for the encrypted identity backup
509
+ * (`oxy.identity.backup.create`), which carries them under the CURRENT phrase
510
+ * so a lost phone costs no more than the current phrase does (R4-4). Commons
511
+ * only. STRICT (R5-6): an unreadable ring, or a listed entry whose private
512
+ * half is missing or does not derive its public key, THROWS — a backup is
513
+ * never sealed without a key the ring says it holds. Never log or transmit the
514
+ * result except inside that encrypted envelope.
515
+ */
516
+ export async function exportRetiredIdentityKeys(selector) {
517
+ const scope = retiredKeyScope(selector);
518
+ if (!scope || isWeb())
519
+ return [];
520
+ await assertMayWrite();
521
+ const out = [];
522
+ for (const entry of await listRetiredIdentityKeys(selector)) {
523
+ const privateKey = await readRetiredStrict(entry.publicKey, scope);
524
+ out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
525
+ }
526
+ return out;
527
+ }
528
+ /** A listed entry's private half, or a throw: Commons' own slot, then (Android) the signer copy. */
529
+ async function readRetiredStrict(publicKey, scope) {
530
+ const store = await ringStore();
531
+ const local = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
532
+ if (local && isHealthyPair(local, publicKey))
533
+ return normalizeSecp256k1PrivateKey(local);
534
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
535
+ const mirrored = (await signer?.readRetired?.(scope, publicKey)) ?? null;
536
+ if (mirrored && isHealthyPair(mirrored.privateKey, publicKey)) {
537
+ return normalizeSecp256k1PrivateKey(mirrored.privateKey);
538
+ }
539
+ throw new RetiredKeyRingUnreadableError('a listed key has no readable private half');
540
+ }
541
+ /**
542
+ * Commons on Android, when the identity leaves this device ("Start over", a
543
+ * forced delete): remove every PERSONAL entry from the identity signer store,
544
+ * so the identity host answers no other app with them before the next person
545
+ * signs in (R5-2). Commons' own slot and index keep them (money, bound to
546
+ * their owner). Elsewhere a no-op.
547
+ */
548
+ export async function unmirrorPersonalRetiredKeys() {
549
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
550
+ if (!signer?.clearRetired)
551
+ return;
552
+ let index = [];
553
+ try {
554
+ index = await readIndex(await ringStore());
555
+ }
556
+ catch {
557
+ // The signer store's own list below still reaches every mirrored entry.
558
+ }
559
+ await clearPersonalMirror(signer, index, new Set());
560
+ }
561
+ /** Remove from the signer store every personal entry not in `allowed`: the index's and the store's own list. */
562
+ async function clearPersonalMirror(signer, index, allowed) {
563
+ if (!signer.clearRetired)
564
+ return;
565
+ const mirrored = new Set(index.filter((entry) => entry.scope === PERSONAL_SCOPE).map((entry) => entry.publicKey));
566
+ for (const publicKey of (await signer.listRetired?.(PERSONAL_SCOPE)) ?? []) {
567
+ mirrored.add(publicKey.toLowerCase());
568
+ }
569
+ for (const publicKey of mirrored) {
570
+ if (!allowed.has(publicKey))
571
+ await signer.clearRetired(PERSONAL_SCOPE, publicKey);
572
+ }
573
+ }
574
+ /**
575
+ * The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
576
+ * for a person whose old identity was imported as a private key and so has no
577
+ * phrase to re-enter. Stores nothing: the caller checks the public key against
578
+ * the account's key history and only then calls {@link retireIdentityKey}.
579
+ */
580
+ export function retiredKeyFromPrivateKey(privateKeyHex) {
581
+ const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
582
+ if (!/^[0-9a-f]{64}$/.test(trimmed) || !isValidSecp256k1PrivateKey(trimmed)) {
583
+ throw new Error('retired key: not a valid private key');
584
+ }
585
+ const privateKey = normalizeSecp256k1PrivateKey(trimmed);
586
+ return { privateKey, publicKey: publicKeyOf(privateKey) };
587
+ }
345
588
  /**
346
589
  * The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
347
590
  * and organization-key derivation; 12–24 words). Stores nothing: the caller