@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.
Files changed (33) hide show
  1. package/dist/cjs/api/identity.js +155 -24
  2. package/dist/cjs/crypto/identityMarker.js +15 -0
  3. package/dist/cjs/crypto/index.js +11 -2
  4. package/dist/cjs/crypto/keyManager.js +111 -16
  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 +315 -64
  8. package/dist/cjs/utils/keychainAccessGroups.js +111 -25
  9. package/dist/esm/api/identity.js +155 -24
  10. package/dist/esm/crypto/identityMarker.js +14 -0
  11. package/dist/esm/crypto/index.js +3 -3
  12. package/dist/esm/crypto/keyManager.js +113 -18
  13. package/dist/esm/crypto/keychainGroupMigration.js +327 -162
  14. package/dist/esm/crypto/organizationKeys.js +34 -6
  15. package/dist/esm/crypto/retiredKeys.js +306 -63
  16. package/dist/esm/utils/keychainAccessGroups.js +107 -24
  17. package/dist/types/api/identity.d.ts +36 -0
  18. package/dist/types/crypto/identityMarker.d.ts +8 -0
  19. package/dist/types/crypto/index.d.ts +3 -3
  20. package/dist/types/crypto/keyManager.d.ts +45 -1
  21. package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
  22. package/dist/types/crypto/organizationKeys.d.ts +4 -2
  23. package/dist/types/crypto/retiredKeys.d.ts +108 -17
  24. package/dist/types/utils/keychainAccessGroups.d.ts +41 -11
  25. package/package.json +1 -1
  26. package/src/api/identity.ts +198 -41
  27. package/src/crypto/identityMarker.ts +15 -0
  28. package/src/crypto/index.ts +12 -1
  29. package/src/crypto/keyManager.ts +127 -26
  30. package/src/crypto/keychainGroupMigration.ts +390 -185
  31. package/src/crypto/organizationKeys.ts +38 -6
  32. package/src/crypto/retiredKeys.ts +369 -65
  33. 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
- group === KEYCHAIN_ACCESS_GROUPS.identity
176
+ base === KEYCHAIN_ACCESS_GROUPS.identity
152
177
  ? 'holder'
153
- : group === KEYCHAIN_ACCESS_GROUPS.wallet
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
- return `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}`;
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, 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}.
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: the read plan, so a delete never
274
- * 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.
275
345
  */
276
346
  export function keychainDeletePlan(
277
347
  access: KeychainAccess,
278
348
  item: KeychainItemClass,
279
349
  ): KeychainGroupOption[] {
280
- 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;
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 (!mayHoldKeychainItem(access.role, item)) {
376
+ if (!mayWriteKeychainItem(access.role, item)) {
296
377
  throw new Error(
297
- `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.`,
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 for a role that may
417
- * not hold the item), deletes reach every location. The constants of the
418
- * 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.
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 plan = keychainReadPlan(access, item);
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) => readThroughPlan(store, key, options ?? {}, plan),
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) => deleteThroughPlan(store, key, options ?? {}, plan),
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
  }