@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
@@ -56,6 +56,7 @@ exports.signOwnChainRecord = signOwnChainRecord;
56
56
  * AEAD, signatures) load on first use, never with the client itself.
57
57
  */
58
58
  const client_1 = require("@oxy.so/contracts/client");
59
+ const client_2 = require("@oxy.so/contracts/client");
59
60
  const platform_1 = require("../utils/platform");
60
61
  const logger_1 = require("../logger");
61
62
  /** Short-TTL read cache for public identity reads. */
@@ -196,7 +197,7 @@ class IdentityApi {
196
197
  if (!userId) {
197
198
  throw new Error('No authenticated user — sign in before rotating your key.');
198
199
  }
199
- const { KeyManager, RecoveryPhraseService, SignatureService, signMessage } = await Promise.resolve().then(() => __importStar(require('../crypto/index')));
200
+ const { KeyManager, RecoveryPhraseService, SignatureService, signMessage, retireIdentityKey } = await Promise.resolve().then(() => __importStar(require('../crypto/index')));
200
201
  // 1. The NEW identity (in memory only). The UI may pre-derive + pre-show
201
202
  // it and pass it back here so the phrase shown === the phrase committed.
202
203
  const pending = options.pendingIdentity ?? (await RecoveryPhraseService.derivePendingIdentity());
@@ -204,6 +205,7 @@ class IdentityApi {
204
205
  // 2. Resolve the OLD signing capability from the chosen proof mode.
205
206
  let oldPublicKey;
206
207
  let signWithOldKey;
208
+ let oldPrivateKeyForRing;
207
209
  if (options.proof === 'phrase') {
208
210
  const phrase = options.phrase?.trim();
209
211
  if (!phrase) {
@@ -212,6 +214,7 @@ class IdentityApi {
212
214
  const oldPrivateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
213
215
  oldPublicKey = KeyManager.derivePublicKey(oldPrivateKey);
214
216
  signWithOldKey = (message) => signMessage(message, oldPrivateKey);
217
+ oldPrivateKeyForRing = oldPrivateKey;
215
218
  }
216
219
  else {
217
220
  const currentPublicKey = await KeyManager.getPublicKey();
@@ -220,6 +223,25 @@ class IdentityApi {
220
223
  }
221
224
  oldPublicKey = currentPublicKey;
222
225
  signWithOldKey = (message) => SignatureService.sign(message);
226
+ oldPrivateKeyForRing = (0, platform_1.isWeb)() ? null : await KeyManager.getPrivateKey();
227
+ }
228
+ // 2b. Keep the key being replaced in the retired ring BEFORE anything
229
+ // changes (oxy ADR 0061 D7 as amended, Peable ADR 0002): coins keep
230
+ // arriving under it after the swap, and the Peable wallet spends them
231
+ // through the `publicKey` selector of `deriveScopedSeed` /
232
+ // `signSocialReceive`. A device that cannot keep it does not rotate:
233
+ // the phrase would become the only way to those coins. Native only —
234
+ // on web no key is stored. Idempotent, so a retried rotation is fine.
235
+ if (!(0, platform_1.isWeb)()) {
236
+ try {
237
+ if (!oldPrivateKeyForRing)
238
+ throw new Error('no readable private key');
239
+ await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
240
+ }
241
+ catch (retainError) {
242
+ logger_1.logger.warn('Could not keep the current key as a retired key; not rotating.', { component: 'oxy.identity', method: 'rotateKey' }, retainError);
243
+ throw new Error('Your current key could not be kept on this device, so it was not rotated. Nothing changed.');
244
+ }
223
245
  }
224
246
  // 3. Request a single-use rotate_key challenge (bearer).
225
247
  const { challenge } = await this.ctx.request('POST', '/auth/rotate/challenge', undefined, {
@@ -376,6 +398,23 @@ class IdentityApi {
376
398
  keys.push(`GET:/u/${encodeURIComponent(userId)}/did.json`);
377
399
  this.ctx.http.invalidateCache({ keys, prefixes: ['GET:/users/me'] });
378
400
  }
401
+ /**
402
+ * The signed-in personal account's current root (`null` without one) and
403
+ * every root a rotation replaced, oldest first, all canonical (uncompressed,
404
+ * lowercase). Commons reads it to accept a previous recovery phrase only when
405
+ * it derives a key that WAS this account's root. A retired root is never any
406
+ * account's again. First-party only; a non-personal account is refused
407
+ * (`IDENTITY_NOT_PERSONAL_ACCOUNT`).
408
+ */
409
+ async keyHistory() {
410
+ const res = await this.ctx.request('GET', '/identity/key-history', undefined, {
411
+ cache: false,
412
+ });
413
+ const parsed = (0, client_1.checkContract)(client_2.identityKeyHistoryCheck, res);
414
+ if (!parsed)
415
+ throw new Error('identity/key-history returned an unexpected response shape');
416
+ return parsed;
417
+ }
379
418
  }
380
419
  exports.IdentityApi = IdentityApi;
381
420
  /** `oxy.identity.links` — joining Commons' root to an account (ADR 0029 D3). */
@@ -12,7 +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.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 = exports.hkdfSha256 = exports.BACKUP_KDF_ENCRYPTION_INFO = exports.RecoveryPhraseService = exports.SignatureService = 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;
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;
16
17
  // MUST stay the first import: the shim has to exist before any module below
17
18
  // evaluates or draws randomness (see `./polyfill`).
18
19
  require("./polyfill");
@@ -37,6 +38,32 @@ Object.defineProperty(exports, "deleteOrganizationKey", { enumerable: true, get:
37
38
  Object.defineProperty(exports, "syncOrganizationKeys", { enumerable: true, get: function () { return organizationKeys_1.syncOrganizationKeys; } });
38
39
  Object.defineProperty(exports, "isOrganizationAccountId", { enumerable: true, get: function () { return organizationKeys_1.isOrganizationAccountId; } });
39
40
  Object.defineProperty(exports, "isHealthyOrganizationKeyPair", { enumerable: true, get: function () { return organizationKeys_1.isHealthyOrganizationKeyPair; } });
41
+ // iOS keychain access groups (docs/identity/ios-keychain-access-groups.md, ADR 0062)
42
+ var keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
43
+ Object.defineProperty(exports, "KEYCHAIN_ACCESS_GROUPS", { enumerable: true, get: function () { return keychainAccessGroups_1.KEYCHAIN_ACCESS_GROUPS; } });
44
+ Object.defineProperty(exports, "KeychainAccessUnavailableError", { enumerable: true, get: function () { return keychainAccessGroups_1.KeychainAccessUnavailableError; } });
45
+ Object.defineProperty(exports, "keychainGroupFor", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainGroupFor; } });
46
+ Object.defineProperty(exports, "mayHoldKeychainItem", { enumerable: true, get: function () { return keychainAccessGroups_1.mayHoldKeychainItem; } });
47
+ Object.defineProperty(exports, "resolveKeychainAccess", { enumerable: true, get: function () { return keychainAccessGroups_1.resolveKeychainAccess; } });
48
+ Object.defineProperty(exports, "keychainAccessGroupValue", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainAccessGroupValue; } });
49
+ Object.defineProperty(exports, "keychainReadPlan", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainReadPlan; } });
50
+ Object.defineProperty(exports, "keychainDeletePlan", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainDeletePlan; } });
51
+ Object.defineProperty(exports, "keychainWriteOption", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainWriteOption; } });
52
+ Object.defineProperty(exports, "ownKeychainItemPlan", { enumerable: true, get: function () { return keychainAccessGroups_1.ownKeychainItemPlan; } });
53
+ Object.defineProperty(exports, "readThroughPlan", { enumerable: true, get: function () { return keychainAccessGroups_1.readThroughPlan; } });
54
+ Object.defineProperty(exports, "deleteThroughPlan", { enumerable: true, get: function () { return keychainAccessGroups_1.deleteThroughPlan; } });
55
+ Object.defineProperty(exports, "groupedKeychainStore", { enumerable: true, get: function () { return keychainAccessGroups_1.groupedKeychainStore; } });
56
+ var keychainGroupMigration_1 = require("./keychainGroupMigration");
57
+ Object.defineProperty(exports, "ensureKeychainGroupsMigrated", { enumerable: true, get: function () { return keychainGroupMigration_1.ensureKeychainGroupsMigrated; } });
58
+ // Retired identity keys: kept for spending after a rotation or replacement
59
+ // (docs/adr/0061-organization-identity-key.md D7 as amended 2026-11-06)
60
+ var retiredKeys_1 = require("./retiredKeys");
61
+ Object.defineProperty(exports, "retireIdentityKey", { enumerable: true, get: function () { return retiredKeys_1.retireIdentityKey; } });
62
+ Object.defineProperty(exports, "listRetiredIdentityKeys", { enumerable: true, get: function () { return retiredKeys_1.listRetiredIdentityKeys; } });
63
+ Object.defineProperty(exports, "forgetRetiredIdentityKey", { enumerable: true, get: function () { return retiredKeys_1.forgetRetiredIdentityKey; } });
64
+ Object.defineProperty(exports, "forgetRetiredIdentityKeys", { enumerable: true, get: function () { return retiredKeys_1.forgetRetiredIdentityKeys; } });
65
+ Object.defineProperty(exports, "syncRetiredKeys", { enumerable: true, get: function () { return retiredKeys_1.syncRetiredKeys; } });
66
+ Object.defineProperty(exports, "retiredKeyFromPhrase", { enumerable: true, get: function () { return retiredKeys_1.retiredKeyFromPhrase; } });
40
67
  var signatureService_1 = require("./signatureService");
41
68
  Object.defineProperty(exports, "SignatureService", { enumerable: true, get: function () { return signatureService_1.SignatureService; } });
42
69
  var recoveryPhrase_1 = require("./recoveryPhrase");
@@ -47,6 +47,10 @@ const logger_1 = require("../logger");
47
47
  const identityDerivations_1 = require("./identityDerivations");
48
48
  const identitySigner_1 = require("./identitySigner");
49
49
  const organizationKeys_1 = require("./organizationKeys");
50
+ const retiredKeys_1 = require("./retiredKeys");
51
+ const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
52
+ const keychainGroupMigration_1 = require("./keychainGroupMigration");
53
+ const keychainItems_1 = require("./keychainItems");
50
54
  const identityMarker_1 = require("./identityMarker");
51
55
  const deviceBackup_1 = require("./deviceBackup");
52
56
  /**
@@ -107,16 +111,6 @@ class IdentityUnavailableError extends Error {
107
111
  }
108
112
  }
109
113
  exports.IdentityUnavailableError = IdentityUnavailableError;
110
- const STORAGE_KEYS = {
111
- PRIVATE_KEY: 'oxy_identity_private_key',
112
- PUBLIC_KEY: 'oxy_identity_public_key',
113
- BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key',
114
- BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key',
115
- BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp',
116
- // The cross-app identity in the iOS keychain access group (iOS only).
117
- SHARED_PRIVATE_KEY: 'oxy_shared_identity_private_key',
118
- SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
119
- };
120
114
  /**
121
115
  * v2 identity slot layout — blast-radius isolation.
122
116
  *
@@ -132,15 +126,8 @@ const STORAGE_KEYS = {
132
126
  * Migration from the legacy layout is lazy + verify-before-delete — see
133
127
  * {@link KeyManager._runSlotMigration}.
134
128
  */
135
- const V2_PRIMARY_KEYCHAIN_SERVICE = 'oxy_identity';
136
- const V2_BACKUP_KEYCHAIN_SERVICE = 'oxy_identity_backup';
137
- const V2_STORAGE_KEYS = {
138
- PRIVATE_KEY: 'oxy_identity_private_key_v2',
139
- PUBLIC_KEY: 'oxy_identity_public_key_v2',
140
- BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
141
- BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
142
- BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
143
- };
129
+ // The names (V2_PRIMARY_KEYCHAIN_SERVICE, V2_BACKUP_KEYCHAIN_SERVICE,
130
+ // V2_STORAGE_KEYS) live in `./keychainItems`, beside every other identity item.
144
131
  /**
145
132
  * Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
146
133
  *
@@ -157,8 +144,7 @@ const V2_STORAGE_KEYS = {
157
144
  * lives ONLY in this slot: it is never mirrored into the identity marker,
158
145
  * {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
159
146
  */
160
- const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
161
- const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
147
+ // RECOVERY_MNEMONIC_KEYCHAIN_SERVICE / RECOVERY_MNEMONIC_STORAGE_KEY: `./keychainItems`.
162
148
  /**
163
149
  * Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
164
150
  * Re-derivable (its loss just re-runs the cheap slot check), so it lives in
@@ -176,29 +162,23 @@ const SLOTS_MIGRATED_FLAG_KEY = 'oxy_identity_slots_migrated_v2';
176
162
  */
177
163
  const ROTATION_PENDING_KEY = 'oxy_identity_rotation_pending_v1';
178
164
  const V2_SLOT_LAYOUT = {
179
- primaryService: V2_PRIMARY_KEYCHAIN_SERVICE,
180
- primaryPrivateKeyName: V2_STORAGE_KEYS.PRIVATE_KEY,
181
- primaryPublicKeyName: V2_STORAGE_KEYS.PUBLIC_KEY,
182
- backupService: V2_BACKUP_KEYCHAIN_SERVICE,
183
- backupPrivateKeyName: V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY,
184
- backupPublicKeyName: V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY,
185
- backupTimestampName: V2_STORAGE_KEYS.BACKUP_TIMESTAMP,
165
+ primaryService: keychainItems_1.V2_PRIMARY_KEYCHAIN_SERVICE,
166
+ primaryPrivateKeyName: keychainItems_1.V2_STORAGE_KEYS.PRIVATE_KEY,
167
+ primaryPublicKeyName: keychainItems_1.V2_STORAGE_KEYS.PUBLIC_KEY,
168
+ backupService: keychainItems_1.V2_BACKUP_KEYCHAIN_SERVICE,
169
+ backupPrivateKeyName: keychainItems_1.V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY,
170
+ backupPublicKeyName: keychainItems_1.V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY,
171
+ backupTimestampName: keychainItems_1.V2_STORAGE_KEYS.BACKUP_TIMESTAMP,
186
172
  };
187
173
  const LEGACY_SLOT_LAYOUT = {
188
174
  primaryService: undefined,
189
- primaryPrivateKeyName: STORAGE_KEYS.PRIVATE_KEY,
190
- primaryPublicKeyName: STORAGE_KEYS.PUBLIC_KEY,
175
+ primaryPrivateKeyName: keychainItems_1.STORAGE_KEYS.PRIVATE_KEY,
176
+ primaryPublicKeyName: keychainItems_1.STORAGE_KEYS.PUBLIC_KEY,
191
177
  backupService: undefined,
192
- backupPrivateKeyName: STORAGE_KEYS.BACKUP_PRIVATE_KEY,
193
- backupPublicKeyName: STORAGE_KEYS.BACKUP_PUBLIC_KEY,
194
- backupTimestampName: STORAGE_KEYS.BACKUP_TIMESTAMP,
178
+ backupPrivateKeyName: keychainItems_1.STORAGE_KEYS.BACKUP_PRIVATE_KEY,
179
+ backupPublicKeyName: keychainItems_1.STORAGE_KEYS.BACKUP_PUBLIC_KEY,
180
+ backupTimestampName: keychainItems_1.STORAGE_KEYS.BACKUP_TIMESTAMP,
195
181
  };
196
- /**
197
- * iOS Keychain Access Group for sharing identities across Oxy apps
198
- * All Oxy apps must have this access group enabled in their entitlements
199
- * Format: [Team ID].so.oxy.shared or group.so.oxy.shared
200
- */
201
- const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
202
182
  /**
203
183
  * Initialize React Native specific modules
204
184
  *
@@ -208,7 +188,7 @@ const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
208
188
  * `expo-secure-store`; off RN it throws (and is never called because every
209
189
  * caller is gated by `isWebPlatform()` / native-only paths).
210
190
  */
211
- async function initSecureStore() {
191
+ async function loadRawSecureStore() {
212
192
  try {
213
193
  return await (0, protocol_1.loadSecureStore)();
214
194
  }
@@ -217,6 +197,29 @@ async function initSecureStore() {
217
197
  throw new Error(`Failed to load expo-secure-store: ${errorMessage}. Make sure expo-secure-store is installed and properly configured.`);
218
198
  }
219
199
  }
200
+ /**
201
+ * The secure store routed to the keychain group of `item`
202
+ * (`../utils/keychainAccessGroups`): on iOS reads walk the item's group then
203
+ * the pre-split shared group, writes carry the item's group and THROW in an app
204
+ * that may not hold it, and in Commons every access first awaits the
205
+ * access-group migration. Android and web are unchanged (`ungrouped`).
206
+ *
207
+ * Throws when the store cannot load or the keychain cannot say which groups
208
+ * this app holds; every caller already treats a throw here as "storage
209
+ * unavailable".
210
+ */
211
+ async function keychainItemStore(item) {
212
+ const raw = await loadRawSecureStore();
213
+ const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
214
+ if (access.mode === 'grouped' && access.role === 'holder') {
215
+ await (0, keychainGroupMigration_1.ensureKeychainGroupsMigrated)();
216
+ }
217
+ return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, item);
218
+ }
219
+ /** The store for the identity slots (primary, backup, legacy, phrase): Commons only on iOS. */
220
+ function initSecureStore() {
221
+ return keychainItemStore('identity-secret');
222
+ }
220
223
  /**
221
224
  * Check if we're on web platform
222
225
  * Identity storage is only available on native platforms (iOS/Android)
@@ -466,8 +469,8 @@ class KeyManager {
466
469
  let v2Private;
467
470
  let v2Public;
468
471
  try {
469
- v2Private = await store.getItemAsync(V2_STORAGE_KEYS.PRIVATE_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
470
- v2Public = await store.getItemAsync(V2_STORAGE_KEYS.PUBLIC_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
472
+ v2Private = await store.getItemAsync(keychainItems_1.V2_STORAGE_KEYS.PRIVATE_KEY, KeyManager._slotOpts(keychainItems_1.V2_PRIMARY_KEYCHAIN_SERVICE));
473
+ v2Public = await store.getItemAsync(keychainItems_1.V2_STORAGE_KEYS.PUBLIC_KEY, KeyManager._slotOpts(keychainItems_1.V2_PRIMARY_KEYCHAIN_SERVICE));
471
474
  }
472
475
  catch (error) {
473
476
  return { mode: 'deferred', cause: error };
@@ -490,8 +493,8 @@ class KeyManager {
490
493
  let legacyPrivate;
491
494
  let legacyPublic;
492
495
  try {
493
- legacyPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
494
- legacyPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
496
+ legacyPrivate = await store.getItemAsync(keychainItems_1.STORAGE_KEYS.PRIVATE_KEY);
497
+ legacyPublic = await store.getItemAsync(keychainItems_1.STORAGE_KEYS.PUBLIC_KEY);
495
498
  }
496
499
  catch (error) {
497
500
  return { mode: 'deferred', cause: error };
@@ -505,10 +508,10 @@ class KeyManager {
505
508
  const canonicalPrivate = KeyManager.canonicalPrivateKey(legacyPrivate);
506
509
  const canonicalPublic = legacyPublic.toLowerCase();
507
510
  try {
508
- await store.setItemAsync(V2_STORAGE_KEYS.PUBLIC_KEY, canonicalPublic, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
509
- await store.setItemAsync(V2_STORAGE_KEYS.PRIVATE_KEY, canonicalPrivate, KeyManager._privateWriteOpts(store, V2_PRIMARY_KEYCHAIN_SERVICE));
510
- const readBackPrivate = await store.getItemAsync(V2_STORAGE_KEYS.PRIVATE_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
511
- const readBackPublic = await store.getItemAsync(V2_STORAGE_KEYS.PUBLIC_KEY, KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE));
511
+ await store.setItemAsync(keychainItems_1.V2_STORAGE_KEYS.PUBLIC_KEY, canonicalPublic, KeyManager._slotOpts(keychainItems_1.V2_PRIMARY_KEYCHAIN_SERVICE));
512
+ await store.setItemAsync(keychainItems_1.V2_STORAGE_KEYS.PRIVATE_KEY, canonicalPrivate, KeyManager._privateWriteOpts(store, keychainItems_1.V2_PRIMARY_KEYCHAIN_SERVICE));
513
+ const readBackPrivate = await store.getItemAsync(keychainItems_1.V2_STORAGE_KEYS.PRIVATE_KEY, KeyManager._slotOpts(keychainItems_1.V2_PRIMARY_KEYCHAIN_SERVICE));
514
+ const readBackPublic = await store.getItemAsync(keychainItems_1.V2_STORAGE_KEYS.PUBLIC_KEY, KeyManager._slotOpts(keychainItems_1.V2_PRIMARY_KEYCHAIN_SERVICE));
512
515
  const verified = readBackPrivate?.toLowerCase() === canonicalPrivate &&
513
516
  readBackPublic?.toLowerCase() === canonicalPublic &&
514
517
  KeyManager._isHealthyPair(readBackPrivate, readBackPublic);
@@ -543,8 +546,8 @@ class KeyManager {
543
546
  let backupPrivate = null;
544
547
  let backupPublic = null;
545
548
  try {
546
- backupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
547
- backupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
549
+ backupPrivate = await store.getItemAsync(keychainItems_1.STORAGE_KEYS.BACKUP_PRIVATE_KEY);
550
+ backupPublic = await store.getItemAsync(keychainItems_1.STORAGE_KEYS.BACKUP_PUBLIC_KEY);
548
551
  }
549
552
  catch (error) {
550
553
  if ((0, logger_1.isDev)()) {
@@ -563,9 +566,9 @@ class KeyManager {
563
566
  seedPrivate = primaryPrivate;
564
567
  seedPublic = primaryPublic;
565
568
  }
566
- await store.setItemAsync(V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, seedPublic, KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE));
567
- await store.setItemAsync(V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, seedPrivate, KeyManager._privateWriteOpts(store, V2_BACKUP_KEYCHAIN_SERVICE));
568
- await store.setItemAsync(V2_STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString(), KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE));
569
+ await store.setItemAsync(keychainItems_1.V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, seedPublic, KeyManager._slotOpts(keychainItems_1.V2_BACKUP_KEYCHAIN_SERVICE));
570
+ await store.setItemAsync(keychainItems_1.V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, seedPrivate, KeyManager._privateWriteOpts(store, keychainItems_1.V2_BACKUP_KEYCHAIN_SERVICE));
571
+ await store.setItemAsync(keychainItems_1.V2_STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString(), KeyManager._slotOpts(keychainItems_1.V2_BACKUP_KEYCHAIN_SERVICE));
569
572
  }
570
573
  catch (error) {
571
574
  logger_1.logger.warn('Failed to migrate identity backup slot to v2 (non-fatal)', { component: 'KeyManager' }, error);
@@ -583,23 +586,23 @@ class KeyManager {
583
586
  }
584
587
  }
585
588
  static async _bestEffortDeleteV2Primary(store) {
586
- await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PRIVATE_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
587
- await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PUBLIC_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
589
+ await KeyManager._bestEffortDelete(store, keychainItems_1.V2_STORAGE_KEYS.PRIVATE_KEY, keychainItems_1.V2_PRIMARY_KEYCHAIN_SERVICE);
590
+ await KeyManager._bestEffortDelete(store, keychainItems_1.V2_STORAGE_KEYS.PUBLIC_KEY, keychainItems_1.V2_PRIMARY_KEYCHAIN_SERVICE);
588
591
  }
589
592
  static async _bestEffortDeleteLegacyPrimaryAndBackup(store) {
590
- await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
591
- await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
592
- await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
593
- await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
594
- await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
593
+ await KeyManager._bestEffortDelete(store, keychainItems_1.STORAGE_KEYS.PRIVATE_KEY);
594
+ await KeyManager._bestEffortDelete(store, keychainItems_1.STORAGE_KEYS.PUBLIC_KEY);
595
+ await KeyManager._bestEffortDelete(store, keychainItems_1.STORAGE_KEYS.BACKUP_PRIVATE_KEY);
596
+ await KeyManager._bestEffortDelete(store, keychainItems_1.STORAGE_KEYS.BACKUP_PUBLIC_KEY);
597
+ await KeyManager._bestEffortDelete(store, keychainItems_1.STORAGE_KEYS.BACKUP_TIMESTAMP);
595
598
  }
596
599
  static async _bestEffortDeleteBackupsAllGenerations(store) {
597
- await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
598
- await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
599
- await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_TIMESTAMP, V2_BACKUP_KEYCHAIN_SERVICE);
600
- await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
601
- await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
602
- await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
600
+ await KeyManager._bestEffortDelete(store, keychainItems_1.V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, keychainItems_1.V2_BACKUP_KEYCHAIN_SERVICE);
601
+ await KeyManager._bestEffortDelete(store, keychainItems_1.V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, keychainItems_1.V2_BACKUP_KEYCHAIN_SERVICE);
602
+ await KeyManager._bestEffortDelete(store, keychainItems_1.V2_STORAGE_KEYS.BACKUP_TIMESTAMP, keychainItems_1.V2_BACKUP_KEYCHAIN_SERVICE);
603
+ await KeyManager._bestEffortDelete(store, keychainItems_1.STORAGE_KEYS.BACKUP_PRIVATE_KEY);
604
+ await KeyManager._bestEffortDelete(store, keychainItems_1.STORAGE_KEYS.BACKUP_PUBLIC_KEY);
605
+ await KeyManager._bestEffortDelete(store, keychainItems_1.STORAGE_KEYS.BACKUP_TIMESTAMP);
603
606
  }
604
607
  /**
605
608
  * Clear the iOS keychain-group copy of the identity (force-delete only) so a
@@ -607,12 +610,12 @@ class KeyManager {
607
610
  * rung. Best-effort — the shared slot is a redundant convenience copy. The
608
611
  * Android signer store is cleared on every delete, in {@link deleteIdentity}.
609
612
  */
610
- static async _clearSharedSlot(store) {
613
+ static async _clearSharedSlot(_identityStore) {
611
614
  try {
612
615
  if ((0, platform_1.isIOS)()) {
613
- const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
614
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
615
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
616
+ // The shared slot lives in two groups of its own, not the identity slots'.
617
+ await (await keychainItemStore('spending-key')).deleteItemAsync(keychainItems_1.STORAGE_KEYS.SHARED_PRIVATE_KEY);
618
+ await (await keychainItemStore('shared')).deleteItemAsync(keychainItems_1.STORAGE_KEYS.SHARED_PUBLIC_KEY);
616
619
  }
617
620
  KeyManager.invalidateSharedCache();
618
621
  }
@@ -679,6 +682,20 @@ class KeyManager {
679
682
  KeyManager.cachedSharedPublicKey = null;
680
683
  KeyManager.cachedHasSharedIdentity = null;
681
684
  }
685
+ /**
686
+ * iOS: write the personal key's cross-app slot — the private key to the
687
+ * wallet group (Commons and the Peable wallet), the public key to the shared
688
+ * group (every Oxy app). Throws in any app that may not hold the private key.
689
+ * @internal
690
+ */
691
+ static async _writeIosSharedSlot(privateKey, publicKey) {
692
+ const privateStore = await keychainItemStore('spending-key');
693
+ const privateOpts = {
694
+ keychainAccessible: privateStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
695
+ };
696
+ await privateStore.setItemAsync(keychainItems_1.STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
697
+ await (await keychainItemStore('shared')).setItemAsync(keychainItems_1.STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, {});
698
+ }
682
699
  /**
683
700
  * Lowercase and pad to canonical 64-hex-char form.
684
701
  *
@@ -714,8 +731,10 @@ class KeyManager {
714
731
  // ==================== SHARED IDENTITY METHODS ====================
715
732
  // One Oxy identity across every Oxy app on a device.
716
733
  //
717
- // iOS: the keychain access group `group.so.oxy.shared`. Every Oxy app reads
718
- // the same keychain item, and no app can wipe another's keychain.
734
+ // iOS: keychain access groups (docs/identity/ios-keychain-access-groups.md).
735
+ // The private key is in `<TEAM>.so.oxy.wallet` (Commons and the Peable
736
+ // wallet only), the public key in `<TEAM>.group.so.oxy.shared`, which every
737
+ // Oxy app reads. Every other app signs in through Commons (ADR 0029).
719
738
  // Android: every app has its own UID, so nothing is shared through storage.
720
739
  // Commons is the ONLY holder of the private key. It keeps the identity
721
740
  // signer store (`setIdentitySignerStore`) for its identity host, and every
@@ -740,21 +759,11 @@ class KeyManager {
740
759
  }
741
760
  const { privateKey, publicKey } = await KeyManager.generateKeyPair();
742
761
  if ((0, platform_1.isIOS)()) {
743
- const store = await initSecureStore();
744
- // Note: keychainAccessGroup requires Keychain Sharing capability in Xcode
745
762
  try {
746
- const privateOpts = {
747
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
748
- keychainAccessGroup: IOS_KEYCHAIN_GROUP, // Enables sharing across apps
749
- };
750
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
751
- const publicOpts = {
752
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
753
- };
754
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
763
+ await KeyManager._writeIosSharedSlot(privateKey, publicKey);
755
764
  }
756
765
  catch (error) {
757
- 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}`);
766
+ 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}`);
758
767
  }
759
768
  }
760
769
  else if ((0, platform_1.isAndroid)()) {
@@ -794,9 +803,8 @@ class KeyManager {
794
803
  try {
795
804
  let publicKey = null;
796
805
  if ((0, platform_1.isIOS)()) {
797
- const store = await initSecureStore();
798
- const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
799
- publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
806
+ const store = await keychainItemStore('shared');
807
+ publicKey = await store.getItemAsync(keychainItems_1.STORAGE_KEYS.SHARED_PUBLIC_KEY, {});
800
808
  }
801
809
  else if ((0, platform_1.isAndroid)()) {
802
810
  const signer = KeyManager.identitySignerStore;
@@ -836,9 +844,10 @@ class KeyManager {
836
844
  return null;
837
845
  }
838
846
  try {
839
- const store = await initSecureStore();
840
- const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
841
- return await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
847
+ // The wallet group: Commons and the Peable wallet. Every other app's plan
848
+ // is empty, so this is `null` there without touching the keychain.
849
+ const store = await keychainItemStore('spending-key');
850
+ return await store.getItemAsync(keychainItems_1.STORAGE_KEYS.SHARED_PRIVATE_KEY, {});
842
851
  }
843
852
  catch (error) {
844
853
  if ((0, logger_1.isDev)()) {
@@ -932,16 +941,7 @@ class KeyManager {
932
941
  const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
933
942
  const publicKey = (0, secp256k1_1.deriveSecp256k1PublicKey)(canonicalPrivate);
934
943
  if ((0, platform_1.isIOS)()) {
935
- const store = await initSecureStore();
936
- const privateOpts = {
937
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
938
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
939
- };
940
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, privateOpts);
941
- const publicOpts = {
942
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
943
- };
944
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
944
+ await KeyManager._writeIosSharedSlot(canonicalPrivate, publicKey);
945
945
  }
946
946
  else if ((0, platform_1.isAndroid)()) {
947
947
  await KeyManager._writeIdentitySigner(canonicalPrivate, publicKey);
@@ -1778,7 +1778,7 @@ class KeyManager {
1778
1778
  }
1779
1779
  try {
1780
1780
  const store = await initSecureStore();
1781
- await store.setItemAsync(RECOVERY_MNEMONIC_STORAGE_KEY, mnemonic, KeyManager._privateWriteOpts(store, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE));
1781
+ await store.setItemAsync(keychainItems_1.RECOVERY_MNEMONIC_STORAGE_KEY, mnemonic, KeyManager._privateWriteOpts(store, keychainItems_1.RECOVERY_MNEMONIC_KEYCHAIN_SERVICE));
1782
1782
  }
1783
1783
  catch (error) {
1784
1784
  if ((0, logger_1.isDev)()) {
@@ -1813,7 +1813,7 @@ class KeyManager {
1813
1813
  }
1814
1814
  try {
1815
1815
  const store = await initSecureStore();
1816
- return await store.getItemAsync(RECOVERY_MNEMONIC_STORAGE_KEY, KeyManager._slotOpts(RECOVERY_MNEMONIC_KEYCHAIN_SERVICE));
1816
+ return await store.getItemAsync(keychainItems_1.RECOVERY_MNEMONIC_STORAGE_KEY, KeyManager._slotOpts(keychainItems_1.RECOVERY_MNEMONIC_KEYCHAIN_SERVICE));
1817
1817
  }
1818
1818
  catch (error) {
1819
1819
  if ((0, logger_1.isDev)()) {
@@ -1859,7 +1859,7 @@ class KeyManager {
1859
1859
  return; // Identity storage is only available on native platforms
1860
1860
  }
1861
1861
  const store = await initSecureStore();
1862
- await KeyManager._bestEffortDelete(store, RECOVERY_MNEMONIC_STORAGE_KEY, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE);
1862
+ await KeyManager._bestEffortDelete(store, keychainItems_1.RECOVERY_MNEMONIC_STORAGE_KEY, keychainItems_1.RECOVERY_MNEMONIC_KEYCHAIN_SERVICE);
1863
1863
  }
1864
1864
  /**
1865
1865
  * Check if a complete, parseable identity exists on this device.
@@ -2078,8 +2078,8 @@ class KeyManager {
2078
2078
  await store.deleteItemAsync(layout.primaryPublicKeyName, readOpts);
2079
2079
  }
2080
2080
  await KeyManager._bestEffortDeleteV2Primary(store);
2081
- await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
2082
- await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
2081
+ await KeyManager._bestEffortDelete(store, keychainItems_1.STORAGE_KEYS.PRIVATE_KEY);
2082
+ await KeyManager._bestEffortDelete(store, keychainItems_1.STORAGE_KEYS.PUBLIC_KEY);
2083
2083
  // Always drop the stored recovery mnemonic — it is scoped to the identity
2084
2084
  // being deleted, so a leftover would let Settings reveal a stale phrase for
2085
2085
  // an identity that no longer exists (or a DIFFERENT one after re-onboarding).
@@ -2089,6 +2089,13 @@ class KeyManager {
2089
2089
  if (force) {
2090
2090
  await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
2091
2091
  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
+ }
2092
2099
  }
2093
2100
  // The device backup goes on EVERY delete, forced or not: it is restored
2094
2101
  // silently, so leaving it would bring a deleted identity back on the next
@@ -2606,7 +2613,8 @@ class KeyManager {
2606
2613
  * `HDKey.fromMasterSeed` and never touches the identity key.
2607
2614
  *
2608
2615
  * Key source (native only):
2609
- * - iOS: the shared identity in `group.so.oxy.shared`, then this device's
2616
+ * - iOS: the shared slot's private key in the wallet keychain group (Commons
2617
+ * and the Peable wallet only; `null` in every other app), then this device's
2610
2618
  * primary identity.
2611
2619
  * - Android: Commons (the one app with a signer store) uses its own primary
2612
2620
  * identity; every other app asks Commons over IPC and never reads a local
@@ -2620,8 +2628,15 @@ class KeyManager {
2620
2628
  * keychain group, or Commons' identity host. It never falls back to the
2621
2629
  * personal key.
2622
2630
  *
2631
+ * With `opts.publicKey` the seed is derived from the key of that account
2632
+ * (personal, or `accountId`'s) whose public key it names: the current key
2633
+ * when it matches, else a RETIRED key Commons kept after a rotation or
2634
+ * replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
2635
+ * retired key never signs in. Never falls back to another key.
2636
+ *
2623
2637
  * @param info Context/domain-binding label (distinct labels → independent seeds).
2624
2638
  * @param opts.accountId An organization's account id, for its key.
2639
+ * @param opts.publicKey Selects one key of that account by its public key.
2625
2640
  * @returns 32 bytes of derived keying material, or `null` on web / when no
2626
2641
  * identity key is available to this app.
2627
2642
  */
@@ -2629,6 +2644,18 @@ class KeyManager {
2629
2644
  if (isWebPlatform()) {
2630
2645
  return null;
2631
2646
  }
2647
+ if (opts?.publicKey !== undefined) {
2648
+ if ((0, platform_1.isAndroid)() && !KeyManager.identitySignerStore) {
2649
+ const selected = KeyManager._selectorKey(opts.publicKey);
2650
+ if (!selected)
2651
+ return null;
2652
+ const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
2653
+ const seed = (await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
2654
+ return seed ? hexToBytes(seed) : null;
2655
+ }
2656
+ const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
2657
+ return selected ? (0, identityDerivations_1.deriveScopedSeedFromKey)(selected, info) : null;
2658
+ }
2632
2659
  if (opts?.accountId !== undefined) {
2633
2660
  return KeyManager._deriveOrganizationScopedSeed(info, opts.accountId);
2634
2661
  }
@@ -2674,7 +2701,8 @@ class KeyManager {
2674
2701
  * compressed public key for the scriptSig.
2675
2702
  *
2676
2703
  * Key source: as {@link deriveScopedSeed}, including `opts.accountId` for an
2677
- * organization's key (ADR 0061 D6). On Android outside Commons the signature
2704
+ * organization's key (ADR 0061 D6) and `opts.publicKey` for one key of the
2705
+ * account, current or retired. On Android outside Commons the signature
2678
2706
  * comes from Commons, which allows it for the wallet app only.
2679
2707
  *
2680
2708
  * @returns the signature, or `null` on web / when no identity key is
@@ -2684,6 +2712,18 @@ class KeyManager {
2684
2712
  if (isWebPlatform()) {
2685
2713
  return null;
2686
2714
  }
2715
+ if (opts?.publicKey !== undefined) {
2716
+ if ((0, platform_1.isAndroid)() && !KeyManager.identitySignerStore) {
2717
+ const selected = KeyManager._selectorKey(opts.publicKey);
2718
+ if (!selected)
2719
+ return null;
2720
+ const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
2721
+ return ((await bridge?.signRetiredSocialReceive?.(index, digest, selected, opts.accountId)) ??
2722
+ null);
2723
+ }
2724
+ const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
2725
+ return selected ? (0, identityDerivations_1.signSocialReceiveDigest)(selected, index, digest) : null;
2726
+ }
2687
2727
  const accountId = opts?.accountId;
2688
2728
  if (accountId !== undefined) {
2689
2729
  // An organization key (ADR 0061 D6); never the personal one in its place.
@@ -2707,6 +2747,34 @@ class KeyManager {
2707
2747
  : ((await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey()));
2708
2748
  return local ? (0, identityDerivations_1.signSocialReceiveDigest)(local, index, digest) : null;
2709
2749
  }
2750
+ /**
2751
+ * The private key of the account (personal, or `accountId`'s organization)
2752
+ * whose public key is `publicKey`, from this app's own storage: the current
2753
+ * key when it matches, else the retired ring. iOS and Commons only; `null`
2754
+ * for a key this device does not hold. Never another key in its place. @internal
2755
+ */
2756
+ /**
2757
+ * The selector in the identity host's encoding (lowercase uncompressed), so a
2758
+ * compressed `#key-1` from a DID or a gateway row selects the same key. @internal
2759
+ */
2760
+ static _selectorKey(publicKey) {
2761
+ return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
2762
+ }
2763
+ static async _selectedPrivateKey(publicKey, accountId) {
2764
+ if ((0, retiredKeys_1.retiredKeyScope)({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
2765
+ return null;
2766
+ }
2767
+ const wanted = KeyManager.canonicalPublicKey(publicKey);
2768
+ const current = accountId !== undefined
2769
+ ? await (0, organizationKeys_1.getOrganizationPrivateKey)(accountId)
2770
+ : (0, platform_1.isAndroid)()
2771
+ ? await KeyManager.getPrivateKey()
2772
+ : ((await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey()));
2773
+ if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
2774
+ return current;
2775
+ }
2776
+ return (0, retiredKeys_1.getRetiredPrivateKey)(wanted, accountId !== undefined ? { accountId } : undefined);
2777
+ }
2710
2778
  /**
2711
2779
  * Get a shortened version of the public key for display
2712
2780
  * Format: first 8 chars...last 8 chars