@oxy.so/core 7.29.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.
@@ -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,8 +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.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.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 = 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;
17
17
  // MUST stay the first import: the shim has to exist before any module below
18
18
  // evaluates or draws randomness (see `./polyfill`).
19
19
  require("./polyfill");
@@ -55,6 +55,15 @@ Object.defineProperty(exports, "deleteThroughPlan", { enumerable: true, get: fun
55
55
  Object.defineProperty(exports, "groupedKeychainStore", { enumerable: true, get: function () { return keychainAccessGroups_1.groupedKeychainStore; } });
56
56
  var keychainGroupMigration_1 = require("./keychainGroupMigration");
57
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; } });
58
67
  var signatureService_1 = require("./signatureService");
59
68
  Object.defineProperty(exports, "SignatureService", { enumerable: true, get: function () { return signatureService_1.SignatureService; } });
60
69
  var recoveryPhrase_1 = require("./recoveryPhrase");
@@ -47,6 +47,7 @@ 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");
50
51
  const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
51
52
  const keychainGroupMigration_1 = require("./keychainGroupMigration");
52
53
  const keychainItems_1 = require("./keychainItems");
@@ -2088,6 +2089,13 @@ class KeyManager {
2088
2089
  if (force) {
2089
2090
  await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
2090
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
+ }
2091
2099
  }
2092
2100
  // The device backup goes on EVERY delete, forced or not: it is restored
2093
2101
  // silently, so leaving it would bring a deleted identity back on the next
@@ -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
@@ -0,0 +1,371 @@
1
+ "use strict";
2
+ /**
3
+ * Retired identity keys: the keys an account HAD, kept on the device for
4
+ * spending only (oxy ADR 0061 D7 as amended 2026-11-06, Peable ADR 0002).
5
+ *
6
+ * The Peable wallet derives from the account's identity key (`deriveScopedSeed`
7
+ * for its HD wallet, `signSocialReceive` for its social-receive addresses), and
8
+ * coins keep arriving under a key after it is replaced: payments already
9
+ * broadcast settle against the key they were reserved under, and late payers
10
+ * and `@username` senders hold old addresses. So a personal rotation
11
+ * (`oxy.identity.rotateKey`) and an organization replacement keep the replaced
12
+ * private key here, in a ring per account — `personal`, and one per
13
+ * organization account id — instead of deleting it.
14
+ *
15
+ * A retired key is NEVER a way to sign in, publish, receive a new reservation or
16
+ * rotate: nothing here signs a challenge, and the identity host refuses
17
+ * `proveIdentity` / `describe` for one. It is reached exactly as the current key
18
+ * is, by derivation and never by export, through the `publicKey` selector of
19
+ * `KeyManager.deriveScopedSeed` / `signSocialReceive`:
20
+ *
21
+ * - iOS: the private key in the wallet keychain group (ADR 0062's
22
+ * `spending-key`: Commons and the Peable wallet only), beside the current
23
+ * private keys; the ring's index (public keys and dates only) in the same
24
+ * group, so the wallet can list it. Only Commons writes either.
25
+ * - Android: Commons' own expo-secure-store slot, mirrored into the identity
26
+ * signer store (`./identitySigner`) that Commons' identity host answers
27
+ * `keyPublicKey` requests from (wallet only). Every other app asks over IPC
28
+ * (`loadCommonsIdentityBridge`) and never gets the key.
29
+ *
30
+ * A retired phrase is not kept: the person wrote it down, and a phrase is a
31
+ * second copy of the key that buys the wallet nothing.
32
+ *
33
+ * A key enters the ring only from Commons: the key being replaced (rotation,
34
+ * organization replacement), or a previous recovery phrase the person adds,
35
+ * which Commons accepts only when Oxy's key history names it as one of the
36
+ * account's retired keys. Never into the current slot.
37
+ *
38
+ * Security: a retired key that was compromised is also the attacker's. The
39
+ * ring makes the coins under it spendable from Peable again; it cannot make
40
+ * them safe. A rotation forced by a compromise must be followed at once by the
41
+ * wallet's "move to your current key" sweep, which races the attacker.
42
+ *
43
+ * This module imports nothing from `./keyManager` (which imports it).
44
+ */
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.retiredKeyScope = retiredKeyScope;
47
+ exports.retireIdentityKey = retireIdentityKey;
48
+ exports.listRetiredIdentityKeys = listRetiredIdentityKeys;
49
+ exports.getRetiredPrivateKey = getRetiredPrivateKey;
50
+ exports.forgetRetiredIdentityKey = forgetRetiredIdentityKey;
51
+ exports.forgetRetiredIdentityKeys = forgetRetiredIdentityKeys;
52
+ exports.syncRetiredKeys = syncRetiredKeys;
53
+ exports.retiredKeyFromPhrase = retiredKeyFromPhrase;
54
+ const bip39_1 = require("@scure/bip39");
55
+ const english_js_1 = require("@scure/bip39/wordlists/english.js");
56
+ const protocol_1 = require("@oxy.so/protocol");
57
+ const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
58
+ const logger_1 = require("../logger");
59
+ const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
60
+ const platform_1 = require("../utils/platform");
61
+ const identitySigner_1 = require("./identitySigner");
62
+ const keychainGroupMigration_1 = require("./keychainGroupMigration");
63
+ const organizationKeys_1 = require("./organizationKeys");
64
+ const PERSONAL_SCOPE = 'personal';
65
+ /** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
66
+ const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
67
+ const INDEX_KEYCHAIN_SERVICE = 'oxy_retired_identity_index';
68
+ const INDEX_STORAGE_KEY = 'oxy_retired_identity_index_v1';
69
+ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
70
+ /**
71
+ * The secure store for the ring. On iOS every item is a `spending-key`
72
+ * (ADR 0062): the private keys AND the index live in the wallet group, which
73
+ * only Commons and the Peable wallet can read, never the shared one. In
74
+ * Commons every access first awaits the access-group migration. On Android
75
+ * the grouping is a no-op and Commons' own services below apply.
76
+ */
77
+ async function ringStore() {
78
+ const raw = await (0, protocol_1.loadSecureStore)();
79
+ const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
80
+ if (access.mode === 'grouped' && access.role === 'holder') {
81
+ await (0, keychainGroupMigration_1.ensureKeychainGroupsMigrated)();
82
+ }
83
+ return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, 'spending-key');
84
+ }
85
+ /** The ring a selector names: `personal`, or the organization's account id. */
86
+ function retiredKeyScope(selector) {
87
+ const accountId = selector?.accountId;
88
+ if (accountId === undefined)
89
+ return PERSONAL_SCOPE;
90
+ return (0, organizationKeys_1.isOrganizationAccountId)(accountId) ? accountId : null;
91
+ }
92
+ function canonicalPublicKey(publicKey) {
93
+ try {
94
+ const normalized = (0, secp256k1_1.normalizeSecp256k1PublicKey)(publicKey.trim());
95
+ return PUBLIC_KEY.test(normalized) ? normalized : null;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ function publicKeyOf(privateKey) {
102
+ return (0, secp256k1_1.deriveSecp256k1PublicKey)((0, secp256k1_1.normalizeSecp256k1PrivateKey)(privateKey));
103
+ }
104
+ function isHealthyPair(privateKey, publicKey) {
105
+ if (!(0, secp256k1_1.isValidSecp256k1PrivateKey)(privateKey) || !(0, secp256k1_1.isValidSecp256k1PublicKey)(publicKey)) {
106
+ return false;
107
+ }
108
+ try {
109
+ return publicKeyOf(privateKey) === (0, secp256k1_1.normalizeSecp256k1PublicKey)(publicKey);
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
115
+ const privateKeyName = (scope, publicKey) => `oxy_retired_identity_private_key.${scope}.${publicKey}`;
116
+ /** The options of a private half: on iOS none but accessibility (the group comes from {@link ringStore}). */
117
+ function privateOptions(store, forWrite) {
118
+ if ((0, platform_1.isIOS)()) {
119
+ return forWrite ? { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY } : {};
120
+ }
121
+ return forWrite
122
+ ? {
123
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
124
+ keychainService: ANDROID_KEYCHAIN_SERVICE,
125
+ }
126
+ : { keychainService: ANDROID_KEYCHAIN_SERVICE };
127
+ }
128
+ /** The index: public keys and dates, beside the private halves. */
129
+ function indexOptions() {
130
+ return (0, platform_1.isIOS)() ? {} : { keychainService: INDEX_KEYCHAIN_SERVICE };
131
+ }
132
+ function isIndexEntry(value) {
133
+ if (typeof value !== 'object' || value === null)
134
+ return false;
135
+ const entry = value;
136
+ return (typeof entry.scope === 'string' &&
137
+ (entry.scope === PERSONAL_SCOPE || (0, organizationKeys_1.isOrganizationAccountId)(entry.scope)) &&
138
+ typeof entry.publicKey === 'string' &&
139
+ PUBLIC_KEY.test(entry.publicKey) &&
140
+ typeof entry.retiredAt === 'number' &&
141
+ Number.isFinite(entry.retiredAt));
142
+ }
143
+ async function readIndex(store) {
144
+ const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
145
+ if (!raw)
146
+ return [];
147
+ try {
148
+ const parsed = JSON.parse(raw);
149
+ return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
150
+ }
151
+ catch (error) {
152
+ logger_1.logger.warn('Retired key index unreadable; treating as empty', {
153
+ component: 'RetiredKeys',
154
+ error: error instanceof Error ? error.name : 'unknown',
155
+ });
156
+ return [];
157
+ }
158
+ }
159
+ async function writeIndex(store, entries) {
160
+ const unique = new Map();
161
+ for (const entry of entries) {
162
+ const id = `${entry.scope}.${entry.publicKey}`;
163
+ if (!unique.has(id))
164
+ unique.set(id, entry);
165
+ }
166
+ const sorted = [...unique.values()].sort((a, b) => a.retiredAt - b.retiredAt || a.publicKey.localeCompare(b.publicKey));
167
+ await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify(sorted), indexOptions());
168
+ }
169
+ function toListed(entry) {
170
+ return entry.scope === PERSONAL_SCOPE
171
+ ? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
172
+ : { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
173
+ }
174
+ /** Whether this app writes the ring: Commons (Android: the signer store it registers; iOS: native). */
175
+ function assertMayWrite() {
176
+ if ((0, platform_1.isWeb)())
177
+ throw new Error('retired key: only a native device holds one');
178
+ if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()?.writeRetired) {
179
+ throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
180
+ }
181
+ }
182
+ /**
183
+ * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
184
+ * key already kept stays, with its first date. Refuses a pair whose public key
185
+ * does not derive from its private key. Resolves once every copy confirmed it
186
+ * (Android: the signer store's read-back too), and throws otherwise, so a
187
+ * caller about to replace the key can stop before anything is lost.
188
+ */
189
+ async function retireIdentityKey(key, selector) {
190
+ const scope = retiredKeyScope(selector);
191
+ if (!scope)
192
+ throw new Error('retired key: accountId must be a 24-hex or uuid account id');
193
+ assertMayWrite();
194
+ if (!isHealthyPair(key.privateKey, key.publicKey)) {
195
+ throw new Error('retired key: the public key does not derive from the private key');
196
+ }
197
+ const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(key.privateKey);
198
+ const publicKey = (0, secp256k1_1.normalizeSecp256k1PublicKey)(key.publicKey);
199
+ const store = await ringStore();
200
+ await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
201
+ const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
202
+ if (readBack !== privateKey) {
203
+ throw new Error('retired key: the keychain did not confirm the key');
204
+ }
205
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
206
+ if (signer?.writeRetired && !(await signer.writeRetired(scope, privateKey, publicKey))) {
207
+ throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
208
+ }
209
+ const index = await readIndex(store);
210
+ if (!index.some((entry) => entry.scope === scope && entry.publicKey === publicKey)) {
211
+ await writeIndex(store, [...index, { scope, publicKey, retiredAt: Date.now() }]);
212
+ }
213
+ }
214
+ /**
215
+ * The retired keys of `selector`'s account this app can reach, oldest first.
216
+ * iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
217
+ * (the wallet only), which leaves out the scope's current key. After an
218
+ * interrupted rotation the local list can include the current key: compare
219
+ * with the DID's `#key-1` before treating an entry as retired.
220
+ */
221
+ async function listRetiredIdentityKeys(selector) {
222
+ const scope = retiredKeyScope(selector);
223
+ if (!scope || (0, platform_1.isWeb)())
224
+ return [];
225
+ try {
226
+ if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()) {
227
+ const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
228
+ const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
229
+ return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
230
+ ? { publicKey, retiredAt: 0 }
231
+ : { publicKey, accountId: scope, retiredAt: 0 });
232
+ }
233
+ if (!(0, platform_1.isIOS)() && !(0, platform_1.isAndroid)())
234
+ return [];
235
+ const store = await ringStore();
236
+ return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
237
+ }
238
+ catch (error) {
239
+ logger_1.logger.warn('Failed to list retired keys', {
240
+ component: 'RetiredKeys',
241
+ error: error instanceof Error ? error.name : 'unknown',
242
+ });
243
+ return [];
244
+ }
245
+ }
246
+ /**
247
+ * The private half of a retired key, where this app may hold it: Commons (both
248
+ * platforms) and, on iOS, the keychain group. `null` on Android outside Commons
249
+ * (ask the identity host instead) and for a key not in the ring. Sign within
250
+ * the app only; never transmit it. @internal — `KeyManager` reads it for the
251
+ * `publicKey` selector.
252
+ */
253
+ async function getRetiredPrivateKey(publicKey, selector) {
254
+ const scope = retiredKeyScope(selector);
255
+ const canonical = canonicalPublicKey(publicKey);
256
+ if (!scope || !canonical || (0, platform_1.isWeb)())
257
+ return null;
258
+ try {
259
+ if ((0, platform_1.isAndroid)()) {
260
+ const signer = (0, identitySigner_1.registeredIdentitySignerStore)();
261
+ if (!signer)
262
+ return null;
263
+ const store = await ringStore();
264
+ const local = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
265
+ if (local && isHealthyPair(local, canonical))
266
+ return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
267
+ // The signer store is a rung: a wiped expo-secure-store slot still has it.
268
+ const mirrored = (await signer.readRetired?.(scope, canonical)) ?? null;
269
+ return mirrored && isHealthyPair(mirrored.privateKey, canonical)
270
+ ? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(mirrored.privateKey)
271
+ : null;
272
+ }
273
+ if (!(0, platform_1.isIOS)())
274
+ return null;
275
+ const store = await ringStore();
276
+ const value = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
277
+ return value && isHealthyPair(value, canonical) ? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(value) : null;
278
+ }
279
+ catch (error) {
280
+ logger_1.logger.warn('Failed to read a retired key', {
281
+ component: 'RetiredKeys',
282
+ error: error instanceof Error ? error.name : 'unknown',
283
+ });
284
+ return null;
285
+ }
286
+ }
287
+ /**
288
+ * Forget a retired key on this device: its private half, its signer copy and
289
+ * its index entry. Commons only, and only on the person's explicit request
290
+ * (their coins under it are then spendable only with its phrase again).
291
+ */
292
+ async function forgetRetiredIdentityKey(publicKey, selector) {
293
+ const scope = retiredKeyScope(selector);
294
+ const canonical = canonicalPublicKey(publicKey);
295
+ if (!scope || !canonical)
296
+ throw new Error('retired key: invalid selector or public key');
297
+ assertMayWrite();
298
+ const store = await ringStore();
299
+ await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
300
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
301
+ await signer?.clearRetired?.(scope, canonical);
302
+ await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
303
+ }
304
+ /**
305
+ * Forget every retired key of one ring (the person's, or an organization's):
306
+ * called when the identity itself is deleted from this device, so a phone
307
+ * handed on keeps none of the person's old keys. Commons only. Resolves how
308
+ * many it removed.
309
+ */
310
+ async function forgetRetiredIdentityKeys(selector) {
311
+ const scope = retiredKeyScope(selector);
312
+ if (!scope || (0, platform_1.isWeb)())
313
+ return 0;
314
+ if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)())
315
+ return 0;
316
+ const store = await ringStore();
317
+ const index = await readIndex(store);
318
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
319
+ let removed = 0;
320
+ for (const entry of index) {
321
+ if (entry.scope !== scope)
322
+ continue;
323
+ await store.deleteItemAsync(privateKeyName(scope, entry.publicKey), privateOptions(store, false));
324
+ await signer?.clearRetired?.(scope, entry.publicKey);
325
+ removed += 1;
326
+ }
327
+ await writeIndex(store, index.filter((entry) => entry.scope !== scope));
328
+ return removed;
329
+ }
330
+ /**
331
+ * Commons on Android: copy every retired key into the identity signer store
332
+ * again, so the identity host answers for it after that file was rebuilt (its
333
+ * self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
334
+ * Resolves how many it mirrored. Elsewhere 0.
335
+ */
336
+ async function syncRetiredKeys() {
337
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
338
+ if (!signer?.writeRetired || !signer.readRetired)
339
+ return 0;
340
+ const store = await ringStore();
341
+ let mirrored = 0;
342
+ for (const entry of await readIndex(store)) {
343
+ const privateKey = await store.getItemAsync(privateKeyName(entry.scope, entry.publicKey), privateOptions(store, false));
344
+ if (!privateKey || !isHealthyPair(privateKey, entry.publicKey))
345
+ continue;
346
+ const current = await signer.readRetired(entry.scope, entry.publicKey);
347
+ if (current?.publicKey.toLowerCase() === entry.publicKey)
348
+ continue;
349
+ if (await signer.writeRetired(entry.scope, (0, secp256k1_1.normalizeSecp256k1PrivateKey)(privateKey), entry.publicKey)) {
350
+ mirrored += 1;
351
+ }
352
+ }
353
+ return mirrored;
354
+ }
355
+ /**
356
+ * The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
357
+ * and organization-key derivation; 12–24 words). Stores nothing: the caller
358
+ * checks the public key against the account's key history (personal:
359
+ * `oxy.identity.keyHistory()`; organization: `oxy.accounts.identityKey.get`)
360
+ * and only then calls {@link retireIdentityKey}.
361
+ */
362
+ async function retiredKeyFromPhrase(phrase) {
363
+ const normalized = phrase.trim().toLowerCase().split(/\s+/).join(' ');
364
+ const words = normalized.split(' ').length;
365
+ if (words < 12 || words > 24 || words % 3 !== 0 || !(0, bip39_1.validateMnemonic)(normalized, english_js_1.wordlist)) {
366
+ throw new Error('retired key: not a valid recovery phrase');
367
+ }
368
+ const seed = await (0, bip39_1.mnemonicToSeed)(normalized);
369
+ const privateKey = Array.from(seed.subarray(0, 32), (b) => b.toString(16).padStart(2, '0')).join('');
370
+ return { privateKey, publicKey: publicKeyOf(privateKey) };
371
+ }