@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
|
@@ -23,6 +23,7 @@ exports.groupedKeychainStore = groupedKeychainStore;
|
|
|
23
23
|
* | `<TEAM>.so.oxy.identity` | Commons only (listed FIRST) | `identity-secret` |
|
|
24
24
|
* | `<TEAM>.so.oxy.wallet` | Commons, the Peable wallet | `spending-key` |
|
|
25
25
|
* | `<TEAM>.group.so.oxy.shared` | every Oxy app | `shared` |
|
|
26
|
+
* | `<TEAM>.<bundle id>` | that app alone | `app` |
|
|
26
27
|
*
|
|
27
28
|
* - `identity-secret`: the personal root (primary and backup slots), the
|
|
28
29
|
* personal and organization recovery phrases, the organization index. Only
|
|
@@ -32,14 +33,21 @@ exports.groupedKeychainStore = groupedKeychainStore;
|
|
|
32
33
|
* (`deriveScopedSeed`, `signSocialReceive`). Commons writes them; the Peable
|
|
33
34
|
* wallet reads them.
|
|
34
35
|
* - `shared`: what every Oxy app may read: the shared public key and the
|
|
35
|
-
* device-session credential.
|
|
36
|
+
* device-session credential (the one cross-app session join point).
|
|
37
|
+
* - `app`: an app's OWN items, readable by no other app: its session
|
|
38
|
+
* credential (`oxy.auth.v1` and the rest of the SDK's session store), its
|
|
39
|
+
* identity pin, its settings, the Peable wallet's imported phrases. The group
|
|
40
|
+
* is the app's application identifier, which Apple makes every app a member
|
|
41
|
+
* of; no app lists it and no other app can name it. Android's counterpart is
|
|
42
|
+
* the app's own storage (ADR 0062 D6).
|
|
36
43
|
*
|
|
37
44
|
* The ROLE of this binary comes from the keychain itself: an item written
|
|
38
45
|
* without a group lands in the app's default group, the first entry of its
|
|
39
46
|
* `keychain-access-groups` entitlement, and `@oxy.so/app-preset` lets only
|
|
40
47
|
* Commons put the identity group first and only the Peable wallet the wallet
|
|
41
48
|
* group. The same probe gives the team prefix, which `kSecAttrAccessGroup`
|
|
42
|
-
* needs and JavaScript cannot otherwise see
|
|
49
|
+
* needs and JavaScript cannot otherwise see; a second probe proves the app's
|
|
50
|
+
* own group (`<prefix><bundle id>`) is writable.
|
|
43
51
|
*
|
|
44
52
|
* Android and web have no access groups: there every helper here answers the
|
|
45
53
|
* single-store behaviour (`ungrouped`), and Android's narrowing is Commons'
|
|
@@ -66,10 +74,6 @@ class KeychainAccessUnavailableError extends Error {
|
|
|
66
74
|
}
|
|
67
75
|
}
|
|
68
76
|
exports.KeychainAccessUnavailableError = KeychainAccessUnavailableError;
|
|
69
|
-
/**
|
|
70
|
-
* The single place each item class is assigned a group. A new kind of item
|
|
71
|
-
* (F1's retained keys, for one) picks its class and reuses everything below.
|
|
72
|
-
*/
|
|
73
77
|
function keychainGroupFor(item) {
|
|
74
78
|
switch (item) {
|
|
75
79
|
case 'identity-secret':
|
|
@@ -78,6 +82,8 @@ function keychainGroupFor(item) {
|
|
|
78
82
|
return 'wallet';
|
|
79
83
|
case 'shared':
|
|
80
84
|
return 'shared';
|
|
85
|
+
case 'app':
|
|
86
|
+
return 'app';
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
/** Whether `role` holds the group `item` lives in. */
|
|
@@ -88,11 +94,14 @@ function mayHoldKeychainItem(role, item) {
|
|
|
88
94
|
case 'wallet':
|
|
89
95
|
return item !== 'identity-secret';
|
|
90
96
|
case 'member':
|
|
91
|
-
return item === 'shared';
|
|
97
|
+
return item === 'shared' || item === 'app';
|
|
92
98
|
}
|
|
93
99
|
}
|
|
94
|
-
/**
|
|
95
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Parse the default access group the probe reported, and the app's own group
|
|
102
|
+
* when the second probe proved it. Exported for tests.
|
|
103
|
+
*/
|
|
104
|
+
function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
|
|
96
105
|
const dot = defaultGroup.indexOf('.');
|
|
97
106
|
if (dot <= 0 || dot === defaultGroup.length - 1) {
|
|
98
107
|
// Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
|
|
@@ -105,7 +114,9 @@ function keychainAccessFromDefaultGroup(defaultGroup) {
|
|
|
105
114
|
: group === exports.KEYCHAIN_ACCESS_GROUPS.wallet
|
|
106
115
|
? 'wallet'
|
|
107
116
|
: 'member';
|
|
108
|
-
return
|
|
117
|
+
return appGroup?.startsWith(prefix)
|
|
118
|
+
? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
|
|
119
|
+
: { mode: 'grouped', prefix, defaultGroup, role };
|
|
109
120
|
}
|
|
110
121
|
let resolvedAccess = null;
|
|
111
122
|
/**
|
|
@@ -133,28 +144,62 @@ async function resolveKeychainAccess() {
|
|
|
133
144
|
if (probe.state === 'unavailable') {
|
|
134
145
|
throw new KeychainAccessUnavailableError(probe.cause);
|
|
135
146
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
147
|
+
if (probe.state !== 'resolved') {
|
|
148
|
+
resolvedAccess = { mode: 'ungrouped' };
|
|
149
|
+
return resolvedAccess;
|
|
150
|
+
}
|
|
151
|
+
const access = keychainAccessFromDefaultGroup(probe.accessGroup);
|
|
152
|
+
if (access.mode === 'ungrouped') {
|
|
153
|
+
resolvedAccess = access;
|
|
154
|
+
return resolvedAccess;
|
|
155
|
+
}
|
|
156
|
+
// The app's own group. A thrown probe is NOT degraded to "no app group": this
|
|
157
|
+
// process would then write the app's items to the old place while the next
|
|
158
|
+
// one reads the new place first, and see a stale value.
|
|
159
|
+
let own;
|
|
160
|
+
try {
|
|
161
|
+
own = await (0, protocol_1.loadKeychainApplicationAccessGroup)(access.prefix);
|
|
162
|
+
}
|
|
163
|
+
catch (cause) {
|
|
164
|
+
throw new KeychainAccessUnavailableError(cause);
|
|
165
|
+
}
|
|
166
|
+
if (own.state === 'unavailable') {
|
|
167
|
+
throw new KeychainAccessUnavailableError(own.cause);
|
|
168
|
+
}
|
|
169
|
+
resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
|
|
140
170
|
return resolvedAccess;
|
|
141
171
|
}
|
|
142
172
|
/** Forget the memoized access. Tests only. @internal */
|
|
143
173
|
function resetKeychainAccessForTests() {
|
|
144
174
|
resolvedAccess = null;
|
|
145
175
|
}
|
|
146
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
|
|
178
|
+
* group" (the app's default): always when ungrouped, and for `app` on a build
|
|
179
|
+
* that cannot place items there (see {@link ownKeychainItemPlan}).
|
|
180
|
+
*/
|
|
147
181
|
function keychainAccessGroupValue(access, group) {
|
|
148
|
-
|
|
182
|
+
if (access.mode === 'ungrouped') {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
if (group === 'app') {
|
|
186
|
+
// Commons' and the Peable wallet's default groups are theirs (no other app
|
|
187
|
+
// writes its own items there), so a build without the app-group probe
|
|
188
|
+
// keeps its own items there, as ADR 0062 D3 put them. Every other app's
|
|
189
|
+
// default is the shared group: no group, the calls it always made.
|
|
190
|
+
return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
|
|
191
|
+
}
|
|
192
|
+
return `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}`;
|
|
149
193
|
}
|
|
150
194
|
function option(value) {
|
|
151
195
|
return value === undefined ? {} : { accessGroup: value };
|
|
152
196
|
}
|
|
153
197
|
/**
|
|
154
|
-
* The option bags a READ of `item` tries, in order: its own group, then
|
|
155
|
-
*
|
|
156
|
-
* drops that fallback). Empty when this
|
|
157
|
-
* answers `null` without touching the
|
|
198
|
+
* The option bags a READ of `item` tries, in order: its own group, then, for
|
|
199
|
+
* an identity secret or a spending key, the shared group, where those lived
|
|
200
|
+
* before the split (release 4 of the doc drops that fallback). Empty when this
|
|
201
|
+
* app may not hold the item: the caller answers `null` without touching the
|
|
202
|
+
* keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
|
|
158
203
|
*/
|
|
159
204
|
function keychainReadPlan(access, item) {
|
|
160
205
|
if (access.mode === 'ungrouped') {
|
|
@@ -165,7 +210,8 @@ function keychainReadPlan(access, item) {
|
|
|
165
210
|
}
|
|
166
211
|
const own = keychainGroupFor(item);
|
|
167
212
|
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
168
|
-
if (own
|
|
213
|
+
if (own === 'identity' || own === 'wallet') {
|
|
214
|
+
// Where the split moved these items from.
|
|
169
215
|
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
170
216
|
}
|
|
171
217
|
return plan;
|
|
@@ -192,25 +238,50 @@ function keychainWriteOption(access, item) {
|
|
|
192
238
|
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
193
239
|
}
|
|
194
240
|
/**
|
|
195
|
-
* The plan for an app's OWN items (
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
241
|
+
* The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
|
|
242
|
+
* app's own group (`<prefix><bundle id>`), which no other app can read, and
|
|
243
|
+
* read from it FIRST. Then:
|
|
244
|
+
*
|
|
245
|
+
* - from where this app kept them before: Commons' and the Peable wallet's
|
|
246
|
+
* default group (D3), which no other app writes its own items to;
|
|
247
|
+
* - for `this-app` names, from every group (`{}`), where they were before the
|
|
248
|
+
* split; deletes reach every group;
|
|
249
|
+
* - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
|
|
250
|
+
* and deletes reach exactly the read locations, earlier ones first, so a
|
|
251
|
+
* failed delete never leaves an older copy as the only one;
|
|
252
|
+
* - for `every-oxy-app-withholding` names, the shared group last on read and
|
|
253
|
+
* first on delete.
|
|
202
254
|
*
|
|
203
|
-
*
|
|
255
|
+
* Nothing is copied or deleted to migrate: the next write lands in the app
|
|
256
|
+
* group and the read order makes it win, so a crash or a failure at any step
|
|
257
|
+
* leaves the value the app had.
|
|
258
|
+
*
|
|
259
|
+
* A native build whose module predates the app-group probe (`appGroup`
|
|
260
|
+
* absent) keeps its items where that build put them: Commons and the wallet in
|
|
261
|
+
* their default group, every other app the calls it always made (the one
|
|
262
|
+
* shared item, until that app's next native build). Android and web: no
|
|
263
|
+
* options.
|
|
204
264
|
*/
|
|
205
|
-
function ownKeychainItemPlan(access) {
|
|
206
|
-
if (access.mode === 'ungrouped'
|
|
265
|
+
function ownKeychainItemPlan(access, names = 'this-app') {
|
|
266
|
+
if (access.mode === 'ungrouped') {
|
|
207
267
|
return { read: [{}], write: {}, delete: [{}] };
|
|
208
268
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
write:
|
|
212
|
-
|
|
213
|
-
|
|
269
|
+
const own = keychainAccessGroupValue(access, keychainGroupFor('app'));
|
|
270
|
+
if (own === undefined) {
|
|
271
|
+
return { read: [{}], write: {}, delete: [{}] };
|
|
272
|
+
}
|
|
273
|
+
const earlier = access.role !== 'member' && own !== access.defaultGroup ? [option(access.defaultGroup)] : [];
|
|
274
|
+
const mine = [option(own), ...earlier];
|
|
275
|
+
switch (names) {
|
|
276
|
+
case 'this-app':
|
|
277
|
+
return { read: [...mine, {}], write: option(own), delete: [{}] };
|
|
278
|
+
case 'every-oxy-app':
|
|
279
|
+
return { read: mine, write: option(own), delete: [...mine].reverse() };
|
|
280
|
+
case 'every-oxy-app-withholding': {
|
|
281
|
+
const read = [...mine, option(keychainAccessGroupValue(access, 'shared'))];
|
|
282
|
+
return { read, write: option(own), delete: [...read].reverse() };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
214
285
|
}
|
|
215
286
|
/** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
|
|
216
287
|
async function readThroughPlan(store, key, options, plan) {
|
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
|