@oxy.so/core 7.32.0 → 7.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -290,9 +290,46 @@ export declare class KeyManager {
290
290
  * `signSocialReceive`) is fresh, so a cached key here would let a caller
291
291
  * compare a new seed against an old key.
292
292
  *
293
+ * iOS, in an app that holds the private key (Commons, the Peable wallet:
294
+ * keychain roles `holder` and `wallet`): DERIVED from the private key in the
295
+ * wallet group, never read from the public-key item, which sits in the shared
296
+ * group every Oxy app can write (R5-1); `null` when that app holds no private
297
+ * key. Every other iOS app reads the public-key item: it holds nothing it
298
+ * could derive from, and it never spends. Money code uses
299
+ * {@link getIdentityPublicKeyForMoney}.
300
+ *
293
301
  * @returns Shared public key or null if no shared identity exists
294
302
  */
295
303
  static getSharedPublicKey(): Promise<string | null>;
304
+ /**
305
+ * Whether this iOS app holds spending keys (keychain role `holder` or
306
+ * `wallet`), so a public key it uses for money must be derived from them.
307
+ * Throws while the keychain cannot say. @internal
308
+ */
309
+ private static _holdsSpendingKeys;
310
+ /**
311
+ * THE public key of the identity a money holder (the Peable wallet, Commons)
312
+ * receives and spends with: the personal key, or with `opts.accountId` that
313
+ * organization's (ADR 0061 D6). It is the public half of exactly the private
314
+ * key {@link deriveScopedSeed} and {@link signSocialReceive} use, so an
315
+ * address shown from it is one this app can spend (R5-1).
316
+ *
317
+ * - iOS (Commons, the Peable wallet): DERIVED from the private key in the
318
+ * restricted keychain groups (personal: the wallet group's slot, then
319
+ * Commons' primary; organization: its pair). Never the shared group's
320
+ * public-key item, which every Oxy app can write. `null` when no private
321
+ * key is held, and in every other iOS app.
322
+ * - Android: inside Commons derived from its own key; elsewhere Commons'
323
+ * identity host (`describe`), which answers from Commons' own signer
324
+ * store over signature-protected IPC.
325
+ * - Web: `null`.
326
+ *
327
+ * Compare the answer with the account's published `#key-1` before showing a
328
+ * receive address (Peable ADR 0002). Lowercase uncompressed (`04…`).
329
+ */
330
+ static getIdentityPublicKeyForMoney(opts?: {
331
+ accountId?: string;
332
+ }): Promise<string | null>;
296
333
  /**
297
334
  * The shared private key, where this app may hold it: iOS only.
298
335
  *
@@ -743,17 +780,23 @@ export declare class KeyManager {
743
780
  * (personal, or `accountId`'s) whose public key it names: the current key
744
781
  * when it matches, else a RETIRED key Commons kept after a rotation or
745
782
  * replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
746
- * retired key never signs in. Never falls back to another key.
783
+ * retired key never signs in. Never falls back to another key. A PERSONAL
784
+ * retired key is reached only with `opts.ownerAccountId`, the signed-in
785
+ * person's account id, and only when the ring keeps it for that account
786
+ * (R5-2); on Android outside Commons the identity host holds only the
787
+ * personal keys of the person Commons is signed in as.
747
788
  *
748
789
  * @param info Context/domain-binding label (distinct labels → independent seeds).
749
790
  * @param opts.accountId An organization's account id, for its key.
750
791
  * @param opts.publicKey Selects one key of that account by its public key.
792
+ * @param opts.ownerAccountId The person's account id, for a personal retired key.
751
793
  * @returns 32 bytes of derived keying material, or `null` on web / when no
752
794
  * identity key is available to this app.
753
795
  */
754
796
  static deriveScopedSeed(info: string, opts?: {
755
797
  accountId?: string;
756
798
  publicKey?: string;
799
+ ownerAccountId?: string;
757
800
  }): Promise<Uint8Array | null>;
758
801
  /**
759
802
  * {@link deriveScopedSeed} over an ORGANIZATION key (ADR 0061 D6): Commons'
@@ -780,6 +823,7 @@ export declare class KeyManager {
780
823
  static signSocialReceive(index: number, digest: string, opts?: {
781
824
  accountId?: string;
782
825
  publicKey?: string;
826
+ ownerAccountId?: string;
783
827
  }): Promise<CommonsSocialReceiveSignature | null>;
784
828
  /**
785
829
  * The private key of the account (personal, or `accountId`'s organization)
@@ -41,10 +41,32 @@
41
41
  *
42
42
  * This module imports nothing from `./keyManager` (which imports it).
43
43
  */
44
- /** Whose ring: the person's (`accountId` absent) or an organization's. */
44
+ /**
45
+ * Whose ring: the person's (`accountId` absent) or an organization's.
46
+ *
47
+ * The personal ring is BOUND TO ITS OWNER (R5-2): every personal entry records
48
+ * the Oxy account id of the person whose key it was, and a personal read
49
+ * reaches only the entries of `ownerAccountId`. A phone outlives the person on
50
+ * it ("Start over" keeps the ring: it is money, recoverable from that account's
51
+ * backup), so the next person on the phone must never list, derive from or
52
+ * sign with a previous person's keys. A personal read without `ownerAccountId`
53
+ * reaches nothing. An entry whose owner is not known (a key the access-group
54
+ * migration kept, an entry written before 7.33.0) is QUARANTINED: invisible to
55
+ * every reader until Commons binds it with {@link claimRetiredIdentityKeys},
56
+ * which requires the account's key history to name it.
57
+ */
45
58
  export interface RetiredKeySelector {
46
59
  /** An organization's account id; absent for the personal ring. */
47
60
  accountId?: string;
61
+ /**
62
+ * The person's Oxy account id, for the PERSONAL ring: whose entries are
63
+ * reached. Ignored for an organization's ring (its owner is `accountId`).
64
+ */
65
+ ownerAccountId?: string;
66
+ }
67
+ /** Thrown when the ring's index cannot be read as the ring: never treated as empty (R5-9). */
68
+ export declare class RetiredKeyRingUnreadableError extends Error {
69
+ constructor(reason: string);
48
70
  }
49
71
  /** One retired key as the ring lists it: public data only. */
50
72
  export interface RetiredIdentityKey {
@@ -63,25 +85,53 @@ export interface RetiredKeyPair {
63
85
  /** The ring a selector names: `personal`, or the organization's account id. */
64
86
  export declare function retiredKeyScope(selector?: RetiredKeySelector): string | null;
65
87
  /**
66
- * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
67
- * key already kept stays, with its first date. Refuses a pair whose public key
68
- * does not derive from its private key. Resolves once every copy confirmed it
88
+ * Keep `key` in the ring of `selector`'s account. Commons only. A PERSONAL key
89
+ * needs `selector.ownerAccountId`, the person whose key it was (R5-2): the
90
+ * signed-in account at a rotation, the account whose key history or backup
91
+ * named it. Idempotent: a key already kept stays, with its first date; a
92
+ * quarantined entry of the same key is bound to that owner; an entry bound to
93
+ * a DIFFERENT owner is refused. Refuses a pair whose public key does not
94
+ * derive from its private key. Resolves once every copy confirmed it
69
95
  * (Android: the signer store's read-back too), and throws otherwise, so a
70
96
  * caller about to replace the key can stop before anything is lost.
71
97
  */
72
98
  export declare function retireIdentityKey(key: RetiredKeyPair, selector?: RetiredKeySelector): Promise<void>;
73
99
  /**
74
100
  * {@link retireIdentityKey} from inside the iOS access-group migration, which
75
- * keeps a replaced pre-split copy before deleting it from the shared group and
76
- * cannot await itself. @internal
101
+ * keeps a pre-split copy before deleting it from the shared group and cannot
102
+ * await itself. A personal key without `ownerAccountId` is kept QUARANTINED:
103
+ * held (it may be money) but reached by nobody until
104
+ * {@link claimRetiredIdentityKeys} binds it. @internal
77
105
  */
78
106
  export declare function retireIdentityKeyDuringMigration(key: RetiredKeyPair, selector?: RetiredKeySelector): Promise<void>;
107
+ /**
108
+ * Bind the QUARANTINED personal entries whose public key `publicKeys` names to
109
+ * `ownerAccountId` (R5-2, R5-3). Commons only, with the signed-in person's
110
+ * account id and that account's key history from Oxy
111
+ * (`oxy.identity.keyHistory()`: retired and current public keys): a key the
112
+ * account's own history names is that account's. Entries bound to anyone, and
113
+ * keys the history does not name, are left as they are. Resolves how many it
114
+ * bound; call {@link syncRetiredKeys} after it on Android.
115
+ */
116
+ export declare function claimRetiredIdentityKeys(ownerAccountId: string, publicKeys: readonly string[]): Promise<number>;
117
+ /**
118
+ * How many personal entries are quarantined (no known owner). Commons shows
119
+ * them as "keys from before" the person can claim by adding the phrase, and
120
+ * {@link claimRetiredIdentityKeys} binds the ones the account's history names.
121
+ */
122
+ export declare function countQuarantinedRetiredKeys(): Promise<number>;
79
123
  /**
80
124
  * The retired keys of `selector`'s account this app can reach, oldest first.
81
- * iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
82
- * (the wallet only), which leaves out the scope's current key. After an
83
- * interrupted rotation the local list can include the current key: compare
84
- * with the DID's `#key-1` before treating an entry as retired.
125
+ * The personal ring answers only `selector.ownerAccountId`'s entries (none
126
+ * without it; R5-2). iOS and Commons: the ring's index. Android elsewhere:
127
+ * Commons' identity host (the wallet only), which holds only the personal
128
+ * entries of the person Commons is signed in as and leaves out the scope's
129
+ * current key. After an interrupted rotation the local list can include the
130
+ * current key: compare with the DID's `#key-1` before treating an entry as
131
+ * retired, and check every entry against the account's key history.
132
+ *
133
+ * STRICT (R5-6): a ring that cannot be read throws (the keychain's error, or
134
+ * {@link RetiredKeyRingUnreadableError}); it never reads as empty.
85
135
  */
86
136
  export declare function listRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<RetiredIdentityKey[]>;
87
137
  /**
@@ -108,23 +158,38 @@ export declare function forgetRetiredIdentityKey(publicKey: string, selector?: R
108
158
  */
109
159
  export declare function forgetRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<number>;
110
160
  /**
111
- * Commons on Android: copy every retired key into the identity signer store
112
- * again, so the identity host answers for it after that file was rebuilt (its
113
- * self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
114
- * Resolves how many it mirrored. Elsewhere 0.
161
+ * Commons on Android: make the identity signer store hold exactly what the
162
+ * identity host may answer — every organization ring, and the personal entries
163
+ * of `ownerAccountId` (the person Commons is signed in as) — so the host
164
+ * answers for them after that file was rebuilt (its self-heal leaves it
165
+ * empty), and never for a previous person's keys (R5-2): every other personal
166
+ * entry is removed from the signer store (its own slot keeps it). Without
167
+ * `ownerAccountId` no personal entry is mirrored. Call on every boot and after
168
+ * every sign-in, beside `syncOrganizationKeys`. Resolves how many it mirrored.
169
+ * Elsewhere 0.
115
170
  */
116
- export declare function syncRetiredKeys(): Promise<number>;
171
+ export declare function syncRetiredKeys(ownerAccountId?: string): Promise<number>;
117
172
  /**
118
- * Every retired key of `selector`'s ring WITH its private half, for the
119
- * encrypted identity backup (`oxy.identity.backup.create`), which carries them
120
- * under the CURRENT phrase so a lost phone costs no more than the current
121
- * phrase does (R4-4). Commons only; an entry whose private half is missing or
122
- * does not derive its public key is left out. Never log or transmit the result
123
- * except inside that encrypted envelope.
173
+ * Every retired key of `selector`'s ring (personal: `ownerAccountId`'s entries)
174
+ * WITH its private half, for the encrypted identity backup
175
+ * (`oxy.identity.backup.create`), which carries them under the CURRENT phrase
176
+ * so a lost phone costs no more than the current phrase does (R4-4). Commons
177
+ * only. STRICT (R5-6): an unreadable ring, or a listed entry whose private
178
+ * half is missing or does not derive its public key, THROWS — a backup is
179
+ * never sealed without a key the ring says it holds. Never log or transmit the
180
+ * result except inside that encrypted envelope.
124
181
  */
125
182
  export declare function exportRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<Array<RetiredKeyPair & {
126
183
  retiredAt: number;
127
184
  }>>;
185
+ /**
186
+ * Commons on Android, when the identity leaves this device ("Start over", a
187
+ * forced delete): remove every PERSONAL entry from the identity signer store,
188
+ * so the identity host answers no other app with them before the next person
189
+ * signs in (R5-2). Commons' own slot and index keep them (money, bound to
190
+ * their owner). Elsewhere a no-op.
191
+ */
192
+ export declare function unmirrorPersonalRetiredKeys(): Promise<void>;
128
193
  /**
129
194
  * The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
130
195
  * for a person whose old identity was imported as a private key and so has no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.32.0",
3
+ "version": "7.33.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -337,7 +337,10 @@ export class IdentityApi {
337
337
  if (!isWeb()) {
338
338
  try {
339
339
  if (!oldPrivateKeyForRing) throw new Error('no readable private key');
340
- await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
340
+ await retireIdentityKey(
341
+ { privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey },
342
+ { ownerAccountId: userId },
343
+ );
341
344
  } catch (retainError) {
342
345
  logger.warn(
343
346
  'Could not keep the current key as a retired key; not rotating.',
@@ -896,9 +899,11 @@ export class IdentityBackupApi {
896
899
  private async retiredKeysToCarry(currentPublicKey: string): Promise<BackupRetiredKey[]> {
897
900
  if (isWeb()) return [];
898
901
  const { exportRetiredIdentityKeys } = await import('../crypto/index');
902
+ const userId = this.ctx.oxy.session.userId;
903
+ if (!userId) throw new Error('No authenticated user — sign in before backing up.');
899
904
  let held: BackupRetiredKey[];
900
905
  try {
901
- held = await exportRetiredIdentityKeys();
906
+ held = await exportRetiredIdentityKeys({ ownerAccountId: userId });
902
907
  } catch {
903
908
  return []; // Not Commons: this app holds no ring to carry.
904
909
  }
@@ -966,6 +971,10 @@ export class IdentityBackupApi {
966
971
  */
967
972
  private async keepCarriedRetiredKeys(payload: BackupPayload): Promise<number> {
968
973
  const { KeyManager, retireIdentityKey } = await import('../crypto/index');
974
+ // The backup is the signed-in account's (its phrase opened it): its keys
975
+ // are kept for that account only (R5-2).
976
+ const ownerAccountId = this.ctx.oxy.session.userId;
977
+ if (!ownerAccountId) throw new Error('No authenticated user — sign in before restoring.');
969
978
  let kept = 0;
970
979
  for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
971
980
  try {
@@ -976,7 +985,10 @@ export class IdentityBackupApi {
976
985
  ) {
977
986
  continue;
978
987
  }
979
- await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey });
988
+ await retireIdentityKey(
989
+ { privateKey: key.privateKey, publicKey: key.publicKey },
990
+ { ownerAccountId },
991
+ );
980
992
  kept += 1;
981
993
  } catch (error) {
982
994
  logger.warn(
@@ -92,6 +92,10 @@ export {
92
92
  retiredKeyFromPhrase,
93
93
  retiredKeyFromPrivateKey,
94
94
  exportRetiredIdentityKeys,
95
+ claimRetiredIdentityKeys,
96
+ countQuarantinedRetiredKeys,
97
+ unmirrorPersonalRetiredKeys,
98
+ RetiredKeyRingUnreadableError,
95
99
  } from './retiredKeys';
96
100
  export type { RetiredIdentityKey, RetiredKeyPair, RetiredKeySelector } from './retiredKeys';
97
101
  export { SignatureService } from './signatureService';
@@ -32,7 +32,7 @@ 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 { getRetiredPrivateKey, retiredKeyScope } from './retiredKeys';
35
+ import { getRetiredPrivateKey, retiredKeyScope, unmirrorPersonalRetiredKeys } from './retiredKeys';
36
36
  import {
37
37
  type KeychainItemClass,
38
38
  groupedKeychainStore,
@@ -1070,6 +1070,14 @@ export class KeyManager {
1070
1070
  * `signSocialReceive`) is fresh, so a cached key here would let a caller
1071
1071
  * compare a new seed against an old key.
1072
1072
  *
1073
+ * iOS, in an app that holds the private key (Commons, the Peable wallet:
1074
+ * keychain roles `holder` and `wallet`): DERIVED from the private key in the
1075
+ * wallet group, never read from the public-key item, which sits in the shared
1076
+ * group every Oxy app can write (R5-1); `null` when that app holds no private
1077
+ * key. Every other iOS app reads the public-key item: it holds nothing it
1078
+ * could derive from, and it never spends. Money code uses
1079
+ * {@link getIdentityPublicKeyForMoney}.
1080
+ *
1073
1081
  * @returns Shared public key or null if no shared identity exists
1074
1082
  */
1075
1083
  static async getSharedPublicKey(): Promise<string | null> {
@@ -1077,6 +1085,16 @@ export class KeyManager {
1077
1085
  return null;
1078
1086
  }
1079
1087
 
1088
+ if (isIOS() && (await KeyManager._holdsSpendingKeys())) {
1089
+ // Not cached: a rotation in Commons changes it under the wallet.
1090
+ try {
1091
+ const privateKey = await KeyManager.getSharedPrivateKey();
1092
+ return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
1093
+ } catch {
1094
+ return null;
1095
+ }
1096
+ }
1097
+
1080
1098
  // Return cached value if available (iOS only; see above).
1081
1099
  if (!isAndroid() && KeyManager.cachedSharedPublicKey !== null) {
1082
1100
  return KeyManager.cachedSharedPublicKey;
@@ -1111,6 +1129,70 @@ export class KeyManager {
1111
1129
  }
1112
1130
  }
1113
1131
 
1132
+ /**
1133
+ * Whether this iOS app holds spending keys (keychain role `holder` or
1134
+ * `wallet`), so a public key it uses for money must be derived from them.
1135
+ * Throws while the keychain cannot say. @internal
1136
+ */
1137
+ private static async _holdsSpendingKeys(): Promise<boolean> {
1138
+ const access = await resolveKeychainAccess();
1139
+ return access.mode === 'grouped' && access.role !== 'member';
1140
+ }
1141
+
1142
+ /**
1143
+ * THE public key of the identity a money holder (the Peable wallet, Commons)
1144
+ * receives and spends with: the personal key, or with `opts.accountId` that
1145
+ * organization's (ADR 0061 D6). It is the public half of exactly the private
1146
+ * key {@link deriveScopedSeed} and {@link signSocialReceive} use, so an
1147
+ * address shown from it is one this app can spend (R5-1).
1148
+ *
1149
+ * - iOS (Commons, the Peable wallet): DERIVED from the private key in the
1150
+ * restricted keychain groups (personal: the wallet group's slot, then
1151
+ * Commons' primary; organization: its pair). Never the shared group's
1152
+ * public-key item, which every Oxy app can write. `null` when no private
1153
+ * key is held, and in every other iOS app.
1154
+ * - Android: inside Commons derived from its own key; elsewhere Commons'
1155
+ * identity host (`describe`), which answers from Commons' own signer
1156
+ * store over signature-protected IPC.
1157
+ * - Web: `null`.
1158
+ *
1159
+ * Compare the answer with the account's published `#key-1` before showing a
1160
+ * receive address (Peable ADR 0002). Lowercase uncompressed (`04…`).
1161
+ */
1162
+ static async getIdentityPublicKeyForMoney(opts?: { accountId?: string }): Promise<string | null> {
1163
+ if (isWebPlatform()) {
1164
+ return null;
1165
+ }
1166
+ const accountId = opts?.accountId;
1167
+ if (accountId !== undefined) {
1168
+ if (!isOrganizationAccountId(accountId)) return null;
1169
+ if (isAndroid() && !KeyManager.identitySignerStore) {
1170
+ const bridge = await loadCommonsIdentityBridge();
1171
+ const description = (await bridge?.describe(accountId)) ?? null;
1172
+ return description?.accountId === accountId && description.publicKey
1173
+ ? KeyManager.canonicalPublicKey(description.publicKey)
1174
+ : null;
1175
+ }
1176
+ const organizationKey = await getOrganizationPrivateKey(accountId);
1177
+ return organizationKey ? KeyManager.derivePublicKey(organizationKey) : null;
1178
+ }
1179
+ if (isAndroid()) {
1180
+ if (KeyManager.identitySignerStore) {
1181
+ const local = await KeyManager.getPrivateKey();
1182
+ return local ? KeyManager.derivePublicKey(local) : null;
1183
+ }
1184
+ const bridge = await loadCommonsIdentityBridge();
1185
+ const publicKey = (await bridge?.describe())?.publicKey ?? null;
1186
+ return publicKey ? KeyManager.canonicalPublicKey(publicKey) : null;
1187
+ }
1188
+ if (!isIOS()) {
1189
+ return null;
1190
+ }
1191
+ const privateKey =
1192
+ (await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
1193
+ return privateKey ? KeyManager.derivePublicKey(privateKey) : null;
1194
+ }
1195
+
1114
1196
  /**
1115
1197
  * The shared private key, where this app may hold it: iOS only.
1116
1198
  *
@@ -2612,10 +2694,10 @@ export class KeyManager {
2612
2694
  await KeyManager._clearSharedSlot(store);
2613
2695
  // The retired ring is NOT cleared (R4-4): it is money under the person's
2614
2696
  // previous keys, and "Start over" or a forced delete must not cost it.
2615
- // The same person restoring their identity finds it again; a different
2616
- // identity on this phone cannot use it (each entry is checked against
2617
- // that account's key history before use). Only deleting the Oxy ACCOUNT
2618
- // clears it, explicitly (`forgetRetiredIdentityKeys`, Commons' flow).
2697
+ // Every personal entry is bound to its owner's account id (R5-2), so the
2698
+ // same person signing in again finds it, and a different person on this
2699
+ // phone never lists, derives from or signs with it. Only deleting the Oxy
2700
+ // ACCOUNT clears it, explicitly (`forgetRetiredIdentityKeys`).
2619
2701
  }
2620
2702
 
2621
2703
  // The device backup goes on EVERY delete, forced or not: it is restored
@@ -2624,8 +2706,19 @@ export class KeyManager {
2624
2706
  await KeyManager._clearDeviceBackup();
2625
2707
 
2626
2708
  // Same for the Android signer store: Commons' identity host must stop
2627
- // answering for a deleted identity, forced or not.
2709
+ // answering for a deleted identity, forced or not — its personal retired
2710
+ // keys included (R5-2): they stay in Commons' own ring, bound to their
2711
+ // owner, but the host must not hand them to the next person on the phone.
2628
2712
  await KeyManager._clearIdentitySigner();
2713
+ try {
2714
+ await unmirrorPersonalRetiredKeys();
2715
+ } catch (error) {
2716
+ logger.warn(
2717
+ 'Failed to withdraw the personal retired keys from the identity host',
2718
+ { component: 'KeyManager' },
2719
+ error,
2720
+ );
2721
+ }
2629
2722
 
2630
2723
  // Clear the marker AFTER key deletion succeeds — a marker must never outlive
2631
2724
  // its identity (a leftover marker would route a truly-absent device to
@@ -3238,17 +3331,22 @@ export class KeyManager {
3238
3331
  * (personal, or `accountId`'s) whose public key it names: the current key
3239
3332
  * when it matches, else a RETIRED key Commons kept after a rotation or
3240
3333
  * replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
3241
- * retired key never signs in. Never falls back to another key.
3334
+ * retired key never signs in. Never falls back to another key. A PERSONAL
3335
+ * retired key is reached only with `opts.ownerAccountId`, the signed-in
3336
+ * person's account id, and only when the ring keeps it for that account
3337
+ * (R5-2); on Android outside Commons the identity host holds only the
3338
+ * personal keys of the person Commons is signed in as.
3242
3339
  *
3243
3340
  * @param info Context/domain-binding label (distinct labels → independent seeds).
3244
3341
  * @param opts.accountId An organization's account id, for its key.
3245
3342
  * @param opts.publicKey Selects one key of that account by its public key.
3343
+ * @param opts.ownerAccountId The person's account id, for a personal retired key.
3246
3344
  * @returns 32 bytes of derived keying material, or `null` on web / when no
3247
3345
  * identity key is available to this app.
3248
3346
  */
3249
3347
  static async deriveScopedSeed(
3250
3348
  info: string,
3251
- opts?: { accountId?: string; publicKey?: string },
3349
+ opts?: { accountId?: string; publicKey?: string; ownerAccountId?: string },
3252
3350
  ): Promise<Uint8Array | null> {
3253
3351
  if (isWebPlatform()) {
3254
3352
  return null;
@@ -3262,7 +3360,7 @@ export class KeyManager {
3262
3360
  (await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
3263
3361
  return seed ? hexToBytes(seed) : null;
3264
3362
  }
3265
- const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
3363
+ const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
3266
3364
  return selected ? deriveScopedSeedFromKey(selected, info) : null;
3267
3365
  }
3268
3366
  if (opts?.accountId !== undefined) {
@@ -3326,7 +3424,7 @@ export class KeyManager {
3326
3424
  static async signSocialReceive(
3327
3425
  index: number,
3328
3426
  digest: string,
3329
- opts?: { accountId?: string; publicKey?: string },
3427
+ opts?: { accountId?: string; publicKey?: string; ownerAccountId?: string },
3330
3428
  ): Promise<CommonsSocialReceiveSignature | null> {
3331
3429
  if (isWebPlatform()) {
3332
3430
  return null;
@@ -3341,7 +3439,7 @@ export class KeyManager {
3341
3439
  null
3342
3440
  );
3343
3441
  }
3344
- const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
3442
+ const selected = await KeyManager._selectedPrivateKey({ ...opts, publicKey: opts.publicKey });
3345
3443
  return selected ? signSocialReceiveDigest(selected, index, digest) : null;
3346
3444
  }
3347
3445
  const accountId = opts?.accountId;
@@ -3382,10 +3480,12 @@ export class KeyManager {
3382
3480
  return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
3383
3481
  }
3384
3482
 
3385
- private static async _selectedPrivateKey(
3386
- publicKey: string,
3387
- accountId?: string,
3388
- ): Promise<string | null> {
3483
+ private static async _selectedPrivateKey(opts: {
3484
+ publicKey: string;
3485
+ accountId?: string;
3486
+ ownerAccountId?: string;
3487
+ }): Promise<string | null> {
3488
+ const { publicKey, accountId, ownerAccountId } = opts;
3389
3489
  if (retiredKeyScope({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
3390
3490
  return null;
3391
3491
  }
@@ -3399,7 +3499,14 @@ export class KeyManager {
3399
3499
  if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
3400
3500
  return current;
3401
3501
  }
3402
- return getRetiredPrivateKey(wanted, accountId !== undefined ? { accountId } : undefined);
3502
+ return getRetiredPrivateKey(
3503
+ wanted,
3504
+ accountId !== undefined
3505
+ ? { accountId }
3506
+ : ownerAccountId !== undefined
3507
+ ? { ownerAccountId }
3508
+ : undefined,
3509
+ );
3403
3510
  }
3404
3511
 
3405
3512
  /**