@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
@@ -32,6 +32,22 @@ import { isDev, logger } from '../logger';
32
32
  import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations';
33
33
  import { type IdentitySignerStore, registerIdentitySignerStore } from './identitySigner';
34
34
  import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys';
35
+ import { forgetRetiredIdentityKeys, getRetiredPrivateKey, retiredKeyScope } from './retiredKeys';
36
+ import {
37
+ type KeychainItemClass,
38
+ groupedKeychainStore,
39
+ resolveKeychainAccess,
40
+ } from '../utils/keychainAccessGroups';
41
+ import { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
42
+ import {
43
+ RECOVERY_MNEMONIC_KEYCHAIN_SERVICE,
44
+ RECOVERY_MNEMONIC_STORAGE_KEY,
45
+ STORAGE_KEYS,
46
+ V2_BACKUP_KEYCHAIN_SERVICE,
47
+ V2_PRIMARY_KEYCHAIN_SERVICE,
48
+ V2_STORAGE_KEYS,
49
+ } from './keychainItems';
50
+ import type { OxySecureStoreOptions } from './secureStoreOptions';
35
51
  import {
36
52
  type IdentityMarker,
37
53
  clearIdentityMarker,
@@ -53,37 +69,6 @@ import {
53
69
  */
54
70
  const DEVICE_BACKUP_TOMBSTONE = JSON.stringify({ deleted: true });
55
71
 
56
- /**
57
- * Options for expo-secure-store calls made by KeyManager.
58
- *
59
- * Defined as a standalone interface (not extending expo-secure-store's
60
- * `SecureStoreOptions`) so that server / Node.js consumers of @oxy.so/core
61
- * do not transitively pull in expo-modules-core's type declarations under
62
- * NodeNext module resolution (which would cause NodeJS.Timeout / number
63
- * pollution across all timer APIs). The fields mirror SecureStoreOptions
64
- * exactly — any object satisfying this interface is safe to pass to the
65
- * real expo-secure-store methods (which accept `options?: object`).
66
- */
67
- interface OxySecureStoreOptions {
68
- /** Keychain service name (Android keystore / iOS keychain tag). */
69
- keychainService?: string;
70
- /**
71
- * Keychain / Keystore item accessibility. Use the constants from the
72
- * `ExpoSecureStoreLike` handle returned by `initSecureStore()`, e.g.
73
- * `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY`.
74
- */
75
- keychainAccessible?: number;
76
- /** Prompt shown to the user for biometric/auth-protected items. */
77
- authenticationPrompt?: string;
78
- /** Whether Face ID / Touch ID / biometric auth is required to read the item. */
79
- requireAuthentication?: boolean;
80
- /**
81
- * iOS Keychain access group. Required for sharing identity material across
82
- * apps in the Oxy ecosystem via Keychain Sharing entitlements.
83
- */
84
- keychainAccessGroup?: string;
85
- }
86
-
87
72
  /**
88
73
  * Thrown when an identity-mutating operation (createIdentity / importKeyPair)
89
74
  * is invoked while a valid identity already exists on the device.
@@ -181,17 +166,6 @@ export type IdentityRecoveryResult =
181
166
  | { recovered: true; source: 'backup' | 'shared' | 'device-backup'; publicKey: string }
182
167
  | { recovered: false; reason: 'not-lost' | 'no-sources' | 'mismatch' | 'unavailable' };
183
168
 
184
- const STORAGE_KEYS = {
185
- PRIVATE_KEY: 'oxy_identity_private_key',
186
- PUBLIC_KEY: 'oxy_identity_public_key',
187
- BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key',
188
- BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key',
189
- BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp',
190
- // The cross-app identity in the iOS keychain access group (iOS only).
191
- SHARED_PRIVATE_KEY: 'oxy_shared_identity_private_key',
192
- SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
193
- } as const;
194
-
195
169
  /**
196
170
  * v2 identity slot layout — blast-radius isolation.
197
171
  *
@@ -207,16 +181,8 @@ const STORAGE_KEYS = {
207
181
  * Migration from the legacy layout is lazy + verify-before-delete — see
208
182
  * {@link KeyManager._runSlotMigration}.
209
183
  */
210
- const V2_PRIMARY_KEYCHAIN_SERVICE = 'oxy_identity';
211
- const V2_BACKUP_KEYCHAIN_SERVICE = 'oxy_identity_backup';
212
-
213
- const V2_STORAGE_KEYS = {
214
- PRIVATE_KEY: 'oxy_identity_private_key_v2',
215
- PUBLIC_KEY: 'oxy_identity_public_key_v2',
216
- BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
217
- BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
218
- BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
219
- } as const;
184
+ // The names (V2_PRIMARY_KEYCHAIN_SERVICE, V2_BACKUP_KEYCHAIN_SERVICE,
185
+ // V2_STORAGE_KEYS) live in `./keychainItems`, beside every other identity item.
220
186
 
221
187
  /**
222
188
  * Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
@@ -234,8 +200,7 @@ const V2_STORAGE_KEYS = {
234
200
  * lives ONLY in this slot: it is never mirrored into the identity marker,
235
201
  * {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
236
202
  */
237
- const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
238
- const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
203
+ // RECOVERY_MNEMONIC_KEYCHAIN_SERVICE / RECOVERY_MNEMONIC_STORAGE_KEY: `./keychainItems`.
239
204
 
240
205
  /**
241
206
  * Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
@@ -301,13 +266,6 @@ type SlotMigrationResult =
301
266
  | { mode: 'legacy'; layout: ResolvedSlotLayout }
302
267
  | { mode: 'deferred'; cause: unknown };
303
268
 
304
- /**
305
- * iOS Keychain Access Group for sharing identities across Oxy apps
306
- * All Oxy apps must have this access group enabled in their entitlements
307
- * Format: [Team ID].so.oxy.shared or group.so.oxy.shared
308
- */
309
- const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
310
-
311
269
  /**
312
270
  * Initialize React Native specific modules
313
271
  *
@@ -317,7 +275,7 @@ const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
317
275
  * `expo-secure-store`; off RN it throws (and is never called because every
318
276
  * caller is gated by `isWebPlatform()` / native-only paths).
319
277
  */
320
- async function initSecureStore(): Promise<ExpoSecureStoreLike> {
278
+ async function loadRawSecureStore(): Promise<ExpoSecureStoreLike> {
321
279
  try {
322
280
  return await loadSecureStore();
323
281
  } catch (error) {
@@ -328,6 +286,31 @@ async function initSecureStore(): Promise<ExpoSecureStoreLike> {
328
286
  }
329
287
  }
330
288
 
289
+ /**
290
+ * The secure store routed to the keychain group of `item`
291
+ * (`../utils/keychainAccessGroups`): on iOS reads walk the item's group then
292
+ * the pre-split shared group, writes carry the item's group and THROW in an app
293
+ * that may not hold it, and in Commons every access first awaits the
294
+ * access-group migration. Android and web are unchanged (`ungrouped`).
295
+ *
296
+ * Throws when the store cannot load or the keychain cannot say which groups
297
+ * this app holds; every caller already treats a throw here as "storage
298
+ * unavailable".
299
+ */
300
+ async function keychainItemStore(item: KeychainItemClass): Promise<ExpoSecureStoreLike> {
301
+ const raw = await loadRawSecureStore();
302
+ const access = await resolveKeychainAccess();
303
+ if (access.mode === 'grouped' && access.role === 'holder') {
304
+ await ensureKeychainGroupsMigrated();
305
+ }
306
+ return groupedKeychainStore(raw, access, item);
307
+ }
308
+
309
+ /** The store for the identity slots (primary, backup, legacy, phrase): Commons only on iOS. */
310
+ function initSecureStore(): Promise<ExpoSecureStoreLike> {
311
+ return keychainItemStore('identity-secret');
312
+ }
313
+
331
314
  /**
332
315
  * Check if we're on web platform
333
316
  * Identity storage is only available on native platforms (iOS/Android)
@@ -875,13 +858,15 @@ export class KeyManager {
875
858
  * Android signer store is cleared on every delete, in {@link deleteIdentity}.
876
859
  */
877
860
  private static async _clearSharedSlot(
878
- store: Awaited<ReturnType<typeof initSecureStore>>,
861
+ _identityStore: Awaited<ReturnType<typeof initSecureStore>>,
879
862
  ): Promise<void> {
880
863
  try {
881
864
  if (isIOS()) {
882
- const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
883
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
884
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
865
+ // The shared slot lives in two groups of its own, not the identity slots'.
866
+ await (await keychainItemStore('spending-key')).deleteItemAsync(
867
+ STORAGE_KEYS.SHARED_PRIVATE_KEY,
868
+ );
869
+ await (await keychainItemStore('shared')).deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
885
870
  }
886
871
  KeyManager.invalidateSharedCache();
887
872
  } catch (error) {
@@ -964,6 +949,25 @@ export class KeyManager {
964
949
  KeyManager.cachedHasSharedIdentity = null;
965
950
  }
966
951
 
952
+ /**
953
+ * iOS: write the personal key's cross-app slot — the private key to the
954
+ * wallet group (Commons and the Peable wallet), the public key to the shared
955
+ * group (every Oxy app). Throws in any app that may not hold the private key.
956
+ * @internal
957
+ */
958
+ private static async _writeIosSharedSlot(privateKey: string, publicKey: string): Promise<void> {
959
+ const privateStore = await keychainItemStore('spending-key');
960
+ const privateOpts: OxySecureStoreOptions = {
961
+ keychainAccessible: privateStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
962
+ };
963
+ await privateStore.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
964
+ await (await keychainItemStore('shared')).setItemAsync(
965
+ STORAGE_KEYS.SHARED_PUBLIC_KEY,
966
+ publicKey,
967
+ {},
968
+ );
969
+ }
970
+
967
971
  /**
968
972
  * Lowercase and pad to canonical 64-hex-char form.
969
973
  *
@@ -1002,8 +1006,10 @@ export class KeyManager {
1002
1006
  // ==================== SHARED IDENTITY METHODS ====================
1003
1007
  // One Oxy identity across every Oxy app on a device.
1004
1008
  //
1005
- // iOS: the keychain access group `group.so.oxy.shared`. Every Oxy app reads
1006
- // the same keychain item, and no app can wipe another's keychain.
1009
+ // iOS: keychain access groups (docs/identity/ios-keychain-access-groups.md).
1010
+ // The private key is in `<TEAM>.so.oxy.wallet` (Commons and the Peable
1011
+ // wallet only), the public key in `<TEAM>.group.so.oxy.shared`, which every
1012
+ // Oxy app reads. Every other app signs in through Commons (ADR 0029).
1007
1013
  // Android: every app has its own UID, so nothing is shared through storage.
1008
1014
  // Commons is the ONLY holder of the private key. It keeps the identity
1009
1015
  // signer store (`setIdentitySignerStore`) for its identity host, and every
@@ -1031,22 +1037,11 @@ export class KeyManager {
1031
1037
  const { privateKey, publicKey } = await KeyManager.generateKeyPair();
1032
1038
 
1033
1039
  if (isIOS()) {
1034
- const store = await initSecureStore();
1035
- // Note: keychainAccessGroup requires Keychain Sharing capability in Xcode
1036
1040
  try {
1037
- const privateOpts: OxySecureStoreOptions = {
1038
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
1039
- keychainAccessGroup: IOS_KEYCHAIN_GROUP, // Enables sharing across apps
1040
- };
1041
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
1042
-
1043
- const publicOpts: OxySecureStoreOptions = {
1044
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
1045
- };
1046
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
1041
+ await KeyManager._writeIosSharedSlot(privateKey, publicKey);
1047
1042
  } catch (error) {
1048
1043
  throw new Error(
1049
- `Failed to create shared identity on iOS. Ensure your app has the Keychain Sharing capability enabled with access group "${IOS_KEYCHAIN_GROUP}". Error: ${error}`,
1044
+ `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}`,
1050
1045
  );
1051
1046
  }
1052
1047
  } else if (isAndroid()) {
@@ -1093,9 +1088,8 @@ export class KeyManager {
1093
1088
  let publicKey: string | null = null;
1094
1089
 
1095
1090
  if (isIOS()) {
1096
- const store = await initSecureStore();
1097
- const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
1098
- publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
1091
+ const store = await keychainItemStore('shared');
1092
+ publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, {});
1099
1093
  } else if (isAndroid()) {
1100
1094
  const signer = KeyManager.identitySignerStore;
1101
1095
  if (signer) {
@@ -1136,9 +1130,10 @@ export class KeyManager {
1136
1130
  }
1137
1131
 
1138
1132
  try {
1139
- const store = await initSecureStore();
1140
- const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
1141
- return await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
1133
+ // The wallet group: Commons and the Peable wallet. Every other app's plan
1134
+ // is empty, so this is `null` there without touching the keychain.
1135
+ const store = await keychainItemStore('spending-key');
1136
+ return await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, {});
1142
1137
  } catch (error) {
1143
1138
  if (isDev()) {
1144
1139
  logger.warn('Failed to get shared private key', { component: 'KeyManager' }, error);
@@ -1245,17 +1240,7 @@ export class KeyManager {
1245
1240
  const publicKey = deriveSecp256k1PublicKey(canonicalPrivate);
1246
1241
 
1247
1242
  if (isIOS()) {
1248
- const store = await initSecureStore();
1249
- const privateOpts: OxySecureStoreOptions = {
1250
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
1251
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
1252
- };
1253
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, privateOpts);
1254
-
1255
- const publicOpts: OxySecureStoreOptions = {
1256
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
1257
- };
1258
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
1243
+ await KeyManager._writeIosSharedSlot(canonicalPrivate, publicKey);
1259
1244
  } else if (isAndroid()) {
1260
1245
  await KeyManager._writeIdentitySigner(canonicalPrivate, publicKey);
1261
1246
  }
@@ -2627,6 +2612,16 @@ export class KeyManager {
2627
2612
  if (force) {
2628
2613
  await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
2629
2614
  await KeyManager._clearSharedSlot(store);
2615
+ // The person's retired keys go with the identity they belonged to.
2616
+ try {
2617
+ await forgetRetiredIdentityKeys();
2618
+ } catch (error) {
2619
+ logger.warn(
2620
+ 'Failed to clear retired keys during delete',
2621
+ { component: 'KeyManager' },
2622
+ error,
2623
+ );
2624
+ }
2630
2625
  }
2631
2626
 
2632
2627
  // The device backup goes on EVERY delete, forced or not: it is restored
@@ -3230,7 +3225,8 @@ export class KeyManager {
3230
3225
  * `HDKey.fromMasterSeed` and never touches the identity key.
3231
3226
  *
3232
3227
  * Key source (native only):
3233
- * - iOS: the shared identity in `group.so.oxy.shared`, then this device's
3228
+ * - iOS: the shared slot's private key in the wallet keychain group (Commons
3229
+ * and the Peable wallet only; `null` in every other app), then this device's
3234
3230
  * primary identity.
3235
3231
  * - Android: Commons (the one app with a signer store) uses its own primary
3236
3232
  * identity; every other app asks Commons over IPC and never reads a local
@@ -3244,18 +3240,37 @@ export class KeyManager {
3244
3240
  * keychain group, or Commons' identity host. It never falls back to the
3245
3241
  * personal key.
3246
3242
  *
3243
+ * With `opts.publicKey` the seed is derived from the key of that account
3244
+ * (personal, or `accountId`'s) whose public key it names: the current key
3245
+ * when it matches, else a RETIRED key Commons kept after a rotation or
3246
+ * replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
3247
+ * retired key never signs in. Never falls back to another key.
3248
+ *
3247
3249
  * @param info Context/domain-binding label (distinct labels → independent seeds).
3248
3250
  * @param opts.accountId An organization's account id, for its key.
3251
+ * @param opts.publicKey Selects one key of that account by its public key.
3249
3252
  * @returns 32 bytes of derived keying material, or `null` on web / when no
3250
3253
  * identity key is available to this app.
3251
3254
  */
3252
3255
  static async deriveScopedSeed(
3253
3256
  info: string,
3254
- opts?: { accountId?: string },
3257
+ opts?: { accountId?: string; publicKey?: string },
3255
3258
  ): Promise<Uint8Array | null> {
3256
3259
  if (isWebPlatform()) {
3257
3260
  return null;
3258
3261
  }
3262
+ if (opts?.publicKey !== undefined) {
3263
+ if (isAndroid() && !KeyManager.identitySignerStore) {
3264
+ const selected = KeyManager._selectorKey(opts.publicKey);
3265
+ if (!selected) return null;
3266
+ const bridge = await loadCommonsIdentityBridge();
3267
+ const seed =
3268
+ (await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
3269
+ return seed ? hexToBytes(seed) : null;
3270
+ }
3271
+ const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
3272
+ return selected ? deriveScopedSeedFromKey(selected, info) : null;
3273
+ }
3259
3274
  if (opts?.accountId !== undefined) {
3260
3275
  return KeyManager._deriveOrganizationScopedSeed(info, opts.accountId);
3261
3276
  }
@@ -3307,7 +3322,8 @@ export class KeyManager {
3307
3322
  * compressed public key for the scriptSig.
3308
3323
  *
3309
3324
  * Key source: as {@link deriveScopedSeed}, including `opts.accountId` for an
3310
- * organization's key (ADR 0061 D6). On Android outside Commons the signature
3325
+ * organization's key (ADR 0061 D6) and `opts.publicKey` for one key of the
3326
+ * account, current or retired. On Android outside Commons the signature
3311
3327
  * comes from Commons, which allows it for the wallet app only.
3312
3328
  *
3313
3329
  * @returns the signature, or `null` on web / when no identity key is
@@ -3316,11 +3332,24 @@ export class KeyManager {
3316
3332
  static async signSocialReceive(
3317
3333
  index: number,
3318
3334
  digest: string,
3319
- opts?: { accountId?: string },
3335
+ opts?: { accountId?: string; publicKey?: string },
3320
3336
  ): Promise<CommonsSocialReceiveSignature | null> {
3321
3337
  if (isWebPlatform()) {
3322
3338
  return null;
3323
3339
  }
3340
+ if (opts?.publicKey !== undefined) {
3341
+ if (isAndroid() && !KeyManager.identitySignerStore) {
3342
+ const selected = KeyManager._selectorKey(opts.publicKey);
3343
+ if (!selected) return null;
3344
+ const bridge = await loadCommonsIdentityBridge();
3345
+ return (
3346
+ (await bridge?.signRetiredSocialReceive?.(index, digest, selected, opts.accountId)) ??
3347
+ null
3348
+ );
3349
+ }
3350
+ const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
3351
+ return selected ? signSocialReceiveDigest(selected, index, digest) : null;
3352
+ }
3324
3353
  const accountId = opts?.accountId;
3325
3354
  if (accountId !== undefined) {
3326
3355
  // An organization key (ADR 0061 D6); never the personal one in its place.
@@ -3345,6 +3374,40 @@ export class KeyManager {
3345
3374
  return local ? signSocialReceiveDigest(local, index, digest) : null;
3346
3375
  }
3347
3376
 
3377
+ /**
3378
+ * The private key of the account (personal, or `accountId`'s organization)
3379
+ * whose public key is `publicKey`, from this app's own storage: the current
3380
+ * key when it matches, else the retired ring. iOS and Commons only; `null`
3381
+ * for a key this device does not hold. Never another key in its place. @internal
3382
+ */
3383
+ /**
3384
+ * The selector in the identity host's encoding (lowercase uncompressed), so a
3385
+ * compressed `#key-1` from a DID or a gateway row selects the same key. @internal
3386
+ */
3387
+ private static _selectorKey(publicKey: string): string | null {
3388
+ return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
3389
+ }
3390
+
3391
+ private static async _selectedPrivateKey(
3392
+ publicKey: string,
3393
+ accountId?: string,
3394
+ ): Promise<string | null> {
3395
+ if (retiredKeyScope({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
3396
+ return null;
3397
+ }
3398
+ const wanted = KeyManager.canonicalPublicKey(publicKey);
3399
+ const current =
3400
+ accountId !== undefined
3401
+ ? await getOrganizationPrivateKey(accountId)
3402
+ : isAndroid()
3403
+ ? await KeyManager.getPrivateKey()
3404
+ : ((await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey()));
3405
+ if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
3406
+ return current;
3407
+ }
3408
+ return getRetiredPrivateKey(wanted, accountId !== undefined ? { accountId } : undefined);
3409
+ }
3410
+
3348
3411
  /**
3349
3412
  * Get a shortened version of the public key for display
3350
3413
  * Format: first 8 chars...last 8 chars