@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/esm/api/identity.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* AEAD, signatures) load on first use, never with the client itself.
|
|
19
19
|
*/
|
|
20
20
|
import { checkContract, IDENTITY_PROOF_ACTIONS, identityLinkCreateResponseCheck, identityLinkStateCheck, } from '@oxy.so/contracts/client';
|
|
21
|
+
import { identityKeyHistoryCheck } from '@oxy.so/contracts/client';
|
|
21
22
|
import { isAndroid, isWeb } from '../utils/platform.js';
|
|
22
23
|
import { logger } from '../logger/index.js';
|
|
23
24
|
/** Short-TTL read cache for public identity reads. */
|
|
@@ -158,7 +159,7 @@ export class IdentityApi {
|
|
|
158
159
|
if (!userId) {
|
|
159
160
|
throw new Error('No authenticated user — sign in before rotating your key.');
|
|
160
161
|
}
|
|
161
|
-
const { KeyManager, RecoveryPhraseService, SignatureService, signMessage } = await import('../crypto/index.js');
|
|
162
|
+
const { KeyManager, RecoveryPhraseService, SignatureService, signMessage, retireIdentityKey } = await import('../crypto/index.js');
|
|
162
163
|
// 1. The NEW identity (in memory only). The UI may pre-derive + pre-show
|
|
163
164
|
// it and pass it back here so the phrase shown === the phrase committed.
|
|
164
165
|
const pending = options.pendingIdentity ?? (await RecoveryPhraseService.derivePendingIdentity());
|
|
@@ -166,6 +167,7 @@ export class IdentityApi {
|
|
|
166
167
|
// 2. Resolve the OLD signing capability from the chosen proof mode.
|
|
167
168
|
let oldPublicKey;
|
|
168
169
|
let signWithOldKey;
|
|
170
|
+
let oldPrivateKeyForRing;
|
|
169
171
|
if (options.proof === 'phrase') {
|
|
170
172
|
const phrase = options.phrase?.trim();
|
|
171
173
|
if (!phrase) {
|
|
@@ -174,6 +176,7 @@ export class IdentityApi {
|
|
|
174
176
|
const oldPrivateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
|
|
175
177
|
oldPublicKey = KeyManager.derivePublicKey(oldPrivateKey);
|
|
176
178
|
signWithOldKey = (message) => signMessage(message, oldPrivateKey);
|
|
179
|
+
oldPrivateKeyForRing = oldPrivateKey;
|
|
177
180
|
}
|
|
178
181
|
else {
|
|
179
182
|
const currentPublicKey = await KeyManager.getPublicKey();
|
|
@@ -182,6 +185,25 @@ export class IdentityApi {
|
|
|
182
185
|
}
|
|
183
186
|
oldPublicKey = currentPublicKey;
|
|
184
187
|
signWithOldKey = (message) => SignatureService.sign(message);
|
|
188
|
+
oldPrivateKeyForRing = isWeb() ? null : await KeyManager.getPrivateKey();
|
|
189
|
+
}
|
|
190
|
+
// 2b. Keep the key being replaced in the retired ring BEFORE anything
|
|
191
|
+
// changes (oxy ADR 0061 D7 as amended, Peable ADR 0002): coins keep
|
|
192
|
+
// arriving under it after the swap, and the Peable wallet spends them
|
|
193
|
+
// through the `publicKey` selector of `deriveScopedSeed` /
|
|
194
|
+
// `signSocialReceive`. A device that cannot keep it does not rotate:
|
|
195
|
+
// the phrase would become the only way to those coins. Native only —
|
|
196
|
+
// on web no key is stored. Idempotent, so a retried rotation is fine.
|
|
197
|
+
if (!isWeb()) {
|
|
198
|
+
try {
|
|
199
|
+
if (!oldPrivateKeyForRing)
|
|
200
|
+
throw new Error('no readable private key');
|
|
201
|
+
await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
|
|
202
|
+
}
|
|
203
|
+
catch (retainError) {
|
|
204
|
+
logger.warn('Could not keep the current key as a retired key; not rotating.', { component: 'oxy.identity', method: 'rotateKey' }, retainError);
|
|
205
|
+
throw new Error('Your current key could not be kept on this device, so it was not rotated. Nothing changed.');
|
|
206
|
+
}
|
|
185
207
|
}
|
|
186
208
|
// 3. Request a single-use rotate_key challenge (bearer).
|
|
187
209
|
const { challenge } = await this.ctx.request('POST', '/auth/rotate/challenge', undefined, {
|
|
@@ -338,6 +360,23 @@ export class IdentityApi {
|
|
|
338
360
|
keys.push(`GET:/u/${encodeURIComponent(userId)}/did.json`);
|
|
339
361
|
this.ctx.http.invalidateCache({ keys, prefixes: ['GET:/users/me'] });
|
|
340
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* The signed-in personal account's current root (`null` without one) and
|
|
365
|
+
* every root a rotation replaced, oldest first, all canonical (uncompressed,
|
|
366
|
+
* lowercase). Commons reads it to accept a previous recovery phrase only when
|
|
367
|
+
* it derives a key that WAS this account's root. A retired root is never any
|
|
368
|
+
* account's again. First-party only; a non-personal account is refused
|
|
369
|
+
* (`IDENTITY_NOT_PERSONAL_ACCOUNT`).
|
|
370
|
+
*/
|
|
371
|
+
async keyHistory() {
|
|
372
|
+
const res = await this.ctx.request('GET', '/identity/key-history', undefined, {
|
|
373
|
+
cache: false,
|
|
374
|
+
});
|
|
375
|
+
const parsed = checkContract(identityKeyHistoryCheck, res);
|
|
376
|
+
if (!parsed)
|
|
377
|
+
throw new Error('identity/key-history returned an unexpected response shape');
|
|
378
|
+
return parsed;
|
|
379
|
+
}
|
|
341
380
|
}
|
|
342
381
|
/** `oxy.identity.links` — joining Commons' root to an account (ADR 0029 D3). */
|
|
343
382
|
export class IdentityLinksApi {
|
package/dist/esm/crypto/index.js
CHANGED
|
@@ -20,6 +20,9 @@ export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKe
|
|
|
20
20
|
// iOS keychain access groups (docs/identity/ios-keychain-access-groups.md, ADR 0062)
|
|
21
21
|
export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
|
|
22
22
|
export { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
|
|
23
|
+
// Retired identity keys: kept for spending after a rotation or replacement
|
|
24
|
+
// (docs/adr/0061-organization-identity-key.md D7 as amended 2026-11-06)
|
|
25
|
+
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, } from './retiredKeys.js';
|
|
23
26
|
export { SignatureService } from './signatureService.js';
|
|
24
27
|
export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase.js';
|
|
25
28
|
// Low-level primitives (encrypted backup, device transfer)
|
|
@@ -11,6 +11,7 @@ import { isDev, logger } from '../logger/index.js';
|
|
|
11
11
|
import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations.js';
|
|
12
12
|
import { registerIdentitySignerStore } from './identitySigner.js';
|
|
13
13
|
import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys.js';
|
|
14
|
+
import { forgetRetiredIdentityKeys, getRetiredPrivateKey, retiredKeyScope } from './retiredKeys.js';
|
|
14
15
|
import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
|
|
15
16
|
import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
|
|
16
17
|
import { RECOVERY_MNEMONIC_KEYCHAIN_SERVICE, RECOVERY_MNEMONIC_STORAGE_KEY, STORAGE_KEYS, V2_BACKUP_KEYCHAIN_SERVICE, V2_PRIMARY_KEYCHAIN_SERVICE, V2_STORAGE_KEYS, } from './keychainItems.js';
|
|
@@ -2049,6 +2050,13 @@ export class KeyManager {
|
|
|
2049
2050
|
if (force) {
|
|
2050
2051
|
await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
|
|
2051
2052
|
await KeyManager._clearSharedSlot(store);
|
|
2053
|
+
// The person's retired keys go with the identity they belonged to.
|
|
2054
|
+
try {
|
|
2055
|
+
await forgetRetiredIdentityKeys();
|
|
2056
|
+
}
|
|
2057
|
+
catch (error) {
|
|
2058
|
+
logger.warn('Failed to clear retired keys during delete', { component: 'KeyManager' }, error);
|
|
2059
|
+
}
|
|
2052
2060
|
}
|
|
2053
2061
|
// The device backup goes on EVERY delete, forced or not: it is restored
|
|
2054
2062
|
// silently, so leaving it would bring a deleted identity back on the next
|
|
@@ -2581,8 +2589,15 @@ export class KeyManager {
|
|
|
2581
2589
|
* keychain group, or Commons' identity host. It never falls back to the
|
|
2582
2590
|
* personal key.
|
|
2583
2591
|
*
|
|
2592
|
+
* With `opts.publicKey` the seed is derived from the key of that account
|
|
2593
|
+
* (personal, or `accountId`'s) whose public key it names: the current key
|
|
2594
|
+
* when it matches, else a RETIRED key Commons kept after a rotation or
|
|
2595
|
+
* replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
|
|
2596
|
+
* retired key never signs in. Never falls back to another key.
|
|
2597
|
+
*
|
|
2584
2598
|
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
2585
2599
|
* @param opts.accountId An organization's account id, for its key.
|
|
2600
|
+
* @param opts.publicKey Selects one key of that account by its public key.
|
|
2586
2601
|
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
2587
2602
|
* identity key is available to this app.
|
|
2588
2603
|
*/
|
|
@@ -2590,6 +2605,18 @@ export class KeyManager {
|
|
|
2590
2605
|
if (isWebPlatform()) {
|
|
2591
2606
|
return null;
|
|
2592
2607
|
}
|
|
2608
|
+
if (opts?.publicKey !== undefined) {
|
|
2609
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
2610
|
+
const selected = KeyManager._selectorKey(opts.publicKey);
|
|
2611
|
+
if (!selected)
|
|
2612
|
+
return null;
|
|
2613
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
2614
|
+
const seed = (await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
|
|
2615
|
+
return seed ? hexToBytes(seed) : null;
|
|
2616
|
+
}
|
|
2617
|
+
const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
|
|
2618
|
+
return selected ? deriveScopedSeedFromKey(selected, info) : null;
|
|
2619
|
+
}
|
|
2593
2620
|
if (opts?.accountId !== undefined) {
|
|
2594
2621
|
return KeyManager._deriveOrganizationScopedSeed(info, opts.accountId);
|
|
2595
2622
|
}
|
|
@@ -2635,7 +2662,8 @@ export class KeyManager {
|
|
|
2635
2662
|
* compressed public key for the scriptSig.
|
|
2636
2663
|
*
|
|
2637
2664
|
* Key source: as {@link deriveScopedSeed}, including `opts.accountId` for an
|
|
2638
|
-
* organization's key (ADR 0061 D6).
|
|
2665
|
+
* organization's key (ADR 0061 D6) and `opts.publicKey` for one key of the
|
|
2666
|
+
* account, current or retired. On Android outside Commons the signature
|
|
2639
2667
|
* comes from Commons, which allows it for the wallet app only.
|
|
2640
2668
|
*
|
|
2641
2669
|
* @returns the signature, or `null` on web / when no identity key is
|
|
@@ -2645,6 +2673,18 @@ export class KeyManager {
|
|
|
2645
2673
|
if (isWebPlatform()) {
|
|
2646
2674
|
return null;
|
|
2647
2675
|
}
|
|
2676
|
+
if (opts?.publicKey !== undefined) {
|
|
2677
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
2678
|
+
const selected = KeyManager._selectorKey(opts.publicKey);
|
|
2679
|
+
if (!selected)
|
|
2680
|
+
return null;
|
|
2681
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
2682
|
+
return ((await bridge?.signRetiredSocialReceive?.(index, digest, selected, opts.accountId)) ??
|
|
2683
|
+
null);
|
|
2684
|
+
}
|
|
2685
|
+
const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
|
|
2686
|
+
return selected ? signSocialReceiveDigest(selected, index, digest) : null;
|
|
2687
|
+
}
|
|
2648
2688
|
const accountId = opts?.accountId;
|
|
2649
2689
|
if (accountId !== undefined) {
|
|
2650
2690
|
// An organization key (ADR 0061 D6); never the personal one in its place.
|
|
@@ -2668,6 +2708,34 @@ export class KeyManager {
|
|
|
2668
2708
|
: ((await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey()));
|
|
2669
2709
|
return local ? signSocialReceiveDigest(local, index, digest) : null;
|
|
2670
2710
|
}
|
|
2711
|
+
/**
|
|
2712
|
+
* The private key of the account (personal, or `accountId`'s organization)
|
|
2713
|
+
* whose public key is `publicKey`, from this app's own storage: the current
|
|
2714
|
+
* key when it matches, else the retired ring. iOS and Commons only; `null`
|
|
2715
|
+
* for a key this device does not hold. Never another key in its place. @internal
|
|
2716
|
+
*/
|
|
2717
|
+
/**
|
|
2718
|
+
* The selector in the identity host's encoding (lowercase uncompressed), so a
|
|
2719
|
+
* compressed `#key-1` from a DID or a gateway row selects the same key. @internal
|
|
2720
|
+
*/
|
|
2721
|
+
static _selectorKey(publicKey) {
|
|
2722
|
+
return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
|
|
2723
|
+
}
|
|
2724
|
+
static async _selectedPrivateKey(publicKey, accountId) {
|
|
2725
|
+
if (retiredKeyScope({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
|
|
2726
|
+
return null;
|
|
2727
|
+
}
|
|
2728
|
+
const wanted = KeyManager.canonicalPublicKey(publicKey);
|
|
2729
|
+
const current = accountId !== undefined
|
|
2730
|
+
? await getOrganizationPrivateKey(accountId)
|
|
2731
|
+
: isAndroid()
|
|
2732
|
+
? await KeyManager.getPrivateKey()
|
|
2733
|
+
: ((await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey()));
|
|
2734
|
+
if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
|
|
2735
|
+
return current;
|
|
2736
|
+
}
|
|
2737
|
+
return getRetiredPrivateKey(wanted, accountId !== undefined ? { accountId } : undefined);
|
|
2738
|
+
}
|
|
2671
2739
|
/**
|
|
2672
2740
|
* Get a shortened version of the public key for display
|
|
2673
2741
|
* Format: first 8 chars...last 8 chars
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retired identity keys: the keys an account HAD, kept on the device for
|
|
3
|
+
* spending only (oxy ADR 0061 D7 as amended 2026-11-06, Peable ADR 0002).
|
|
4
|
+
*
|
|
5
|
+
* The Peable wallet derives from the account's identity key (`deriveScopedSeed`
|
|
6
|
+
* for its HD wallet, `signSocialReceive` for its social-receive addresses), and
|
|
7
|
+
* coins keep arriving under a key after it is replaced: payments already
|
|
8
|
+
* broadcast settle against the key they were reserved under, and late payers
|
|
9
|
+
* and `@username` senders hold old addresses. So a personal rotation
|
|
10
|
+
* (`oxy.identity.rotateKey`) and an organization replacement keep the replaced
|
|
11
|
+
* private key here, in a ring per account — `personal`, and one per
|
|
12
|
+
* organization account id — instead of deleting it.
|
|
13
|
+
*
|
|
14
|
+
* A retired key is NEVER a way to sign in, publish, receive a new reservation or
|
|
15
|
+
* rotate: nothing here signs a challenge, and the identity host refuses
|
|
16
|
+
* `proveIdentity` / `describe` for one. It is reached exactly as the current key
|
|
17
|
+
* is, by derivation and never by export, through the `publicKey` selector of
|
|
18
|
+
* `KeyManager.deriveScopedSeed` / `signSocialReceive`:
|
|
19
|
+
*
|
|
20
|
+
* - iOS: the private key in the wallet keychain group (ADR 0062's
|
|
21
|
+
* `spending-key`: Commons and the Peable wallet only), beside the current
|
|
22
|
+
* private keys; the ring's index (public keys and dates only) in the same
|
|
23
|
+
* group, so the wallet can list it. Only Commons writes either.
|
|
24
|
+
* - Android: Commons' own expo-secure-store slot, mirrored into the identity
|
|
25
|
+
* signer store (`./identitySigner`) that Commons' identity host answers
|
|
26
|
+
* `keyPublicKey` requests from (wallet only). Every other app asks over IPC
|
|
27
|
+
* (`loadCommonsIdentityBridge`) and never gets the key.
|
|
28
|
+
*
|
|
29
|
+
* A retired phrase is not kept: the person wrote it down, and a phrase is a
|
|
30
|
+
* second copy of the key that buys the wallet nothing.
|
|
31
|
+
*
|
|
32
|
+
* A key enters the ring only from Commons: the key being replaced (rotation,
|
|
33
|
+
* organization replacement), or a previous recovery phrase the person adds,
|
|
34
|
+
* which Commons accepts only when Oxy's key history names it as one of the
|
|
35
|
+
* account's retired keys. Never into the current slot.
|
|
36
|
+
*
|
|
37
|
+
* Security: a retired key that was compromised is also the attacker's. The
|
|
38
|
+
* ring makes the coins under it spendable from Peable again; it cannot make
|
|
39
|
+
* them safe. A rotation forced by a compromise must be followed at once by the
|
|
40
|
+
* wallet's "move to your current key" sweep, which races the attacker.
|
|
41
|
+
*
|
|
42
|
+
* This module imports nothing from `./keyManager` (which imports it).
|
|
43
|
+
*/
|
|
44
|
+
import { mnemonicToSeed, validateMnemonic } from '@scure/bip39';
|
|
45
|
+
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
|
46
|
+
import { loadCommonsIdentityBridge, loadSecureStore, } from '@oxy.so/protocol';
|
|
47
|
+
import { deriveSecp256k1PublicKey, isValidSecp256k1PrivateKey, isValidSecp256k1PublicKey, normalizeSecp256k1PrivateKey, normalizeSecp256k1PublicKey, } from '@oxy.so/protocol/secp256k1';
|
|
48
|
+
import { logger } from '../logger/index.js';
|
|
49
|
+
import { groupedKeychainStore, resolveKeychainAccess } from '../utils/keychainAccessGroups.js';
|
|
50
|
+
import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
|
|
51
|
+
import { registeredIdentitySignerStore } from './identitySigner.js';
|
|
52
|
+
import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
|
|
53
|
+
import { isOrganizationAccountId } from './organizationKeys.js';
|
|
54
|
+
const PERSONAL_SCOPE = 'personal';
|
|
55
|
+
/** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
|
|
56
|
+
const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
|
|
57
|
+
const INDEX_KEYCHAIN_SERVICE = 'oxy_retired_identity_index';
|
|
58
|
+
const INDEX_STORAGE_KEY = 'oxy_retired_identity_index_v1';
|
|
59
|
+
const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
60
|
+
/**
|
|
61
|
+
* The secure store for the ring. On iOS every item is a `spending-key`
|
|
62
|
+
* (ADR 0062): the private keys AND the index live in the wallet group, which
|
|
63
|
+
* only Commons and the Peable wallet can read, never the shared one. In
|
|
64
|
+
* Commons every access first awaits the access-group migration. On Android
|
|
65
|
+
* the grouping is a no-op and Commons' own services below apply.
|
|
66
|
+
*/
|
|
67
|
+
async function ringStore() {
|
|
68
|
+
const raw = await loadSecureStore();
|
|
69
|
+
const access = await resolveKeychainAccess();
|
|
70
|
+
if (access.mode === 'grouped' && access.role === 'holder') {
|
|
71
|
+
await ensureKeychainGroupsMigrated();
|
|
72
|
+
}
|
|
73
|
+
return groupedKeychainStore(raw, access, 'spending-key');
|
|
74
|
+
}
|
|
75
|
+
/** The ring a selector names: `personal`, or the organization's account id. */
|
|
76
|
+
export function retiredKeyScope(selector) {
|
|
77
|
+
const accountId = selector?.accountId;
|
|
78
|
+
if (accountId === undefined)
|
|
79
|
+
return PERSONAL_SCOPE;
|
|
80
|
+
return isOrganizationAccountId(accountId) ? accountId : null;
|
|
81
|
+
}
|
|
82
|
+
function canonicalPublicKey(publicKey) {
|
|
83
|
+
try {
|
|
84
|
+
const normalized = normalizeSecp256k1PublicKey(publicKey.trim());
|
|
85
|
+
return PUBLIC_KEY.test(normalized) ? normalized : null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function publicKeyOf(privateKey) {
|
|
92
|
+
return deriveSecp256k1PublicKey(normalizeSecp256k1PrivateKey(privateKey));
|
|
93
|
+
}
|
|
94
|
+
function isHealthyPair(privateKey, publicKey) {
|
|
95
|
+
if (!isValidSecp256k1PrivateKey(privateKey) || !isValidSecp256k1PublicKey(publicKey)) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return publicKeyOf(privateKey) === normalizeSecp256k1PublicKey(publicKey);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const privateKeyName = (scope, publicKey) => `oxy_retired_identity_private_key.${scope}.${publicKey}`;
|
|
106
|
+
/** The options of a private half: on iOS none but accessibility (the group comes from {@link ringStore}). */
|
|
107
|
+
function privateOptions(store, forWrite) {
|
|
108
|
+
if (isIOS()) {
|
|
109
|
+
return forWrite ? { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY } : {};
|
|
110
|
+
}
|
|
111
|
+
return forWrite
|
|
112
|
+
? {
|
|
113
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
114
|
+
keychainService: ANDROID_KEYCHAIN_SERVICE,
|
|
115
|
+
}
|
|
116
|
+
: { keychainService: ANDROID_KEYCHAIN_SERVICE };
|
|
117
|
+
}
|
|
118
|
+
/** The index: public keys and dates, beside the private halves. */
|
|
119
|
+
function indexOptions() {
|
|
120
|
+
return isIOS() ? {} : { keychainService: INDEX_KEYCHAIN_SERVICE };
|
|
121
|
+
}
|
|
122
|
+
function isIndexEntry(value) {
|
|
123
|
+
if (typeof value !== 'object' || value === null)
|
|
124
|
+
return false;
|
|
125
|
+
const entry = value;
|
|
126
|
+
return (typeof entry.scope === 'string' &&
|
|
127
|
+
(entry.scope === PERSONAL_SCOPE || isOrganizationAccountId(entry.scope)) &&
|
|
128
|
+
typeof entry.publicKey === 'string' &&
|
|
129
|
+
PUBLIC_KEY.test(entry.publicKey) &&
|
|
130
|
+
typeof entry.retiredAt === 'number' &&
|
|
131
|
+
Number.isFinite(entry.retiredAt));
|
|
132
|
+
}
|
|
133
|
+
async function readIndex(store) {
|
|
134
|
+
const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
|
|
135
|
+
if (!raw)
|
|
136
|
+
return [];
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(raw);
|
|
139
|
+
return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
logger.warn('Retired key index unreadable; treating as empty', {
|
|
143
|
+
component: 'RetiredKeys',
|
|
144
|
+
error: error instanceof Error ? error.name : 'unknown',
|
|
145
|
+
});
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function writeIndex(store, entries) {
|
|
150
|
+
const unique = new Map();
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
const id = `${entry.scope}.${entry.publicKey}`;
|
|
153
|
+
if (!unique.has(id))
|
|
154
|
+
unique.set(id, entry);
|
|
155
|
+
}
|
|
156
|
+
const sorted = [...unique.values()].sort((a, b) => a.retiredAt - b.retiredAt || a.publicKey.localeCompare(b.publicKey));
|
|
157
|
+
await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify(sorted), indexOptions());
|
|
158
|
+
}
|
|
159
|
+
function toListed(entry) {
|
|
160
|
+
return entry.scope === PERSONAL_SCOPE
|
|
161
|
+
? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
|
|
162
|
+
: { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
|
|
163
|
+
}
|
|
164
|
+
/** Whether this app writes the ring: Commons (Android: the signer store it registers; iOS: native). */
|
|
165
|
+
function assertMayWrite() {
|
|
166
|
+
if (isWeb())
|
|
167
|
+
throw new Error('retired key: only a native device holds one');
|
|
168
|
+
if (isAndroid() && !registeredIdentitySignerStore()?.writeRetired) {
|
|
169
|
+
throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
|
|
174
|
+
* key already kept stays, with its first date. Refuses a pair whose public key
|
|
175
|
+
* does not derive from its private key. Resolves once every copy confirmed it
|
|
176
|
+
* (Android: the signer store's read-back too), and throws otherwise, so a
|
|
177
|
+
* caller about to replace the key can stop before anything is lost.
|
|
178
|
+
*/
|
|
179
|
+
export async function retireIdentityKey(key, selector) {
|
|
180
|
+
const scope = retiredKeyScope(selector);
|
|
181
|
+
if (!scope)
|
|
182
|
+
throw new Error('retired key: accountId must be a 24-hex or uuid account id');
|
|
183
|
+
assertMayWrite();
|
|
184
|
+
if (!isHealthyPair(key.privateKey, key.publicKey)) {
|
|
185
|
+
throw new Error('retired key: the public key does not derive from the private key');
|
|
186
|
+
}
|
|
187
|
+
const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
|
|
188
|
+
const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
|
|
189
|
+
const store = await ringStore();
|
|
190
|
+
await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
|
|
191
|
+
const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
|
|
192
|
+
if (readBack !== privateKey) {
|
|
193
|
+
throw new Error('retired key: the keychain did not confirm the key');
|
|
194
|
+
}
|
|
195
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
196
|
+
if (signer?.writeRetired && !(await signer.writeRetired(scope, privateKey, publicKey))) {
|
|
197
|
+
throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
|
|
198
|
+
}
|
|
199
|
+
const index = await readIndex(store);
|
|
200
|
+
if (!index.some((entry) => entry.scope === scope && entry.publicKey === publicKey)) {
|
|
201
|
+
await writeIndex(store, [...index, { scope, publicKey, retiredAt: Date.now() }]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The retired keys of `selector`'s account this app can reach, oldest first.
|
|
206
|
+
* iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
|
|
207
|
+
* (the wallet only), which leaves out the scope's current key. After an
|
|
208
|
+
* interrupted rotation the local list can include the current key: compare
|
|
209
|
+
* with the DID's `#key-1` before treating an entry as retired.
|
|
210
|
+
*/
|
|
211
|
+
export async function listRetiredIdentityKeys(selector) {
|
|
212
|
+
const scope = retiredKeyScope(selector);
|
|
213
|
+
if (!scope || isWeb())
|
|
214
|
+
return [];
|
|
215
|
+
try {
|
|
216
|
+
if (isAndroid() && !registeredIdentitySignerStore()) {
|
|
217
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
218
|
+
const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
|
|
219
|
+
return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
|
|
220
|
+
? { publicKey, retiredAt: 0 }
|
|
221
|
+
: { publicKey, accountId: scope, retiredAt: 0 });
|
|
222
|
+
}
|
|
223
|
+
if (!isIOS() && !isAndroid())
|
|
224
|
+
return [];
|
|
225
|
+
const store = await ringStore();
|
|
226
|
+
return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
logger.warn('Failed to list retired keys', {
|
|
230
|
+
component: 'RetiredKeys',
|
|
231
|
+
error: error instanceof Error ? error.name : 'unknown',
|
|
232
|
+
});
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The private half of a retired key, where this app may hold it: Commons (both
|
|
238
|
+
* platforms) and, on iOS, the keychain group. `null` on Android outside Commons
|
|
239
|
+
* (ask the identity host instead) and for a key not in the ring. Sign within
|
|
240
|
+
* the app only; never transmit it. @internal — `KeyManager` reads it for the
|
|
241
|
+
* `publicKey` selector.
|
|
242
|
+
*/
|
|
243
|
+
export async function getRetiredPrivateKey(publicKey, selector) {
|
|
244
|
+
const scope = retiredKeyScope(selector);
|
|
245
|
+
const canonical = canonicalPublicKey(publicKey);
|
|
246
|
+
if (!scope || !canonical || isWeb())
|
|
247
|
+
return null;
|
|
248
|
+
try {
|
|
249
|
+
if (isAndroid()) {
|
|
250
|
+
const signer = registeredIdentitySignerStore();
|
|
251
|
+
if (!signer)
|
|
252
|
+
return null;
|
|
253
|
+
const store = await ringStore();
|
|
254
|
+
const local = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
255
|
+
if (local && isHealthyPair(local, canonical))
|
|
256
|
+
return normalizeSecp256k1PrivateKey(local);
|
|
257
|
+
// The signer store is a rung: a wiped expo-secure-store slot still has it.
|
|
258
|
+
const mirrored = (await signer.readRetired?.(scope, canonical)) ?? null;
|
|
259
|
+
return mirrored && isHealthyPair(mirrored.privateKey, canonical)
|
|
260
|
+
? normalizeSecp256k1PrivateKey(mirrored.privateKey)
|
|
261
|
+
: null;
|
|
262
|
+
}
|
|
263
|
+
if (!isIOS())
|
|
264
|
+
return null;
|
|
265
|
+
const store = await ringStore();
|
|
266
|
+
const value = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
267
|
+
return value && isHealthyPair(value, canonical) ? normalizeSecp256k1PrivateKey(value) : null;
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
logger.warn('Failed to read a retired key', {
|
|
271
|
+
component: 'RetiredKeys',
|
|
272
|
+
error: error instanceof Error ? error.name : 'unknown',
|
|
273
|
+
});
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Forget a retired key on this device: its private half, its signer copy and
|
|
279
|
+
* its index entry. Commons only, and only on the person's explicit request
|
|
280
|
+
* (their coins under it are then spendable only with its phrase again).
|
|
281
|
+
*/
|
|
282
|
+
export async function forgetRetiredIdentityKey(publicKey, selector) {
|
|
283
|
+
const scope = retiredKeyScope(selector);
|
|
284
|
+
const canonical = canonicalPublicKey(publicKey);
|
|
285
|
+
if (!scope || !canonical)
|
|
286
|
+
throw new Error('retired key: invalid selector or public key');
|
|
287
|
+
assertMayWrite();
|
|
288
|
+
const store = await ringStore();
|
|
289
|
+
await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
290
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
291
|
+
await signer?.clearRetired?.(scope, canonical);
|
|
292
|
+
await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Forget every retired key of one ring (the person's, or an organization's):
|
|
296
|
+
* called when the identity itself is deleted from this device, so a phone
|
|
297
|
+
* handed on keeps none of the person's old keys. Commons only. Resolves how
|
|
298
|
+
* many it removed.
|
|
299
|
+
*/
|
|
300
|
+
export async function forgetRetiredIdentityKeys(selector) {
|
|
301
|
+
const scope = retiredKeyScope(selector);
|
|
302
|
+
if (!scope || isWeb())
|
|
303
|
+
return 0;
|
|
304
|
+
if (isAndroid() && !registeredIdentitySignerStore())
|
|
305
|
+
return 0;
|
|
306
|
+
const store = await ringStore();
|
|
307
|
+
const index = await readIndex(store);
|
|
308
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
309
|
+
let removed = 0;
|
|
310
|
+
for (const entry of index) {
|
|
311
|
+
if (entry.scope !== scope)
|
|
312
|
+
continue;
|
|
313
|
+
await store.deleteItemAsync(privateKeyName(scope, entry.publicKey), privateOptions(store, false));
|
|
314
|
+
await signer?.clearRetired?.(scope, entry.publicKey);
|
|
315
|
+
removed += 1;
|
|
316
|
+
}
|
|
317
|
+
await writeIndex(store, index.filter((entry) => entry.scope !== scope));
|
|
318
|
+
return removed;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Commons on Android: copy every retired key into the identity signer store
|
|
322
|
+
* again, so the identity host answers for it after that file was rebuilt (its
|
|
323
|
+
* self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
|
|
324
|
+
* Resolves how many it mirrored. Elsewhere 0.
|
|
325
|
+
*/
|
|
326
|
+
export async function syncRetiredKeys() {
|
|
327
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
328
|
+
if (!signer?.writeRetired || !signer.readRetired)
|
|
329
|
+
return 0;
|
|
330
|
+
const store = await ringStore();
|
|
331
|
+
let mirrored = 0;
|
|
332
|
+
for (const entry of await readIndex(store)) {
|
|
333
|
+
const privateKey = await store.getItemAsync(privateKeyName(entry.scope, entry.publicKey), privateOptions(store, false));
|
|
334
|
+
if (!privateKey || !isHealthyPair(privateKey, entry.publicKey))
|
|
335
|
+
continue;
|
|
336
|
+
const current = await signer.readRetired(entry.scope, entry.publicKey);
|
|
337
|
+
if (current?.publicKey.toLowerCase() === entry.publicKey)
|
|
338
|
+
continue;
|
|
339
|
+
if (await signer.writeRetired(entry.scope, normalizeSecp256k1PrivateKey(privateKey), entry.publicKey)) {
|
|
340
|
+
mirrored += 1;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return mirrored;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
|
|
347
|
+
* and organization-key derivation; 12–24 words). Stores nothing: the caller
|
|
348
|
+
* checks the public key against the account's key history (personal:
|
|
349
|
+
* `oxy.identity.keyHistory()`; organization: `oxy.accounts.identityKey.get`)
|
|
350
|
+
* and only then calls {@link retireIdentityKey}.
|
|
351
|
+
*/
|
|
352
|
+
export async function retiredKeyFromPhrase(phrase) {
|
|
353
|
+
const normalized = phrase.trim().toLowerCase().split(/\s+/).join(' ');
|
|
354
|
+
const words = normalized.split(' ').length;
|
|
355
|
+
if (words < 12 || words > 24 || words % 3 !== 0 || !validateMnemonic(normalized, wordlist)) {
|
|
356
|
+
throw new Error('retired key: not a valid recovery phrase');
|
|
357
|
+
}
|
|
358
|
+
const seed = await mnemonicToSeed(normalized);
|
|
359
|
+
const privateKey = Array.from(seed.subarray(0, 32), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
360
|
+
return { privateKey, publicKey: publicKeyOf(privateKey) };
|
|
361
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type IdentityKeyHistory } from '@oxy.so/contracts/client';
|
|
1
2
|
import type { AuthMethodsResponse, BackupStatusResponse, DidDocument, DomainVerificationInstructions, ExportBundle, IdentityLinkCreateResponse, IdentityLinkState, IdentityRootStatus, OxySignedRecordType, ReauthProof, SignedRecordEnvelope, VerifiedDomain } from '@oxy.so/contracts';
|
|
2
3
|
import type { OxyContext } from '../client/context';
|
|
3
4
|
import type { PendingIdentityResult } from '../crypto/recoveryPhrase';
|
|
@@ -176,6 +177,15 @@ export declare class IdentityApi {
|
|
|
176
177
|
* user's derived DID document (which embeds auth methods + verified domains).
|
|
177
178
|
*/
|
|
178
179
|
private invalidate;
|
|
180
|
+
/**
|
|
181
|
+
* The signed-in personal account's current root (`null` without one) and
|
|
182
|
+
* every root a rotation replaced, oldest first, all canonical (uncompressed,
|
|
183
|
+
* lowercase). Commons reads it to accept a previous recovery phrase only when
|
|
184
|
+
* it derives a key that WAS this account's root. A retired root is never any
|
|
185
|
+
* account's again. First-party only; a non-personal account is refused
|
|
186
|
+
* (`IDENTITY_NOT_PERSONAL_ACCOUNT`).
|
|
187
|
+
*/
|
|
188
|
+
keyHistory(): Promise<IdentityKeyHistory>;
|
|
179
189
|
}
|
|
180
190
|
/** `oxy.identity.links` — joining Commons' root to an account (ADR 0029 D3). */
|
|
181
191
|
export declare class IdentityLinksApi {
|
|
@@ -40,6 +40,29 @@ export interface IdentitySignerStore {
|
|
|
40
40
|
writeOrganization?(accountId: string, privateKey: string, publicKey: string): Promise<boolean>;
|
|
41
41
|
/** Drop that organization's pair. */
|
|
42
42
|
clearOrganization?(accountId: string): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* The RETIRED key ring: keys a scope rotated away from (a personal rotation,
|
|
45
|
+
* or an organization key replacement, ADR 0061 D7), kept so the identity host
|
|
46
|
+
* can still derive and sign with them for the wallet when a request names
|
|
47
|
+
* their public key. Spending only: the host never signs anyone in with one.
|
|
48
|
+
*
|
|
49
|
+
* `scope` is `'personal'` or an organization's account id (24-hex or uuid);
|
|
50
|
+
* `publicKey` is the retired key's lowercase uncompressed SEC1 hex (130),
|
|
51
|
+
* which names its entry. The native side refuses anything else. Clearing the
|
|
52
|
+
* personal pair ({@link clear}) or an organization's never drops a ring.
|
|
53
|
+
* Optional so a store built before retired keys still type-checks; Commons
|
|
54
|
+
* offers all four or none.
|
|
55
|
+
*/
|
|
56
|
+
readRetired?(scope: string, publicKey: string): Promise<{
|
|
57
|
+
privateKey: string;
|
|
58
|
+
publicKey: string;
|
|
59
|
+
} | null>;
|
|
60
|
+
/** Keep a retired pair in `scope`'s ring. Resolves whether a read-back confirmed it. */
|
|
61
|
+
writeRetired?(scope: string, privateKey: string, publicKey: string): Promise<boolean>;
|
|
62
|
+
/** The public keys in `scope`'s ring (healthy entries only, sorted); `[]` when none. */
|
|
63
|
+
listRetired?(scope: string): Promise<string[]>;
|
|
64
|
+
/** Drop one retired key from `scope`'s ring. */
|
|
65
|
+
clearRetired?(scope: string, publicKey: string): Promise<void>;
|
|
43
66
|
}
|
|
44
67
|
/** @internal */
|
|
45
68
|
export declare function registerIdentitySignerStore(store: IdentitySignerStore | null): void;
|
|
@@ -23,6 +23,8 @@ export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFo
|
|
|
23
23
|
export type { KeychainAccess, KeychainAccessGroupName, KeychainGroupOption, KeychainItemClass, KeychainRole, KeychainStoreLike, } from '../utils/keychainAccessGroups';
|
|
24
24
|
export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
|
|
25
25
|
export type { KeychainGroupMigrationReport, KeychainItemMigrationOutcome, } from './keychainGroupMigration';
|
|
26
|
+
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, } from './retiredKeys';
|
|
27
|
+
export type { RetiredIdentityKey, RetiredKeyPair, RetiredKeySelector } from './retiredKeys';
|
|
26
28
|
export { SignatureService } from './signatureService';
|
|
27
29
|
export type { SignedMessage, AuthChallenge } from './signatureService';
|
|
28
30
|
export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase';
|