@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
@@ -59,6 +59,7 @@ const client_1 = require("@oxy.so/contracts/client");
59
59
  const client_2 = require("@oxy.so/contracts/client");
60
60
  const platform_1 = require("../utils/platform");
61
61
  const logger_1 = require("../logger");
62
+ const errorUtils_1 = require("../utils/errorUtils");
62
63
  /** Short-TTL read cache for public identity reads. */
63
64
  const SHORT_TTL = 60 * 1000;
64
65
  /**
@@ -236,7 +237,7 @@ class IdentityApi {
236
237
  try {
237
238
  if (!oldPrivateKeyForRing)
238
239
  throw new Error('no readable private key');
239
- await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
240
+ await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey }, { ownerAccountId: userId });
240
241
  }
241
242
  catch (retainError) {
242
243
  logger_1.logger.warn('Could not keep the current key as a retired key; not rotating.', { component: 'oxy.identity', method: 'rotateKey' }, retainError);
@@ -354,10 +355,31 @@ class IdentityApi {
354
355
  }
355
356
  }
356
357
  }
358
+ // 8. The rotation deleted the encrypted backup (it was sealed under the
359
+ // replaced phrase). Seal a new one under the new phrase, carrying the
360
+ // retired ring — now with the key just replaced — so the coins under it
361
+ // survive a lost phone as long as the NEW phrase does (R4-4). Always,
362
+ // not only when one existed: after a rotation it is the only copy of the
363
+ // replaced key off this phone. Best-effort: the rotation already succeeded.
364
+ let backupRefreshed;
365
+ if (!(0, platform_1.isWeb)() && !localPersistFailed) {
366
+ try {
367
+ await this.backup.create(pending.phrase);
368
+ backupRefreshed = true;
369
+ }
370
+ catch (backupError) {
371
+ backupRefreshed = false;
372
+ logger_1.logger.warn('Key rotated, but the encrypted backup could not be re-created under the new phrase.', { component: 'oxy.identity', method: 'rotateKey' }, backupError);
373
+ }
374
+ }
357
375
  this.invalidate(userId);
358
- return localPersistFailed
359
- ? { newPublicKey, newPhrase: pending.phrase, words: pending.words, localPersistFailed: true }
360
- : { newPublicKey, newPhrase: pending.phrase, words: pending.words };
376
+ return {
377
+ newPublicKey,
378
+ newPhrase: pending.phrase,
379
+ words: pending.words,
380
+ ...(localPersistFailed ? { localPersistFailed: true } : {}),
381
+ ...(backupRefreshed === undefined ? {} : { backupRefreshed }),
382
+ };
361
383
  }
362
384
  /**
363
385
  * The signed-in user's signed, open-format data-export bundle
@@ -549,6 +571,10 @@ const BACKUP_ALGORITHM = 'xchacha20poly1305';
549
571
  * to recognise WHICH identity a backup belongs to, but only a prefix.
550
572
  */
551
573
  const PUBLIC_KEY_HINT_LENGTH = 16;
574
+ /** Whether a request error is the restore lookup's constant-shape 404. */
575
+ function isNotFound(error) {
576
+ return (0, errorUtils_1.extractErrorStatus)(error) === 404;
577
+ }
552
578
  /** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
553
579
  function toHex(bytes) {
554
580
  let out = '';
@@ -608,7 +634,13 @@ class IdentityBackupApi {
608
634
  const publicKey = KeyManager.derivePublicKey(privateKey);
609
635
  const createdAt = new Date().toISOString();
610
636
  const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
611
- const payload = { privateKey, publicKey, createdAt };
637
+ const retiredKeys = await this.retiredKeysToCarry(publicKey);
638
+ const payload = {
639
+ privateKey,
640
+ publicKey,
641
+ createdAt,
642
+ ...(retiredKeys.length > 0 ? { retiredKeys } : {}),
643
+ };
612
644
  const plaintext = new TextEncoder().encode(JSON.stringify(payload));
613
645
  const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
614
646
  const { nonce, ciphertext } = encryptAead(backupKey, plaintext, aad);
@@ -626,6 +658,115 @@ class IdentityBackupApi {
626
658
  cache: false,
627
659
  });
628
660
  }
661
+ /**
662
+ * The retired ring this device holds for the person, limited to keys Oxy's
663
+ * key history names as THIS account's retired keys (the ring's iOS group is
664
+ * writable by the wallet app too, and a previous person's ring can outlive a
665
+ * "Start over"). Empty where no ring is held (web, any app but Commons).
666
+ * Throws when the history cannot be read while a ring exists, so a backup
667
+ * never silently drops keys it should carry.
668
+ */
669
+ async retiredKeysToCarry(currentPublicKey) {
670
+ if ((0, platform_1.isWeb)())
671
+ return [];
672
+ const { exportRetiredIdentityKeys } = await Promise.resolve().then(() => __importStar(require('../crypto/index')));
673
+ const userId = this.ctx.oxy.session.userId;
674
+ if (!userId)
675
+ throw new Error('No authenticated user — sign in before backing up.');
676
+ let held;
677
+ try {
678
+ held = await exportRetiredIdentityKeys({ ownerAccountId: userId });
679
+ }
680
+ catch {
681
+ return []; // Not Commons: this app holds no ring to carry.
682
+ }
683
+ if (held.length === 0)
684
+ return [];
685
+ const history = await this.ctx.request('GET', '/identity/key-history', undefined, {
686
+ cache: false,
687
+ });
688
+ const parsed = (0, client_1.checkContract)(client_2.identityKeyHistoryCheck, history);
689
+ if (!parsed)
690
+ throw new Error('identity/key-history returned an unexpected response shape');
691
+ const retired = new Set(parsed.retired.map((entry) => entry.publicKey.toLowerCase()));
692
+ const current = currentPublicKey.toLowerCase();
693
+ return held.filter((key) => key.publicKey.toLowerCase() !== current && retired.has(key.publicKey.toLowerCase()));
694
+ }
695
+ /**
696
+ * Fetch and open the backup the phrase locates, checking it is the phrase's
697
+ * own identity. With `missingIsNull`, `null` when there is none (404);
698
+ * otherwise the request's own error propagates.
699
+ */
700
+ async open(phrase, missingIsNull = false) {
701
+ const { KeyManager, RecoveryPhraseService, decryptAead } = await Promise.resolve().then(() => __importStar(require('../crypto/index')));
702
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
703
+ let envelope;
704
+ try {
705
+ envelope = await this.ctx.request('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
706
+ }
707
+ catch (error) {
708
+ if (missingIsNull && isNotFound(error))
709
+ return null;
710
+ throw error;
711
+ }
712
+ if (envelope.algorithm !== BACKUP_ALGORITHM) {
713
+ throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
714
+ }
715
+ const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
716
+ const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
717
+ const payload = JSON.parse(new TextDecoder().decode(plaintext));
718
+ if (!payload.privateKey || !payload.publicKey) {
719
+ throw new Error('Backup payload is missing key material');
720
+ }
721
+ const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
722
+ const payloadPk = payload.publicKey.toLowerCase();
723
+ const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
724
+ if (phrasePk !== payloadPk || privatePk !== payloadPk) {
725
+ throw new Error('Backup payload does not match the recovery phrase');
726
+ }
727
+ return payload;
728
+ }
729
+ /**
730
+ * Keep every retired key a backup carries in this device's ring. Each pair
731
+ * must be healthy and must not be the current key; the envelope itself is
732
+ * authenticated by the phrase. Commons only (the ring refuses elsewhere).
733
+ * Resolves how many keys are now held; a key that fails is skipped.
734
+ */
735
+ async keepCarriedRetiredKeys(payload) {
736
+ const { KeyManager, retireIdentityKey } = await Promise.resolve().then(() => __importStar(require('../crypto/index')));
737
+ // The backup is the signed-in account's (its phrase opened it): its keys
738
+ // are kept for that account only (R5-2).
739
+ const ownerAccountId = this.ctx.oxy.session.userId;
740
+ if (!ownerAccountId)
741
+ throw new Error('No authenticated user — sign in before restoring.');
742
+ let kept = 0;
743
+ for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
744
+ try {
745
+ if (typeof key?.privateKey !== 'string' || typeof key.publicKey !== 'string')
746
+ continue;
747
+ if (key.publicKey.toLowerCase() === payload.publicKey.toLowerCase())
748
+ continue;
749
+ if (KeyManager.derivePublicKey(key.privateKey).toLowerCase() !== key.publicKey.toLowerCase()) {
750
+ continue;
751
+ }
752
+ await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey }, { ownerAccountId });
753
+ kept += 1;
754
+ }
755
+ catch (error) {
756
+ logger_1.logger.warn('Could not keep a retired key from the encrypted backup', { component: 'oxy.identity', method: 'backup.restoreRetiredKeys' }, error);
757
+ }
758
+ }
759
+ return kept;
760
+ }
761
+ /**
762
+ * Restore ONLY the retired keys the phrase's backup carries into this
763
+ * device's ring (Commons, after a phrase import or a silent device-backup
764
+ * restore). Resolves how many were kept; 0 when there is no backup.
765
+ */
766
+ async restoreRetiredKeys(phrase) {
767
+ const payload = await this.open(phrase, true);
768
+ return payload ? this.keepCarriedRetiredKeys(payload) : 0;
769
+ }
629
770
  /**
630
771
  * Whether the signed-in user has a stored backup, plus its non-sensitive hint
631
772
  * and timestamp. Returns no ciphertext and no locator.
@@ -656,25 +797,15 @@ class IdentityBackupApi {
656
797
  * fails authentication (tamper), or an existing identity blocks the import.
657
798
  */
658
799
  async restore(phrase, options) {
659
- const { KeyManager, RecoveryPhraseService, decryptAead } = await Promise.resolve().then(() => __importStar(require('../crypto/index')));
660
- const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
661
- const envelope = await this.ctx.request('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
662
- if (envelope.algorithm !== BACKUP_ALGORITHM) {
663
- throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
664
- }
665
- const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
666
- const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
667
- const payload = JSON.parse(new TextDecoder().decode(plaintext));
668
- if (!payload.privateKey || !payload.publicKey) {
669
- throw new Error('Backup payload is missing key material');
670
- }
671
- const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
672
- const payloadPk = payload.publicKey.toLowerCase();
673
- const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
674
- if (phrasePk !== payloadPk || privatePk !== payloadPk) {
675
- throw new Error('Backup payload does not match the recovery phrase');
676
- }
677
- return KeyManager.importKeyPair(payload.privateKey, { overwrite: options?.overwrite === true });
800
+ const { KeyManager } = await Promise.resolve().then(() => __importStar(require('../crypto/index')));
801
+ const payload = await this.open(phrase);
802
+ if (!payload)
803
+ throw new Error('No encrypted backup was found for this recovery phrase.');
804
+ const publicKey = await KeyManager.importKeyPair(payload.privateKey, {
805
+ overwrite: options?.overwrite === true,
806
+ });
807
+ await this.keepCarriedRetiredKeys(payload);
808
+ return publicKey;
678
809
  }
679
810
  }
680
811
  exports.IdentityBackupApi = IdentityBackupApi;
@@ -30,6 +30,7 @@
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
31
  exports.IDENTITY_MARKER_STORAGE_KEY = void 0;
32
32
  exports.readIdentityMarker = readIdentityMarker;
33
+ exports.readIdentityMarkerStrict = readIdentityMarkerStrict;
33
34
  exports.writeIdentityMarker = writeIdentityMarker;
34
35
  exports.updateIdentityMarker = updateIdentityMarker;
35
36
  exports.clearIdentityMarker = clearIdentityMarker;
@@ -152,6 +153,20 @@ async function readIdentityMarker() {
152
153
  return null;
153
154
  }
154
155
  }
156
+ /**
157
+ * Read the marker, THROWING when the storage cannot answer (instead of the
158
+ * fail-open `null` of {@link readIdentityMarker}). For a caller that must not
159
+ * mistake "unreadable" for "never written": the iOS keychain migration, which
160
+ * adopts a pre-split secret only when this marker vouches for it, and records
161
+ * once that there is nothing to adopt.
162
+ */
163
+ async function readIdentityMarkerStrict() {
164
+ const storage = await getStorage();
165
+ if (!storage) {
166
+ throw new Error('The identity marker storage is unavailable.');
167
+ }
168
+ return deserialize(await storage.getItem(exports.IDENTITY_MARKER_STORAGE_KEY));
169
+ }
155
170
  /**
156
171
  * Write (create/replace) the marker. Returns `true` when it durably landed,
157
172
  * `false` when storage was unavailable or the write threw. Callers treat a
@@ -12,8 +12,8 @@
12
12
  * common chunk, which is the cost the lazy loading exists to avoid.
13
13
  */
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.deriveScopedSeedFromKey = exports.MAX_SOCIAL_RECEIVE_INDEX = exports.SOCIAL_RECEIVE_CHAIN_CODE_KEY = exports.SCOPED_SEED_KDF_SALT = exports.deriveSharedSecret = exports.AEAD_NONCE_LENGTH = exports.AEAD_KEY_LENGTH = exports.decryptAead = exports.encryptAead = exports.hkdfSha256 = exports.BACKUP_KDF_ENCRYPTION_INFO = exports.RecoveryPhraseService = exports.SignatureService = exports.retiredKeyFromPhrase = exports.syncRetiredKeys = exports.forgetRetiredIdentityKeys = exports.forgetRetiredIdentityKey = exports.listRetiredIdentityKeys = exports.retireIdentityKey = exports.ensureKeychainGroupsMigrated = exports.groupedKeychainStore = exports.deleteThroughPlan = exports.readThroughPlan = exports.ownKeychainItemPlan = exports.keychainWriteOption = exports.keychainDeletePlan = exports.keychainReadPlan = exports.keychainAccessGroupValue = exports.resolveKeychainAccess = exports.mayHoldKeychainItem = exports.keychainGroupFor = exports.KeychainAccessUnavailableError = exports.KEYCHAIN_ACCESS_GROUPS = exports.isHealthyOrganizationKeyPair = exports.isOrganizationAccountId = exports.syncOrganizationKeys = exports.deleteOrganizationKey = exports.listOrganizationKeys = exports.getOrganizationRecoveryPhrase = exports.getOrganizationPrivateKey = exports.getOrganizationPublicKey = exports.storeOrganizationKey = exports.organizationKeyFromPhrase = exports.generateOrganizationKey = exports.updateIdentityMarker = exports.readIdentityMarker = exports.IdentityUnavailableError = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = void 0;
16
- exports.verifySignature = exports.signMessage = exports.loadCommonsIdentityBridge = exports.canonicalize = exports.createContentHasher = exports.solveRegistrationPow = exports.deriveIdentityLinkCode = exports.signIdentityProof = exports.digestIdentityPayload = exports.authChallengeDigest = exports.signSocialReceiveDigest = void 0;
15
+ exports.hkdfSha256 = exports.BACKUP_KDF_ENCRYPTION_INFO = exports.RecoveryPhraseService = exports.SignatureService = exports.RetiredKeyRingUnreadableError = exports.unmirrorPersonalRetiredKeys = exports.countQuarantinedRetiredKeys = exports.claimRetiredIdentityKeys = exports.exportRetiredIdentityKeys = exports.retiredKeyFromPrivateKey = exports.retiredKeyFromPhrase = exports.syncRetiredKeys = exports.forgetRetiredIdentityKeys = exports.forgetRetiredIdentityKey = exports.listRetiredIdentityKeys = exports.retireIdentityKey = exports.getPreSplitExposure = exports.ensureKeychainGroupsMigrated = exports.groupedKeychainStore = exports.deleteThroughPlan = exports.readThroughPlan = exports.ownKeychainItemPlan = exports.keychainWriteOption = exports.keychainDeletePlan = exports.keychainReadPlan = exports.keychainAccessGroupValue = exports.resolveKeychainAccess = exports.DEV_KEYCHAIN_GROUP_SUFFIX = exports.mayWriteKeychainItem = exports.mayHoldKeychainItem = exports.keychainGroupFor = exports.KeychainAccessUnavailableError = exports.KEYCHAIN_ACCESS_GROUPS = exports.isHealthyOrganizationKeyPair = exports.isOrganizationAccountId = exports.syncOrganizationKeys = exports.deleteOrganizationKey = exports.listOrganizationKeys = exports.getOrganizationRecoveryPhrase = exports.getOrganizationPrivateKey = exports.getOrganizationPublicKey = exports.storeOrganizationKey = exports.organizationKeyFromPhrase = exports.generateOrganizationKey = exports.updateIdentityMarker = exports.readIdentityMarker = exports.IdentityUnavailableError = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = void 0;
16
+ exports.verifySignature = exports.signMessage = exports.loadCommonsIdentityBridge = exports.canonicalize = exports.createContentHasher = exports.solveRegistrationPow = exports.deriveIdentityLinkCode = exports.signIdentityProof = exports.digestIdentityPayload = exports.authChallengeDigest = exports.signSocialReceiveDigest = exports.deriveScopedSeedFromKey = exports.MAX_SOCIAL_RECEIVE_INDEX = exports.SOCIAL_RECEIVE_CHAIN_CODE_KEY = exports.SCOPED_SEED_KDF_SALT = exports.deriveSharedSecret = exports.AEAD_NONCE_LENGTH = exports.AEAD_KEY_LENGTH = exports.decryptAead = exports.encryptAead = void 0;
17
17
  // MUST stay the first import: the shim has to exist before any module below
18
18
  // evaluates or draws randomness (see `./polyfill`).
19
19
  require("./polyfill");
@@ -44,6 +44,8 @@ Object.defineProperty(exports, "KEYCHAIN_ACCESS_GROUPS", { enumerable: true, get
44
44
  Object.defineProperty(exports, "KeychainAccessUnavailableError", { enumerable: true, get: function () { return keychainAccessGroups_1.KeychainAccessUnavailableError; } });
45
45
  Object.defineProperty(exports, "keychainGroupFor", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainGroupFor; } });
46
46
  Object.defineProperty(exports, "mayHoldKeychainItem", { enumerable: true, get: function () { return keychainAccessGroups_1.mayHoldKeychainItem; } });
47
+ Object.defineProperty(exports, "mayWriteKeychainItem", { enumerable: true, get: function () { return keychainAccessGroups_1.mayWriteKeychainItem; } });
48
+ Object.defineProperty(exports, "DEV_KEYCHAIN_GROUP_SUFFIX", { enumerable: true, get: function () { return keychainAccessGroups_1.DEV_KEYCHAIN_GROUP_SUFFIX; } });
47
49
  Object.defineProperty(exports, "resolveKeychainAccess", { enumerable: true, get: function () { return keychainAccessGroups_1.resolveKeychainAccess; } });
48
50
  Object.defineProperty(exports, "keychainAccessGroupValue", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainAccessGroupValue; } });
49
51
  Object.defineProperty(exports, "keychainReadPlan", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainReadPlan; } });
@@ -55,6 +57,7 @@ Object.defineProperty(exports, "deleteThroughPlan", { enumerable: true, get: fun
55
57
  Object.defineProperty(exports, "groupedKeychainStore", { enumerable: true, get: function () { return keychainAccessGroups_1.groupedKeychainStore; } });
56
58
  var keychainGroupMigration_1 = require("./keychainGroupMigration");
57
59
  Object.defineProperty(exports, "ensureKeychainGroupsMigrated", { enumerable: true, get: function () { return keychainGroupMigration_1.ensureKeychainGroupsMigrated; } });
60
+ Object.defineProperty(exports, "getPreSplitExposure", { enumerable: true, get: function () { return keychainGroupMigration_1.getPreSplitExposure; } });
58
61
  // Retired identity keys: kept for spending after a rotation or replacement
59
62
  // (docs/adr/0061-organization-identity-key.md D7 as amended 2026-11-06)
60
63
  var retiredKeys_1 = require("./retiredKeys");
@@ -64,6 +67,12 @@ Object.defineProperty(exports, "forgetRetiredIdentityKey", { enumerable: true, g
64
67
  Object.defineProperty(exports, "forgetRetiredIdentityKeys", { enumerable: true, get: function () { return retiredKeys_1.forgetRetiredIdentityKeys; } });
65
68
  Object.defineProperty(exports, "syncRetiredKeys", { enumerable: true, get: function () { return retiredKeys_1.syncRetiredKeys; } });
66
69
  Object.defineProperty(exports, "retiredKeyFromPhrase", { enumerable: true, get: function () { return retiredKeys_1.retiredKeyFromPhrase; } });
70
+ Object.defineProperty(exports, "retiredKeyFromPrivateKey", { enumerable: true, get: function () { return retiredKeys_1.retiredKeyFromPrivateKey; } });
71
+ Object.defineProperty(exports, "exportRetiredIdentityKeys", { enumerable: true, get: function () { return retiredKeys_1.exportRetiredIdentityKeys; } });
72
+ Object.defineProperty(exports, "claimRetiredIdentityKeys", { enumerable: true, get: function () { return retiredKeys_1.claimRetiredIdentityKeys; } });
73
+ Object.defineProperty(exports, "countQuarantinedRetiredKeys", { enumerable: true, get: function () { return retiredKeys_1.countQuarantinedRetiredKeys; } });
74
+ Object.defineProperty(exports, "unmirrorPersonalRetiredKeys", { enumerable: true, get: function () { return retiredKeys_1.unmirrorPersonalRetiredKeys; } });
75
+ Object.defineProperty(exports, "RetiredKeyRingUnreadableError", { enumerable: true, get: function () { return retiredKeys_1.RetiredKeyRingUnreadableError; } });
67
76
  var signatureService_1 = require("./signatureService");
68
77
  Object.defineProperty(exports, "SignatureService", { enumerable: true, get: function () { return signatureService_1.SignatureService; } });
69
78
  var recoveryPhrase_1 = require("./recoveryPhrase");
@@ -211,9 +211,7 @@ async function loadRawSecureStore() {
211
211
  async function keychainItemStore(item) {
212
212
  const raw = await loadRawSecureStore();
213
213
  const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
214
- if (access.mode === 'grouped' && access.role === 'holder') {
215
- await (0, keychainGroupMigration_1.ensureKeychainGroupsMigrated)();
216
- }
214
+ await (0, keychainGroupMigration_1.prepareKeychainAccess)(access);
217
215
  return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, item);
218
216
  }
219
217
  /** The store for the identity slots (primary, backup, legacy, phrase): Commons only on iOS. */
@@ -790,12 +788,30 @@ class KeyManager {
790
788
  * `signSocialReceive`) is fresh, so a cached key here would let a caller
791
789
  * compare a new seed against an old key.
792
790
  *
791
+ * iOS, in an app that holds the private key (Commons, the Peable wallet:
792
+ * keychain roles `holder` and `wallet`): DERIVED from the private key in the
793
+ * wallet group, never read from the public-key item, which sits in the shared
794
+ * group every Oxy app can write (R5-1); `null` when that app holds no private
795
+ * key. Every other iOS app reads the public-key item: it holds nothing it
796
+ * could derive from, and it never spends. Money code uses
797
+ * {@link getIdentityPublicKeyForMoney}.
798
+ *
793
799
  * @returns Shared public key or null if no shared identity exists
794
800
  */
795
801
  static async getSharedPublicKey() {
796
802
  if (isWebPlatform()) {
797
803
  return null;
798
804
  }
805
+ if ((0, platform_1.isIOS)() && (await KeyManager._holdsSpendingKeys())) {
806
+ // Not cached: a rotation in Commons changes it under the wallet.
807
+ try {
808
+ const privateKey = await KeyManager.getSharedPrivateKey();
809
+ return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
810
+ }
811
+ catch {
812
+ return null;
813
+ }
814
+ }
799
815
  // Return cached value if available (iOS only; see above).
800
816
  if (!(0, platform_1.isAndroid)() && KeyManager.cachedSharedPublicKey !== null) {
801
817
  return KeyManager.cachedSharedPublicKey;
@@ -828,6 +844,68 @@ class KeyManager {
828
844
  return null;
829
845
  }
830
846
  }
847
+ /**
848
+ * Whether this iOS app holds spending keys (keychain role `holder` or
849
+ * `wallet`), so a public key it uses for money must be derived from them.
850
+ * Throws while the keychain cannot say. @internal
851
+ */
852
+ static async _holdsSpendingKeys() {
853
+ const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
854
+ return access.mode === 'grouped' && access.role !== 'member';
855
+ }
856
+ /**
857
+ * THE public key of the identity a money holder (the Peable wallet, Commons)
858
+ * receives and spends with: the personal key, or with `opts.accountId` that
859
+ * organization's (ADR 0061 D6). It is the public half of exactly the private
860
+ * key {@link deriveScopedSeed} and {@link signSocialReceive} use, so an
861
+ * address shown from it is one this app can spend (R5-1).
862
+ *
863
+ * - iOS (Commons, the Peable wallet): DERIVED from the private key in the
864
+ * restricted keychain groups (personal: the wallet group's slot, then
865
+ * Commons' primary; organization: its pair). Never the shared group's
866
+ * public-key item, which every Oxy app can write. `null` when no private
867
+ * key is held, and in every other iOS app.
868
+ * - Android: inside Commons derived from its own key; elsewhere Commons'
869
+ * identity host (`describe`), which answers from Commons' own signer
870
+ * store over signature-protected IPC.
871
+ * - Web: `null`.
872
+ *
873
+ * Compare the answer with the account's published `#key-1` before showing a
874
+ * receive address (Peable ADR 0002). Lowercase uncompressed (`04…`).
875
+ */
876
+ static async getIdentityPublicKeyForMoney(opts) {
877
+ if (isWebPlatform()) {
878
+ return null;
879
+ }
880
+ const accountId = opts?.accountId;
881
+ if (accountId !== undefined) {
882
+ if (!(0, organizationKeys_1.isOrganizationAccountId)(accountId))
883
+ return null;
884
+ if ((0, platform_1.isAndroid)() && !KeyManager.identitySignerStore) {
885
+ const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
886
+ const description = (await bridge?.describe(accountId)) ?? null;
887
+ return description?.accountId === accountId && description.publicKey
888
+ ? KeyManager.canonicalPublicKey(description.publicKey)
889
+ : null;
890
+ }
891
+ const organizationKey = await (0, organizationKeys_1.getOrganizationPrivateKey)(accountId);
892
+ return organizationKey ? KeyManager.derivePublicKey(organizationKey) : null;
893
+ }
894
+ if ((0, platform_1.isAndroid)()) {
895
+ if (KeyManager.identitySignerStore) {
896
+ const local = await KeyManager.getPrivateKey();
897
+ return local ? KeyManager.derivePublicKey(local) : null;
898
+ }
899
+ const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
900
+ const publicKey = (await bridge?.describe())?.publicKey ?? null;
901
+ return publicKey ? KeyManager.canonicalPublicKey(publicKey) : null;
902
+ }
903
+ if (!(0, platform_1.isIOS)()) {
904
+ return null;
905
+ }
906
+ const privateKey = (await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
907
+ return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
908
+ }
831
909
  /**
832
910
  * The shared private key, where this app may hold it: iOS only.
833
911
  *
@@ -2089,21 +2167,28 @@ class KeyManager {
2089
2167
  if (force) {
2090
2168
  await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
2091
2169
  await KeyManager._clearSharedSlot(store);
2092
- // The person's retired keys go with the identity they belonged to.
2093
- try {
2094
- await (0, retiredKeys_1.forgetRetiredIdentityKeys)();
2095
- }
2096
- catch (error) {
2097
- logger_1.logger.warn('Failed to clear retired keys during delete', { component: 'KeyManager' }, error);
2098
- }
2170
+ // The retired ring is NOT cleared (R4-4): it is money under the person's
2171
+ // previous keys, and "Start over" or a forced delete must not cost it.
2172
+ // Every personal entry is bound to its owner's account id (R5-2), so the
2173
+ // same person signing in again finds it, and a different person on this
2174
+ // phone never lists, derives from or signs with it. Only deleting the Oxy
2175
+ // ACCOUNT clears it, explicitly (`forgetRetiredIdentityKeys`).
2099
2176
  }
2100
2177
  // The device backup goes on EVERY delete, forced or not: it is restored
2101
2178
  // silently, so leaving it would bring a deleted identity back on the next
2102
2179
  // launch. Best-effort, like the other recovery sources.
2103
2180
  await KeyManager._clearDeviceBackup();
2104
2181
  // Same for the Android signer store: Commons' identity host must stop
2105
- // answering for a deleted identity, forced or not.
2182
+ // answering for a deleted identity, forced or not — its personal retired
2183
+ // keys included (R5-2): they stay in Commons' own ring, bound to their
2184
+ // owner, but the host must not hand them to the next person on the phone.
2106
2185
  await KeyManager._clearIdentitySigner();
2186
+ try {
2187
+ await (0, retiredKeys_1.unmirrorPersonalRetiredKeys)();
2188
+ }
2189
+ catch (error) {
2190
+ logger_1.logger.warn('Failed to withdraw the personal retired keys from the identity host', { component: 'KeyManager' }, error);
2191
+ }
2107
2192
  // Clear the marker AFTER key deletion succeeds — a marker must never outlive
2108
2193
  // its identity (a leftover marker would route a truly-absent device to
2109
2194
  // `recovery` instead of `welcome`).
@@ -2632,11 +2717,16 @@ class KeyManager {
2632
2717
  * (personal, or `accountId`'s) whose public key it names: the current key
2633
2718
  * when it matches, else a RETIRED key Commons kept after a rotation or
2634
2719
  * replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
2635
- * retired key never signs in. Never falls back to another key.
2720
+ * retired key never signs in. Never falls back to another key. A PERSONAL
2721
+ * retired key is reached only with `opts.ownerAccountId`, the signed-in
2722
+ * person's account id, and only when the ring keeps it for that account
2723
+ * (R5-2); on Android outside Commons the identity host holds only the
2724
+ * personal keys of the person Commons is signed in as.
2636
2725
  *
2637
2726
  * @param info Context/domain-binding label (distinct labels → independent seeds).
2638
2727
  * @param opts.accountId An organization's account id, for its key.
2639
2728
  * @param opts.publicKey Selects one key of that account by its public key.
2729
+ * @param opts.ownerAccountId The person's account id, for a personal retired key.
2640
2730
  * @returns 32 bytes of derived keying material, or `null` on web / when no
2641
2731
  * identity key is available to this app.
2642
2732
  */
@@ -2653,7 +2743,7 @@ class KeyManager {
2653
2743
  const seed = (await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
2654
2744
  return seed ? hexToBytes(seed) : null;
2655
2745
  }
2656
- const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
2746
+ const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
2657
2747
  return selected ? (0, identityDerivations_1.deriveScopedSeedFromKey)(selected, info) : null;
2658
2748
  }
2659
2749
  if (opts?.accountId !== undefined) {
@@ -2721,7 +2811,7 @@ class KeyManager {
2721
2811
  return ((await bridge?.signRetiredSocialReceive?.(index, digest, selected, opts.accountId)) ??
2722
2812
  null);
2723
2813
  }
2724
- const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
2814
+ const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
2725
2815
  return selected ? (0, identityDerivations_1.signSocialReceiveDigest)(selected, index, digest) : null;
2726
2816
  }
2727
2817
  const accountId = opts?.accountId;
@@ -2760,7 +2850,8 @@ class KeyManager {
2760
2850
  static _selectorKey(publicKey) {
2761
2851
  return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
2762
2852
  }
2763
- static async _selectedPrivateKey(publicKey, accountId) {
2853
+ static async _selectedPrivateKey(opts) {
2854
+ const { publicKey, accountId, ownerAccountId } = opts;
2764
2855
  if ((0, retiredKeys_1.retiredKeyScope)({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
2765
2856
  return null;
2766
2857
  }
@@ -2773,7 +2864,11 @@ class KeyManager {
2773
2864
  if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
2774
2865
  return current;
2775
2866
  }
2776
- return (0, retiredKeys_1.getRetiredPrivateKey)(wanted, accountId !== undefined ? { accountId } : undefined);
2867
+ return (0, retiredKeys_1.getRetiredPrivateKey)(wanted, accountId !== undefined
2868
+ ? { accountId }
2869
+ : ownerAccountId !== undefined
2870
+ ? { ownerAccountId }
2871
+ : undefined);
2777
2872
  }
2778
2873
  /**
2779
2874
  * Get a shortened version of the public key for display