@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.
@@ -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 { type KeychainDefaultAccessGroup, loadKeychainDefaultAccessGroup } from '@oxy.so/protocol';
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. */
@@ -45,19 +58,34 @@ export const KEYCHAIN_ACCESS_GROUPS = {
45
58
 
46
59
  export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
47
60
 
61
+ /**
62
+ * Every group an item class can be assigned: the three Oxy groups, or `app`,
63
+ * this app's own group, whose name is the app's and so is not a constant.
64
+ */
65
+ export type KeychainGroup = KeychainAccessGroupName | 'app';
66
+
48
67
  /** What a keychain item IS, which decides its group. See the table above. */
49
- export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared';
68
+ export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared' | 'app';
50
69
 
51
70
  /** Which holder this binary is, from its default access group. */
52
71
  export type KeychainRole = 'holder' | 'wallet' | 'member';
53
72
 
54
73
  /**
55
74
  * - `grouped`: iOS with the probe answered. `prefix` ends with its dot
56
- * (`ABCDE12345.`); `defaultGroup` is the full default group string.
75
+ * (`ABCDE12345.`); `defaultGroup` is the full default group string;
76
+ * `appGroup`, when the native module proved it, this app's own group
77
+ * (`ABCDE12345.so.oxy.mention`). Absent on a native build that predates that
78
+ * probe: see {@link ownKeychainItemPlan} for what such a build does.
57
79
  * - `ungrouped`: Android, web, or an iOS native build without the probe.
58
80
  */
59
81
  export type KeychainAccess =
60
- | { mode: 'grouped'; prefix: string; defaultGroup: string; role: KeychainRole }
82
+ | {
83
+ mode: 'grouped';
84
+ prefix: string;
85
+ defaultGroup: string;
86
+ role: KeychainRole;
87
+ appGroup?: string;
88
+ }
61
89
  | { mode: 'ungrouped' };
62
90
 
63
91
  /** The `expo-secure-store` option this module sets (its real name since 58). */
@@ -77,7 +105,9 @@ export class KeychainAccessUnavailableError extends Error {
77
105
  * The single place each item class is assigned a group. A new kind of item
78
106
  * (F1's retained keys, for one) picks its class and reuses everything below.
79
107
  */
80
- export function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupName {
108
+ export function keychainGroupFor(item: Exclude<KeychainItemClass, 'app'>): KeychainAccessGroupName;
109
+ export function keychainGroupFor(item: KeychainItemClass): KeychainGroup;
110
+ export function keychainGroupFor(item: KeychainItemClass): KeychainGroup {
81
111
  switch (item) {
82
112
  case 'identity-secret':
83
113
  return 'identity';
@@ -85,6 +115,8 @@ export function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupNa
85
115
  return 'wallet';
86
116
  case 'shared':
87
117
  return 'shared';
118
+ case 'app':
119
+ return 'app';
88
120
  }
89
121
  }
90
122
 
@@ -96,12 +128,18 @@ export function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass)
96
128
  case 'wallet':
97
129
  return item !== 'identity-secret';
98
130
  case 'member':
99
- return item === 'shared';
131
+ return item === 'shared' || item === 'app';
100
132
  }
101
133
  }
102
134
 
103
- /** Parse the default access group the probe reported. Exported for tests. */
104
- export function keychainAccessFromDefaultGroup(defaultGroup: string): KeychainAccess {
135
+ /**
136
+ * Parse the default access group the probe reported, and the app's own group
137
+ * when the second probe proved it. Exported for tests.
138
+ */
139
+ export function keychainAccessFromDefaultGroup(
140
+ defaultGroup: string,
141
+ appGroup?: string,
142
+ ): KeychainAccess {
105
143
  const dot = defaultGroup.indexOf('.');
106
144
  if (dot <= 0 || dot === defaultGroup.length - 1) {
107
145
  // Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
@@ -115,7 +153,9 @@ export function keychainAccessFromDefaultGroup(defaultGroup: string): KeychainAc
115
153
  : group === KEYCHAIN_ACCESS_GROUPS.wallet
116
154
  ? 'wallet'
117
155
  : 'member';
118
- return { mode: 'grouped', prefix, defaultGroup, role };
156
+ return appGroup?.startsWith(prefix)
157
+ ? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
158
+ : { mode: 'grouped', prefix, defaultGroup, role };
119
159
  }
120
160
 
121
161
  let resolvedAccess: KeychainAccess | null = null;
@@ -144,10 +184,31 @@ export async function resolveKeychainAccess(): Promise<KeychainAccess> {
144
184
  if (probe.state === 'unavailable') {
145
185
  throw new KeychainAccessUnavailableError(probe.cause);
146
186
  }
147
- resolvedAccess =
148
- probe.state === 'resolved'
149
- ? keychainAccessFromDefaultGroup(probe.accessGroup)
150
- : { mode: 'ungrouped' };
187
+ if (probe.state !== 'resolved') {
188
+ resolvedAccess = { mode: 'ungrouped' };
189
+ return resolvedAccess;
190
+ }
191
+ const access = keychainAccessFromDefaultGroup(probe.accessGroup);
192
+ if (access.mode === 'ungrouped') {
193
+ resolvedAccess = access;
194
+ return resolvedAccess;
195
+ }
196
+ // The app's own group. A thrown probe is NOT degraded to "no app group": this
197
+ // process would then write the app's items to the old place while the next
198
+ // one reads the new place first, and see a stale value.
199
+ let own: KeychainApplicationAccessGroup;
200
+ try {
201
+ own = await loadKeychainApplicationAccessGroup(access.prefix);
202
+ } catch (cause) {
203
+ throw new KeychainAccessUnavailableError(cause);
204
+ }
205
+ if (own.state === 'unavailable') {
206
+ throw new KeychainAccessUnavailableError(own.cause);
207
+ }
208
+ resolvedAccess = keychainAccessFromDefaultGroup(
209
+ probe.accessGroup,
210
+ own.state === 'resolved' ? own.accessGroup : undefined,
211
+ );
151
212
  return resolvedAccess;
152
213
  }
153
214
 
@@ -156,12 +217,26 @@ export function resetKeychainAccessForTests(): void {
156
217
  resolvedAccess = null;
157
218
  }
158
219
 
159
- /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
220
+ /**
221
+ * The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
222
+ * group" (the app's default): always when ungrouped, and for `app` on a build
223
+ * that cannot place items there (see {@link ownKeychainItemPlan}).
224
+ */
160
225
  export function keychainAccessGroupValue(
161
226
  access: KeychainAccess,
162
- group: KeychainAccessGroupName,
227
+ group: KeychainGroup,
163
228
  ): string | undefined {
164
- return access.mode === 'grouped' ? `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
229
+ if (access.mode === 'ungrouped') {
230
+ return undefined;
231
+ }
232
+ if (group === 'app') {
233
+ // Commons' and the Peable wallet's default groups are theirs (no other app
234
+ // writes its own items there), so a build without the app-group probe
235
+ // keeps its own items there, as ADR 0062 D3 put them. Every other app's
236
+ // default is the shared group: no group, the calls it always made.
237
+ return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
238
+ }
239
+ return `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}`;
165
240
  }
166
241
 
167
242
  function option(value: string | undefined): KeychainGroupOption {
@@ -169,10 +244,11 @@ function option(value: string | undefined): KeychainGroupOption {
169
244
  }
170
245
 
171
246
  /**
172
- * The option bags a READ of `item` tries, in order: its own group, then the
173
- * shared group, where every item lived before the split (release 4 of the doc
174
- * drops that fallback). Empty when this app may not hold the item: the caller
175
- * answers `null` without touching the keychain.
247
+ * The option bags a READ of `item` tries, in order: its own group, then, for
248
+ * an identity secret or a spending key, the shared group, where those lived
249
+ * before the split (release 4 of the doc drops that fallback). Empty when this
250
+ * app may not hold the item: the caller answers `null` without touching the
251
+ * keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
176
252
  */
177
253
  export function keychainReadPlan(
178
254
  access: KeychainAccess,
@@ -186,7 +262,8 @@ export function keychainReadPlan(
186
262
  }
187
263
  const own = keychainGroupFor(item);
188
264
  const plan = [option(keychainAccessGroupValue(access, own))];
189
- if (own !== 'shared') {
265
+ if (own === 'identity' || own === 'wallet') {
266
+ // Where the split moved these items from.
190
267
  plan.push(option(keychainAccessGroupValue(access, 'shared')));
191
268
  }
192
269
  return plan;
@@ -224,29 +301,79 @@ export function keychainWriteOption(
224
301
  }
225
302
 
226
303
  /**
227
- * The plan for an app's OWN items (its session credential, its settings, an
228
- * imported wallet's phrase) in Commons and the Peable wallet, whose default
229
- * group the split changed: written to the default group explicitly — where a
230
- * write without a group goes anyway — and read from it FIRST, then from every
231
- * group (`{}`), where all of them were written before. Without the explicit
232
- * order a read could return whichever of two copies the keychain met first.
233
- * Deletes reach every group, as they always did.
304
+ * Who else writes an own item's NAME, which decides whose an earlier copy of
305
+ * it can be.
234
306
  *
235
- * Every other app (`member`) and Android/web keep the calls they always made.
307
+ * - `this-app`: no other app uses the name (the Peable wallet's own items). A
308
+ * copy anywhere this app can read is its own.
309
+ * - `every-oxy-app`: every Oxy app writes the same name (the SDK's session
310
+ * store: `oxy.auth.v1`, the warm token, the identity pin). Before the
311
+ * app group, every iOS app but Commons and the Peable wallet wrote it to the
312
+ * ONE copy in the shared group, so a copy there may be another app's
313
+ * session, or Commons' vault session. Only this app's own locations are
314
+ * read, written or deleted; the shared copy is left to the apps still
315
+ * running a build that uses it.
316
+ * - `every-oxy-app-withholding`: as `every-oxy-app`, for a value whose only
317
+ * effect is to WITHHOLD (the explicit sign-out marker). A shared copy still
318
+ * counts — honouring another app's marker can keep this app from signing in
319
+ * automatically, never sign it in — and its delete reaches that copy, as a
320
+ * release always did before.
236
321
  */
237
- export function ownKeychainItemPlan(access: KeychainAccess): {
322
+ export type OwnKeychainItemNames = 'this-app' | 'every-oxy-app' | 'every-oxy-app-withholding';
323
+
324
+ /**
325
+ * The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
326
+ * app's own group (`<prefix><bundle id>`), which no other app can read, and
327
+ * read from it FIRST. Then:
328
+ *
329
+ * - from where this app kept them before: Commons' and the Peable wallet's
330
+ * default group (D3), which no other app writes its own items to;
331
+ * - for `this-app` names, from every group (`{}`), where they were before the
332
+ * split; deletes reach every group;
333
+ * - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
334
+ * and deletes reach exactly the read locations, earlier ones first, so a
335
+ * failed delete never leaves an older copy as the only one;
336
+ * - for `every-oxy-app-withholding` names, the shared group last on read and
337
+ * first on delete.
338
+ *
339
+ * Nothing is copied or deleted to migrate: the next write lands in the app
340
+ * group and the read order makes it win, so a crash or a failure at any step
341
+ * leaves the value the app had.
342
+ *
343
+ * A native build whose module predates the app-group probe (`appGroup`
344
+ * absent) keeps its items where that build put them: Commons and the wallet in
345
+ * their default group, every other app the calls it always made (the one
346
+ * shared item, until that app's next native build). Android and web: no
347
+ * options.
348
+ */
349
+ export function ownKeychainItemPlan(
350
+ access: KeychainAccess,
351
+ names: OwnKeychainItemNames = 'this-app',
352
+ ): {
238
353
  read: KeychainGroupOption[];
239
354
  write: KeychainGroupOption;
240
355
  delete: KeychainGroupOption[];
241
356
  } {
242
- if (access.mode === 'ungrouped' || access.role === 'member') {
357
+ if (access.mode === 'ungrouped') {
243
358
  return { read: [{}], write: {}, delete: [{}] };
244
359
  }
245
- return {
246
- read: [option(access.defaultGroup), {}],
247
- write: option(access.defaultGroup),
248
- delete: [{}],
249
- };
360
+ const own = keychainAccessGroupValue(access, keychainGroupFor('app'));
361
+ if (own === undefined) {
362
+ return { read: [{}], write: {}, delete: [{}] };
363
+ }
364
+ const earlier =
365
+ access.role !== 'member' && own !== access.defaultGroup ? [option(access.defaultGroup)] : [];
366
+ const mine = [option(own), ...earlier];
367
+ switch (names) {
368
+ case 'this-app':
369
+ return { read: [...mine, {}], write: option(own), delete: [{}] };
370
+ case 'every-oxy-app':
371
+ return { read: mine, write: option(own), delete: [...mine].reverse() };
372
+ case 'every-oxy-app-withholding': {
373
+ const read = [...mine, option(keychainAccessGroupValue(access, 'shared'))];
374
+ return { read, write: option(own), delete: [...read].reverse() };
375
+ }
376
+ }
250
377
  }
251
378
 
252
379
  /** The subset of `expo-secure-store` a grouped store wraps. */