@oxy.so/core 7.31.0 → 7.33.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 +155 -24
- package/dist/cjs/crypto/identityMarker.js +15 -0
- package/dist/cjs/crypto/index.js +11 -2
- package/dist/cjs/crypto/keyManager.js +111 -16
- package/dist/cjs/crypto/keychainGroupMigration.js +327 -160
- package/dist/cjs/crypto/organizationKeys.js +33 -5
- package/dist/cjs/crypto/retiredKeys.js +315 -64
- package/dist/cjs/utils/keychainAccessGroups.js +111 -25
- package/dist/esm/api/identity.js +155 -24
- package/dist/esm/crypto/identityMarker.js +14 -0
- package/dist/esm/crypto/index.js +3 -3
- package/dist/esm/crypto/keyManager.js +113 -18
- package/dist/esm/crypto/keychainGroupMigration.js +327 -162
- package/dist/esm/crypto/organizationKeys.js +34 -6
- package/dist/esm/crypto/retiredKeys.js +306 -63
- package/dist/esm/utils/keychainAccessGroups.js +107 -24
- package/dist/types/api/identity.d.ts +36 -0
- package/dist/types/crypto/identityMarker.d.ts +8 -0
- package/dist/types/crypto/index.d.ts +3 -3
- package/dist/types/crypto/keyManager.d.ts +45 -1
- package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
- package/dist/types/crypto/organizationKeys.d.ts +4 -2
- package/dist/types/crypto/retiredKeys.d.ts +108 -17
- package/dist/types/utils/keychainAccessGroups.d.ts +41 -11
- package/package.json +1 -1
- package/src/api/identity.ts +198 -41
- package/src/crypto/identityMarker.ts +15 -0
- package/src/crypto/index.ts +12 -1
- package/src/crypto/keyManager.ts +127 -26
- package/src/crypto/keychainGroupMigration.ts +390 -185
- package/src/crypto/organizationKeys.ts +38 -6
- package/src/crypto/retiredKeys.ts +369 -65
- package/src/utils/keychainAccessGroups.ts +121 -24
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.KeychainAccessUnavailableError = exports.KEYCHAIN_ACCESS_GROUPS = void 0;
|
|
3
|
+
exports.KeychainAccessUnavailableError = exports.DEV_KEYCHAIN_GROUP_SUFFIX = exports.KEYCHAIN_ACCESS_GROUPS = void 0;
|
|
4
4
|
exports.keychainGroupFor = keychainGroupFor;
|
|
5
|
+
exports.mayWriteKeychainItem = mayWriteKeychainItem;
|
|
5
6
|
exports.mayHoldKeychainItem = mayHoldKeychainItem;
|
|
6
7
|
exports.keychainAccessFromDefaultGroup = keychainAccessFromDefaultGroup;
|
|
7
8
|
exports.resolveKeychainAccess = resolveKeychainAccess;
|
|
8
9
|
exports.resetKeychainAccessForTests = resetKeychainAccessForTests;
|
|
9
10
|
exports.keychainAccessGroupValue = keychainAccessGroupValue;
|
|
11
|
+
exports.setHolderSharedSecretFallback = setHolderSharedSecretFallback;
|
|
12
|
+
exports.closeWalletSharedSecretFallback = closeWalletSharedSecretFallback;
|
|
10
13
|
exports.keychainReadPlan = keychainReadPlan;
|
|
11
14
|
exports.keychainDeletePlan = keychainDeletePlan;
|
|
12
15
|
exports.keychainWriteOption = keychainWriteOption;
|
|
@@ -65,6 +68,15 @@ exports.KEYCHAIN_ACCESS_GROUPS = {
|
|
|
65
68
|
wallet: 'so.oxy.wallet',
|
|
66
69
|
shared: 'group.so.oxy.shared',
|
|
67
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* The suffix a DEVELOPMENT build's restricted groups carry (`so.oxy.commons.dev`,
|
|
73
|
+
* `to.peable.app.dev`: `so.oxy.identity.dev`, `so.oxy.wallet.dev`). A dev build
|
|
74
|
+
* sits beside the production app on the same phone, is signed by the same team
|
|
75
|
+
* and runs JavaScript from a dev server, so it never shares a restricted group
|
|
76
|
+
* with production (`@oxy.so/app-preset` enforces the entitlements). The shared
|
|
77
|
+
* group has no variant: it holds nothing secret.
|
|
78
|
+
*/
|
|
79
|
+
exports.DEV_KEYCHAIN_GROUP_SUFFIX = '.dev';
|
|
68
80
|
/** Thrown while the probe cannot answer (before first unlock). Transient. */
|
|
69
81
|
class KeychainAccessUnavailableError extends Error {
|
|
70
82
|
constructor(cause) {
|
|
@@ -86,6 +98,16 @@ function keychainGroupFor(item) {
|
|
|
86
98
|
return 'app';
|
|
87
99
|
}
|
|
88
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether `role` may WRITE `item`. Only Commons writes a secret: the Peable
|
|
103
|
+
* wallet reads, derives and signs with spending keys (and the retired ring)
|
|
104
|
+
* but never adds one — a key the wallet could add is one the wallet could
|
|
105
|
+
* choose. (The keychain itself lets every app in the wallet group write it;
|
|
106
|
+
* this is the SDK's half, and readers check what they find, ADR 0062.)
|
|
107
|
+
*/
|
|
108
|
+
function mayWriteKeychainItem(role, item) {
|
|
109
|
+
return role === 'holder' || item === 'shared' || item === 'app';
|
|
110
|
+
}
|
|
89
111
|
/** Whether `role` holds the group `item` lives in. */
|
|
90
112
|
function mayHoldKeychainItem(role, item) {
|
|
91
113
|
switch (role) {
|
|
@@ -109,14 +131,18 @@ function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
|
|
|
109
131
|
}
|
|
110
132
|
const prefix = defaultGroup.slice(0, dot + 1);
|
|
111
133
|
const group = defaultGroup.slice(dot + 1);
|
|
112
|
-
const
|
|
134
|
+
const dev = group.endsWith(exports.DEV_KEYCHAIN_GROUP_SUFFIX);
|
|
135
|
+
const base = dev ? group.slice(0, -exports.DEV_KEYCHAIN_GROUP_SUFFIX.length) : group;
|
|
136
|
+
const role = base === exports.KEYCHAIN_ACCESS_GROUPS.identity
|
|
113
137
|
? 'holder'
|
|
114
|
-
:
|
|
138
|
+
: base === exports.KEYCHAIN_ACCESS_GROUPS.wallet
|
|
115
139
|
? 'wallet'
|
|
116
140
|
: 'member';
|
|
141
|
+
// A `.dev` suffix on anything but a restricted group is not a variant.
|
|
142
|
+
const variant = dev && role !== 'member' ? { variant: exports.DEV_KEYCHAIN_GROUP_SUFFIX } : {};
|
|
117
143
|
return appGroup?.startsWith(prefix)
|
|
118
|
-
? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
|
|
119
|
-
: { mode: 'grouped', prefix, defaultGroup, role };
|
|
144
|
+
? { mode: 'grouped', prefix, defaultGroup, role, appGroup, ...variant }
|
|
145
|
+
: { mode: 'grouped', prefix, defaultGroup, role, ...variant };
|
|
120
146
|
}
|
|
121
147
|
let resolvedAccess = null;
|
|
122
148
|
/**
|
|
@@ -169,9 +195,11 @@ async function resolveKeychainAccess() {
|
|
|
169
195
|
resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
|
|
170
196
|
return resolvedAccess;
|
|
171
197
|
}
|
|
172
|
-
/** Forget the memoized access. Tests only. @internal */
|
|
198
|
+
/** Forget the memoized access and the fallback state. Tests only. @internal */
|
|
173
199
|
function resetKeychainAccessForTests() {
|
|
174
200
|
resolvedAccess = null;
|
|
201
|
+
holderSharedFallback = false;
|
|
202
|
+
walletSharedFallbackClosed = false;
|
|
175
203
|
}
|
|
176
204
|
/**
|
|
177
205
|
* The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
|
|
@@ -189,17 +217,54 @@ function keychainAccessGroupValue(access, group) {
|
|
|
189
217
|
// default is the shared group: no group, the calls it always made.
|
|
190
218
|
return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
|
|
191
219
|
}
|
|
192
|
-
|
|
220
|
+
const variant = group === 'shared' ? '' : (access.variant ?? '');
|
|
221
|
+
return `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}${variant}`;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Whether a SECRET read may still fall back to the pre-split shared group
|
|
225
|
+
* (R4-7). Every Oxy app can write that group, and keychain items survive an
|
|
226
|
+
* uninstall, so a secret found there is one Commons did not necessarily write.
|
|
227
|
+
*
|
|
228
|
+
* - Commons (`holder`): CLOSED, except while its one-shot migration has proved
|
|
229
|
+
* the shared copies are its own (they carry the key its sandbox marker
|
|
230
|
+
* records) and could not move them all yet; the migration opens it for that
|
|
231
|
+
* launch only ({@link setHolderSharedSecretFallback}).
|
|
232
|
+
* - The Peable wallet: open until Commons' migration has finished (the
|
|
233
|
+
* completion record Commons mirrors into the wallet group), because until
|
|
234
|
+
* then an upgraded phone's keys are still in the shared group; closed for
|
|
235
|
+
* good once it has seen that record ({@link closeWalletSharedSecretFallback}).
|
|
236
|
+
*/
|
|
237
|
+
let holderSharedFallback = false;
|
|
238
|
+
let walletSharedFallbackClosed = false;
|
|
239
|
+
/** The migration's switch for Commons' fallback. @internal */
|
|
240
|
+
function setHolderSharedSecretFallback(open) {
|
|
241
|
+
holderSharedFallback = open;
|
|
242
|
+
}
|
|
243
|
+
/** The wallet saw Commons' completion record: no shared fallback again in this process. @internal */
|
|
244
|
+
function closeWalletSharedSecretFallback() {
|
|
245
|
+
walletSharedFallbackClosed = true;
|
|
246
|
+
}
|
|
247
|
+
function sharedFallbackOpen(access) {
|
|
248
|
+
switch (access.role) {
|
|
249
|
+
case 'holder':
|
|
250
|
+
return holderSharedFallback;
|
|
251
|
+
case 'wallet':
|
|
252
|
+
return !walletSharedFallbackClosed;
|
|
253
|
+
case 'member':
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
193
256
|
}
|
|
194
257
|
function option(value) {
|
|
195
258
|
return value === undefined ? {} : { accessGroup: value };
|
|
196
259
|
}
|
|
197
260
|
/**
|
|
198
|
-
* The option bags a READ of `item` tries, in order: its own group, then
|
|
199
|
-
* an identity secret or a spending key,
|
|
200
|
-
*
|
|
201
|
-
* app may not hold the item: the caller
|
|
202
|
-
*
|
|
261
|
+
* The option bags a READ of `item` tries, in order: its own group, then — for
|
|
262
|
+
* an identity secret or a spending key, and only while
|
|
263
|
+
* {@link sharedFallbackOpen} says so — the shared group, where those lived
|
|
264
|
+
* before the split. Empty when this app may not hold the item: the caller
|
|
265
|
+
* answers `null` without touching the keychain. Evaluated per read: the
|
|
266
|
+
* fallback closes during a process's life. An app's own items (`app`) take
|
|
267
|
+
* {@link ownKeychainItemPlan}.
|
|
203
268
|
*/
|
|
204
269
|
function keychainReadPlan(access, item) {
|
|
205
270
|
if (access.mode === 'ungrouped') {
|
|
@@ -210,18 +275,29 @@ function keychainReadPlan(access, item) {
|
|
|
210
275
|
}
|
|
211
276
|
const own = keychainGroupFor(item);
|
|
212
277
|
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
213
|
-
if (own === 'identity' || own === 'wallet') {
|
|
214
|
-
// Where the split moved these items from.
|
|
278
|
+
if ((own === 'identity' || own === 'wallet') && sharedFallbackOpen(access)) {
|
|
215
279
|
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
216
280
|
}
|
|
217
281
|
return plan;
|
|
218
282
|
}
|
|
219
283
|
/**
|
|
220
|
-
* Every place a DELETE of `item` must reach:
|
|
221
|
-
*
|
|
284
|
+
* Every place a DELETE of `item` must reach: its own group AND the shared
|
|
285
|
+
* group, whatever the read fallback says, so a delete never leaves a copy
|
|
286
|
+
* behind where every Oxy app can read it.
|
|
222
287
|
*/
|
|
223
288
|
function keychainDeletePlan(access, item) {
|
|
224
|
-
|
|
289
|
+
if (access.mode === 'ungrouped') {
|
|
290
|
+
return [{}];
|
|
291
|
+
}
|
|
292
|
+
if (!mayHoldKeychainItem(access.role, item)) {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
const own = keychainGroupFor(item);
|
|
296
|
+
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
297
|
+
if (own === 'identity' || own === 'wallet') {
|
|
298
|
+
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
299
|
+
}
|
|
300
|
+
return plan;
|
|
225
301
|
}
|
|
226
302
|
/**
|
|
227
303
|
* The option a WRITE of `item` carries. THROWS when this app may not hold the
|
|
@@ -232,8 +308,8 @@ function keychainWriteOption(access, item) {
|
|
|
232
308
|
if (access.mode === 'ungrouped') {
|
|
233
309
|
return {};
|
|
234
310
|
}
|
|
235
|
-
if (!
|
|
236
|
-
throw new Error(`This app (keychain role "${access.role}") may not
|
|
311
|
+
if (!mayWriteKeychainItem(access.role, item)) {
|
|
312
|
+
throw new Error(`This app (keychain role "${access.role}") may not write a ${item} keychain item; only Commons writes the identity and the keys the wallet spends.`);
|
|
237
313
|
}
|
|
238
314
|
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
239
315
|
}
|
|
@@ -301,19 +377,29 @@ async function deleteThroughPlan(store, key, options, plan) {
|
|
|
301
377
|
}
|
|
302
378
|
/**
|
|
303
379
|
* `store` with every call routed to `item`'s groups for this app: reads walk
|
|
304
|
-
* the read plan, writes carry the write option (throwing
|
|
305
|
-
* not
|
|
306
|
-
*
|
|
380
|
+
* the read plan (evaluated per read), writes carry the write option (throwing
|
|
381
|
+
* for a role that may not write the item), deletes reach every location. A
|
|
382
|
+
* secret WRITE also deletes the item's copy in the shared group (R4-6): a value
|
|
383
|
+
* Commons writes replaces whatever any app could read there, and a copy that
|
|
384
|
+
* cannot be removed fails the write instead of staying readable. The constants
|
|
385
|
+
* of the wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still
|
|
386
|
+
* reads.
|
|
307
387
|
*/
|
|
308
388
|
function groupedKeychainStore(store, access, item) {
|
|
309
|
-
const
|
|
389
|
+
const sharedCopy = access.mode === 'grouped' && (item === 'identity-secret' || item === 'spending-key')
|
|
390
|
+
? option(keychainAccessGroupValue(access, 'shared'))
|
|
391
|
+
: null;
|
|
310
392
|
const wrapped = {
|
|
311
|
-
getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {},
|
|
393
|
+
getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, keychainReadPlan(access, item)),
|
|
312
394
|
setItemAsync: async (key, value, options) => {
|
|
313
395
|
const group = keychainWriteOption(access, item);
|
|
314
396
|
await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
|
|
397
|
+
if (sharedCopy) {
|
|
398
|
+
const { keychainAccessible: _accessible, ...rest } = (options ?? {});
|
|
399
|
+
await store.deleteItemAsync(key, { ...rest, ...sharedCopy });
|
|
400
|
+
}
|
|
315
401
|
},
|
|
316
|
-
deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {},
|
|
402
|
+
deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, keychainDeletePlan(access, item)),
|
|
317
403
|
};
|
|
318
404
|
return Object.assign(Object.create(store), wrapped);
|
|
319
405
|
}
|
package/dist/esm/api/identity.js
CHANGED
|
@@ -21,6 +21,7 @@ import { checkContract, IDENTITY_PROOF_ACTIONS, identityLinkCreateResponseCheck,
|
|
|
21
21
|
import { identityKeyHistoryCheck } from '@oxy.so/contracts/client';
|
|
22
22
|
import { isAndroid, isWeb } from '../utils/platform.js';
|
|
23
23
|
import { logger } from '../logger/index.js';
|
|
24
|
+
import { extractErrorStatus } from '../utils/errorUtils.js';
|
|
24
25
|
/** Short-TTL read cache for public identity reads. */
|
|
25
26
|
const SHORT_TTL = 60 * 1000;
|
|
26
27
|
/**
|
|
@@ -198,7 +199,7 @@ export class IdentityApi {
|
|
|
198
199
|
try {
|
|
199
200
|
if (!oldPrivateKeyForRing)
|
|
200
201
|
throw new Error('no readable private key');
|
|
201
|
-
await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey });
|
|
202
|
+
await retireIdentityKey({ privateKey: oldPrivateKeyForRing, publicKey: oldPublicKey }, { ownerAccountId: userId });
|
|
202
203
|
}
|
|
203
204
|
catch (retainError) {
|
|
204
205
|
logger.warn('Could not keep the current key as a retired key; not rotating.', { component: 'oxy.identity', method: 'rotateKey' }, retainError);
|
|
@@ -316,10 +317,31 @@ export class IdentityApi {
|
|
|
316
317
|
}
|
|
317
318
|
}
|
|
318
319
|
}
|
|
320
|
+
// 8. The rotation deleted the encrypted backup (it was sealed under the
|
|
321
|
+
// replaced phrase). Seal a new one under the new phrase, carrying the
|
|
322
|
+
// retired ring — now with the key just replaced — so the coins under it
|
|
323
|
+
// survive a lost phone as long as the NEW phrase does (R4-4). Always,
|
|
324
|
+
// not only when one existed: after a rotation it is the only copy of the
|
|
325
|
+
// replaced key off this phone. Best-effort: the rotation already succeeded.
|
|
326
|
+
let backupRefreshed;
|
|
327
|
+
if (!isWeb() && !localPersistFailed) {
|
|
328
|
+
try {
|
|
329
|
+
await this.backup.create(pending.phrase);
|
|
330
|
+
backupRefreshed = true;
|
|
331
|
+
}
|
|
332
|
+
catch (backupError) {
|
|
333
|
+
backupRefreshed = false;
|
|
334
|
+
logger.warn('Key rotated, but the encrypted backup could not be re-created under the new phrase.', { component: 'oxy.identity', method: 'rotateKey' }, backupError);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
319
337
|
this.invalidate(userId);
|
|
320
|
-
return
|
|
321
|
-
|
|
322
|
-
|
|
338
|
+
return {
|
|
339
|
+
newPublicKey,
|
|
340
|
+
newPhrase: pending.phrase,
|
|
341
|
+
words: pending.words,
|
|
342
|
+
...(localPersistFailed ? { localPersistFailed: true } : {}),
|
|
343
|
+
...(backupRefreshed === undefined ? {} : { backupRefreshed }),
|
|
344
|
+
};
|
|
323
345
|
}
|
|
324
346
|
/**
|
|
325
347
|
* The signed-in user's signed, open-format data-export bundle
|
|
@@ -508,6 +530,10 @@ const BACKUP_ALGORITHM = 'xchacha20poly1305';
|
|
|
508
530
|
* to recognise WHICH identity a backup belongs to, but only a prefix.
|
|
509
531
|
*/
|
|
510
532
|
const PUBLIC_KEY_HINT_LENGTH = 16;
|
|
533
|
+
/** Whether a request error is the restore lookup's constant-shape 404. */
|
|
534
|
+
function isNotFound(error) {
|
|
535
|
+
return extractErrorStatus(error) === 404;
|
|
536
|
+
}
|
|
511
537
|
/** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
|
|
512
538
|
function toHex(bytes) {
|
|
513
539
|
let out = '';
|
|
@@ -567,7 +593,13 @@ export class IdentityBackupApi {
|
|
|
567
593
|
const publicKey = KeyManager.derivePublicKey(privateKey);
|
|
568
594
|
const createdAt = new Date().toISOString();
|
|
569
595
|
const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
|
|
570
|
-
const
|
|
596
|
+
const retiredKeys = await this.retiredKeysToCarry(publicKey);
|
|
597
|
+
const payload = {
|
|
598
|
+
privateKey,
|
|
599
|
+
publicKey,
|
|
600
|
+
createdAt,
|
|
601
|
+
...(retiredKeys.length > 0 ? { retiredKeys } : {}),
|
|
602
|
+
};
|
|
571
603
|
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
572
604
|
const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
|
|
573
605
|
const { nonce, ciphertext } = encryptAead(backupKey, plaintext, aad);
|
|
@@ -585,6 +617,115 @@ export class IdentityBackupApi {
|
|
|
585
617
|
cache: false,
|
|
586
618
|
});
|
|
587
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* The retired ring this device holds for the person, limited to keys Oxy's
|
|
622
|
+
* key history names as THIS account's retired keys (the ring's iOS group is
|
|
623
|
+
* writable by the wallet app too, and a previous person's ring can outlive a
|
|
624
|
+
* "Start over"). Empty where no ring is held (web, any app but Commons).
|
|
625
|
+
* Throws when the history cannot be read while a ring exists, so a backup
|
|
626
|
+
* never silently drops keys it should carry.
|
|
627
|
+
*/
|
|
628
|
+
async retiredKeysToCarry(currentPublicKey) {
|
|
629
|
+
if (isWeb())
|
|
630
|
+
return [];
|
|
631
|
+
const { exportRetiredIdentityKeys } = await import('../crypto/index.js');
|
|
632
|
+
const userId = this.ctx.oxy.session.userId;
|
|
633
|
+
if (!userId)
|
|
634
|
+
throw new Error('No authenticated user — sign in before backing up.');
|
|
635
|
+
let held;
|
|
636
|
+
try {
|
|
637
|
+
held = await exportRetiredIdentityKeys({ ownerAccountId: userId });
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
return []; // Not Commons: this app holds no ring to carry.
|
|
641
|
+
}
|
|
642
|
+
if (held.length === 0)
|
|
643
|
+
return [];
|
|
644
|
+
const history = await this.ctx.request('GET', '/identity/key-history', undefined, {
|
|
645
|
+
cache: false,
|
|
646
|
+
});
|
|
647
|
+
const parsed = checkContract(identityKeyHistoryCheck, history);
|
|
648
|
+
if (!parsed)
|
|
649
|
+
throw new Error('identity/key-history returned an unexpected response shape');
|
|
650
|
+
const retired = new Set(parsed.retired.map((entry) => entry.publicKey.toLowerCase()));
|
|
651
|
+
const current = currentPublicKey.toLowerCase();
|
|
652
|
+
return held.filter((key) => key.publicKey.toLowerCase() !== current && retired.has(key.publicKey.toLowerCase()));
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Fetch and open the backup the phrase locates, checking it is the phrase's
|
|
656
|
+
* own identity. With `missingIsNull`, `null` when there is none (404);
|
|
657
|
+
* otherwise the request's own error propagates.
|
|
658
|
+
*/
|
|
659
|
+
async open(phrase, missingIsNull = false) {
|
|
660
|
+
const { KeyManager, RecoveryPhraseService, decryptAead } = await import('../crypto/index.js');
|
|
661
|
+
const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
|
|
662
|
+
let envelope;
|
|
663
|
+
try {
|
|
664
|
+
envelope = await this.ctx.request('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
if (missingIsNull && isNotFound(error))
|
|
668
|
+
return null;
|
|
669
|
+
throw error;
|
|
670
|
+
}
|
|
671
|
+
if (envelope.algorithm !== BACKUP_ALGORITHM) {
|
|
672
|
+
throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
|
|
673
|
+
}
|
|
674
|
+
const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
|
|
675
|
+
const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
|
|
676
|
+
const payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
677
|
+
if (!payload.privateKey || !payload.publicKey) {
|
|
678
|
+
throw new Error('Backup payload is missing key material');
|
|
679
|
+
}
|
|
680
|
+
const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
|
|
681
|
+
const payloadPk = payload.publicKey.toLowerCase();
|
|
682
|
+
const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
|
|
683
|
+
if (phrasePk !== payloadPk || privatePk !== payloadPk) {
|
|
684
|
+
throw new Error('Backup payload does not match the recovery phrase');
|
|
685
|
+
}
|
|
686
|
+
return payload;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Keep every retired key a backup carries in this device's ring. Each pair
|
|
690
|
+
* must be healthy and must not be the current key; the envelope itself is
|
|
691
|
+
* authenticated by the phrase. Commons only (the ring refuses elsewhere).
|
|
692
|
+
* Resolves how many keys are now held; a key that fails is skipped.
|
|
693
|
+
*/
|
|
694
|
+
async keepCarriedRetiredKeys(payload) {
|
|
695
|
+
const { KeyManager, retireIdentityKey } = await import('../crypto/index.js');
|
|
696
|
+
// The backup is the signed-in account's (its phrase opened it): its keys
|
|
697
|
+
// are kept for that account only (R5-2).
|
|
698
|
+
const ownerAccountId = this.ctx.oxy.session.userId;
|
|
699
|
+
if (!ownerAccountId)
|
|
700
|
+
throw new Error('No authenticated user — sign in before restoring.');
|
|
701
|
+
let kept = 0;
|
|
702
|
+
for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
|
|
703
|
+
try {
|
|
704
|
+
if (typeof key?.privateKey !== 'string' || typeof key.publicKey !== 'string')
|
|
705
|
+
continue;
|
|
706
|
+
if (key.publicKey.toLowerCase() === payload.publicKey.toLowerCase())
|
|
707
|
+
continue;
|
|
708
|
+
if (KeyManager.derivePublicKey(key.privateKey).toLowerCase() !== key.publicKey.toLowerCase()) {
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey }, { ownerAccountId });
|
|
712
|
+
kept += 1;
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
logger.warn('Could not keep a retired key from the encrypted backup', { component: 'oxy.identity', method: 'backup.restoreRetiredKeys' }, error);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return kept;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Restore ONLY the retired keys the phrase's backup carries into this
|
|
722
|
+
* device's ring (Commons, after a phrase import or a silent device-backup
|
|
723
|
+
* restore). Resolves how many were kept; 0 when there is no backup.
|
|
724
|
+
*/
|
|
725
|
+
async restoreRetiredKeys(phrase) {
|
|
726
|
+
const payload = await this.open(phrase, true);
|
|
727
|
+
return payload ? this.keepCarriedRetiredKeys(payload) : 0;
|
|
728
|
+
}
|
|
588
729
|
/**
|
|
589
730
|
* Whether the signed-in user has a stored backup, plus its non-sensitive hint
|
|
590
731
|
* and timestamp. Returns no ciphertext and no locator.
|
|
@@ -615,24 +756,14 @@ export class IdentityBackupApi {
|
|
|
615
756
|
* fails authentication (tamper), or an existing identity blocks the import.
|
|
616
757
|
*/
|
|
617
758
|
async restore(phrase, options) {
|
|
618
|
-
const { KeyManager
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
if (!payload.privateKey || !payload.publicKey) {
|
|
628
|
-
throw new Error('Backup payload is missing key material');
|
|
629
|
-
}
|
|
630
|
-
const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
|
|
631
|
-
const payloadPk = payload.publicKey.toLowerCase();
|
|
632
|
-
const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
|
|
633
|
-
if (phrasePk !== payloadPk || privatePk !== payloadPk) {
|
|
634
|
-
throw new Error('Backup payload does not match the recovery phrase');
|
|
635
|
-
}
|
|
636
|
-
return KeyManager.importKeyPair(payload.privateKey, { overwrite: options?.overwrite === true });
|
|
759
|
+
const { KeyManager } = await import('../crypto/index.js');
|
|
760
|
+
const payload = await this.open(phrase);
|
|
761
|
+
if (!payload)
|
|
762
|
+
throw new Error('No encrypted backup was found for this recovery phrase.');
|
|
763
|
+
const publicKey = await KeyManager.importKeyPair(payload.privateKey, {
|
|
764
|
+
overwrite: options?.overwrite === true,
|
|
765
|
+
});
|
|
766
|
+
await this.keepCarriedRetiredKeys(payload);
|
|
767
|
+
return publicKey;
|
|
637
768
|
}
|
|
638
769
|
}
|
|
@@ -145,6 +145,20 @@ export async function readIdentityMarker() {
|
|
|
145
145
|
return null;
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Read the marker, THROWING when the storage cannot answer (instead of the
|
|
150
|
+
* fail-open `null` of {@link readIdentityMarker}). For a caller that must not
|
|
151
|
+
* mistake "unreadable" for "never written": the iOS keychain migration, which
|
|
152
|
+
* adopts a pre-split secret only when this marker vouches for it, and records
|
|
153
|
+
* once that there is nothing to adopt.
|
|
154
|
+
*/
|
|
155
|
+
export async function readIdentityMarkerStrict() {
|
|
156
|
+
const storage = await getStorage();
|
|
157
|
+
if (!storage) {
|
|
158
|
+
throw new Error('The identity marker storage is unavailable.');
|
|
159
|
+
}
|
|
160
|
+
return deserialize(await storage.getItem(IDENTITY_MARKER_STORAGE_KEY));
|
|
161
|
+
}
|
|
148
162
|
/**
|
|
149
163
|
* Write (create/replace) the marker. Returns `true` when it durably landed,
|
|
150
164
|
* `false` when storage was unavailable or the write threw. Callers treat a
|
package/dist/esm/crypto/index.js
CHANGED
|
@@ -18,11 +18,11 @@ export { readIdentityMarker, updateIdentityMarker } from './identityMarker.js';
|
|
|
18
18
|
// Organization keys on the device (docs/adr/0061-organization-identity-key.md D6)
|
|
19
19
|
export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys.js';
|
|
20
20
|
// iOS keychain access groups (docs/identity/ios-keychain-access-groups.md, ADR 0062)
|
|
21
|
-
export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
|
|
22
|
-
export { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
|
|
21
|
+
export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, mayWriteKeychainItem, DEV_KEYCHAIN_GROUP_SUFFIX, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
|
|
22
|
+
export { ensureKeychainGroupsMigrated, getPreSplitExposure, } from './keychainGroupMigration.js';
|
|
23
23
|
// Retired identity keys: kept for spending after a rotation or replacement
|
|
24
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';
|
|
25
|
+
export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, claimRetiredIdentityKeys, countQuarantinedRetiredKeys, unmirrorPersonalRetiredKeys, RetiredKeyRingUnreadableError, } from './retiredKeys.js';
|
|
26
26
|
export { SignatureService } from './signatureService.js';
|
|
27
27
|
export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase.js';
|
|
28
28
|
// Low-level primitives (encrypted backup, device transfer)
|