@oxy.so/core 7.28.0 → 7.30.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 (44) hide show
  1. package/dist/cjs/api/identity.js +40 -1
  2. package/dist/cjs/crypto/index.js +28 -1
  3. package/dist/cjs/crypto/keyManager.js +175 -107
  4. package/dist/cjs/crypto/keychainGroupMigration.js +353 -0
  5. package/dist/cjs/crypto/keychainItems.js +70 -0
  6. package/dist/cjs/crypto/organizationKeys.js +80 -72
  7. package/dist/cjs/crypto/retiredKeys.js +371 -0
  8. package/dist/cjs/crypto/secureStoreOptions.js +2 -0
  9. package/dist/cjs/session/index.js +11 -1
  10. package/dist/cjs/utils/keychainAccessGroups.js +248 -0
  11. package/dist/esm/api/identity.js +40 -1
  12. package/dist/esm/crypto/index.js +6 -0
  13. package/dist/esm/crypto/keyManager.js +132 -64
  14. package/dist/esm/crypto/keychainGroupMigration.js +348 -0
  15. package/dist/esm/crypto/keychainItems.js +61 -0
  16. package/dist/esm/crypto/organizationKeys.js +54 -46
  17. package/dist/esm/crypto/retiredKeys.js +361 -0
  18. package/dist/esm/crypto/secureStoreOptions.js +1 -0
  19. package/dist/esm/session/index.js +3 -0
  20. package/dist/esm/utils/keychainAccessGroups.js +231 -0
  21. package/dist/types/api/identity.d.ts +10 -0
  22. package/dist/types/crypto/identitySigner.d.ts +23 -0
  23. package/dist/types/crypto/index.d.ts +6 -0
  24. package/dist/types/crypto/keyManager.d.ts +32 -2
  25. package/dist/types/crypto/keychainGroupMigration.d.ts +27 -0
  26. package/dist/types/crypto/keychainItems.d.ts +41 -0
  27. package/dist/types/crypto/organizationKeys.d.ts +11 -9
  28. package/dist/types/crypto/retiredKeys.d.ts +116 -0
  29. package/dist/types/crypto/secureStoreOptions.d.ts +15 -0
  30. package/dist/types/session/index.d.ts +2 -0
  31. package/dist/types/utils/keychainAccessGroups.d.ts +104 -0
  32. package/package.json +3 -3
  33. package/src/api/identity.ts +46 -3
  34. package/src/crypto/identitySigner.ts +23 -0
  35. package/src/crypto/index.ts +40 -0
  36. package/src/crypto/keyManager.ts +165 -102
  37. package/src/crypto/keychainGroupMigration.ts +440 -0
  38. package/src/crypto/keychainItems.ts +73 -0
  39. package/src/crypto/organizationKeys.ts +72 -57
  40. package/src/crypto/retiredKeys.ts +453 -0
  41. package/src/crypto/secureStoreOptions.ts +32 -0
  42. package/src/session/index.ts +11 -0
  43. package/src/utils/keychainAccessGroups.ts +308 -0
  44. package/src/types/expo-secure-store.d.ts +0 -21
@@ -18,6 +18,7 @@
18
18
  * AEAD, signatures) load on first use, never with the client itself.
19
19
  */
20
20
  import { checkContract, IDENTITY_PROOF_ACTIONS, identityLinkCreateResponseCheck, identityLinkStateCheck, } from '@oxy.so/contracts/client';
21
+ import { identityKeyHistoryCheck } from '@oxy.so/contracts/client';
21
22
  import { isAndroid, isWeb } from '../utils/platform.js';
22
23
  import { logger } from '../logger/index.js';
23
24
  /** Short-TTL read cache for public identity reads. */
@@ -158,7 +159,7 @@ export class IdentityApi {
158
159
  if (!userId) {
159
160
  throw new Error('No authenticated user — sign in before rotating your key.');
160
161
  }
161
- const { KeyManager, RecoveryPhraseService, SignatureService, signMessage } = await import('../crypto/index.js');
162
+ const { KeyManager, RecoveryPhraseService, SignatureService, signMessage, retireIdentityKey } = await import('../crypto/index.js');
162
163
  // 1. The NEW identity (in memory only). The UI may pre-derive + pre-show
163
164
  // it and pass it back here so the phrase shown === the phrase committed.
164
165
  const pending = options.pendingIdentity ?? (await RecoveryPhraseService.derivePendingIdentity());
@@ -166,6 +167,7 @@ export class IdentityApi {
166
167
  // 2. Resolve the OLD signing capability from the chosen proof mode.
167
168
  let oldPublicKey;
168
169
  let signWithOldKey;
170
+ let oldPrivateKeyForRing;
169
171
  if (options.proof === 'phrase') {
170
172
  const phrase = options.phrase?.trim();
171
173
  if (!phrase) {
@@ -174,6 +176,7 @@ export class IdentityApi {
174
176
  const oldPrivateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
175
177
  oldPublicKey = KeyManager.derivePublicKey(oldPrivateKey);
176
178
  signWithOldKey = (message) => signMessage(message, oldPrivateKey);
179
+ oldPrivateKeyForRing = oldPrivateKey;
177
180
  }
178
181
  else {
179
182
  const currentPublicKey = await KeyManager.getPublicKey();
@@ -182,6 +185,25 @@ export class IdentityApi {
182
185
  }
183
186
  oldPublicKey = currentPublicKey;
184
187
  signWithOldKey = (message) => SignatureService.sign(message);
188
+ oldPrivateKeyForRing = isWeb() ? null : await KeyManager.getPrivateKey();
189
+ }
190
+ // 2b. Keep the key being replaced in the retired ring BEFORE anything
191
+ // changes (oxy ADR 0061 D7 as amended, Peable ADR 0002): coins keep
192
+ // arriving under it after the swap, and the Peable wallet spends them
193
+ // through the `publicKey` selector of `deriveScopedSeed` /
194
+ // `signSocialReceive`. A device that cannot keep it does not rotate:
195
+ // the phrase would become the only way to those coins. Native only —
196
+ // on web no key is stored. Idempotent, so a retried rotation is fine.
197
+ if (!isWeb()) {
198
+ try {
199
+ if (!oldPrivateKeyForRing)
200
+ throw new Error('no readable private key');
201
+ await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
202
+ }
203
+ catch (retainError) {
204
+ logger.warn('Could not keep the current key as a retired key; not rotating.', { component: 'oxy.identity', method: 'rotateKey' }, retainError);
205
+ throw new Error('Your current key could not be kept on this device, so it was not rotated. Nothing changed.');
206
+ }
185
207
  }
186
208
  // 3. Request a single-use rotate_key challenge (bearer).
187
209
  const { challenge } = await this.ctx.request('POST', '/auth/rotate/challenge', undefined, {
@@ -338,6 +360,23 @@ export class IdentityApi {
338
360
  keys.push(`GET:/u/${encodeURIComponent(userId)}/did.json`);
339
361
  this.ctx.http.invalidateCache({ keys, prefixes: ['GET:/users/me'] });
340
362
  }
363
+ /**
364
+ * The signed-in personal account's current root (`null` without one) and
365
+ * every root a rotation replaced, oldest first, all canonical (uncompressed,
366
+ * lowercase). Commons reads it to accept a previous recovery phrase only when
367
+ * it derives a key that WAS this account's root. A retired root is never any
368
+ * account's again. First-party only; a non-personal account is refused
369
+ * (`IDENTITY_NOT_PERSONAL_ACCOUNT`).
370
+ */
371
+ async keyHistory() {
372
+ const res = await this.ctx.request('GET', '/identity/key-history', undefined, {
373
+ cache: false,
374
+ });
375
+ const parsed = checkContract(identityKeyHistoryCheck, res);
376
+ if (!parsed)
377
+ throw new Error('identity/key-history returned an unexpected response shape');
378
+ return parsed;
379
+ }
341
380
  }
342
381
  /** `oxy.identity.links` — joining Commons' root to an account (ADR 0029 D3). */
343
382
  export class IdentityLinksApi {
@@ -17,6 +17,12 @@ export { KeyManager, IdentityAlreadyExistsError, IdentityPersistError, IdentityU
17
17
  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
+ // 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';
23
+ // Retired identity keys: kept for spending after a rotation or replacement
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';
20
26
  export { SignatureService } from './signatureService.js';
21
27
  export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase.js';
22
28
  // Low-level primitives (encrypted backup, device transfer)
@@ -11,6 +11,10 @@ import { isDev, logger } from '../logger/index.js';
11
11
  import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations.js';
12
12
  import { registerIdentitySignerStore } from './identitySigner.js';
13
13
  import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys.js';
14
+ import { forgetRetiredIdentityKeys, getRetiredPrivateKey, retiredKeyScope } from './retiredKeys.js';
15
+ import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
16
+ import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
17
+ import { RECOVERY_MNEMONIC_KEYCHAIN_SERVICE, RECOVERY_MNEMONIC_STORAGE_KEY, STORAGE_KEYS, V2_BACKUP_KEYCHAIN_SERVICE, V2_PRIMARY_KEYCHAIN_SERVICE, V2_STORAGE_KEYS, } from './keychainItems.js';
14
18
  import { clearIdentityMarker, readIdentityMarker, updateIdentityMarker, writeIdentityMarker, } from './identityMarker.js';
15
19
  import { DEVICE_BACKUP_VERSION, parseDeviceBackup, serializeDeviceBackup, } from './deviceBackup.js';
16
20
  /**
@@ -68,16 +72,6 @@ export class IdentityUnavailableError extends Error {
68
72
  this.name = 'IdentityUnavailableError';
69
73
  }
70
74
  }
71
- const STORAGE_KEYS = {
72
- PRIVATE_KEY: 'oxy_identity_private_key',
73
- PUBLIC_KEY: 'oxy_identity_public_key',
74
- BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key',
75
- BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key',
76
- BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp',
77
- // The cross-app identity in the iOS keychain access group (iOS only).
78
- SHARED_PRIVATE_KEY: 'oxy_shared_identity_private_key',
79
- SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
80
- };
81
75
  /**
82
76
  * v2 identity slot layout — blast-radius isolation.
83
77
  *
@@ -93,15 +87,8 @@ const STORAGE_KEYS = {
93
87
  * Migration from the legacy layout is lazy + verify-before-delete — see
94
88
  * {@link KeyManager._runSlotMigration}.
95
89
  */
96
- const V2_PRIMARY_KEYCHAIN_SERVICE = 'oxy_identity';
97
- const V2_BACKUP_KEYCHAIN_SERVICE = 'oxy_identity_backup';
98
- const V2_STORAGE_KEYS = {
99
- PRIVATE_KEY: 'oxy_identity_private_key_v2',
100
- PUBLIC_KEY: 'oxy_identity_public_key_v2',
101
- BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
102
- BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
103
- BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
104
- };
90
+ // The names (V2_PRIMARY_KEYCHAIN_SERVICE, V2_BACKUP_KEYCHAIN_SERVICE,
91
+ // V2_STORAGE_KEYS) live in `./keychainItems`, beside every other identity item.
105
92
  /**
106
93
  * Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
107
94
  *
@@ -118,8 +105,7 @@ const V2_STORAGE_KEYS = {
118
105
  * lives ONLY in this slot: it is never mirrored into the identity marker,
119
106
  * {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
120
107
  */
121
- const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
122
- const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
108
+ // RECOVERY_MNEMONIC_KEYCHAIN_SERVICE / RECOVERY_MNEMONIC_STORAGE_KEY: `./keychainItems`.
123
109
  /**
124
110
  * Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
125
111
  * Re-derivable (its loss just re-runs the cheap slot check), so it lives in
@@ -154,12 +140,6 @@ const LEGACY_SLOT_LAYOUT = {
154
140
  backupPublicKeyName: STORAGE_KEYS.BACKUP_PUBLIC_KEY,
155
141
  backupTimestampName: STORAGE_KEYS.BACKUP_TIMESTAMP,
156
142
  };
157
- /**
158
- * iOS Keychain Access Group for sharing identities across Oxy apps
159
- * All Oxy apps must have this access group enabled in their entitlements
160
- * Format: [Team ID].so.oxy.shared or group.so.oxy.shared
161
- */
162
- const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
163
143
  /**
164
144
  * Initialize React Native specific modules
165
145
  *
@@ -169,7 +149,7 @@ const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
169
149
  * `expo-secure-store`; off RN it throws (and is never called because every
170
150
  * caller is gated by `isWebPlatform()` / native-only paths).
171
151
  */
172
- async function initSecureStore() {
152
+ async function loadRawSecureStore() {
173
153
  try {
174
154
  return await loadSecureStore();
175
155
  }
@@ -178,6 +158,29 @@ async function initSecureStore() {
178
158
  throw new Error(`Failed to load expo-secure-store: ${errorMessage}. Make sure expo-secure-store is installed and properly configured.`);
179
159
  }
180
160
  }
161
+ /**
162
+ * The secure store routed to the keychain group of `item`
163
+ * (`../utils/keychainAccessGroups`): on iOS reads walk the item's group then
164
+ * the pre-split shared group, writes carry the item's group and THROW in an app
165
+ * that may not hold it, and in Commons every access first awaits the
166
+ * access-group migration. Android and web are unchanged (`ungrouped`).
167
+ *
168
+ * Throws when the store cannot load or the keychain cannot say which groups
169
+ * this app holds; every caller already treats a throw here as "storage
170
+ * unavailable".
171
+ */
172
+ async function keychainItemStore(item) {
173
+ const raw = await loadRawSecureStore();
174
+ const access = await resolveKeychainAccess();
175
+ if (access.mode === 'grouped' && access.role === 'holder') {
176
+ await ensureKeychainGroupsMigrated();
177
+ }
178
+ return groupedKeychainStore(raw, access, item);
179
+ }
180
+ /** The store for the identity slots (primary, backup, legacy, phrase): Commons only on iOS. */
181
+ function initSecureStore() {
182
+ return keychainItemStore('identity-secret');
183
+ }
181
184
  /**
182
185
  * Check if we're on web platform
183
186
  * Identity storage is only available on native platforms (iOS/Android)
@@ -568,12 +571,12 @@ export class KeyManager {
568
571
  * rung. Best-effort — the shared slot is a redundant convenience copy. The
569
572
  * Android signer store is cleared on every delete, in {@link deleteIdentity}.
570
573
  */
571
- static async _clearSharedSlot(store) {
574
+ static async _clearSharedSlot(_identityStore) {
572
575
  try {
573
576
  if (isIOS()) {
574
- const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
575
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
576
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
577
+ // The shared slot lives in two groups of its own, not the identity slots'.
578
+ await (await keychainItemStore('spending-key')).deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
579
+ await (await keychainItemStore('shared')).deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
577
580
  }
578
581
  KeyManager.invalidateSharedCache();
579
582
  }
@@ -640,6 +643,20 @@ export class KeyManager {
640
643
  KeyManager.cachedSharedPublicKey = null;
641
644
  KeyManager.cachedHasSharedIdentity = null;
642
645
  }
646
+ /**
647
+ * iOS: write the personal key's cross-app slot — the private key to the
648
+ * wallet group (Commons and the Peable wallet), the public key to the shared
649
+ * group (every Oxy app). Throws in any app that may not hold the private key.
650
+ * @internal
651
+ */
652
+ static async _writeIosSharedSlot(privateKey, publicKey) {
653
+ const privateStore = await keychainItemStore('spending-key');
654
+ const privateOpts = {
655
+ keychainAccessible: privateStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
656
+ };
657
+ await privateStore.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
658
+ await (await keychainItemStore('shared')).setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, {});
659
+ }
643
660
  /**
644
661
  * Lowercase and pad to canonical 64-hex-char form.
645
662
  *
@@ -675,8 +692,10 @@ export class KeyManager {
675
692
  // ==================== SHARED IDENTITY METHODS ====================
676
693
  // One Oxy identity across every Oxy app on a device.
677
694
  //
678
- // iOS: the keychain access group `group.so.oxy.shared`. Every Oxy app reads
679
- // the same keychain item, and no app can wipe another's keychain.
695
+ // iOS: keychain access groups (docs/identity/ios-keychain-access-groups.md).
696
+ // The private key is in `<TEAM>.so.oxy.wallet` (Commons and the Peable
697
+ // wallet only), the public key in `<TEAM>.group.so.oxy.shared`, which every
698
+ // Oxy app reads. Every other app signs in through Commons (ADR 0029).
680
699
  // Android: every app has its own UID, so nothing is shared through storage.
681
700
  // Commons is the ONLY holder of the private key. It keeps the identity
682
701
  // signer store (`setIdentitySignerStore`) for its identity host, and every
@@ -701,21 +720,11 @@ export class KeyManager {
701
720
  }
702
721
  const { privateKey, publicKey } = await KeyManager.generateKeyPair();
703
722
  if (isIOS()) {
704
- const store = await initSecureStore();
705
- // Note: keychainAccessGroup requires Keychain Sharing capability in Xcode
706
723
  try {
707
- const privateOpts = {
708
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
709
- keychainAccessGroup: IOS_KEYCHAIN_GROUP, // Enables sharing across apps
710
- };
711
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
712
- const publicOpts = {
713
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
714
- };
715
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
724
+ await KeyManager._writeIosSharedSlot(privateKey, publicKey);
716
725
  }
717
726
  catch (error) {
718
- throw new Error(`Failed to create shared identity on iOS. Ensure your app has the Keychain Sharing capability enabled with access group "${IOS_KEYCHAIN_GROUP}". Error: ${error}`);
727
+ throw new Error(`Failed to create shared identity on iOS. Only Commons may write it, with the keychain access groups of docs/identity/ios-keychain-access-groups.md. Error: ${error}`);
719
728
  }
720
729
  }
721
730
  else if (isAndroid()) {
@@ -755,9 +764,8 @@ export class KeyManager {
755
764
  try {
756
765
  let publicKey = null;
757
766
  if (isIOS()) {
758
- const store = await initSecureStore();
759
- const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
760
- publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
767
+ const store = await keychainItemStore('shared');
768
+ publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, {});
761
769
  }
762
770
  else if (isAndroid()) {
763
771
  const signer = KeyManager.identitySignerStore;
@@ -797,9 +805,10 @@ export class KeyManager {
797
805
  return null;
798
806
  }
799
807
  try {
800
- const store = await initSecureStore();
801
- const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
802
- return await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
808
+ // The wallet group: Commons and the Peable wallet. Every other app's plan
809
+ // is empty, so this is `null` there without touching the keychain.
810
+ const store = await keychainItemStore('spending-key');
811
+ return await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, {});
803
812
  }
804
813
  catch (error) {
805
814
  if (isDev()) {
@@ -893,16 +902,7 @@ export class KeyManager {
893
902
  const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
894
903
  const publicKey = deriveSecp256k1PublicKey(canonicalPrivate);
895
904
  if (isIOS()) {
896
- const store = await initSecureStore();
897
- const privateOpts = {
898
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
899
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
900
- };
901
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, privateOpts);
902
- const publicOpts = {
903
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
904
- };
905
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
905
+ await KeyManager._writeIosSharedSlot(canonicalPrivate, publicKey);
906
906
  }
907
907
  else if (isAndroid()) {
908
908
  await KeyManager._writeIdentitySigner(canonicalPrivate, publicKey);
@@ -2050,6 +2050,13 @@ export class KeyManager {
2050
2050
  if (force) {
2051
2051
  await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
2052
2052
  await KeyManager._clearSharedSlot(store);
2053
+ // The person's retired keys go with the identity they belonged to.
2054
+ try {
2055
+ await forgetRetiredIdentityKeys();
2056
+ }
2057
+ catch (error) {
2058
+ logger.warn('Failed to clear retired keys during delete', { component: 'KeyManager' }, error);
2059
+ }
2053
2060
  }
2054
2061
  // The device backup goes on EVERY delete, forced or not: it is restored
2055
2062
  // silently, so leaving it would bring a deleted identity back on the next
@@ -2567,7 +2574,8 @@ export class KeyManager {
2567
2574
  * `HDKey.fromMasterSeed` and never touches the identity key.
2568
2575
  *
2569
2576
  * Key source (native only):
2570
- * - iOS: the shared identity in `group.so.oxy.shared`, then this device's
2577
+ * - iOS: the shared slot's private key in the wallet keychain group (Commons
2578
+ * and the Peable wallet only; `null` in every other app), then this device's
2571
2579
  * primary identity.
2572
2580
  * - Android: Commons (the one app with a signer store) uses its own primary
2573
2581
  * identity; every other app asks Commons over IPC and never reads a local
@@ -2581,8 +2589,15 @@ export class KeyManager {
2581
2589
  * keychain group, or Commons' identity host. It never falls back to the
2582
2590
  * personal key.
2583
2591
  *
2592
+ * With `opts.publicKey` the seed is derived from the key of that account
2593
+ * (personal, or `accountId`'s) whose public key it names: the current key
2594
+ * when it matches, else a RETIRED key Commons kept after a rotation or
2595
+ * replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
2596
+ * retired key never signs in. Never falls back to another key.
2597
+ *
2584
2598
  * @param info Context/domain-binding label (distinct labels → independent seeds).
2585
2599
  * @param opts.accountId An organization's account id, for its key.
2600
+ * @param opts.publicKey Selects one key of that account by its public key.
2586
2601
  * @returns 32 bytes of derived keying material, or `null` on web / when no
2587
2602
  * identity key is available to this app.
2588
2603
  */
@@ -2590,6 +2605,18 @@ export class KeyManager {
2590
2605
  if (isWebPlatform()) {
2591
2606
  return null;
2592
2607
  }
2608
+ if (opts?.publicKey !== undefined) {
2609
+ if (isAndroid() && !KeyManager.identitySignerStore) {
2610
+ const selected = KeyManager._selectorKey(opts.publicKey);
2611
+ if (!selected)
2612
+ return null;
2613
+ const bridge = await loadCommonsIdentityBridge();
2614
+ const seed = (await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
2615
+ return seed ? hexToBytes(seed) : null;
2616
+ }
2617
+ const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
2618
+ return selected ? deriveScopedSeedFromKey(selected, info) : null;
2619
+ }
2593
2620
  if (opts?.accountId !== undefined) {
2594
2621
  return KeyManager._deriveOrganizationScopedSeed(info, opts.accountId);
2595
2622
  }
@@ -2635,7 +2662,8 @@ export class KeyManager {
2635
2662
  * compressed public key for the scriptSig.
2636
2663
  *
2637
2664
  * Key source: as {@link deriveScopedSeed}, including `opts.accountId` for an
2638
- * organization's key (ADR 0061 D6). On Android outside Commons the signature
2665
+ * organization's key (ADR 0061 D6) and `opts.publicKey` for one key of the
2666
+ * account, current or retired. On Android outside Commons the signature
2639
2667
  * comes from Commons, which allows it for the wallet app only.
2640
2668
  *
2641
2669
  * @returns the signature, or `null` on web / when no identity key is
@@ -2645,6 +2673,18 @@ export class KeyManager {
2645
2673
  if (isWebPlatform()) {
2646
2674
  return null;
2647
2675
  }
2676
+ if (opts?.publicKey !== undefined) {
2677
+ if (isAndroid() && !KeyManager.identitySignerStore) {
2678
+ const selected = KeyManager._selectorKey(opts.publicKey);
2679
+ if (!selected)
2680
+ return null;
2681
+ const bridge = await loadCommonsIdentityBridge();
2682
+ return ((await bridge?.signRetiredSocialReceive?.(index, digest, selected, opts.accountId)) ??
2683
+ null);
2684
+ }
2685
+ const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
2686
+ return selected ? signSocialReceiveDigest(selected, index, digest) : null;
2687
+ }
2648
2688
  const accountId = opts?.accountId;
2649
2689
  if (accountId !== undefined) {
2650
2690
  // An organization key (ADR 0061 D6); never the personal one in its place.
@@ -2668,6 +2708,34 @@ export class KeyManager {
2668
2708
  : ((await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey()));
2669
2709
  return local ? signSocialReceiveDigest(local, index, digest) : null;
2670
2710
  }
2711
+ /**
2712
+ * The private key of the account (personal, or `accountId`'s organization)
2713
+ * whose public key is `publicKey`, from this app's own storage: the current
2714
+ * key when it matches, else the retired ring. iOS and Commons only; `null`
2715
+ * for a key this device does not hold. Never another key in its place. @internal
2716
+ */
2717
+ /**
2718
+ * The selector in the identity host's encoding (lowercase uncompressed), so a
2719
+ * compressed `#key-1` from a DID or a gateway row selects the same key. @internal
2720
+ */
2721
+ static _selectorKey(publicKey) {
2722
+ return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
2723
+ }
2724
+ static async _selectedPrivateKey(publicKey, accountId) {
2725
+ if (retiredKeyScope({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
2726
+ return null;
2727
+ }
2728
+ const wanted = KeyManager.canonicalPublicKey(publicKey);
2729
+ const current = accountId !== undefined
2730
+ ? await getOrganizationPrivateKey(accountId)
2731
+ : isAndroid()
2732
+ ? await KeyManager.getPrivateKey()
2733
+ : ((await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey()));
2734
+ if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
2735
+ return current;
2736
+ }
2737
+ return getRetiredPrivateKey(wanted, accountId !== undefined ? { accountId } : undefined);
2738
+ }
2671
2739
  /**
2672
2740
  * Get a shortened version of the public key for display
2673
2741
  * Format: first 8 chars...last 8 chars