@oxy.so/core 7.29.0 → 7.31.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.
@@ -23,6 +23,7 @@ exports.groupedKeychainStore = groupedKeychainStore;
23
23
  * | `<TEAM>.so.oxy.identity` | Commons only (listed FIRST) | `identity-secret` |
24
24
  * | `<TEAM>.so.oxy.wallet` | Commons, the Peable wallet | `spending-key` |
25
25
  * | `<TEAM>.group.so.oxy.shared` | every Oxy app | `shared` |
26
+ * | `<TEAM>.<bundle id>` | that app alone | `app` |
26
27
  *
27
28
  * - `identity-secret`: the personal root (primary and backup slots), the
28
29
  * personal and organization recovery phrases, the organization index. Only
@@ -32,14 +33,21 @@ exports.groupedKeychainStore = groupedKeychainStore;
32
33
  * (`deriveScopedSeed`, `signSocialReceive`). Commons writes them; the Peable
33
34
  * wallet reads them.
34
35
  * - `shared`: what every Oxy app may read: the shared public key and the
35
- * device-session credential.
36
+ * device-session credential (the one cross-app session join point).
37
+ * - `app`: an app's OWN items, readable by no other app: its session
38
+ * credential (`oxy.auth.v1` and the rest of the SDK's session store), its
39
+ * identity pin, its settings, the Peable wallet's imported phrases. The group
40
+ * is the app's application identifier, which Apple makes every app a member
41
+ * of; no app lists it and no other app can name it. Android's counterpart is
42
+ * the app's own storage (ADR 0062 D6).
36
43
  *
37
44
  * The ROLE of this binary comes from the keychain itself: an item written
38
45
  * without a group lands in the app's default group, the first entry of its
39
46
  * `keychain-access-groups` entitlement, and `@oxy.so/app-preset` lets only
40
47
  * Commons put the identity group first and only the Peable wallet the wallet
41
48
  * group. The same probe gives the team prefix, which `kSecAttrAccessGroup`
42
- * needs and JavaScript cannot otherwise see.
49
+ * needs and JavaScript cannot otherwise see; a second probe proves the app's
50
+ * own group (`<prefix><bundle id>`) is writable.
43
51
  *
44
52
  * Android and web have no access groups: there every helper here answers the
45
53
  * single-store behaviour (`ungrouped`), and Android's narrowing is Commons'
@@ -66,10 +74,6 @@ class KeychainAccessUnavailableError extends Error {
66
74
  }
67
75
  }
68
76
  exports.KeychainAccessUnavailableError = KeychainAccessUnavailableError;
69
- /**
70
- * The single place each item class is assigned a group. A new kind of item
71
- * (F1's retained keys, for one) picks its class and reuses everything below.
72
- */
73
77
  function keychainGroupFor(item) {
74
78
  switch (item) {
75
79
  case 'identity-secret':
@@ -78,6 +82,8 @@ function keychainGroupFor(item) {
78
82
  return 'wallet';
79
83
  case 'shared':
80
84
  return 'shared';
85
+ case 'app':
86
+ return 'app';
81
87
  }
82
88
  }
83
89
  /** Whether `role` holds the group `item` lives in. */
@@ -88,11 +94,14 @@ function mayHoldKeychainItem(role, item) {
88
94
  case 'wallet':
89
95
  return item !== 'identity-secret';
90
96
  case 'member':
91
- return item === 'shared';
97
+ return item === 'shared' || item === 'app';
92
98
  }
93
99
  }
94
- /** Parse the default access group the probe reported. Exported for tests. */
95
- function keychainAccessFromDefaultGroup(defaultGroup) {
100
+ /**
101
+ * Parse the default access group the probe reported, and the app's own group
102
+ * when the second probe proved it. Exported for tests.
103
+ */
104
+ function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
96
105
  const dot = defaultGroup.indexOf('.');
97
106
  if (dot <= 0 || dot === defaultGroup.length - 1) {
98
107
  // Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
@@ -105,7 +114,9 @@ function keychainAccessFromDefaultGroup(defaultGroup) {
105
114
  : group === exports.KEYCHAIN_ACCESS_GROUPS.wallet
106
115
  ? 'wallet'
107
116
  : 'member';
108
- return { mode: 'grouped', prefix, defaultGroup, role };
117
+ return appGroup?.startsWith(prefix)
118
+ ? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
119
+ : { mode: 'grouped', prefix, defaultGroup, role };
109
120
  }
110
121
  let resolvedAccess = null;
111
122
  /**
@@ -133,28 +144,62 @@ async function resolveKeychainAccess() {
133
144
  if (probe.state === 'unavailable') {
134
145
  throw new KeychainAccessUnavailableError(probe.cause);
135
146
  }
136
- resolvedAccess =
137
- probe.state === 'resolved'
138
- ? keychainAccessFromDefaultGroup(probe.accessGroup)
139
- : { mode: 'ungrouped' };
147
+ if (probe.state !== 'resolved') {
148
+ resolvedAccess = { mode: 'ungrouped' };
149
+ return resolvedAccess;
150
+ }
151
+ const access = keychainAccessFromDefaultGroup(probe.accessGroup);
152
+ if (access.mode === 'ungrouped') {
153
+ resolvedAccess = access;
154
+ return resolvedAccess;
155
+ }
156
+ // The app's own group. A thrown probe is NOT degraded to "no app group": this
157
+ // process would then write the app's items to the old place while the next
158
+ // one reads the new place first, and see a stale value.
159
+ let own;
160
+ try {
161
+ own = await (0, protocol_1.loadKeychainApplicationAccessGroup)(access.prefix);
162
+ }
163
+ catch (cause) {
164
+ throw new KeychainAccessUnavailableError(cause);
165
+ }
166
+ if (own.state === 'unavailable') {
167
+ throw new KeychainAccessUnavailableError(own.cause);
168
+ }
169
+ resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
140
170
  return resolvedAccess;
141
171
  }
142
172
  /** Forget the memoized access. Tests only. @internal */
143
173
  function resetKeychainAccessForTests() {
144
174
  resolvedAccess = null;
145
175
  }
146
- /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
176
+ /**
177
+ * The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
178
+ * group" (the app's default): always when ungrouped, and for `app` on a build
179
+ * that cannot place items there (see {@link ownKeychainItemPlan}).
180
+ */
147
181
  function keychainAccessGroupValue(access, group) {
148
- return access.mode === 'grouped' ? `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
182
+ if (access.mode === 'ungrouped') {
183
+ return undefined;
184
+ }
185
+ if (group === 'app') {
186
+ // Commons' and the Peable wallet's default groups are theirs (no other app
187
+ // writes its own items there), so a build without the app-group probe
188
+ // keeps its own items there, as ADR 0062 D3 put them. Every other app's
189
+ // default is the shared group: no group, the calls it always made.
190
+ return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
191
+ }
192
+ return `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}`;
149
193
  }
150
194
  function option(value) {
151
195
  return value === undefined ? {} : { accessGroup: value };
152
196
  }
153
197
  /**
154
- * The option bags a READ of `item` tries, in order: its own group, then the
155
- * shared group, where every item lived before the split (release 4 of the doc
156
- * drops that fallback). Empty when this app may not hold the item: the caller
157
- * answers `null` without touching the keychain.
198
+ * The option bags a READ of `item` tries, in order: its own group, then, for
199
+ * an identity secret or a spending key, the shared group, where those lived
200
+ * before the split (release 4 of the doc drops that fallback). Empty when this
201
+ * app may not hold the item: the caller answers `null` without touching the
202
+ * keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
158
203
  */
159
204
  function keychainReadPlan(access, item) {
160
205
  if (access.mode === 'ungrouped') {
@@ -165,7 +210,8 @@ function keychainReadPlan(access, item) {
165
210
  }
166
211
  const own = keychainGroupFor(item);
167
212
  const plan = [option(keychainAccessGroupValue(access, own))];
168
- if (own !== 'shared') {
213
+ if (own === 'identity' || own === 'wallet') {
214
+ // Where the split moved these items from.
169
215
  plan.push(option(keychainAccessGroupValue(access, 'shared')));
170
216
  }
171
217
  return plan;
@@ -192,25 +238,50 @@ function keychainWriteOption(access, item) {
192
238
  return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
193
239
  }
194
240
  /**
195
- * The plan for an app's OWN items (its session credential, its settings, an
196
- * imported wallet's phrase) in Commons and the Peable wallet, whose default
197
- * group the split changed: written to the default group explicitly — where a
198
- * write without a group goes anyway — and read from it FIRST, then from every
199
- * group (`{}`), where all of them were written before. Without the explicit
200
- * order a read could return whichever of two copies the keychain met first.
201
- * Deletes reach every group, as they always did.
241
+ * The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
242
+ * app's own group (`<prefix><bundle id>`), which no other app can read, and
243
+ * read from it FIRST. Then:
244
+ *
245
+ * - from where this app kept them before: Commons' and the Peable wallet's
246
+ * default group (D3), which no other app writes its own items to;
247
+ * - for `this-app` names, from every group (`{}`), where they were before the
248
+ * split; deletes reach every group;
249
+ * - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
250
+ * and deletes reach exactly the read locations, earlier ones first, so a
251
+ * failed delete never leaves an older copy as the only one;
252
+ * - for `every-oxy-app-withholding` names, the shared group last on read and
253
+ * first on delete.
202
254
  *
203
- * Every other app (`member`) and Android/web keep the calls they always made.
255
+ * Nothing is copied or deleted to migrate: the next write lands in the app
256
+ * group and the read order makes it win, so a crash or a failure at any step
257
+ * leaves the value the app had.
258
+ *
259
+ * A native build whose module predates the app-group probe (`appGroup`
260
+ * absent) keeps its items where that build put them: Commons and the wallet in
261
+ * their default group, every other app the calls it always made (the one
262
+ * shared item, until that app's next native build). Android and web: no
263
+ * options.
204
264
  */
205
- function ownKeychainItemPlan(access) {
206
- if (access.mode === 'ungrouped' || access.role === 'member') {
265
+ function ownKeychainItemPlan(access, names = 'this-app') {
266
+ if (access.mode === 'ungrouped') {
207
267
  return { read: [{}], write: {}, delete: [{}] };
208
268
  }
209
- return {
210
- read: [option(access.defaultGroup), {}],
211
- write: option(access.defaultGroup),
212
- delete: [{}],
213
- };
269
+ const own = keychainAccessGroupValue(access, keychainGroupFor('app'));
270
+ if (own === undefined) {
271
+ return { read: [{}], write: {}, delete: [{}] };
272
+ }
273
+ const earlier = access.role !== 'member' && own !== access.defaultGroup ? [option(access.defaultGroup)] : [];
274
+ const mine = [option(own), ...earlier];
275
+ switch (names) {
276
+ case 'this-app':
277
+ return { read: [...mine, {}], write: option(own), delete: [{}] };
278
+ case 'every-oxy-app':
279
+ return { read: mine, write: option(own), delete: [...mine].reverse() };
280
+ case 'every-oxy-app-withholding': {
281
+ const read = [...mine, option(keychainAccessGroupValue(access, 'shared'))];
282
+ return { read, write: option(own), delete: [...read].reverse() };
283
+ }
284
+ }
214
285
  }
215
286
  /** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
216
287
  async function readThroughPlan(store, key, options, plan) {
@@ -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 {
@@ -20,6 +20,9 @@ export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKe
20
20
  // iOS keychain access groups (docs/identity/ios-keychain-access-groups.md, ADR 0062)
21
21
  export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
22
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';
23
26
  export { SignatureService } from './signatureService.js';
24
27
  export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase.js';
25
28
  // Low-level primitives (encrypted backup, device transfer)
@@ -11,6 +11,7 @@ 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';
14
15
  import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
15
16
  import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
16
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';
@@ -2049,6 +2050,13 @@ export class KeyManager {
2049
2050
  if (force) {
2050
2051
  await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
2051
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
+ }
2052
2060
  }
2053
2061
  // The device backup goes on EVERY delete, forced or not: it is restored
2054
2062
  // silently, so leaving it would bring a deleted identity back on the next
@@ -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