@oxy.so/core 7.29.0 → 7.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/cjs/session/authStateStore.js +13 -0
- package/dist/cjs/session/index.js +5 -2
- package/dist/cjs/utils/keychainAccessGroups.js +107 -36
- 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/esm/session/authStateStore.js +12 -0
- package/dist/esm/session/index.js +1 -1
- package/dist/esm/utils/keychainAccessGroups.js +108 -37
- 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 +3 -1
- package/dist/types/crypto/keyManager.d.ts +23 -1
- package/dist/types/crypto/retiredKeys.d.ts +116 -0
- package/dist/types/session/authStateStore.d.ts +9 -0
- package/dist/types/session/index.d.ts +2 -2
- package/dist/types/utils/keychainAccessGroups.d.ts +72 -20
- package/package.json +3 -3
- package/src/api/identity.ts +46 -3
- package/src/crypto/identitySigner.ts +23 -0
- package/src/crypto/index.ts +13 -0
- package/src/crypto/keyManager.ts +81 -3
- package/src/crypto/keychainGroupMigration.ts +6 -1
- package/src/crypto/retiredKeys.ts +453 -0
- package/src/session/authStateStore.ts +14 -0
- package/src/session/index.ts +8 -1
- package/src/utils/keychainAccessGroups.ts +165 -38
package/src/crypto/keyManager.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { isDev, logger } from '../logger';
|
|
|
32
32
|
import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations';
|
|
33
33
|
import { type IdentitySignerStore, registerIdentitySignerStore } from './identitySigner';
|
|
34
34
|
import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys';
|
|
35
|
+
import { forgetRetiredIdentityKeys, getRetiredPrivateKey, retiredKeyScope } from './retiredKeys';
|
|
35
36
|
import {
|
|
36
37
|
type KeychainItemClass,
|
|
37
38
|
groupedKeychainStore,
|
|
@@ -2611,6 +2612,16 @@ export class KeyManager {
|
|
|
2611
2612
|
if (force) {
|
|
2612
2613
|
await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
|
|
2613
2614
|
await KeyManager._clearSharedSlot(store);
|
|
2615
|
+
// The person's retired keys go with the identity they belonged to.
|
|
2616
|
+
try {
|
|
2617
|
+
await forgetRetiredIdentityKeys();
|
|
2618
|
+
} catch (error) {
|
|
2619
|
+
logger.warn(
|
|
2620
|
+
'Failed to clear retired keys during delete',
|
|
2621
|
+
{ component: 'KeyManager' },
|
|
2622
|
+
error,
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2614
2625
|
}
|
|
2615
2626
|
|
|
2616
2627
|
// The device backup goes on EVERY delete, forced or not: it is restored
|
|
@@ -3229,18 +3240,37 @@ export class KeyManager {
|
|
|
3229
3240
|
* keychain group, or Commons' identity host. It never falls back to the
|
|
3230
3241
|
* personal key.
|
|
3231
3242
|
*
|
|
3243
|
+
* With `opts.publicKey` the seed is derived from the key of that account
|
|
3244
|
+
* (personal, or `accountId`'s) whose public key it names: the current key
|
|
3245
|
+
* when it matches, else a RETIRED key Commons kept after a rotation or
|
|
3246
|
+
* replacement (`./retiredKeys`, ADR 0061 D7 as amended). Spending only: a
|
|
3247
|
+
* retired key never signs in. Never falls back to another key.
|
|
3248
|
+
*
|
|
3232
3249
|
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
3233
3250
|
* @param opts.accountId An organization's account id, for its key.
|
|
3251
|
+
* @param opts.publicKey Selects one key of that account by its public key.
|
|
3234
3252
|
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
3235
3253
|
* identity key is available to this app.
|
|
3236
3254
|
*/
|
|
3237
3255
|
static async deriveScopedSeed(
|
|
3238
3256
|
info: string,
|
|
3239
|
-
opts?: { accountId?: string },
|
|
3257
|
+
opts?: { accountId?: string; publicKey?: string },
|
|
3240
3258
|
): Promise<Uint8Array | null> {
|
|
3241
3259
|
if (isWebPlatform()) {
|
|
3242
3260
|
return null;
|
|
3243
3261
|
}
|
|
3262
|
+
if (opts?.publicKey !== undefined) {
|
|
3263
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
3264
|
+
const selected = KeyManager._selectorKey(opts.publicKey);
|
|
3265
|
+
if (!selected) return null;
|
|
3266
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
3267
|
+
const seed =
|
|
3268
|
+
(await bridge?.deriveRetiredScopedSeed?.(info, selected, opts.accountId)) ?? null;
|
|
3269
|
+
return seed ? hexToBytes(seed) : null;
|
|
3270
|
+
}
|
|
3271
|
+
const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
|
|
3272
|
+
return selected ? deriveScopedSeedFromKey(selected, info) : null;
|
|
3273
|
+
}
|
|
3244
3274
|
if (opts?.accountId !== undefined) {
|
|
3245
3275
|
return KeyManager._deriveOrganizationScopedSeed(info, opts.accountId);
|
|
3246
3276
|
}
|
|
@@ -3292,7 +3322,8 @@ export class KeyManager {
|
|
|
3292
3322
|
* compressed public key for the scriptSig.
|
|
3293
3323
|
*
|
|
3294
3324
|
* Key source: as {@link deriveScopedSeed}, including `opts.accountId` for an
|
|
3295
|
-
* organization's key (ADR 0061 D6).
|
|
3325
|
+
* organization's key (ADR 0061 D6) and `opts.publicKey` for one key of the
|
|
3326
|
+
* account, current or retired. On Android outside Commons the signature
|
|
3296
3327
|
* comes from Commons, which allows it for the wallet app only.
|
|
3297
3328
|
*
|
|
3298
3329
|
* @returns the signature, or `null` on web / when no identity key is
|
|
@@ -3301,11 +3332,24 @@ export class KeyManager {
|
|
|
3301
3332
|
static async signSocialReceive(
|
|
3302
3333
|
index: number,
|
|
3303
3334
|
digest: string,
|
|
3304
|
-
opts?: { accountId?: string },
|
|
3335
|
+
opts?: { accountId?: string; publicKey?: string },
|
|
3305
3336
|
): Promise<CommonsSocialReceiveSignature | null> {
|
|
3306
3337
|
if (isWebPlatform()) {
|
|
3307
3338
|
return null;
|
|
3308
3339
|
}
|
|
3340
|
+
if (opts?.publicKey !== undefined) {
|
|
3341
|
+
if (isAndroid() && !KeyManager.identitySignerStore) {
|
|
3342
|
+
const selected = KeyManager._selectorKey(opts.publicKey);
|
|
3343
|
+
if (!selected) return null;
|
|
3344
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
3345
|
+
return (
|
|
3346
|
+
(await bridge?.signRetiredSocialReceive?.(index, digest, selected, opts.accountId)) ??
|
|
3347
|
+
null
|
|
3348
|
+
);
|
|
3349
|
+
}
|
|
3350
|
+
const selected = await KeyManager._selectedPrivateKey(opts.publicKey, opts.accountId);
|
|
3351
|
+
return selected ? signSocialReceiveDigest(selected, index, digest) : null;
|
|
3352
|
+
}
|
|
3309
3353
|
const accountId = opts?.accountId;
|
|
3310
3354
|
if (accountId !== undefined) {
|
|
3311
3355
|
// An organization key (ADR 0061 D6); never the personal one in its place.
|
|
@@ -3330,6 +3374,40 @@ export class KeyManager {
|
|
|
3330
3374
|
return local ? signSocialReceiveDigest(local, index, digest) : null;
|
|
3331
3375
|
}
|
|
3332
3376
|
|
|
3377
|
+
/**
|
|
3378
|
+
* The private key of the account (personal, or `accountId`'s organization)
|
|
3379
|
+
* whose public key is `publicKey`, from this app's own storage: the current
|
|
3380
|
+
* key when it matches, else the retired ring. iOS and Commons only; `null`
|
|
3381
|
+
* for a key this device does not hold. Never another key in its place. @internal
|
|
3382
|
+
*/
|
|
3383
|
+
/**
|
|
3384
|
+
* The selector in the identity host's encoding (lowercase uncompressed), so a
|
|
3385
|
+
* compressed `#key-1` from a DID or a gateway row selects the same key. @internal
|
|
3386
|
+
*/
|
|
3387
|
+
private static _selectorKey(publicKey: string): string | null {
|
|
3388
|
+
return KeyManager.isValidPublicKey(publicKey) ? KeyManager.canonicalPublicKey(publicKey) : null;
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
private static async _selectedPrivateKey(
|
|
3392
|
+
publicKey: string,
|
|
3393
|
+
accountId?: string,
|
|
3394
|
+
): Promise<string | null> {
|
|
3395
|
+
if (retiredKeyScope({ accountId }) === null || !KeyManager.isValidPublicKey(publicKey)) {
|
|
3396
|
+
return null;
|
|
3397
|
+
}
|
|
3398
|
+
const wanted = KeyManager.canonicalPublicKey(publicKey);
|
|
3399
|
+
const current =
|
|
3400
|
+
accountId !== undefined
|
|
3401
|
+
? await getOrganizationPrivateKey(accountId)
|
|
3402
|
+
: isAndroid()
|
|
3403
|
+
? await KeyManager.getPrivateKey()
|
|
3404
|
+
: ((await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey()));
|
|
3405
|
+
if (current && KeyManager.canonicalPublicKey(KeyManager.derivePublicKey(current)) === wanted) {
|
|
3406
|
+
return current;
|
|
3407
|
+
}
|
|
3408
|
+
return getRetiredPrivateKey(wanted, accountId !== undefined ? { accountId } : undefined);
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3333
3411
|
/**
|
|
3334
3412
|
* Get a shortened version of the public key for display
|
|
3335
3413
|
* Format: first 8 chars...last 8 chars
|
|
@@ -110,7 +110,12 @@ type Unit =
|
|
|
110
110
|
merge?: (shared: string, target: string) => string | null;
|
|
111
111
|
};
|
|
112
112
|
|
|
113
|
-
const slot = (
|
|
113
|
+
const slot = (
|
|
114
|
+
key: string,
|
|
115
|
+
item: Exclude<KeychainItemClass, 'app'>,
|
|
116
|
+
service?: string,
|
|
117
|
+
deviceOnly = true,
|
|
118
|
+
): Slot => ({
|
|
114
119
|
key,
|
|
115
120
|
service,
|
|
116
121
|
group: keychainGroupFor(item),
|
|
@@ -0,0 +1,453 @@
|
|
|
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
|
+
|
|
45
|
+
import { mnemonicToSeed, validateMnemonic } from '@scure/bip39';
|
|
46
|
+
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
|
47
|
+
import {
|
|
48
|
+
type ExpoSecureStoreLike,
|
|
49
|
+
loadCommonsIdentityBridge,
|
|
50
|
+
loadSecureStore,
|
|
51
|
+
} from '@oxy.so/protocol';
|
|
52
|
+
import {
|
|
53
|
+
deriveSecp256k1PublicKey,
|
|
54
|
+
isValidSecp256k1PrivateKey,
|
|
55
|
+
isValidSecp256k1PublicKey,
|
|
56
|
+
normalizeSecp256k1PrivateKey,
|
|
57
|
+
normalizeSecp256k1PublicKey,
|
|
58
|
+
} from '@oxy.so/protocol/secp256k1';
|
|
59
|
+
import { logger } from '../logger';
|
|
60
|
+
import { groupedKeychainStore, resolveKeychainAccess } from '../utils/keychainAccessGroups';
|
|
61
|
+
import { isAndroid, isIOS, isWeb } from '../utils/platform';
|
|
62
|
+
import { registeredIdentitySignerStore } from './identitySigner';
|
|
63
|
+
import { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
|
|
64
|
+
import { isOrganizationAccountId } from './organizationKeys';
|
|
65
|
+
import type { OxySecureStoreOptions as SecureStoreOptions } from './secureStoreOptions';
|
|
66
|
+
|
|
67
|
+
/** Whose ring: the person's (`accountId` absent) or an organization's. */
|
|
68
|
+
export interface RetiredKeySelector {
|
|
69
|
+
/** An organization's account id; absent for the personal ring. */
|
|
70
|
+
accountId?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One retired key as the ring lists it: public data only. */
|
|
74
|
+
export interface RetiredIdentityKey {
|
|
75
|
+
/** Lowercase uncompressed secp256k1 public key (`04…`, 130 hex). */
|
|
76
|
+
publicKey: string;
|
|
77
|
+
/** The organization it was the key of; absent for a personal root. */
|
|
78
|
+
accountId?: string;
|
|
79
|
+
/** Unix milliseconds when this device kept it. */
|
|
80
|
+
retiredAt: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A pair to keep in the ring. */
|
|
84
|
+
export interface RetiredKeyPair {
|
|
85
|
+
privateKey: string;
|
|
86
|
+
publicKey: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const PERSONAL_SCOPE = 'personal';
|
|
90
|
+
/** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
|
|
91
|
+
const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
|
|
92
|
+
const INDEX_KEYCHAIN_SERVICE = 'oxy_retired_identity_index';
|
|
93
|
+
const INDEX_STORAGE_KEY = 'oxy_retired_identity_index_v1';
|
|
94
|
+
const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The secure store for the ring. On iOS every item is a `spending-key`
|
|
98
|
+
* (ADR 0062): the private keys AND the index live in the wallet group, which
|
|
99
|
+
* only Commons and the Peable wallet can read, never the shared one. In
|
|
100
|
+
* Commons every access first awaits the access-group migration. On Android
|
|
101
|
+
* the grouping is a no-op and Commons' own services below apply.
|
|
102
|
+
*/
|
|
103
|
+
async function ringStore(): Promise<ExpoSecureStoreLike> {
|
|
104
|
+
const raw = await loadSecureStore();
|
|
105
|
+
const access = await resolveKeychainAccess();
|
|
106
|
+
if (access.mode === 'grouped' && access.role === 'holder') {
|
|
107
|
+
await ensureKeychainGroupsMigrated();
|
|
108
|
+
}
|
|
109
|
+
return groupedKeychainStore(raw, access, 'spending-key');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The ring a selector names: `personal`, or the organization's account id. */
|
|
113
|
+
export function retiredKeyScope(selector?: RetiredKeySelector): string | null {
|
|
114
|
+
const accountId = selector?.accountId;
|
|
115
|
+
if (accountId === undefined) return PERSONAL_SCOPE;
|
|
116
|
+
return isOrganizationAccountId(accountId) ? accountId : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function canonicalPublicKey(publicKey: string): string | null {
|
|
120
|
+
try {
|
|
121
|
+
const normalized = normalizeSecp256k1PublicKey(publicKey.trim());
|
|
122
|
+
return PUBLIC_KEY.test(normalized) ? normalized : null;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function publicKeyOf(privateKey: string): string {
|
|
129
|
+
return deriveSecp256k1PublicKey(normalizeSecp256k1PrivateKey(privateKey));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isHealthyPair(privateKey: string, publicKey: string): boolean {
|
|
133
|
+
if (!isValidSecp256k1PrivateKey(privateKey) || !isValidSecp256k1PublicKey(publicKey)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
return publicKeyOf(privateKey) === normalizeSecp256k1PublicKey(publicKey);
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const privateKeyName = (scope: string, publicKey: string) =>
|
|
144
|
+
`oxy_retired_identity_private_key.${scope}.${publicKey}`;
|
|
145
|
+
|
|
146
|
+
/** The options of a private half: on iOS none but accessibility (the group comes from {@link ringStore}). */
|
|
147
|
+
function privateOptions(store: ExpoSecureStoreLike, forWrite: boolean): SecureStoreOptions {
|
|
148
|
+
if (isIOS()) {
|
|
149
|
+
return forWrite ? { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY } : {};
|
|
150
|
+
}
|
|
151
|
+
return forWrite
|
|
152
|
+
? {
|
|
153
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
154
|
+
keychainService: ANDROID_KEYCHAIN_SERVICE,
|
|
155
|
+
}
|
|
156
|
+
: { keychainService: ANDROID_KEYCHAIN_SERVICE };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The index: public keys and dates, beside the private halves. */
|
|
160
|
+
function indexOptions(): SecureStoreOptions {
|
|
161
|
+
return isIOS() ? {} : { keychainService: INDEX_KEYCHAIN_SERVICE };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface IndexEntry {
|
|
165
|
+
scope: string;
|
|
166
|
+
publicKey: string;
|
|
167
|
+
retiredAt: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isIndexEntry(value: unknown): value is IndexEntry {
|
|
171
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
172
|
+
const entry = value as Record<string, unknown>;
|
|
173
|
+
return (
|
|
174
|
+
typeof entry.scope === 'string' &&
|
|
175
|
+
(entry.scope === PERSONAL_SCOPE || isOrganizationAccountId(entry.scope)) &&
|
|
176
|
+
typeof entry.publicKey === 'string' &&
|
|
177
|
+
PUBLIC_KEY.test(entry.publicKey) &&
|
|
178
|
+
typeof entry.retiredAt === 'number' &&
|
|
179
|
+
Number.isFinite(entry.retiredAt)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function readIndex(store: ExpoSecureStoreLike): Promise<IndexEntry[]> {
|
|
184
|
+
const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
|
|
185
|
+
if (!raw) return [];
|
|
186
|
+
try {
|
|
187
|
+
const parsed: unknown = JSON.parse(raw);
|
|
188
|
+
return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
|
|
189
|
+
} catch (error) {
|
|
190
|
+
logger.warn('Retired key index unreadable; treating as empty', {
|
|
191
|
+
component: 'RetiredKeys',
|
|
192
|
+
error: error instanceof Error ? error.name : 'unknown',
|
|
193
|
+
});
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function writeIndex(store: ExpoSecureStoreLike, entries: IndexEntry[]): Promise<void> {
|
|
199
|
+
const unique = new Map<string, IndexEntry>();
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
const id = `${entry.scope}.${entry.publicKey}`;
|
|
202
|
+
if (!unique.has(id)) unique.set(id, entry);
|
|
203
|
+
}
|
|
204
|
+
const sorted = [...unique.values()].sort(
|
|
205
|
+
(a, b) => a.retiredAt - b.retiredAt || a.publicKey.localeCompare(b.publicKey),
|
|
206
|
+
);
|
|
207
|
+
await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify(sorted), indexOptions());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function toListed(entry: IndexEntry): RetiredIdentityKey {
|
|
211
|
+
return entry.scope === PERSONAL_SCOPE
|
|
212
|
+
? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
|
|
213
|
+
: { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Whether this app writes the ring: Commons (Android: the signer store it registers; iOS: native). */
|
|
217
|
+
function assertMayWrite(): void {
|
|
218
|
+
if (isWeb()) throw new Error('retired key: only a native device holds one');
|
|
219
|
+
if (isAndroid() && !registeredIdentitySignerStore()?.writeRetired) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
'On Android only Commons holds retired keys; this app has no identity signer store for them.',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
|
|
228
|
+
* key already kept stays, with its first date. Refuses a pair whose public key
|
|
229
|
+
* does not derive from its private key. Resolves once every copy confirmed it
|
|
230
|
+
* (Android: the signer store's read-back too), and throws otherwise, so a
|
|
231
|
+
* caller about to replace the key can stop before anything is lost.
|
|
232
|
+
*/
|
|
233
|
+
export async function retireIdentityKey(
|
|
234
|
+
key: RetiredKeyPair,
|
|
235
|
+
selector?: RetiredKeySelector,
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
const scope = retiredKeyScope(selector);
|
|
238
|
+
if (!scope) throw new Error('retired key: accountId must be a 24-hex or uuid account id');
|
|
239
|
+
assertMayWrite();
|
|
240
|
+
if (!isHealthyPair(key.privateKey, key.publicKey)) {
|
|
241
|
+
throw new Error('retired key: the public key does not derive from the private key');
|
|
242
|
+
}
|
|
243
|
+
const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
|
|
244
|
+
const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
|
|
245
|
+
const store = await ringStore();
|
|
246
|
+
await store.setItemAsync(
|
|
247
|
+
privateKeyName(scope, publicKey),
|
|
248
|
+
privateKey,
|
|
249
|
+
privateOptions(store, true),
|
|
250
|
+
);
|
|
251
|
+
const readBack = await store.getItemAsync(
|
|
252
|
+
privateKeyName(scope, publicKey),
|
|
253
|
+
privateOptions(store, false),
|
|
254
|
+
);
|
|
255
|
+
if (readBack !== privateKey) {
|
|
256
|
+
throw new Error('retired key: the keychain did not confirm the key');
|
|
257
|
+
}
|
|
258
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
259
|
+
if (signer?.writeRetired && !(await signer.writeRetired(scope, privateKey, publicKey))) {
|
|
260
|
+
throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
|
|
261
|
+
}
|
|
262
|
+
const index = await readIndex(store);
|
|
263
|
+
if (!index.some((entry) => entry.scope === scope && entry.publicKey === publicKey)) {
|
|
264
|
+
await writeIndex(store, [...index, { scope, publicKey, retiredAt: Date.now() }]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The retired keys of `selector`'s account this app can reach, oldest first.
|
|
270
|
+
* iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
|
|
271
|
+
* (the wallet only), which leaves out the scope's current key. After an
|
|
272
|
+
* interrupted rotation the local list can include the current key: compare
|
|
273
|
+
* with the DID's `#key-1` before treating an entry as retired.
|
|
274
|
+
*/
|
|
275
|
+
export async function listRetiredIdentityKeys(
|
|
276
|
+
selector?: RetiredKeySelector,
|
|
277
|
+
): Promise<RetiredIdentityKey[]> {
|
|
278
|
+
const scope = retiredKeyScope(selector);
|
|
279
|
+
if (!scope || isWeb()) return [];
|
|
280
|
+
try {
|
|
281
|
+
if (isAndroid() && !registeredIdentitySignerStore()) {
|
|
282
|
+
const bridge = await loadCommonsIdentityBridge();
|
|
283
|
+
const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
|
|
284
|
+
return (keys ?? []).map((publicKey) =>
|
|
285
|
+
scope === PERSONAL_SCOPE
|
|
286
|
+
? { publicKey, retiredAt: 0 }
|
|
287
|
+
: { publicKey, accountId: scope, retiredAt: 0 },
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (!isIOS() && !isAndroid()) return [];
|
|
291
|
+
const store = await ringStore();
|
|
292
|
+
return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
logger.warn('Failed to list retired keys', {
|
|
295
|
+
component: 'RetiredKeys',
|
|
296
|
+
error: error instanceof Error ? error.name : 'unknown',
|
|
297
|
+
});
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The private half of a retired key, where this app may hold it: Commons (both
|
|
304
|
+
* platforms) and, on iOS, the keychain group. `null` on Android outside Commons
|
|
305
|
+
* (ask the identity host instead) and for a key not in the ring. Sign within
|
|
306
|
+
* the app only; never transmit it. @internal — `KeyManager` reads it for the
|
|
307
|
+
* `publicKey` selector.
|
|
308
|
+
*/
|
|
309
|
+
export async function getRetiredPrivateKey(
|
|
310
|
+
publicKey: string,
|
|
311
|
+
selector?: RetiredKeySelector,
|
|
312
|
+
): Promise<string | null> {
|
|
313
|
+
const scope = retiredKeyScope(selector);
|
|
314
|
+
const canonical = canonicalPublicKey(publicKey);
|
|
315
|
+
if (!scope || !canonical || isWeb()) return null;
|
|
316
|
+
try {
|
|
317
|
+
if (isAndroid()) {
|
|
318
|
+
const signer = registeredIdentitySignerStore();
|
|
319
|
+
if (!signer) return null;
|
|
320
|
+
const store = await ringStore();
|
|
321
|
+
const local = await store.getItemAsync(
|
|
322
|
+
privateKeyName(scope, canonical),
|
|
323
|
+
privateOptions(store, false),
|
|
324
|
+
);
|
|
325
|
+
if (local && isHealthyPair(local, canonical)) return normalizeSecp256k1PrivateKey(local);
|
|
326
|
+
// The signer store is a rung: a wiped expo-secure-store slot still has it.
|
|
327
|
+
const mirrored = (await signer.readRetired?.(scope, canonical)) ?? null;
|
|
328
|
+
return mirrored && isHealthyPair(mirrored.privateKey, canonical)
|
|
329
|
+
? normalizeSecp256k1PrivateKey(mirrored.privateKey)
|
|
330
|
+
: null;
|
|
331
|
+
}
|
|
332
|
+
if (!isIOS()) return null;
|
|
333
|
+
const store = await ringStore();
|
|
334
|
+
const value = await store.getItemAsync(
|
|
335
|
+
privateKeyName(scope, canonical),
|
|
336
|
+
privateOptions(store, false),
|
|
337
|
+
);
|
|
338
|
+
return value && isHealthyPair(value, canonical) ? normalizeSecp256k1PrivateKey(value) : null;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
logger.warn('Failed to read a retired key', {
|
|
341
|
+
component: 'RetiredKeys',
|
|
342
|
+
error: error instanceof Error ? error.name : 'unknown',
|
|
343
|
+
});
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Forget a retired key on this device: its private half, its signer copy and
|
|
350
|
+
* its index entry. Commons only, and only on the person's explicit request
|
|
351
|
+
* (their coins under it are then spendable only with its phrase again).
|
|
352
|
+
*/
|
|
353
|
+
export async function forgetRetiredIdentityKey(
|
|
354
|
+
publicKey: string,
|
|
355
|
+
selector?: RetiredKeySelector,
|
|
356
|
+
): Promise<void> {
|
|
357
|
+
const scope = retiredKeyScope(selector);
|
|
358
|
+
const canonical = canonicalPublicKey(publicKey);
|
|
359
|
+
if (!scope || !canonical) throw new Error('retired key: invalid selector or public key');
|
|
360
|
+
assertMayWrite();
|
|
361
|
+
const store = await ringStore();
|
|
362
|
+
await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
|
|
363
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
364
|
+
await signer?.clearRetired?.(scope, canonical);
|
|
365
|
+
await writeIndex(
|
|
366
|
+
store,
|
|
367
|
+
(await readIndex(store)).filter(
|
|
368
|
+
(entry) => !(entry.scope === scope && entry.publicKey === canonical),
|
|
369
|
+
),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Forget every retired key of one ring (the person's, or an organization's):
|
|
375
|
+
* called when the identity itself is deleted from this device, so a phone
|
|
376
|
+
* handed on keeps none of the person's old keys. Commons only. Resolves how
|
|
377
|
+
* many it removed.
|
|
378
|
+
*/
|
|
379
|
+
export async function forgetRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<number> {
|
|
380
|
+
const scope = retiredKeyScope(selector);
|
|
381
|
+
if (!scope || isWeb()) return 0;
|
|
382
|
+
if (isAndroid() && !registeredIdentitySignerStore()) return 0;
|
|
383
|
+
const store = await ringStore();
|
|
384
|
+
const index = await readIndex(store);
|
|
385
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
386
|
+
let removed = 0;
|
|
387
|
+
for (const entry of index) {
|
|
388
|
+
if (entry.scope !== scope) continue;
|
|
389
|
+
await store.deleteItemAsync(
|
|
390
|
+
privateKeyName(scope, entry.publicKey),
|
|
391
|
+
privateOptions(store, false),
|
|
392
|
+
);
|
|
393
|
+
await signer?.clearRetired?.(scope, entry.publicKey);
|
|
394
|
+
removed += 1;
|
|
395
|
+
}
|
|
396
|
+
await writeIndex(
|
|
397
|
+
store,
|
|
398
|
+
index.filter((entry) => entry.scope !== scope),
|
|
399
|
+
);
|
|
400
|
+
return removed;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Commons on Android: copy every retired key into the identity signer store
|
|
405
|
+
* again, so the identity host answers for it after that file was rebuilt (its
|
|
406
|
+
* self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
|
|
407
|
+
* Resolves how many it mirrored. Elsewhere 0.
|
|
408
|
+
*/
|
|
409
|
+
export async function syncRetiredKeys(): Promise<number> {
|
|
410
|
+
const signer = isAndroid() ? registeredIdentitySignerStore() : null;
|
|
411
|
+
if (!signer?.writeRetired || !signer.readRetired) return 0;
|
|
412
|
+
const store = await ringStore();
|
|
413
|
+
let mirrored = 0;
|
|
414
|
+
for (const entry of await readIndex(store)) {
|
|
415
|
+
const privateKey = await store.getItemAsync(
|
|
416
|
+
privateKeyName(entry.scope, entry.publicKey),
|
|
417
|
+
privateOptions(store, false),
|
|
418
|
+
);
|
|
419
|
+
if (!privateKey || !isHealthyPair(privateKey, entry.publicKey)) continue;
|
|
420
|
+
const current = await signer.readRetired(entry.scope, entry.publicKey);
|
|
421
|
+
if (current?.publicKey.toLowerCase() === entry.publicKey) continue;
|
|
422
|
+
if (
|
|
423
|
+
await signer.writeRetired(
|
|
424
|
+
entry.scope,
|
|
425
|
+
normalizeSecp256k1PrivateKey(privateKey),
|
|
426
|
+
entry.publicKey,
|
|
427
|
+
)
|
|
428
|
+
) {
|
|
429
|
+
mirrored += 1;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return mirrored;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
|
|
437
|
+
* and organization-key derivation; 12–24 words). Stores nothing: the caller
|
|
438
|
+
* checks the public key against the account's key history (personal:
|
|
439
|
+
* `oxy.identity.keyHistory()`; organization: `oxy.accounts.identityKey.get`)
|
|
440
|
+
* and only then calls {@link retireIdentityKey}.
|
|
441
|
+
*/
|
|
442
|
+
export async function retiredKeyFromPhrase(phrase: string): Promise<RetiredKeyPair> {
|
|
443
|
+
const normalized = phrase.trim().toLowerCase().split(/\s+/).join(' ');
|
|
444
|
+
const words = normalized.split(' ').length;
|
|
445
|
+
if (words < 12 || words > 24 || words % 3 !== 0 || !validateMnemonic(normalized, wordlist)) {
|
|
446
|
+
throw new Error('retired key: not a valid recovery phrase');
|
|
447
|
+
}
|
|
448
|
+
const seed = await mnemonicToSeed(normalized);
|
|
449
|
+
const privateKey = Array.from(seed.subarray(0, 32), (b) => b.toString(16).padStart(2, '0')).join(
|
|
450
|
+
'',
|
|
451
|
+
);
|
|
452
|
+
return { privateKey, publicKey: publicKeyOf(privateKey) };
|
|
453
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { logger } from '../logger';
|
|
21
|
+
import type { OwnKeychainItemNames } from '../utils/keychainAccessGroups';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* The persisted session credential set for a single origin.
|
|
@@ -150,6 +151,19 @@ export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
|
150
151
|
export const AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY =
|
|
151
152
|
'oxy.auth.automatic-identity-signin-suppressed.v1';
|
|
152
153
|
|
|
154
|
+
/**
|
|
155
|
+
* What `ownKeychainItemPlan` (ADR 0062 D6) must know about a key a native
|
|
156
|
+
* session store keeps — this module's three and the identity pin: every Oxy app
|
|
157
|
+
* writes it under the same name, so on iOS a copy in the shared group may be
|
|
158
|
+
* another app's session and is never read as this app's. The sign-out marker
|
|
159
|
+
* can only withhold, so a shared copy of it still counts.
|
|
160
|
+
*/
|
|
161
|
+
export function nativeSessionItemNames(key: string): OwnKeychainItemNames {
|
|
162
|
+
return key === AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY
|
|
163
|
+
? 'every-oxy-app-withholding'
|
|
164
|
+
: 'every-oxy-app';
|
|
165
|
+
}
|
|
166
|
+
|
|
153
167
|
/**
|
|
154
168
|
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
155
169
|
* `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
|
package/src/session/index.ts
CHANGED
|
@@ -134,7 +134,10 @@ export {
|
|
|
134
134
|
createWebAuthStateStore,
|
|
135
135
|
createNativeAuthStateStore,
|
|
136
136
|
createMemoryAuthStateStore,
|
|
137
|
+
nativeSessionItemNames,
|
|
137
138
|
AUTH_STATE_STORAGE_KEY,
|
|
139
|
+
AUTH_STATE_TOKEN_STORAGE_KEY,
|
|
140
|
+
AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY,
|
|
138
141
|
} from './authStateStore';
|
|
139
142
|
export type {
|
|
140
143
|
PersistedAuthState,
|
|
@@ -176,7 +179,11 @@ export {
|
|
|
176
179
|
deleteThroughPlan,
|
|
177
180
|
resolveKeychainAccess,
|
|
178
181
|
} from '../utils/keychainAccessGroups';
|
|
179
|
-
export type {
|
|
182
|
+
export type {
|
|
183
|
+
KeychainAccess,
|
|
184
|
+
KeychainGroupOption,
|
|
185
|
+
OwnKeychainItemNames,
|
|
186
|
+
} from '../utils/keychainAccessGroups';
|
|
180
187
|
|
|
181
188
|
// Identity-bound sessions (the identity vault). The pin is the durable
|
|
182
189
|
// `{publicKey, accountId}` binding between this device's PRIMARY identity key
|