@oxy.so/core 7.30.0 → 7.32.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 +146 -23
- package/dist/cjs/crypto/identityMarker.js +15 -0
- package/dist/cjs/crypto/index.js +7 -2
- package/dist/cjs/crypto/keyManager.js +7 -10
- package/dist/cjs/crypto/keychainGroupMigration.js +327 -160
- package/dist/cjs/crypto/organizationKeys.js +33 -5
- package/dist/cjs/crypto/retiredKeys.js +80 -18
- package/dist/cjs/session/authStateStore.js +13 -0
- package/dist/cjs/session/index.js +5 -2
- package/dist/cjs/utils/keychainAccessGroups.js +208 -51
- package/dist/esm/api/identity.js +146 -23
- package/dist/esm/crypto/identityMarker.js +14 -0
- package/dist/esm/crypto/index.js +3 -3
- package/dist/esm/crypto/keyManager.js +9 -12
- package/dist/esm/crypto/keychainGroupMigration.js +327 -162
- package/dist/esm/crypto/organizationKeys.js +34 -6
- package/dist/esm/crypto/retiredKeys.js +76 -17
- package/dist/esm/session/authStateStore.js +12 -0
- package/dist/esm/session/index.js +1 -1
- package/dist/esm/utils/keychainAccessGroups.js +205 -51
- 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 +4 -4
- 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 +30 -4
- 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 +108 -26
- package/package.json +2 -2
- package/src/api/identity.ts +185 -40
- package/src/crypto/identityMarker.ts +15 -0
- package/src/crypto/index.ts +10 -1
- package/src/crypto/keyManager.ts +9 -15
- package/src/crypto/keychainGroupMigration.ts +396 -186
- package/src/crypto/organizationKeys.ts +38 -6
- package/src/crypto/retiredKeys.ts +87 -17
- package/src/session/authStateStore.ts +14 -0
- package/src/session/index.ts +8 -1
- package/src/utils/keychainAccessGroups.ts +276 -52
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* | `<TEAM>.so.oxy.identity` | Commons only (listed FIRST) | `identity-secret` |
|
|
8
8
|
* | `<TEAM>.so.oxy.wallet` | Commons, the Peable wallet | `spending-key` |
|
|
9
9
|
* | `<TEAM>.group.so.oxy.shared` | every Oxy app | `shared` |
|
|
10
|
+
* | `<TEAM>.<bundle id>` | that app alone | `app` |
|
|
10
11
|
*
|
|
11
12
|
* - `identity-secret`: the personal root (primary and backup slots), the
|
|
12
13
|
* personal and organization recovery phrases, the organization index. Only
|
|
@@ -16,14 +17,21 @@
|
|
|
16
17
|
* (`deriveScopedSeed`, `signSocialReceive`). Commons writes them; the Peable
|
|
17
18
|
* wallet reads them.
|
|
18
19
|
* - `shared`: what every Oxy app may read: the shared public key and the
|
|
19
|
-
* device-session credential.
|
|
20
|
+
* device-session credential (the one cross-app session join point).
|
|
21
|
+
* - `app`: an app's OWN items, readable by no other app: its session
|
|
22
|
+
* credential (`oxy.auth.v1` and the rest of the SDK's session store), its
|
|
23
|
+
* identity pin, its settings, the Peable wallet's imported phrases. The group
|
|
24
|
+
* is the app's application identifier, which Apple makes every app a member
|
|
25
|
+
* of; no app lists it and no other app can name it. Android's counterpart is
|
|
26
|
+
* the app's own storage (ADR 0062 D6).
|
|
20
27
|
*
|
|
21
28
|
* The ROLE of this binary comes from the keychain itself: an item written
|
|
22
29
|
* without a group lands in the app's default group, the first entry of its
|
|
23
30
|
* `keychain-access-groups` entitlement, and `@oxy.so/app-preset` lets only
|
|
24
31
|
* Commons put the identity group first and only the Peable wallet the wallet
|
|
25
32
|
* group. The same probe gives the team prefix, which `kSecAttrAccessGroup`
|
|
26
|
-
* needs and JavaScript cannot otherwise see
|
|
33
|
+
* needs and JavaScript cannot otherwise see; a second probe proves the app's
|
|
34
|
+
* own group (`<prefix><bundle id>`) is writable.
|
|
27
35
|
*
|
|
28
36
|
* Android and web have no access groups: there every helper here answers the
|
|
29
37
|
* single-store behaviour (`ungrouped`), and Android's narrowing is Commons'
|
|
@@ -33,7 +41,12 @@
|
|
|
33
41
|
* This module never imports the identity stack: services' device-session store
|
|
34
42
|
* and Peable's wallet storage use it too.
|
|
35
43
|
*/
|
|
36
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
type KeychainApplicationAccessGroup,
|
|
46
|
+
type KeychainDefaultAccessGroup,
|
|
47
|
+
loadKeychainApplicationAccessGroup,
|
|
48
|
+
loadKeychainDefaultAccessGroup,
|
|
49
|
+
} from '@oxy.so/protocol';
|
|
37
50
|
import { isIOS } from './platform';
|
|
38
51
|
|
|
39
52
|
/** The group names after the team prefix, exactly as the entitlements list them. */
|
|
@@ -43,21 +56,48 @@ export const KEYCHAIN_ACCESS_GROUPS = {
|
|
|
43
56
|
shared: 'group.so.oxy.shared',
|
|
44
57
|
} as const;
|
|
45
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
|
+
|
|
46
69
|
export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
|
|
47
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Every group an item class can be assigned: the three Oxy groups, or `app`,
|
|
73
|
+
* this app's own group, whose name is the app's and so is not a constant.
|
|
74
|
+
*/
|
|
75
|
+
export type KeychainGroup = KeychainAccessGroupName | 'app';
|
|
76
|
+
|
|
48
77
|
/** What a keychain item IS, which decides its group. See the table above. */
|
|
49
|
-
export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared';
|
|
78
|
+
export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared' | 'app';
|
|
50
79
|
|
|
51
80
|
/** Which holder this binary is, from its default access group. */
|
|
52
81
|
export type KeychainRole = 'holder' | 'wallet' | 'member';
|
|
53
82
|
|
|
54
83
|
/**
|
|
55
84
|
* - `grouped`: iOS with the probe answered. `prefix` ends with its dot
|
|
56
|
-
* (`ABCDE12345.`); `defaultGroup` is the full default group string
|
|
85
|
+
* (`ABCDE12345.`); `defaultGroup` is the full default group string;
|
|
86
|
+
* `appGroup`, when the native module proved it, this app's own group
|
|
87
|
+
* (`ABCDE12345.so.oxy.mention`). Absent on a native build that predates that
|
|
88
|
+
* probe: see {@link ownKeychainItemPlan} for what such a build does.
|
|
57
89
|
* - `ungrouped`: Android, web, or an iOS native build without the probe.
|
|
58
90
|
*/
|
|
59
91
|
export type KeychainAccess =
|
|
60
|
-
| {
|
|
92
|
+
| {
|
|
93
|
+
mode: 'grouped';
|
|
94
|
+
prefix: string;
|
|
95
|
+
defaultGroup: string;
|
|
96
|
+
role: KeychainRole;
|
|
97
|
+
appGroup?: string;
|
|
98
|
+
/** {@link DEV_KEYCHAIN_GROUP_SUFFIX} for a dev build; absent for production. */
|
|
99
|
+
variant?: typeof DEV_KEYCHAIN_GROUP_SUFFIX;
|
|
100
|
+
}
|
|
61
101
|
| { mode: 'ungrouped' };
|
|
62
102
|
|
|
63
103
|
/** The `expo-secure-store` option this module sets (its real name since 58). */
|
|
@@ -77,7 +117,9 @@ export class KeychainAccessUnavailableError extends Error {
|
|
|
77
117
|
* The single place each item class is assigned a group. A new kind of item
|
|
78
118
|
* (F1's retained keys, for one) picks its class and reuses everything below.
|
|
79
119
|
*/
|
|
80
|
-
export function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupName
|
|
120
|
+
export function keychainGroupFor(item: Exclude<KeychainItemClass, 'app'>): KeychainAccessGroupName;
|
|
121
|
+
export function keychainGroupFor(item: KeychainItemClass): KeychainGroup;
|
|
122
|
+
export function keychainGroupFor(item: KeychainItemClass): KeychainGroup {
|
|
81
123
|
switch (item) {
|
|
82
124
|
case 'identity-secret':
|
|
83
125
|
return 'identity';
|
|
@@ -85,9 +127,22 @@ export function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupNa
|
|
|
85
127
|
return 'wallet';
|
|
86
128
|
case 'shared':
|
|
87
129
|
return 'shared';
|
|
130
|
+
case 'app':
|
|
131
|
+
return 'app';
|
|
88
132
|
}
|
|
89
133
|
}
|
|
90
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
|
+
|
|
91
146
|
/** Whether `role` holds the group `item` lives in. */
|
|
92
147
|
export function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean {
|
|
93
148
|
switch (role) {
|
|
@@ -96,12 +151,18 @@ export function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass)
|
|
|
96
151
|
case 'wallet':
|
|
97
152
|
return item !== 'identity-secret';
|
|
98
153
|
case 'member':
|
|
99
|
-
return item === 'shared';
|
|
154
|
+
return item === 'shared' || item === 'app';
|
|
100
155
|
}
|
|
101
156
|
}
|
|
102
157
|
|
|
103
|
-
/**
|
|
104
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Parse the default access group the probe reported, and the app's own group
|
|
160
|
+
* when the second probe proved it. Exported for tests.
|
|
161
|
+
*/
|
|
162
|
+
export function keychainAccessFromDefaultGroup(
|
|
163
|
+
defaultGroup: string,
|
|
164
|
+
appGroup?: string,
|
|
165
|
+
): KeychainAccess {
|
|
105
166
|
const dot = defaultGroup.indexOf('.');
|
|
106
167
|
if (dot <= 0 || dot === defaultGroup.length - 1) {
|
|
107
168
|
// Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
|
|
@@ -109,13 +170,19 @@ export function keychainAccessFromDefaultGroup(defaultGroup: string): KeychainAc
|
|
|
109
170
|
}
|
|
110
171
|
const prefix = defaultGroup.slice(0, dot + 1);
|
|
111
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;
|
|
112
175
|
const role: KeychainRole =
|
|
113
|
-
|
|
176
|
+
base === KEYCHAIN_ACCESS_GROUPS.identity
|
|
114
177
|
? 'holder'
|
|
115
|
-
:
|
|
178
|
+
: base === KEYCHAIN_ACCESS_GROUPS.wallet
|
|
116
179
|
? 'wallet'
|
|
117
180
|
: 'member';
|
|
118
|
-
|
|
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 } : {};
|
|
183
|
+
return appGroup?.startsWith(prefix)
|
|
184
|
+
? { mode: 'grouped', prefix, defaultGroup, role, appGroup, ...variant }
|
|
185
|
+
: { mode: 'grouped', prefix, defaultGroup, role, ...variant };
|
|
119
186
|
}
|
|
120
187
|
|
|
121
188
|
let resolvedAccess: KeychainAccess | null = null;
|
|
@@ -144,24 +211,100 @@ export async function resolveKeychainAccess(): Promise<KeychainAccess> {
|
|
|
144
211
|
if (probe.state === 'unavailable') {
|
|
145
212
|
throw new KeychainAccessUnavailableError(probe.cause);
|
|
146
213
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
214
|
+
if (probe.state !== 'resolved') {
|
|
215
|
+
resolvedAccess = { mode: 'ungrouped' };
|
|
216
|
+
return resolvedAccess;
|
|
217
|
+
}
|
|
218
|
+
const access = keychainAccessFromDefaultGroup(probe.accessGroup);
|
|
219
|
+
if (access.mode === 'ungrouped') {
|
|
220
|
+
resolvedAccess = access;
|
|
221
|
+
return resolvedAccess;
|
|
222
|
+
}
|
|
223
|
+
// The app's own group. A thrown probe is NOT degraded to "no app group": this
|
|
224
|
+
// process would then write the app's items to the old place while the next
|
|
225
|
+
// one reads the new place first, and see a stale value.
|
|
226
|
+
let own: KeychainApplicationAccessGroup;
|
|
227
|
+
try {
|
|
228
|
+
own = await loadKeychainApplicationAccessGroup(access.prefix);
|
|
229
|
+
} catch (cause) {
|
|
230
|
+
throw new KeychainAccessUnavailableError(cause);
|
|
231
|
+
}
|
|
232
|
+
if (own.state === 'unavailable') {
|
|
233
|
+
throw new KeychainAccessUnavailableError(own.cause);
|
|
234
|
+
}
|
|
235
|
+
resolvedAccess = keychainAccessFromDefaultGroup(
|
|
236
|
+
probe.accessGroup,
|
|
237
|
+
own.state === 'resolved' ? own.accessGroup : undefined,
|
|
238
|
+
);
|
|
151
239
|
return resolvedAccess;
|
|
152
240
|
}
|
|
153
241
|
|
|
154
|
-
/** Forget the memoized access. Tests only. @internal */
|
|
242
|
+
/** Forget the memoized access and the fallback state. Tests only. @internal */
|
|
155
243
|
export function resetKeychainAccessForTests(): void {
|
|
156
244
|
resolvedAccess = null;
|
|
245
|
+
holderSharedFallback = false;
|
|
246
|
+
walletSharedFallbackClosed = false;
|
|
157
247
|
}
|
|
158
248
|
|
|
159
|
-
/**
|
|
249
|
+
/**
|
|
250
|
+
* The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
|
|
251
|
+
* group" (the app's default): always when ungrouped, and for `app` on a build
|
|
252
|
+
* that cannot place items there (see {@link ownKeychainItemPlan}).
|
|
253
|
+
*/
|
|
160
254
|
export function keychainAccessGroupValue(
|
|
161
255
|
access: KeychainAccess,
|
|
162
|
-
group:
|
|
256
|
+
group: KeychainGroup,
|
|
163
257
|
): string | undefined {
|
|
164
|
-
|
|
258
|
+
if (access.mode === 'ungrouped') {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
if (group === 'app') {
|
|
262
|
+
// Commons' and the Peable wallet's default groups are theirs (no other app
|
|
263
|
+
// writes its own items there), so a build without the app-group probe
|
|
264
|
+
// keeps its own items there, as ADR 0062 D3 put them. Every other app's
|
|
265
|
+
// default is the shared group: no group, the calls it always made.
|
|
266
|
+
return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
|
|
267
|
+
}
|
|
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
|
+
}
|
|
165
308
|
}
|
|
166
309
|
|
|
167
310
|
function option(value: string | undefined): KeychainGroupOption {
|
|
@@ -169,10 +312,13 @@ function option(value: string | undefined): KeychainGroupOption {
|
|
|
169
312
|
}
|
|
170
313
|
|
|
171
314
|
/**
|
|
172
|
-
* The option bags a READ of `item` tries, in order: its own group, then
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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}.
|
|
176
322
|
*/
|
|
177
323
|
export function keychainReadPlan(
|
|
178
324
|
access: KeychainAccess,
|
|
@@ -186,21 +332,33 @@ export function keychainReadPlan(
|
|
|
186
332
|
}
|
|
187
333
|
const own = keychainGroupFor(item);
|
|
188
334
|
const plan = [option(keychainAccessGroupValue(access, own))];
|
|
189
|
-
if (own
|
|
335
|
+
if ((own === 'identity' || own === 'wallet') && sharedFallbackOpen(access)) {
|
|
190
336
|
plan.push(option(keychainAccessGroupValue(access, 'shared')));
|
|
191
337
|
}
|
|
192
338
|
return plan;
|
|
193
339
|
}
|
|
194
340
|
|
|
195
341
|
/**
|
|
196
|
-
* Every place a DELETE of `item` must reach:
|
|
197
|
-
*
|
|
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.
|
|
198
345
|
*/
|
|
199
346
|
export function keychainDeletePlan(
|
|
200
347
|
access: KeychainAccess,
|
|
201
348
|
item: KeychainItemClass,
|
|
202
349
|
): KeychainGroupOption[] {
|
|
203
|
-
|
|
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;
|
|
204
362
|
}
|
|
205
363
|
|
|
206
364
|
/**
|
|
@@ -215,38 +373,88 @@ export function keychainWriteOption(
|
|
|
215
373
|
if (access.mode === 'ungrouped') {
|
|
216
374
|
return {};
|
|
217
375
|
}
|
|
218
|
-
if (!
|
|
376
|
+
if (!mayWriteKeychainItem(access.role, item)) {
|
|
219
377
|
throw new Error(
|
|
220
|
-
`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.`,
|
|
221
379
|
);
|
|
222
380
|
}
|
|
223
381
|
return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
|
|
224
382
|
}
|
|
225
383
|
|
|
226
384
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
385
|
+
* Who else writes an own item's NAME, which decides whose an earlier copy of
|
|
386
|
+
* it can be.
|
|
387
|
+
*
|
|
388
|
+
* - `this-app`: no other app uses the name (the Peable wallet's own items). A
|
|
389
|
+
* copy anywhere this app can read is its own.
|
|
390
|
+
* - `every-oxy-app`: every Oxy app writes the same name (the SDK's session
|
|
391
|
+
* store: `oxy.auth.v1`, the warm token, the identity pin). Before the
|
|
392
|
+
* app group, every iOS app but Commons and the Peable wallet wrote it to the
|
|
393
|
+
* ONE copy in the shared group, so a copy there may be another app's
|
|
394
|
+
* session, or Commons' vault session. Only this app's own locations are
|
|
395
|
+
* read, written or deleted; the shared copy is left to the apps still
|
|
396
|
+
* running a build that uses it.
|
|
397
|
+
* - `every-oxy-app-withholding`: as `every-oxy-app`, for a value whose only
|
|
398
|
+
* effect is to WITHHOLD (the explicit sign-out marker). A shared copy still
|
|
399
|
+
* counts — honouring another app's marker can keep this app from signing in
|
|
400
|
+
* automatically, never sign it in — and its delete reaches that copy, as a
|
|
401
|
+
* release always did before.
|
|
402
|
+
*/
|
|
403
|
+
export type OwnKeychainItemNames = 'this-app' | 'every-oxy-app' | 'every-oxy-app-withholding';
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
|
|
407
|
+
* app's own group (`<prefix><bundle id>`), which no other app can read, and
|
|
408
|
+
* read from it FIRST. Then:
|
|
409
|
+
*
|
|
410
|
+
* - from where this app kept them before: Commons' and the Peable wallet's
|
|
411
|
+
* default group (D3), which no other app writes its own items to;
|
|
412
|
+
* - for `this-app` names, from every group (`{}`), where they were before the
|
|
413
|
+
* split; deletes reach every group;
|
|
414
|
+
* - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
|
|
415
|
+
* and deletes reach exactly the read locations, earlier ones first, so a
|
|
416
|
+
* failed delete never leaves an older copy as the only one;
|
|
417
|
+
* - for `every-oxy-app-withholding` names, the shared group last on read and
|
|
418
|
+
* first on delete.
|
|
234
419
|
*
|
|
235
|
-
*
|
|
420
|
+
* Nothing is copied or deleted to migrate: the next write lands in the app
|
|
421
|
+
* group and the read order makes it win, so a crash or a failure at any step
|
|
422
|
+
* leaves the value the app had.
|
|
423
|
+
*
|
|
424
|
+
* A native build whose module predates the app-group probe (`appGroup`
|
|
425
|
+
* absent) keeps its items where that build put them: Commons and the wallet in
|
|
426
|
+
* their default group, every other app the calls it always made (the one
|
|
427
|
+
* shared item, until that app's next native build). Android and web: no
|
|
428
|
+
* options.
|
|
236
429
|
*/
|
|
237
|
-
export function ownKeychainItemPlan(
|
|
430
|
+
export function ownKeychainItemPlan(
|
|
431
|
+
access: KeychainAccess,
|
|
432
|
+
names: OwnKeychainItemNames = 'this-app',
|
|
433
|
+
): {
|
|
238
434
|
read: KeychainGroupOption[];
|
|
239
435
|
write: KeychainGroupOption;
|
|
240
436
|
delete: KeychainGroupOption[];
|
|
241
437
|
} {
|
|
242
|
-
if (access.mode === 'ungrouped'
|
|
438
|
+
if (access.mode === 'ungrouped') {
|
|
243
439
|
return { read: [{}], write: {}, delete: [{}] };
|
|
244
440
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
write:
|
|
248
|
-
|
|
249
|
-
|
|
441
|
+
const own = keychainAccessGroupValue(access, keychainGroupFor('app'));
|
|
442
|
+
if (own === undefined) {
|
|
443
|
+
return { read: [{}], write: {}, delete: [{}] };
|
|
444
|
+
}
|
|
445
|
+
const earlier =
|
|
446
|
+
access.role !== 'member' && own !== access.defaultGroup ? [option(access.defaultGroup)] : [];
|
|
447
|
+
const mine = [option(own), ...earlier];
|
|
448
|
+
switch (names) {
|
|
449
|
+
case 'this-app':
|
|
450
|
+
return { read: [...mine, {}], write: option(own), delete: [{}] };
|
|
451
|
+
case 'every-oxy-app':
|
|
452
|
+
return { read: mine, write: option(own), delete: [...mine].reverse() };
|
|
453
|
+
case 'every-oxy-app-withholding': {
|
|
454
|
+
const read = [...mine, option(keychainAccessGroupValue(access, 'shared'))];
|
|
455
|
+
return { read, write: option(own), delete: [...read].reverse() };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
250
458
|
}
|
|
251
459
|
|
|
252
460
|
/** The subset of `expo-secure-store` a grouped store wraps. */
|
|
@@ -286,23 +494,39 @@ export async function deleteThroughPlan(
|
|
|
286
494
|
|
|
287
495
|
/**
|
|
288
496
|
* `store` with every call routed to `item`'s groups for this app: reads walk
|
|
289
|
-
* the read plan, writes carry the write option (throwing
|
|
290
|
-
* not
|
|
291
|
-
*
|
|
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.
|
|
292
504
|
*/
|
|
293
505
|
export function groupedKeychainStore<S extends KeychainStoreLike>(
|
|
294
506
|
store: S,
|
|
295
507
|
access: KeychainAccess,
|
|
296
508
|
item: KeychainItemClass,
|
|
297
509
|
): S {
|
|
298
|
-
const
|
|
510
|
+
const sharedCopy =
|
|
511
|
+
access.mode === 'grouped' && (item === 'identity-secret' || item === 'spending-key')
|
|
512
|
+
? option(keychainAccessGroupValue(access, 'shared'))
|
|
513
|
+
: null;
|
|
299
514
|
const wrapped: KeychainStoreLike = {
|
|
300
|
-
getItemAsync: (key, options) =>
|
|
515
|
+
getItemAsync: (key, options) =>
|
|
516
|
+
readThroughPlan(store, key, options ?? {}, keychainReadPlan(access, item)),
|
|
301
517
|
setItemAsync: async (key, value, options) => {
|
|
302
518
|
const group = keychainWriteOption(access, item);
|
|
303
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
|
+
}
|
|
304
527
|
},
|
|
305
|
-
deleteItemAsync: (key, options) =>
|
|
528
|
+
deleteItemAsync: (key, options) =>
|
|
529
|
+
deleteThroughPlan(store, key, options ?? {}, keychainDeletePlan(access, item)),
|
|
306
530
|
};
|
|
307
531
|
return Object.assign(Object.create(store) as S, wrapped);
|
|
308
532
|
}
|