@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
|
@@ -56,6 +56,16 @@ export const KEYCHAIN_ACCESS_GROUPS = {
|
|
|
56
56
|
shared: 'group.so.oxy.shared',
|
|
57
57
|
} as const;
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The suffix a DEVELOPMENT build's restricted groups carry (`so.oxy.commons.dev`,
|
|
61
|
+
* `to.peable.app.dev`: `so.oxy.identity.dev`, `so.oxy.wallet.dev`). A dev build
|
|
62
|
+
* sits beside the production app on the same phone, is signed by the same team
|
|
63
|
+
* and runs JavaScript from a dev server, so it never shares a restricted group
|
|
64
|
+
* with production (`@oxy.so/app-preset` enforces the entitlements). The shared
|
|
65
|
+
* group has no variant: it holds nothing secret.
|
|
66
|
+
*/
|
|
67
|
+
export const DEV_KEYCHAIN_GROUP_SUFFIX = '.dev' as const;
|
|
68
|
+
|
|
59
69
|
export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
|
|
60
70
|
|
|
61
71
|
/**
|
|
@@ -85,6 +95,8 @@ export type KeychainAccess =
|
|
|
85
95
|
defaultGroup: string;
|
|
86
96
|
role: KeychainRole;
|
|
87
97
|
appGroup?: string;
|
|
98
|
+
/** {@link DEV_KEYCHAIN_GROUP_SUFFIX} for a dev build; absent for production. */
|
|
99
|
+
variant?: typeof DEV_KEYCHAIN_GROUP_SUFFIX;
|
|
88
100
|
}
|
|
89
101
|
| { mode: 'ungrouped' };
|
|
90
102
|
|
|
@@ -120,6 +132,17 @@ export function keychainGroupFor(item: KeychainItemClass): KeychainGroup {
|
|
|
120
132
|
}
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Whether `role` may WRITE `item`. Only Commons writes a secret: the Peable
|
|
137
|
+
* wallet reads, derives and signs with spending keys (and the retired ring)
|
|
138
|
+
* but never adds one — a key the wallet could add is one the wallet could
|
|
139
|
+
* choose. (The keychain itself lets every app in the wallet group write it;
|
|
140
|
+
* this is the SDK's half, and readers check what they find, ADR 0062.)
|
|
141
|
+
*/
|
|
142
|
+
export function mayWriteKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean {
|
|
143
|
+
return role === 'holder' || item === 'shared' || item === 'app';
|
|
144
|
+
}
|
|
145
|
+
|
|
123
146
|
/** Whether `role` holds the group `item` lives in. */
|
|
124
147
|
export function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean {
|
|
125
148
|
switch (role) {
|
|
@@ -147,15 +170,19 @@ export function keychainAccessFromDefaultGroup(
|
|
|
147
170
|
}
|
|
148
171
|
const prefix = defaultGroup.slice(0, dot + 1);
|
|
149
172
|
const group = defaultGroup.slice(dot + 1);
|
|
173
|
+
const dev = group.endsWith(DEV_KEYCHAIN_GROUP_SUFFIX);
|
|
174
|
+
const base = dev ? group.slice(0, -DEV_KEYCHAIN_GROUP_SUFFIX.length) : group;
|
|
150
175
|
const role: KeychainRole =
|
|
151
|
-
|
|
176
|
+
base === KEYCHAIN_ACCESS_GROUPS.identity
|
|
152
177
|
? 'holder'
|
|
153
|
-
:
|
|
178
|
+
: base === KEYCHAIN_ACCESS_GROUPS.wallet
|
|
154
179
|
? 'wallet'
|
|
155
180
|
: 'member';
|
|
181
|
+
// A `.dev` suffix on anything but a restricted group is not a variant.
|
|
182
|
+
const variant = dev && role !== 'member' ? { variant: DEV_KEYCHAIN_GROUP_SUFFIX } : {};
|
|
156
183
|
return appGroup?.startsWith(prefix)
|
|
157
|
-
? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
|
|
158
|
-
: { mode: 'grouped', prefix, defaultGroup, role };
|
|
184
|
+
? { mode: 'grouped', prefix, defaultGroup, role, appGroup, ...variant }
|
|
185
|
+
: { mode: 'grouped', prefix, defaultGroup, role, ...variant };
|
|
159
186
|
}
|
|
160
187
|
|
|
161
188
|
let resolvedAccess: KeychainAccess | null = null;
|
|
@@ -212,9 +239,11 @@ export async function resolveKeychainAccess(): Promise<KeychainAccess> {
|
|
|
212
239
|
return resolvedAccess;
|
|
213
240
|
}
|
|
214
241
|
|
|
215
|
-
/** Forget the memoized access. Tests only. @internal */
|
|
242
|
+
/** Forget the memoized access and the fallback state. Tests only. @internal */
|
|
216
243
|
export function resetKeychainAccessForTests(): void {
|
|
217
244
|
resolvedAccess = null;
|
|
245
|
+
holderSharedFallback = false;
|
|
246
|
+
walletSharedFallbackClosed = false;
|
|
218
247
|
}
|
|
219
248
|
|
|
220
249
|
/**
|
|
@@ -236,7 +265,46 @@ export function keychainAccessGroupValue(
|
|
|
236
265
|
// default is the shared group: no group, the calls it always made.
|
|
237
266
|
return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
|
|
238
267
|
}
|
|
239
|
-
|
|
268
|
+
const variant = group === 'shared' ? '' : (access.variant ?? '');
|
|
269
|
+
return `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}${variant}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether a SECRET read may still fall back to the pre-split shared group
|
|
274
|
+
* (R4-7). Every Oxy app can write that group, and keychain items survive an
|
|
275
|
+
* uninstall, so a secret found there is one Commons did not necessarily write.
|
|
276
|
+
*
|
|
277
|
+
* - Commons (`holder`): CLOSED, except while its one-shot migration has proved
|
|
278
|
+
* the shared copies are its own (they carry the key its sandbox marker
|
|
279
|
+
* records) and could not move them all yet; the migration opens it for that
|
|
280
|
+
* launch only ({@link setHolderSharedSecretFallback}).
|
|
281
|
+
* - The Peable wallet: open until Commons' migration has finished (the
|
|
282
|
+
* completion record Commons mirrors into the wallet group), because until
|
|
283
|
+
* then an upgraded phone's keys are still in the shared group; closed for
|
|
284
|
+
* good once it has seen that record ({@link closeWalletSharedSecretFallback}).
|
|
285
|
+
*/
|
|
286
|
+
let holderSharedFallback = false;
|
|
287
|
+
let walletSharedFallbackClosed = false;
|
|
288
|
+
|
|
289
|
+
/** The migration's switch for Commons' fallback. @internal */
|
|
290
|
+
export function setHolderSharedSecretFallback(open: boolean): void {
|
|
291
|
+
holderSharedFallback = open;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The wallet saw Commons' completion record: no shared fallback again in this process. @internal */
|
|
295
|
+
export function closeWalletSharedSecretFallback(): void {
|
|
296
|
+
walletSharedFallbackClosed = true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sharedFallbackOpen(access: Extract<KeychainAccess, { mode: 'grouped' }>): boolean {
|
|
300
|
+
switch (access.role) {
|
|
301
|
+
case 'holder':
|
|
302
|
+
return holderSharedFallback;
|
|
303
|
+
case 'wallet':
|
|
304
|
+
return !walletSharedFallbackClosed;
|
|
305
|
+
case 'member':
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
240
308
|
}
|
|
241
309
|
|
|
242
310
|
function option(value: string | undefined): KeychainGroupOption {
|
|
@@ -244,11 +312,13 @@ function option(value: string | undefined): KeychainGroupOption {
|
|
|
244
312
|
}
|
|
245
313
|
|
|
246
314
|
/**
|
|
247
|
-
* The option bags a READ of `item` tries, in order: its own group, then
|
|
248
|
-
* an identity secret or a spending key,
|
|
249
|
-
*
|
|
250
|
-
* app may not hold the item: the caller
|
|
251
|
-
*
|
|
315
|
+
* The option bags a READ of `item` tries, in order: its own group, then — for
|
|
316
|
+
* an identity secret or a spending key, and only while
|
|
317
|
+
* {@link sharedFallbackOpen} says so — the shared group, where those lived
|
|
318
|
+
* before the split. Empty when this app may not hold the item: the caller
|
|
319
|
+
* answers `null` without touching the keychain. Evaluated per read: the
|
|
320
|
+
* fallback closes during a process's life. An app's own items (`app`) take
|
|
321
|
+
* {@link ownKeychainItemPlan}.
|
|
252
322
|
*/
|
|
253
323
|
export function keychainReadPlan(
|
|
254
324
|
access: KeychainAccess,
|
|
@@ -262,22 +332,33 @@ export function keychainReadPlan(
|
|
|
262
332
|
}
|
|
263
333
|
const own = keychainGroupFor(item);
|
|
264
334
|
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
265
|
-
if (own === 'identity' || own === 'wallet') {
|
|
266
|
-
// Where the split moved these items from.
|
|
335
|
+
if ((own === 'identity' || own === 'wallet') && sharedFallbackOpen(access)) {
|
|
267
336
|
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
268
337
|
}
|
|
269
338
|
return plan;
|
|
270
339
|
}
|
|
271
340
|
|
|
272
341
|
/**
|
|
273
|
-
* Every place a DELETE of `item` must reach:
|
|
274
|
-
*
|
|
342
|
+
* Every place a DELETE of `item` must reach: its own group AND the shared
|
|
343
|
+
* group, whatever the read fallback says, so a delete never leaves a copy
|
|
344
|
+
* behind where every Oxy app can read it.
|
|
275
345
|
*/
|
|
276
346
|
export function keychainDeletePlan(
|
|
277
347
|
access: KeychainAccess,
|
|
278
348
|
item: KeychainItemClass,
|
|
279
349
|
): KeychainGroupOption[] {
|
|
280
|
-
|
|
350
|
+
if (access.mode === 'ungrouped') {
|
|
351
|
+
return [{}];
|
|
352
|
+
}
|
|
353
|
+
if (!mayHoldKeychainItem(access.role, item)) {
|
|
354
|
+
return [];
|
|
355
|
+
}
|
|
356
|
+
const own = keychainGroupFor(item);
|
|
357
|
+
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
358
|
+
if (own === 'identity' || own === 'wallet') {
|
|
359
|
+
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
360
|
+
}
|
|
361
|
+
return plan;
|
|
281
362
|
}
|
|
282
363
|
|
|
283
364
|
/**
|
|
@@ -292,9 +373,9 @@ export function keychainWriteOption(
|
|
|
292
373
|
if (access.mode === 'ungrouped') {
|
|
293
374
|
return {};
|
|
294
375
|
}
|
|
295
|
-
if (!
|
|
376
|
+
if (!mayWriteKeychainItem(access.role, item)) {
|
|
296
377
|
throw new Error(
|
|
297
|
-
`This app (keychain role "${access.role}") may not
|
|
378
|
+
`This app (keychain role "${access.role}") may not write a ${item} keychain item; only Commons writes the identity and the keys the wallet spends.`,
|
|
298
379
|
);
|
|
299
380
|
}
|
|
300
381
|
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
@@ -413,23 +494,39 @@ export async function deleteThroughPlan(
|
|
|
413
494
|
|
|
414
495
|
/**
|
|
415
496
|
* `store` with every call routed to `item`'s groups for this app: reads walk
|
|
416
|
-
* the read plan, writes carry the write option (throwing
|
|
417
|
-
* not
|
|
418
|
-
*
|
|
497
|
+
* the read plan (evaluated per read), writes carry the write option (throwing
|
|
498
|
+
* for a role that may not write the item), deletes reach every location. A
|
|
499
|
+
* secret WRITE also deletes the item's copy in the shared group (R4-6): a value
|
|
500
|
+
* Commons writes replaces whatever any app could read there, and a copy that
|
|
501
|
+
* cannot be removed fails the write instead of staying readable. The constants
|
|
502
|
+
* of the wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still
|
|
503
|
+
* reads.
|
|
419
504
|
*/
|
|
420
505
|
export function groupedKeychainStore<S extends KeychainStoreLike>(
|
|
421
506
|
store: S,
|
|
422
507
|
access: KeychainAccess,
|
|
423
508
|
item: KeychainItemClass,
|
|
424
509
|
): S {
|
|
425
|
-
const
|
|
510
|
+
const sharedCopy =
|
|
511
|
+
access.mode === 'grouped' && (item === 'identity-secret' || item === 'spending-key')
|
|
512
|
+
? option(keychainAccessGroupValue(access, 'shared'))
|
|
513
|
+
: null;
|
|
426
514
|
const wrapped: KeychainStoreLike = {
|
|
427
|
-
getItemAsync: (key, options) =>
|
|
515
|
+
getItemAsync: (key, options) =>
|
|
516
|
+
readThroughPlan(store, key, options ?? {}, keychainReadPlan(access, item)),
|
|
428
517
|
setItemAsync: async (key, value, options) => {
|
|
429
518
|
const group = keychainWriteOption(access, item);
|
|
430
519
|
await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
|
|
520
|
+
if (sharedCopy) {
|
|
521
|
+
const { keychainAccessible: _accessible, ...rest } = (options ?? {}) as Record<
|
|
522
|
+
string,
|
|
523
|
+
unknown
|
|
524
|
+
>;
|
|
525
|
+
await store.deleteItemAsync(key, { ...rest, ...sharedCopy });
|
|
526
|
+
}
|
|
431
527
|
},
|
|
432
|
-
deleteItemAsync: (key, options) =>
|
|
528
|
+
deleteItemAsync: (key, options) =>
|
|
529
|
+
deleteThroughPlan(store, key, options ?? {}, keychainDeletePlan(access, item)),
|
|
433
530
|
};
|
|
434
531
|
return Object.assign(Object.create(store) as S, wrapped);
|
|
435
532
|
}
|