@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
@@ -46,6 +46,7 @@ const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
46
46
  const platform_1 = require("../utils/platform");
47
47
  const identitySigner_1 = require("./identitySigner");
48
48
  const keychainGroupMigration_1 = require("./keychainGroupMigration");
49
+ const retiredKeys_1 = require("./retiredKeys");
49
50
  const keychainItems_1 = require("./keychainItems");
50
51
  Object.defineProperty(exports, "isOrganizationAccountId", { enumerable: true, get: function () { return keychainItems_1.isOrganizationAccountId; } });
51
52
  const ORGANIZATION_PHRASE_WORDS = 24;
@@ -115,9 +116,7 @@ async function organizationKeyFromPhrase(phrase) {
115
116
  async function secureStore(item) {
116
117
  const raw = await (0, protocol_1.loadSecureStore)();
117
118
  const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
118
- if (access.mode === 'grouped' && access.role === 'holder') {
119
- await (0, keychainGroupMigration_1.ensureKeychainGroupsMigrated)();
120
- }
119
+ await (0, keychainGroupMigration_1.prepareKeychainAccess)(access);
121
120
  return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, item);
122
121
  }
123
122
  const pairStore = () => secureStore('spending-key');
@@ -208,8 +207,10 @@ async function readLocalPair(accountId) {
208
207
  * it; on iOS only Commons holds the groups it writes (any other app throws).
209
208
  *
210
209
  * Refuses a pair whose public key does not derive from its private key, and a
211
- * phrase that does not derive the pair. Overwrites whatever this device held for
212
- * the organization (a replaced key is the caller's decision, ADR 0061 D7).
210
+ * phrase that does not derive the pair. A DIFFERENT key this device already
211
+ * held for the organization is first kept in the organization's retired ring
212
+ * (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
213
+ * cannot be confirmed nothing is overwritten and this throws.
213
214
  */
214
215
  async function storeOrganizationKey(accountId, key) {
215
216
  assertAccountId(accountId);
@@ -231,6 +232,18 @@ async function storeOrganizationKey(accountId, key) {
231
232
  throw new Error('On Android only Commons holds organization keys; this app has no identity signer store for them.');
232
233
  }
233
234
  const store = await pairStore();
235
+ const previous = await heldPrivateKey(store, accountId);
236
+ if (previous) {
237
+ const previousPublic = (0, secp256k1_1.deriveSecp256k1PublicKey)(previous);
238
+ if (previousPublic !== publicKey) {
239
+ try {
240
+ await (0, retiredKeys_1.retireIdentityKey)({ privateKey: previous, publicKey: previousPublic }, { accountId });
241
+ }
242
+ catch (error) {
243
+ 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'})`);
244
+ }
245
+ }
246
+ }
234
247
  await store.setItemAsync((0, keychainItems_1.organizationPrivateKeyName)(accountId), privateKey, pairOptions(store, true));
235
248
  await store.setItemAsync((0, keychainItems_1.organizationPublicKeyName)(accountId), publicKey, pairOptions(store, false));
236
249
  if (signer?.writeOrganization &&
@@ -311,6 +324,21 @@ async function listOrganizationKeys() {
311
324
  return [];
312
325
  return readIndex(await indexStore());
313
326
  }
327
+ /**
328
+ * The private key this device holds for `accountId`, whatever its public half
329
+ * says (an unhealthy pair is still a key coins may sit under): the slot, then
330
+ * on Android the signer store's copy. `null` when there is none.
331
+ */
332
+ async function heldPrivateKey(store, accountId) {
333
+ const local = await store.getItemAsync((0, keychainItems_1.organizationPrivateKeyName)(accountId), pairOptions(store, false));
334
+ if (local && (0, secp256k1_1.isValidSecp256k1PrivateKey)(local))
335
+ return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
336
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
337
+ const mirrored = (await signer?.readOrganization?.(accountId)) ?? null;
338
+ return mirrored && (0, secp256k1_1.isValidSecp256k1PrivateKey)(mirrored.privateKey)
339
+ ? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(mirrored.privateKey)
340
+ : null;
341
+ }
314
342
  /** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
315
343
  async function deleteOrganizationKey(accountId) {
316
344
  assertAccountId(accountId);
@@ -43,13 +43,20 @@
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;
49
+ exports.retireIdentityKeyDuringMigration = retireIdentityKeyDuringMigration;
50
+ exports.claimRetiredIdentityKeys = claimRetiredIdentityKeys;
51
+ exports.countQuarantinedRetiredKeys = countQuarantinedRetiredKeys;
48
52
  exports.listRetiredIdentityKeys = listRetiredIdentityKeys;
49
53
  exports.getRetiredPrivateKey = getRetiredPrivateKey;
50
54
  exports.forgetRetiredIdentityKey = forgetRetiredIdentityKey;
51
55
  exports.forgetRetiredIdentityKeys = forgetRetiredIdentityKeys;
52
56
  exports.syncRetiredKeys = syncRetiredKeys;
57
+ exports.exportRetiredIdentityKeys = exportRetiredIdentityKeys;
58
+ exports.unmirrorPersonalRetiredKeys = unmirrorPersonalRetiredKeys;
59
+ exports.retiredKeyFromPrivateKey = retiredKeyFromPrivateKey;
53
60
  exports.retiredKeyFromPhrase = retiredKeyFromPhrase;
54
61
  const bip39_1 = require("@scure/bip39");
55
62
  const english_js_1 = require("@scure/bip39/wordlists/english.js");
@@ -59,8 +66,16 @@ const logger_1 = require("../logger");
59
66
  const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
60
67
  const platform_1 = require("../utils/platform");
61
68
  const identitySigner_1 = require("./identitySigner");
69
+ const keychainItems_1 = require("./keychainItems");
62
70
  const keychainGroupMigration_1 = require("./keychainGroupMigration");
63
- const organizationKeys_1 = require("./organizationKeys");
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;
64
79
  const PERSONAL_SCOPE = 'personal';
65
80
  /** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
66
81
  const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
@@ -71,15 +86,15 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
71
86
  * The secure store for the ring. On iOS every item is a `spending-key`
72
87
  * (ADR 0062): the private keys AND the index live in the wallet group, which
73
88
  * only Commons and the Peable wallet can read, never the shared one. In
74
- * Commons every access first awaits the access-group migration. On Android
75
- * the grouping is a no-op and Commons' own services below apply.
89
+ * Commons every access first awaits the access-group migration (`gate: false`
90
+ * is the migration itself, keeping a pre-split copy). On Android the grouping
91
+ * is a no-op and Commons' own services below apply.
76
92
  */
77
- async function ringStore() {
93
+ async function ringStore(gate = true) {
78
94
  const raw = await (0, protocol_1.loadSecureStore)();
79
95
  const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
80
- if (access.mode === 'grouped' && access.role === 'holder') {
81
- await (0, keychainGroupMigration_1.ensureKeychainGroupsMigrated)();
82
- }
96
+ if (gate)
97
+ await (0, keychainGroupMigration_1.prepareKeychainAccess)(access);
83
98
  return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, 'spending-key');
84
99
  }
85
100
  /** The ring a selector names: `personal`, or the organization's account id. */
@@ -87,7 +102,7 @@ function retiredKeyScope(selector) {
87
102
  const accountId = selector?.accountId;
88
103
  if (accountId === undefined)
89
104
  return PERSONAL_SCOPE;
90
- return (0, organizationKeys_1.isOrganizationAccountId)(accountId) ? accountId : null;
105
+ return (0, keychainItems_1.isOrganizationAccountId)(accountId) ? accountId : null;
91
106
  }
92
107
  function canonicalPublicKey(publicKey) {
93
108
  try {
@@ -129,32 +144,58 @@ function privateOptions(store, forWrite) {
129
144
  function indexOptions() {
130
145
  return (0, platform_1.isIOS)() ? {} : { keychainService: INDEX_KEYCHAIN_SERVICE };
131
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
+ }
132
165
  function isIndexEntry(value) {
133
166
  if (typeof value !== 'object' || value === null)
134
167
  return false;
135
168
  const entry = value;
136
169
  return (typeof entry.scope === 'string' &&
137
- (entry.scope === PERSONAL_SCOPE || (0, organizationKeys_1.isOrganizationAccountId)(entry.scope)) &&
170
+ (entry.scope === PERSONAL_SCOPE || (0, keychainItems_1.isOrganizationAccountId)(entry.scope)) &&
138
171
  typeof entry.publicKey === 'string' &&
139
172
  PUBLIC_KEY.test(entry.publicKey) &&
140
173
  typeof entry.retiredAt === 'number' &&
141
- Number.isFinite(entry.retiredAt));
174
+ Number.isFinite(entry.retiredAt) &&
175
+ (entry.owner === undefined || (typeof entry.owner === 'string' && ACCOUNT_ID.test(entry.owner))));
142
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
+ */
143
184
  async function readIndex(store) {
144
185
  const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
145
186
  if (!raw)
146
187
  return [];
188
+ let parsed;
147
189
  try {
148
- const parsed = JSON.parse(raw);
149
- return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
190
+ parsed = JSON.parse(raw);
150
191
  }
151
- catch (error) {
152
- logger_1.logger.warn('Retired key index unreadable; treating as empty', {
153
- component: 'RetiredKeys',
154
- error: error instanceof Error ? error.name : 'unknown',
155
- });
156
- 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');
157
197
  }
198
+ return parsed.map((entry) => entry.owner === undefined ? entry : { ...entry, owner: entry.owner.toLowerCase() });
158
199
  }
159
200
  async function writeIndex(store, entries) {
160
201
  const unique = new Map();
@@ -167,81 +208,178 @@ async function writeIndex(store, entries) {
167
208
  await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify(sorted), indexOptions());
168
209
  }
169
210
  function toListed(entry) {
211
+ // The owner is the reader's own id; it is not repeated.
170
212
  return entry.scope === PERSONAL_SCOPE
171
213
  ? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
172
214
  : { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
173
215
  }
174
- /** Whether this app writes the ring: Commons (Android: the signer store it registers; iOS: native). */
175
- function assertMayWrite() {
216
+ /**
217
+ * Whether this app writes the ring: Commons only (Android: the signer store it
218
+ * registers; iOS: the keychain role `holder`). The Peable wallet holds the
219
+ * wallet group too, but only reads, derives and signs: a key the wallet could
220
+ * add to the ring is a key it could choose (R4-14).
221
+ */
222
+ async function assertMayWrite() {
176
223
  if ((0, platform_1.isWeb)())
177
224
  throw new Error('retired key: only a native device holds one');
178
225
  if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()?.writeRetired) {
179
226
  throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
180
227
  }
228
+ if ((0, platform_1.isIOS)()) {
229
+ const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
230
+ if (access.mode === 'grouped' && access.role !== 'holder') {
231
+ throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
232
+ }
233
+ }
181
234
  }
182
235
  /**
183
- * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
184
- * key already kept stays, with its first date. Refuses a pair whose public key
185
- * 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
186
243
  * (Android: the signer store's read-back too), and throws otherwise, so a
187
244
  * caller about to replace the key can stop before anything is lost.
188
245
  */
189
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
+ }
251
+ await keepRetired(key, selector, true);
252
+ }
253
+ /**
254
+ * {@link retireIdentityKey} from inside the iOS access-group migration, which
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
259
+ */
260
+ async function retireIdentityKeyDuringMigration(key, selector) {
261
+ await keepRetired(key, selector, false);
262
+ }
263
+ async function keepRetired(key, selector, gate) {
190
264
  const scope = retiredKeyScope(selector);
191
265
  if (!scope)
192
266
  throw new Error('retired key: accountId must be a 24-hex or uuid account id');
193
- assertMayWrite();
267
+ const owner = scope === PERSONAL_SCOPE ? ownerOf(selector?.ownerAccountId) : null;
268
+ await assertMayWrite();
194
269
  if (!isHealthyPair(key.privateKey, key.publicKey)) {
195
270
  throw new Error('retired key: the public key does not derive from the private key');
196
271
  }
197
272
  const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(key.privateKey);
198
273
  const publicKey = (0, secp256k1_1.normalizeSecp256k1PublicKey)(key.publicKey);
199
- const store = await ringStore();
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
+ }
200
281
  await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
201
282
  const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
202
283
  if (readBack !== privateKey) {
203
284
  throw new Error('retired key: the keychain did not confirm the key');
204
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.
205
302
  const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
206
- 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))) {
207
306
  throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
208
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();
209
327
  const index = await readIndex(store);
210
- if (!index.some((entry) => entry.scope === scope && entry.publicKey === publicKey)) {
211
- await writeIndex(store, [...index, { scope, publicKey, retiredAt: Date.now() }]);
212
- }
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;
213
351
  }
214
352
  /**
215
353
  * The retired keys of `selector`'s account this app can reach, oldest first.
216
- * iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
217
- * (the wallet only), which leaves out the scope's current key. After an
218
- * interrupted rotation the local list can include the current key: compare
219
- * 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.
220
364
  */
221
365
  async function listRetiredIdentityKeys(selector) {
222
366
  const scope = retiredKeyScope(selector);
223
367
  if (!scope || (0, platform_1.isWeb)())
224
368
  return [];
225
- try {
226
- if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()) {
227
- const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
228
- const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
229
- return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
230
- ? { publicKey, retiredAt: 0 }
231
- : { publicKey, accountId: scope, retiredAt: 0 });
232
- }
233
- if (!(0, platform_1.isIOS)() && !(0, platform_1.isAndroid)())
234
- return [];
235
- const store = await ringStore();
236
- return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
237
- }
238
- catch (error) {
239
- logger_1.logger.warn('Failed to list retired keys', {
240
- component: 'RetiredKeys',
241
- error: error instanceof Error ? error.name : 'unknown',
242
- });
369
+ const owner = ownerOf(selector?.ownerAccountId);
370
+ if (scope === PERSONAL_SCOPE && owner === null)
243
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 });
244
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);
245
383
  }
246
384
  /**
247
385
  * The private half of a retired key, where this app may hold it: Commons (both
@@ -255,12 +393,18 @@ async function getRetiredPrivateKey(publicKey, selector) {
255
393
  const canonical = canonicalPublicKey(publicKey);
256
394
  if (!scope || !canonical || (0, platform_1.isWeb)())
257
395
  return null;
396
+ const owner = ownerOf(selector?.ownerAccountId);
397
+ if (scope === PERSONAL_SCOPE && owner === null)
398
+ return null;
258
399
  try {
259
400
  if ((0, platform_1.isAndroid)()) {
260
401
  const signer = (0, identitySigner_1.registeredIdentitySignerStore)();
261
402
  if (!signer)
262
403
  return null;
263
404
  const store = await ringStore();
405
+ if (!(await readIndex(store)).some((entry) => isVisible(entry, scope, owner) && entry.publicKey === canonical)) {
406
+ return null;
407
+ }
264
408
  const local = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
265
409
  if (local && isHealthyPair(local, canonical))
266
410
  return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
@@ -273,6 +417,11 @@ async function getRetiredPrivateKey(publicKey, selector) {
273
417
  if (!(0, platform_1.isIOS)())
274
418
  return null;
275
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
+ }
276
425
  const value = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
277
426
  return value && isHealthyPair(value, canonical) ? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(value) : null;
278
427
  }
@@ -294,18 +443,24 @@ async function forgetRetiredIdentityKey(publicKey, selector) {
294
443
  const canonical = canonicalPublicKey(publicKey);
295
444
  if (!scope || !canonical)
296
445
  throw new Error('retired key: invalid selector or public key');
297
- assertMayWrite();
446
+ const owner = ownerOf(selector?.ownerAccountId);
447
+ await assertMayWrite();
298
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');
299
452
  await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
300
453
  const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
301
454
  await signer?.clearRetired?.(scope, canonical);
302
455
  await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
303
456
  }
304
457
  /**
305
- * Forget every retired key of one ring (the person's, or an organization's):
306
- * called when the identity itself is deleted from this device, so a phone
307
- * handed on keeps none of the person's old keys. Commons only. Resolves how
308
- * many it removed.
458
+ * Forget every retired key of one ring (the person's, or an organization's).
459
+ * Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
460
+ * delete-account flow), which tells them the coins go with it. Deleting the
461
+ * identity from this device — "Start over", a forced delete — never calls it:
462
+ * the ring is money, and the same person restoring their identity later must
463
+ * find it (R4-4). Resolves how many it removed.
309
464
  */
310
465
  async function forgetRetiredIdentityKeys(selector) {
311
466
  const scope = retiredKeyScope(selector);
@@ -313,33 +468,47 @@ async function forgetRetiredIdentityKeys(selector) {
313
468
  return 0;
314
469
  if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)())
315
470
  return 0;
471
+ const owner = ownerOf(selector?.ownerAccountId);
472
+ if (scope === PERSONAL_SCOPE && owner === null)
473
+ return 0;
316
474
  const store = await ringStore();
317
475
  const index = await readIndex(store);
318
476
  const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
319
477
  let removed = 0;
320
478
  for (const entry of index) {
321
- if (entry.scope !== scope)
479
+ if (!isVisible(entry, scope, owner))
322
480
  continue;
323
481
  await store.deleteItemAsync(privateKeyName(scope, entry.publicKey), privateOptions(store, false));
324
482
  await signer?.clearRetired?.(scope, entry.publicKey);
325
483
  removed += 1;
326
484
  }
327
- await writeIndex(store, index.filter((entry) => entry.scope !== scope));
485
+ await writeIndex(store, index.filter((entry) => !isVisible(entry, scope, owner)));
328
486
  return removed;
329
487
  }
330
488
  /**
331
- * Commons on Android: copy every retired key into the identity signer store
332
- * again, so the identity host answers for it after that file was rebuilt (its
333
- * self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
334
- * 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.
335
498
  */
336
- async function syncRetiredKeys() {
499
+ async function syncRetiredKeys(ownerAccountId) {
337
500
  const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
338
501
  if (!signer?.writeRetired || !signer.readRetired)
339
502
  return 0;
503
+ const owner = ownerOf(ownerAccountId);
340
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);
341
508
  let mirrored = 0;
342
- 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;
343
512
  const privateKey = await store.getItemAsync(privateKeyName(entry.scope, entry.publicKey), privateOptions(store, false));
344
513
  if (!privateKey || !isHealthyPair(privateKey, entry.publicKey))
345
514
  continue;
@@ -352,6 +521,88 @@ async function syncRetiredKeys() {
352
521
  }
353
522
  return mirrored;
354
523
  }
524
+ /**
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.
533
+ */
534
+ async function exportRetiredIdentityKeys(selector) {
535
+ const scope = retiredKeyScope(selector);
536
+ if (!scope || (0, platform_1.isWeb)())
537
+ return [];
538
+ await assertMayWrite();
539
+ const out = [];
540
+ for (const entry of await listRetiredIdentityKeys(selector)) {
541
+ const privateKey = await readRetiredStrict(entry.publicKey, scope);
542
+ out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
543
+ }
544
+ return out;
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
+ }
592
+ /**
593
+ * The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
594
+ * for a person whose old identity was imported as a private key and so has no
595
+ * phrase to re-enter. Stores nothing: the caller checks the public key against
596
+ * the account's key history and only then calls {@link retireIdentityKey}.
597
+ */
598
+ function retiredKeyFromPrivateKey(privateKeyHex) {
599
+ const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
600
+ if (!/^[0-9a-f]{64}$/.test(trimmed) || !(0, secp256k1_1.isValidSecp256k1PrivateKey)(trimmed)) {
601
+ throw new Error('retired key: not a valid private key');
602
+ }
603
+ const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(trimmed);
604
+ return { privateKey, publicKey: publicKeyOf(privateKey) };
605
+ }
355
606
  /**
356
607
  * The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
357
608
  * and organization-key derivation; 12–24 words). Stores nothing: the caller