@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.
- package/dist/cjs/api/identity.js +40 -1
- package/dist/cjs/crypto/index.js +11 -2
- package/dist/cjs/crypto/keyManager.js +69 -1
- package/dist/cjs/crypto/retiredKeys.js +371 -0
- package/dist/esm/api/identity.js +40 -1
- package/dist/esm/crypto/index.js +3 -0
- package/dist/esm/crypto/keyManager.js +69 -1
- package/dist/esm/crypto/retiredKeys.js +361 -0
- package/dist/types/api/identity.d.ts +10 -0
- package/dist/types/crypto/identitySigner.d.ts +23 -0
- package/dist/types/crypto/index.d.ts +2 -0
- package/dist/types/crypto/keyManager.d.ts +23 -1
- package/dist/types/crypto/retiredKeys.d.ts +116 -0
- package/package.json +3 -3
- package/src/api/identity.ts +46 -3
- package/src/crypto/identitySigner.ts +23 -0
- package/src/crypto/index.ts +11 -0
- package/src/crypto/keyManager.ts +81 -3
- package/src/crypto/retiredKeys.ts +453 -0
package/dist/cjs/api/identity.js
CHANGED
|
@@ -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). */
|
package/dist/cjs/crypto/index.js
CHANGED
|
@@ -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.
|
|
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).
|
|
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
|
+
}
|