@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.
Files changed (40) hide show
  1. package/dist/cjs/api/identity.js +146 -23
  2. package/dist/cjs/crypto/identityMarker.js +15 -0
  3. package/dist/cjs/crypto/index.js +7 -2
  4. package/dist/cjs/crypto/keyManager.js +7 -10
  5. package/dist/cjs/crypto/keychainGroupMigration.js +327 -160
  6. package/dist/cjs/crypto/organizationKeys.js +33 -5
  7. package/dist/cjs/crypto/retiredKeys.js +80 -18
  8. package/dist/cjs/session/authStateStore.js +13 -0
  9. package/dist/cjs/session/index.js +5 -2
  10. package/dist/cjs/utils/keychainAccessGroups.js +208 -51
  11. package/dist/esm/api/identity.js +146 -23
  12. package/dist/esm/crypto/identityMarker.js +14 -0
  13. package/dist/esm/crypto/index.js +3 -3
  14. package/dist/esm/crypto/keyManager.js +9 -12
  15. package/dist/esm/crypto/keychainGroupMigration.js +327 -162
  16. package/dist/esm/crypto/organizationKeys.js +34 -6
  17. package/dist/esm/crypto/retiredKeys.js +76 -17
  18. package/dist/esm/session/authStateStore.js +12 -0
  19. package/dist/esm/session/index.js +1 -1
  20. package/dist/esm/utils/keychainAccessGroups.js +205 -51
  21. package/dist/types/api/identity.d.ts +36 -0
  22. package/dist/types/crypto/identityMarker.d.ts +8 -0
  23. package/dist/types/crypto/index.d.ts +4 -4
  24. package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
  25. package/dist/types/crypto/organizationKeys.d.ts +4 -2
  26. package/dist/types/crypto/retiredKeys.d.ts +30 -4
  27. package/dist/types/session/authStateStore.d.ts +9 -0
  28. package/dist/types/session/index.d.ts +2 -2
  29. package/dist/types/utils/keychainAccessGroups.d.ts +108 -26
  30. package/package.json +2 -2
  31. package/src/api/identity.ts +185 -40
  32. package/src/crypto/identityMarker.ts +15 -0
  33. package/src/crypto/index.ts +10 -1
  34. package/src/crypto/keyManager.ts +9 -15
  35. package/src/crypto/keychainGroupMigration.ts +396 -186
  36. package/src/crypto/organizationKeys.ts +38 -6
  37. package/src/crypto/retiredKeys.ts +87 -17
  38. package/src/session/authStateStore.ts +14 -0
  39. package/src/session/index.ts +8 -1
  40. 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 { 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. */
@@ -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
- | { mode: 'grouped'; prefix: string; defaultGroup: string; role: KeychainRole }
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
- /** Parse the default access group the probe reported. Exported for tests. */
104
- export function keychainAccessFromDefaultGroup(defaultGroup: string): KeychainAccess {
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
- group === KEYCHAIN_ACCESS_GROUPS.identity
176
+ base === KEYCHAIN_ACCESS_GROUPS.identity
114
177
  ? 'holder'
115
- : group === KEYCHAIN_ACCESS_GROUPS.wallet
178
+ : base === KEYCHAIN_ACCESS_GROUPS.wallet
116
179
  ? 'wallet'
117
180
  : 'member';
118
- return { mode: 'grouped', prefix, defaultGroup, role };
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
- resolvedAccess =
148
- probe.state === 'resolved'
149
- ? keychainAccessFromDefaultGroup(probe.accessGroup)
150
- : { mode: 'ungrouped' };
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
- /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
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: KeychainAccessGroupName,
256
+ group: KeychainGroup,
163
257
  ): string | undefined {
164
- return access.mode === 'grouped' ? `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
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 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.
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 !== 'shared') {
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: the read plan, so a delete never
197
- * leaves a pre-split copy behind in the shared group.
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
- return keychainReadPlan(access, item);
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 (!mayHoldKeychainItem(access.role, item)) {
376
+ if (!mayWriteKeychainItem(access.role, item)) {
219
377
  throw new Error(
220
- `This app (keychain role "${access.role}") may not hold a ${item} keychain item; only Commons writes the identity.`,
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
- * 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.
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
- * Every other app (`member`) and Android/web keep the calls they always made.
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(access: KeychainAccess): {
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' || access.role === 'member') {
438
+ if (access.mode === 'ungrouped') {
243
439
  return { read: [{}], write: {}, delete: [{}] };
244
440
  }
245
- return {
246
- read: [option(access.defaultGroup), {}],
247
- write: option(access.defaultGroup),
248
- delete: [{}],
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 for a role that may
290
- * not hold the item), deletes reach every location. The constants of the
291
- * wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
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 plan = keychainReadPlan(access, item);
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) => readThroughPlan(store, key, options ?? {}, plan),
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) => deleteThroughPlan(store, key, options ?? {}, plan),
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
  }