@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
@@ -1,12 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.KeychainAccessUnavailableError = exports.KEYCHAIN_ACCESS_GROUPS = void 0;
3
+ exports.KeychainAccessUnavailableError = exports.DEV_KEYCHAIN_GROUP_SUFFIX = exports.KEYCHAIN_ACCESS_GROUPS = void 0;
4
4
  exports.keychainGroupFor = keychainGroupFor;
5
+ exports.mayWriteKeychainItem = mayWriteKeychainItem;
5
6
  exports.mayHoldKeychainItem = mayHoldKeychainItem;
6
7
  exports.keychainAccessFromDefaultGroup = keychainAccessFromDefaultGroup;
7
8
  exports.resolveKeychainAccess = resolveKeychainAccess;
8
9
  exports.resetKeychainAccessForTests = resetKeychainAccessForTests;
9
10
  exports.keychainAccessGroupValue = keychainAccessGroupValue;
11
+ exports.setHolderSharedSecretFallback = setHolderSharedSecretFallback;
12
+ exports.closeWalletSharedSecretFallback = closeWalletSharedSecretFallback;
10
13
  exports.keychainReadPlan = keychainReadPlan;
11
14
  exports.keychainDeletePlan = keychainDeletePlan;
12
15
  exports.keychainWriteOption = keychainWriteOption;
@@ -65,6 +68,15 @@ exports.KEYCHAIN_ACCESS_GROUPS = {
65
68
  wallet: 'so.oxy.wallet',
66
69
  shared: 'group.so.oxy.shared',
67
70
  };
71
+ /**
72
+ * The suffix a DEVELOPMENT build's restricted groups carry (`so.oxy.commons.dev`,
73
+ * `to.peable.app.dev`: `so.oxy.identity.dev`, `so.oxy.wallet.dev`). A dev build
74
+ * sits beside the production app on the same phone, is signed by the same team
75
+ * and runs JavaScript from a dev server, so it never shares a restricted group
76
+ * with production (`@oxy.so/app-preset` enforces the entitlements). The shared
77
+ * group has no variant: it holds nothing secret.
78
+ */
79
+ exports.DEV_KEYCHAIN_GROUP_SUFFIX = '.dev';
68
80
  /** Thrown while the probe cannot answer (before first unlock). Transient. */
69
81
  class KeychainAccessUnavailableError extends Error {
70
82
  constructor(cause) {
@@ -86,6 +98,16 @@ function keychainGroupFor(item) {
86
98
  return 'app';
87
99
  }
88
100
  }
101
+ /**
102
+ * Whether `role` may WRITE `item`. Only Commons writes a secret: the Peable
103
+ * wallet reads, derives and signs with spending keys (and the retired ring)
104
+ * but never adds one — a key the wallet could add is one the wallet could
105
+ * choose. (The keychain itself lets every app in the wallet group write it;
106
+ * this is the SDK's half, and readers check what they find, ADR 0062.)
107
+ */
108
+ function mayWriteKeychainItem(role, item) {
109
+ return role === 'holder' || item === 'shared' || item === 'app';
110
+ }
89
111
  /** Whether `role` holds the group `item` lives in. */
90
112
  function mayHoldKeychainItem(role, item) {
91
113
  switch (role) {
@@ -109,14 +131,18 @@ function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
109
131
  }
110
132
  const prefix = defaultGroup.slice(0, dot + 1);
111
133
  const group = defaultGroup.slice(dot + 1);
112
- const role = group === exports.KEYCHAIN_ACCESS_GROUPS.identity
134
+ const dev = group.endsWith(exports.DEV_KEYCHAIN_GROUP_SUFFIX);
135
+ const base = dev ? group.slice(0, -exports.DEV_KEYCHAIN_GROUP_SUFFIX.length) : group;
136
+ const role = base === exports.KEYCHAIN_ACCESS_GROUPS.identity
113
137
  ? 'holder'
114
- : group === exports.KEYCHAIN_ACCESS_GROUPS.wallet
138
+ : base === exports.KEYCHAIN_ACCESS_GROUPS.wallet
115
139
  ? 'wallet'
116
140
  : 'member';
141
+ // A `.dev` suffix on anything but a restricted group is not a variant.
142
+ const variant = dev && role !== 'member' ? { variant: exports.DEV_KEYCHAIN_GROUP_SUFFIX } : {};
117
143
  return appGroup?.startsWith(prefix)
118
- ? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
119
- : { mode: 'grouped', prefix, defaultGroup, role };
144
+ ? { mode: 'grouped', prefix, defaultGroup, role, appGroup, ...variant }
145
+ : { mode: 'grouped', prefix, defaultGroup, role, ...variant };
120
146
  }
121
147
  let resolvedAccess = null;
122
148
  /**
@@ -169,9 +195,11 @@ async function resolveKeychainAccess() {
169
195
  resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
170
196
  return resolvedAccess;
171
197
  }
172
- /** Forget the memoized access. Tests only. @internal */
198
+ /** Forget the memoized access and the fallback state. Tests only. @internal */
173
199
  function resetKeychainAccessForTests() {
174
200
  resolvedAccess = null;
201
+ holderSharedFallback = false;
202
+ walletSharedFallbackClosed = false;
175
203
  }
176
204
  /**
177
205
  * The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
@@ -189,17 +217,54 @@ function keychainAccessGroupValue(access, group) {
189
217
  // default is the shared group: no group, the calls it always made.
190
218
  return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
191
219
  }
192
- return `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}`;
220
+ const variant = group === 'shared' ? '' : (access.variant ?? '');
221
+ return `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}${variant}`;
222
+ }
223
+ /**
224
+ * Whether a SECRET read may still fall back to the pre-split shared group
225
+ * (R4-7). Every Oxy app can write that group, and keychain items survive an
226
+ * uninstall, so a secret found there is one Commons did not necessarily write.
227
+ *
228
+ * - Commons (`holder`): CLOSED, except while its one-shot migration has proved
229
+ * the shared copies are its own (they carry the key its sandbox marker
230
+ * records) and could not move them all yet; the migration opens it for that
231
+ * launch only ({@link setHolderSharedSecretFallback}).
232
+ * - The Peable wallet: open until Commons' migration has finished (the
233
+ * completion record Commons mirrors into the wallet group), because until
234
+ * then an upgraded phone's keys are still in the shared group; closed for
235
+ * good once it has seen that record ({@link closeWalletSharedSecretFallback}).
236
+ */
237
+ let holderSharedFallback = false;
238
+ let walletSharedFallbackClosed = false;
239
+ /** The migration's switch for Commons' fallback. @internal */
240
+ function setHolderSharedSecretFallback(open) {
241
+ holderSharedFallback = open;
242
+ }
243
+ /** The wallet saw Commons' completion record: no shared fallback again in this process. @internal */
244
+ function closeWalletSharedSecretFallback() {
245
+ walletSharedFallbackClosed = true;
246
+ }
247
+ function sharedFallbackOpen(access) {
248
+ switch (access.role) {
249
+ case 'holder':
250
+ return holderSharedFallback;
251
+ case 'wallet':
252
+ return !walletSharedFallbackClosed;
253
+ case 'member':
254
+ return false;
255
+ }
193
256
  }
194
257
  function option(value) {
195
258
  return value === undefined ? {} : { accessGroup: value };
196
259
  }
197
260
  /**
198
- * The option bags a READ of `item` tries, in order: its own group, then, for
199
- * an identity secret or a spending key, the shared group, where those lived
200
- * before the split (release 4 of the doc drops that fallback). Empty when this
201
- * app may not hold the item: the caller answers `null` without touching the
202
- * keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
261
+ * The option bags a READ of `item` tries, in order: its own group, then — for
262
+ * an identity secret or a spending key, and only while
263
+ * {@link sharedFallbackOpen} says so — the shared group, where those lived
264
+ * before the split. Empty when this app may not hold the item: the caller
265
+ * answers `null` without touching the keychain. Evaluated per read: the
266
+ * fallback closes during a process's life. An app's own items (`app`) take
267
+ * {@link ownKeychainItemPlan}.
203
268
  */
204
269
  function keychainReadPlan(access, item) {
205
270
  if (access.mode === 'ungrouped') {
@@ -210,18 +275,29 @@ function keychainReadPlan(access, item) {
210
275
  }
211
276
  const own = keychainGroupFor(item);
212
277
  const plan = [option(keychainAccessGroupValue(access, own))];
213
- if (own === 'identity' || own === 'wallet') {
214
- // Where the split moved these items from.
278
+ if ((own === 'identity' || own === 'wallet') && sharedFallbackOpen(access)) {
215
279
  plan.push(option(keychainAccessGroupValue(access, 'shared')));
216
280
  }
217
281
  return plan;
218
282
  }
219
283
  /**
220
- * Every place a DELETE of `item` must reach: the read plan, so a delete never
221
- * leaves a pre-split copy behind in the shared group.
284
+ * Every place a DELETE of `item` must reach: its own group AND the shared
285
+ * group, whatever the read fallback says, so a delete never leaves a copy
286
+ * behind where every Oxy app can read it.
222
287
  */
223
288
  function keychainDeletePlan(access, item) {
224
- return keychainReadPlan(access, item);
289
+ if (access.mode === 'ungrouped') {
290
+ return [{}];
291
+ }
292
+ if (!mayHoldKeychainItem(access.role, item)) {
293
+ return [];
294
+ }
295
+ const own = keychainGroupFor(item);
296
+ const plan = [option(keychainAccessGroupValue(access, own))];
297
+ if (own === 'identity' || own === 'wallet') {
298
+ plan.push(option(keychainAccessGroupValue(access, 'shared')));
299
+ }
300
+ return plan;
225
301
  }
226
302
  /**
227
303
  * The option a WRITE of `item` carries. THROWS when this app may not hold the
@@ -232,8 +308,8 @@ function keychainWriteOption(access, item) {
232
308
  if (access.mode === 'ungrouped') {
233
309
  return {};
234
310
  }
235
- if (!mayHoldKeychainItem(access.role, item)) {
236
- throw new Error(`This app (keychain role "${access.role}") may not hold a ${item} keychain item; only Commons writes the identity.`);
311
+ if (!mayWriteKeychainItem(access.role, item)) {
312
+ throw new Error(`This app (keychain role "${access.role}") may not write a ${item} keychain item; only Commons writes the identity and the keys the wallet spends.`);
237
313
  }
238
314
  return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
239
315
  }
@@ -301,19 +377,29 @@ async function deleteThroughPlan(store, key, options, plan) {
301
377
  }
302
378
  /**
303
379
  * `store` with every call routed to `item`'s groups for this app: reads walk
304
- * the read plan, writes carry the write option (throwing for a role that may
305
- * not hold the item), deletes reach every location. The constants of the
306
- * wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
380
+ * the read plan (evaluated per read), writes carry the write option (throwing
381
+ * for a role that may not write the item), deletes reach every location. A
382
+ * secret WRITE also deletes the item's copy in the shared group (R4-6): a value
383
+ * Commons writes replaces whatever any app could read there, and a copy that
384
+ * cannot be removed fails the write instead of staying readable. The constants
385
+ * of the wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still
386
+ * reads.
307
387
  */
308
388
  function groupedKeychainStore(store, access, item) {
309
- const plan = keychainReadPlan(access, item);
389
+ const sharedCopy = access.mode === 'grouped' && (item === 'identity-secret' || item === 'spending-key')
390
+ ? option(keychainAccessGroupValue(access, 'shared'))
391
+ : null;
310
392
  const wrapped = {
311
- getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, plan),
393
+ getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, keychainReadPlan(access, item)),
312
394
  setItemAsync: async (key, value, options) => {
313
395
  const group = keychainWriteOption(access, item);
314
396
  await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
397
+ if (sharedCopy) {
398
+ const { keychainAccessible: _accessible, ...rest } = (options ?? {});
399
+ await store.deleteItemAsync(key, { ...rest, ...sharedCopy });
400
+ }
315
401
  },
316
- deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, plan),
402
+ deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, keychainDeletePlan(access, item)),
317
403
  };
318
404
  return Object.assign(Object.create(store), wrapped);
319
405
  }
@@ -21,6 +21,7 @@ import { checkContract, IDENTITY_PROOF_ACTIONS, identityLinkCreateResponseCheck,
21
21
  import { identityKeyHistoryCheck } from '@oxy.so/contracts/client';
22
22
  import { isAndroid, isWeb } from '../utils/platform.js';
23
23
  import { logger } from '../logger/index.js';
24
+ import { extractErrorStatus } from '../utils/errorUtils.js';
24
25
  /** Short-TTL read cache for public identity reads. */
25
26
  const SHORT_TTL = 60 * 1000;
26
27
  /**
@@ -198,7 +199,7 @@ export class IdentityApi {
198
199
  try {
199
200
  if (!oldPrivateKeyForRing)
200
201
  throw new Error('no readable private key');
201
- await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
202
+ await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey }, { ownerAccountId: userId });
202
203
  }
203
204
  catch (retainError) {
204
205
  logger.warn('Could not keep the current key as a retired key; not rotating.', { component: 'oxy.identity', method: 'rotateKey' }, retainError);
@@ -316,10 +317,31 @@ export class IdentityApi {
316
317
  }
317
318
  }
318
319
  }
320
+ // 8. The rotation deleted the encrypted backup (it was sealed under the
321
+ // replaced phrase). Seal a new one under the new phrase, carrying the
322
+ // retired ring — now with the key just replaced — so the coins under it
323
+ // survive a lost phone as long as the NEW phrase does (R4-4). Always,
324
+ // not only when one existed: after a rotation it is the only copy of the
325
+ // replaced key off this phone. Best-effort: the rotation already succeeded.
326
+ let backupRefreshed;
327
+ if (!isWeb() && !localPersistFailed) {
328
+ try {
329
+ await this.backup.create(pending.phrase);
330
+ backupRefreshed = true;
331
+ }
332
+ catch (backupError) {
333
+ backupRefreshed = false;
334
+ logger.warn('Key rotated, but the encrypted backup could not be re-created under the new phrase.', { component: 'oxy.identity', method: 'rotateKey' }, backupError);
335
+ }
336
+ }
319
337
  this.invalidate(userId);
320
- return localPersistFailed
321
- ? { newPublicKey, newPhrase: pending.phrase, words: pending.words, localPersistFailed: true }
322
- : { newPublicKey, newPhrase: pending.phrase, words: pending.words };
338
+ return {
339
+ newPublicKey,
340
+ newPhrase: pending.phrase,
341
+ words: pending.words,
342
+ ...(localPersistFailed ? { localPersistFailed: true } : {}),
343
+ ...(backupRefreshed === undefined ? {} : { backupRefreshed }),
344
+ };
323
345
  }
324
346
  /**
325
347
  * The signed-in user's signed, open-format data-export bundle
@@ -508,6 +530,10 @@ const BACKUP_ALGORITHM = 'xchacha20poly1305';
508
530
  * to recognise WHICH identity a backup belongs to, but only a prefix.
509
531
  */
510
532
  const PUBLIC_KEY_HINT_LENGTH = 16;
533
+ /** Whether a request error is the restore lookup's constant-shape 404. */
534
+ function isNotFound(error) {
535
+ return extractErrorStatus(error) === 404;
536
+ }
511
537
  /** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
512
538
  function toHex(bytes) {
513
539
  let out = '';
@@ -567,7 +593,13 @@ export class IdentityBackupApi {
567
593
  const publicKey = KeyManager.derivePublicKey(privateKey);
568
594
  const createdAt = new Date().toISOString();
569
595
  const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
570
- const payload = { privateKey, publicKey, createdAt };
596
+ const retiredKeys = await this.retiredKeysToCarry(publicKey);
597
+ const payload = {
598
+ privateKey,
599
+ publicKey,
600
+ createdAt,
601
+ ...(retiredKeys.length > 0 ? { retiredKeys } : {}),
602
+ };
571
603
  const plaintext = new TextEncoder().encode(JSON.stringify(payload));
572
604
  const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
573
605
  const { nonce, ciphertext } = encryptAead(backupKey, plaintext, aad);
@@ -585,6 +617,115 @@ export class IdentityBackupApi {
585
617
  cache: false,
586
618
  });
587
619
  }
620
+ /**
621
+ * The retired ring this device holds for the person, limited to keys Oxy's
622
+ * key history names as THIS account's retired keys (the ring's iOS group is
623
+ * writable by the wallet app too, and a previous person's ring can outlive a
624
+ * "Start over"). Empty where no ring is held (web, any app but Commons).
625
+ * Throws when the history cannot be read while a ring exists, so a backup
626
+ * never silently drops keys it should carry.
627
+ */
628
+ async retiredKeysToCarry(currentPublicKey) {
629
+ if (isWeb())
630
+ return [];
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.');
635
+ let held;
636
+ try {
637
+ held = await exportRetiredIdentityKeys({ ownerAccountId: userId });
638
+ }
639
+ catch {
640
+ return []; // Not Commons: this app holds no ring to carry.
641
+ }
642
+ if (held.length === 0)
643
+ return [];
644
+ const history = await this.ctx.request('GET', '/identity/key-history', undefined, {
645
+ cache: false,
646
+ });
647
+ const parsed = checkContract(identityKeyHistoryCheck, history);
648
+ if (!parsed)
649
+ throw new Error('identity/key-history returned an unexpected response shape');
650
+ const retired = new Set(parsed.retired.map((entry) => entry.publicKey.toLowerCase()));
651
+ const current = currentPublicKey.toLowerCase();
652
+ return held.filter((key) => key.publicKey.toLowerCase() !== current && retired.has(key.publicKey.toLowerCase()));
653
+ }
654
+ /**
655
+ * Fetch and open the backup the phrase locates, checking it is the phrase's
656
+ * own identity. With `missingIsNull`, `null` when there is none (404);
657
+ * otherwise the request's own error propagates.
658
+ */
659
+ async open(phrase, missingIsNull = false) {
660
+ const { KeyManager, RecoveryPhraseService, decryptAead } = await import('../crypto/index.js');
661
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
662
+ let envelope;
663
+ try {
664
+ envelope = await this.ctx.request('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
665
+ }
666
+ catch (error) {
667
+ if (missingIsNull && isNotFound(error))
668
+ return null;
669
+ throw error;
670
+ }
671
+ if (envelope.algorithm !== BACKUP_ALGORITHM) {
672
+ throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
673
+ }
674
+ const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
675
+ const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
676
+ const payload = JSON.parse(new TextDecoder().decode(plaintext));
677
+ if (!payload.privateKey || !payload.publicKey) {
678
+ throw new Error('Backup payload is missing key material');
679
+ }
680
+ const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
681
+ const payloadPk = payload.publicKey.toLowerCase();
682
+ const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
683
+ if (phrasePk !== payloadPk || privatePk !== payloadPk) {
684
+ throw new Error('Backup payload does not match the recovery phrase');
685
+ }
686
+ return payload;
687
+ }
688
+ /**
689
+ * Keep every retired key a backup carries in this device's ring. Each pair
690
+ * must be healthy and must not be the current key; the envelope itself is
691
+ * authenticated by the phrase. Commons only (the ring refuses elsewhere).
692
+ * Resolves how many keys are now held; a key that fails is skipped.
693
+ */
694
+ async keepCarriedRetiredKeys(payload) {
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.');
701
+ let kept = 0;
702
+ for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
703
+ try {
704
+ if (typeof key?.privateKey !== 'string' || typeof key.publicKey !== 'string')
705
+ continue;
706
+ if (key.publicKey.toLowerCase() === payload.publicKey.toLowerCase())
707
+ continue;
708
+ if (KeyManager.derivePublicKey(key.privateKey).toLowerCase() !== key.publicKey.toLowerCase()) {
709
+ continue;
710
+ }
711
+ await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey }, { ownerAccountId });
712
+ kept += 1;
713
+ }
714
+ catch (error) {
715
+ logger.warn('Could not keep a retired key from the encrypted backup', { component: 'oxy.identity', method: 'backup.restoreRetiredKeys' }, error);
716
+ }
717
+ }
718
+ return kept;
719
+ }
720
+ /**
721
+ * Restore ONLY the retired keys the phrase's backup carries into this
722
+ * device's ring (Commons, after a phrase import or a silent device-backup
723
+ * restore). Resolves how many were kept; 0 when there is no backup.
724
+ */
725
+ async restoreRetiredKeys(phrase) {
726
+ const payload = await this.open(phrase, true);
727
+ return payload ? this.keepCarriedRetiredKeys(payload) : 0;
728
+ }
588
729
  /**
589
730
  * Whether the signed-in user has a stored backup, plus its non-sensitive hint
590
731
  * and timestamp. Returns no ciphertext and no locator.
@@ -615,24 +756,14 @@ export class IdentityBackupApi {
615
756
  * fails authentication (tamper), or an existing identity blocks the import.
616
757
  */
617
758
  async restore(phrase, options) {
618
- const { KeyManager, RecoveryPhraseService, decryptAead } = await import('../crypto/index.js');
619
- const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
620
- const envelope = await this.ctx.request('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
621
- if (envelope.algorithm !== BACKUP_ALGORITHM) {
622
- throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
623
- }
624
- const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
625
- const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
626
- const payload = JSON.parse(new TextDecoder().decode(plaintext));
627
- if (!payload.privateKey || !payload.publicKey) {
628
- throw new Error('Backup payload is missing key material');
629
- }
630
- const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
631
- const payloadPk = payload.publicKey.toLowerCase();
632
- const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
633
- if (phrasePk !== payloadPk || privatePk !== payloadPk) {
634
- throw new Error('Backup payload does not match the recovery phrase');
635
- }
636
- return KeyManager.importKeyPair(payload.privateKey, { overwrite: options?.overwrite === true });
759
+ const { KeyManager } = await import('../crypto/index.js');
760
+ const payload = await this.open(phrase);
761
+ if (!payload)
762
+ throw new Error('No encrypted backup was found for this recovery phrase.');
763
+ const publicKey = await KeyManager.importKeyPair(payload.privateKey, {
764
+ overwrite: options?.overwrite === true,
765
+ });
766
+ await this.keepCarriedRetiredKeys(payload);
767
+ return publicKey;
637
768
  }
638
769
  }
@@ -145,6 +145,20 @@ export async function readIdentityMarker() {
145
145
  return null;
146
146
  }
147
147
  }
148
+ /**
149
+ * Read the marker, THROWING when the storage cannot answer (instead of the
150
+ * fail-open `null` of {@link readIdentityMarker}). For a caller that must not
151
+ * mistake "unreadable" for "never written": the iOS keychain migration, which
152
+ * adopts a pre-split secret only when this marker vouches for it, and records
153
+ * once that there is nothing to adopt.
154
+ */
155
+ export async function readIdentityMarkerStrict() {
156
+ const storage = await getStorage();
157
+ if (!storage) {
158
+ throw new Error('The identity marker storage is unavailable.');
159
+ }
160
+ return deserialize(await storage.getItem(IDENTITY_MARKER_STORAGE_KEY));
161
+ }
148
162
  /**
149
163
  * Write (create/replace) the marker. Returns `true` when it durably landed,
150
164
  * `false` when storage was unavailable or the write threw. Callers treat a
@@ -18,11 +18,11 @@ export { readIdentityMarker, updateIdentityMarker } from './identityMarker.js';
18
18
  // Organization keys on the device (docs/adr/0061-organization-identity-key.md D6)
19
19
  export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys.js';
20
20
  // iOS keychain access groups (docs/identity/ios-keychain-access-groups.md, ADR 0062)
21
- export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
22
- export { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
21
+ export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, mayWriteKeychainItem, DEV_KEYCHAIN_GROUP_SUFFIX, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
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, } 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)