@oxy.so/core 7.31.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 (32) 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/utils/keychainAccessGroups.js +111 -25
  9. package/dist/esm/api/identity.js +146 -23
  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 +9 -12
  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 +76 -17
  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/keychainGroupMigration.d.ts +28 -6
  21. package/dist/types/crypto/organizationKeys.d.ts +4 -2
  22. package/dist/types/crypto/retiredKeys.d.ts +30 -4
  23. package/dist/types/utils/keychainAccessGroups.d.ts +41 -11
  24. package/package.json +1 -1
  25. package/src/api/identity.ts +185 -40
  26. package/src/crypto/identityMarker.ts +15 -0
  27. package/src/crypto/index.ts +8 -1
  28. package/src/crypto/keyManager.ts +9 -15
  29. package/src/crypto/keychainGroupMigration.ts +390 -185
  30. package/src/crypto/organizationKeys.ts +38 -6
  31. package/src/crypto/retiredKeys.ts +87 -17
  32. package/src/utils/keychainAccessGroups.ts +121 -24
@@ -46,6 +46,7 @@ const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
46
46
  const platform_1 = require("../utils/platform");
47
47
  const identitySigner_1 = require("./identitySigner");
48
48
  const keychainGroupMigration_1 = require("./keychainGroupMigration");
49
+ const retiredKeys_1 = require("./retiredKeys");
49
50
  const keychainItems_1 = require("./keychainItems");
50
51
  Object.defineProperty(exports, "isOrganizationAccountId", { enumerable: true, get: function () { return keychainItems_1.isOrganizationAccountId; } });
51
52
  const ORGANIZATION_PHRASE_WORDS = 24;
@@ -115,9 +116,7 @@ async function organizationKeyFromPhrase(phrase) {
115
116
  async function secureStore(item) {
116
117
  const raw = await (0, protocol_1.loadSecureStore)();
117
118
  const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
118
- if (access.mode === 'grouped' && access.role === 'holder') {
119
- await (0, keychainGroupMigration_1.ensureKeychainGroupsMigrated)();
120
- }
119
+ await (0, keychainGroupMigration_1.prepareKeychainAccess)(access);
121
120
  return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, item);
122
121
  }
123
122
  const pairStore = () => secureStore('spending-key');
@@ -208,8 +207,10 @@ async function readLocalPair(accountId) {
208
207
  * it; on iOS only Commons holds the groups it writes (any other app throws).
209
208
  *
210
209
  * Refuses a pair whose public key does not derive from its private key, and a
211
- * phrase that does not derive the pair. Overwrites whatever this device held for
212
- * the organization (a replaced key is the caller's decision, ADR 0061 D7).
210
+ * phrase that does not derive the pair. A DIFFERENT key this device already
211
+ * held for the organization is first kept in the organization's retired ring
212
+ * (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
213
+ * cannot be confirmed nothing is overwritten and this throws.
213
214
  */
214
215
  async function storeOrganizationKey(accountId, key) {
215
216
  assertAccountId(accountId);
@@ -231,6 +232,18 @@ async function storeOrganizationKey(accountId, key) {
231
232
  throw new Error('On Android only Commons holds organization keys; this app has no identity signer store for them.');
232
233
  }
233
234
  const store = await pairStore();
235
+ const previous = await heldPrivateKey(store, accountId);
236
+ if (previous) {
237
+ const previousPublic = (0, secp256k1_1.deriveSecp256k1PublicKey)(previous);
238
+ if (previousPublic !== publicKey) {
239
+ try {
240
+ await (0, retiredKeys_1.retireIdentityKey)({ privateKey: previous, publicKey: previousPublic }, { accountId });
241
+ }
242
+ catch (error) {
243
+ throw new Error(`organization key: the key this device held could not be kept as a previous key, so it was not replaced (${error instanceof Error ? error.message : 'unknown'})`);
244
+ }
245
+ }
246
+ }
234
247
  await store.setItemAsync((0, keychainItems_1.organizationPrivateKeyName)(accountId), privateKey, pairOptions(store, true));
235
248
  await store.setItemAsync((0, keychainItems_1.organizationPublicKeyName)(accountId), publicKey, pairOptions(store, false));
236
249
  if (signer?.writeOrganization &&
@@ -311,6 +324,21 @@ async function listOrganizationKeys() {
311
324
  return [];
312
325
  return readIndex(await indexStore());
313
326
  }
327
+ /**
328
+ * The private key this device holds for `accountId`, whatever its public half
329
+ * says (an unhealthy pair is still a key coins may sit under): the slot, then
330
+ * on Android the signer store's copy. `null` when there is none.
331
+ */
332
+ async function heldPrivateKey(store, accountId) {
333
+ const local = await store.getItemAsync((0, keychainItems_1.organizationPrivateKeyName)(accountId), pairOptions(store, false));
334
+ if (local && (0, secp256k1_1.isValidSecp256k1PrivateKey)(local))
335
+ return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
336
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
337
+ const mirrored = (await signer?.readOrganization?.(accountId)) ?? null;
338
+ return mirrored && (0, secp256k1_1.isValidSecp256k1PrivateKey)(mirrored.privateKey)
339
+ ? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(mirrored.privateKey)
340
+ : null;
341
+ }
314
342
  /** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
315
343
  async function deleteOrganizationKey(accountId) {
316
344
  assertAccountId(accountId);
@@ -45,11 +45,14 @@
45
45
  Object.defineProperty(exports, "__esModule", { value: true });
46
46
  exports.retiredKeyScope = retiredKeyScope;
47
47
  exports.retireIdentityKey = retireIdentityKey;
48
+ exports.retireIdentityKeyDuringMigration = retireIdentityKeyDuringMigration;
48
49
  exports.listRetiredIdentityKeys = listRetiredIdentityKeys;
49
50
  exports.getRetiredPrivateKey = getRetiredPrivateKey;
50
51
  exports.forgetRetiredIdentityKey = forgetRetiredIdentityKey;
51
52
  exports.forgetRetiredIdentityKeys = forgetRetiredIdentityKeys;
52
53
  exports.syncRetiredKeys = syncRetiredKeys;
54
+ exports.exportRetiredIdentityKeys = exportRetiredIdentityKeys;
55
+ exports.retiredKeyFromPrivateKey = retiredKeyFromPrivateKey;
53
56
  exports.retiredKeyFromPhrase = retiredKeyFromPhrase;
54
57
  const bip39_1 = require("@scure/bip39");
55
58
  const english_js_1 = require("@scure/bip39/wordlists/english.js");
@@ -59,8 +62,8 @@ const logger_1 = require("../logger");
59
62
  const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
60
63
  const platform_1 = require("../utils/platform");
61
64
  const identitySigner_1 = require("./identitySigner");
65
+ const keychainItems_1 = require("./keychainItems");
62
66
  const keychainGroupMigration_1 = require("./keychainGroupMigration");
63
- const organizationKeys_1 = require("./organizationKeys");
64
67
  const PERSONAL_SCOPE = 'personal';
65
68
  /** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
66
69
  const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
@@ -71,15 +74,15 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
71
74
  * The secure store for the ring. On iOS every item is a `spending-key`
72
75
  * (ADR 0062): the private keys AND the index live in the wallet group, which
73
76
  * only Commons and the Peable wallet can read, never the shared one. In
74
- * Commons every access first awaits the access-group migration. On Android
75
- * the grouping is a no-op and Commons' own services below apply.
77
+ * Commons every access first awaits the access-group migration (`gate: false`
78
+ * is the migration itself, keeping a pre-split copy). On Android the grouping
79
+ * is a no-op and Commons' own services below apply.
76
80
  */
77
- async function ringStore() {
81
+ async function ringStore(gate = true) {
78
82
  const raw = await (0, protocol_1.loadSecureStore)();
79
83
  const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
80
- if (access.mode === 'grouped' && access.role === 'holder') {
81
- await (0, keychainGroupMigration_1.ensureKeychainGroupsMigrated)();
82
- }
84
+ if (gate)
85
+ await (0, keychainGroupMigration_1.prepareKeychainAccess)(access);
83
86
  return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, 'spending-key');
84
87
  }
85
88
  /** The ring a selector names: `personal`, or the organization's account id. */
@@ -87,7 +90,7 @@ function retiredKeyScope(selector) {
87
90
  const accountId = selector?.accountId;
88
91
  if (accountId === undefined)
89
92
  return PERSONAL_SCOPE;
90
- return (0, organizationKeys_1.isOrganizationAccountId)(accountId) ? accountId : null;
93
+ return (0, keychainItems_1.isOrganizationAccountId)(accountId) ? accountId : null;
91
94
  }
92
95
  function canonicalPublicKey(publicKey) {
93
96
  try {
@@ -134,7 +137,7 @@ function isIndexEntry(value) {
134
137
  return false;
135
138
  const entry = value;
136
139
  return (typeof entry.scope === 'string' &&
137
- (entry.scope === PERSONAL_SCOPE || (0, organizationKeys_1.isOrganizationAccountId)(entry.scope)) &&
140
+ (entry.scope === PERSONAL_SCOPE || (0, keychainItems_1.isOrganizationAccountId)(entry.scope)) &&
138
141
  typeof entry.publicKey === 'string' &&
139
142
  PUBLIC_KEY.test(entry.publicKey) &&
140
143
  typeof entry.retiredAt === 'number' &&
@@ -171,13 +174,24 @@ function toListed(entry) {
171
174
  ? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
172
175
  : { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
173
176
  }
174
- /** Whether this app writes the ring: Commons (Android: the signer store it registers; iOS: native). */
175
- function assertMayWrite() {
177
+ /**
178
+ * Whether this app writes the ring: Commons only (Android: the signer store it
179
+ * registers; iOS: the keychain role `holder`). The Peable wallet holds the
180
+ * wallet group too, but only reads, derives and signs: a key the wallet could
181
+ * add to the ring is a key it could choose (R4-14).
182
+ */
183
+ async function assertMayWrite() {
176
184
  if ((0, platform_1.isWeb)())
177
185
  throw new Error('retired key: only a native device holds one');
178
186
  if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()?.writeRetired) {
179
187
  throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
180
188
  }
189
+ if ((0, platform_1.isIOS)()) {
190
+ const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
191
+ if (access.mode === 'grouped' && access.role !== 'holder') {
192
+ throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
193
+ }
194
+ }
181
195
  }
182
196
  /**
183
197
  * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
@@ -187,16 +201,27 @@ function assertMayWrite() {
187
201
  * caller about to replace the key can stop before anything is lost.
188
202
  */
189
203
  async function retireIdentityKey(key, selector) {
204
+ await keepRetired(key, selector, true);
205
+ }
206
+ /**
207
+ * {@link retireIdentityKey} from inside the iOS access-group migration, which
208
+ * keeps a replaced pre-split copy before deleting it from the shared group and
209
+ * cannot await itself. @internal
210
+ */
211
+ async function retireIdentityKeyDuringMigration(key, selector) {
212
+ await keepRetired(key, selector, false);
213
+ }
214
+ async function keepRetired(key, selector, gate) {
190
215
  const scope = retiredKeyScope(selector);
191
216
  if (!scope)
192
217
  throw new Error('retired key: accountId must be a 24-hex or uuid account id');
193
- assertMayWrite();
218
+ await assertMayWrite();
194
219
  if (!isHealthyPair(key.privateKey, key.publicKey)) {
195
220
  throw new Error('retired key: the public key does not derive from the private key');
196
221
  }
197
222
  const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(key.privateKey);
198
223
  const publicKey = (0, secp256k1_1.normalizeSecp256k1PublicKey)(key.publicKey);
199
- const store = await ringStore();
224
+ const store = await ringStore(gate);
200
225
  await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
201
226
  const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
202
227
  if (readBack !== privateKey) {
@@ -294,7 +319,7 @@ async function forgetRetiredIdentityKey(publicKey, selector) {
294
319
  const canonical = canonicalPublicKey(publicKey);
295
320
  if (!scope || !canonical)
296
321
  throw new Error('retired key: invalid selector or public key');
297
- assertMayWrite();
322
+ await assertMayWrite();
298
323
  const store = await ringStore();
299
324
  await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
300
325
  const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
@@ -302,10 +327,12 @@ async function forgetRetiredIdentityKey(publicKey, selector) {
302
327
  await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
303
328
  }
304
329
  /**
305
- * Forget every retired key of one ring (the person's, or an organization's):
306
- * called when the identity itself is deleted from this device, so a phone
307
- * handed on keeps none of the person's old keys. Commons only. Resolves how
308
- * many it removed.
330
+ * Forget every retired key of one ring (the person's, or an organization's).
331
+ * Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
332
+ * delete-account flow), which tells them the coins go with it. Deleting the
333
+ * identity from this device — "Start over", a forced delete — never calls it:
334
+ * the ring is money, and the same person restoring their identity later must
335
+ * find it (R4-4). Resolves how many it removed.
309
336
  */
310
337
  async function forgetRetiredIdentityKeys(selector) {
311
338
  const scope = retiredKeyScope(selector);
@@ -352,6 +379,41 @@ async function syncRetiredKeys() {
352
379
  }
353
380
  return mirrored;
354
381
  }
382
+ /**
383
+ * Every retired key of `selector`'s ring WITH its private half, for the
384
+ * encrypted identity backup (`oxy.identity.backup.create`), which carries them
385
+ * under the CURRENT phrase so a lost phone costs no more than the current
386
+ * phrase does (R4-4). Commons only; an entry whose private half is missing or
387
+ * does not derive its public key is left out. Never log or transmit the result
388
+ * except inside that encrypted envelope.
389
+ */
390
+ async function exportRetiredIdentityKeys(selector) {
391
+ const scope = retiredKeyScope(selector);
392
+ if (!scope || (0, platform_1.isWeb)())
393
+ return [];
394
+ await assertMayWrite();
395
+ const out = [];
396
+ for (const entry of await listRetiredIdentityKeys(selector)) {
397
+ const privateKey = await getRetiredPrivateKey(entry.publicKey, selector);
398
+ if (privateKey)
399
+ out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
400
+ }
401
+ return out;
402
+ }
403
+ /**
404
+ * The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
405
+ * for a person whose old identity was imported as a private key and so has no
406
+ * phrase to re-enter. Stores nothing: the caller checks the public key against
407
+ * the account's key history and only then calls {@link retireIdentityKey}.
408
+ */
409
+ function retiredKeyFromPrivateKey(privateKeyHex) {
410
+ const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
411
+ if (!/^[0-9a-f]{64}$/.test(trimmed) || !(0, secp256k1_1.isValidSecp256k1PrivateKey)(trimmed)) {
412
+ throw new Error('retired key: not a valid private key');
413
+ }
414
+ const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(trimmed);
415
+ return { privateKey, publicKey: publicKeyOf(privateKey) };
416
+ }
355
417
  /**
356
418
  * The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
357
419
  * and organization-key derivation; 12–24 words). Stores nothing: the caller
@@ -1,12 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.KeychainAccessUnavailableError = exports.KEYCHAIN_ACCESS_GROUPS = void 0;
3
+ exports.KeychainAccessUnavailableError = exports.DEV_KEYCHAIN_GROUP_SUFFIX = exports.KEYCHAIN_ACCESS_GROUPS = void 0;
4
4
  exports.keychainGroupFor = keychainGroupFor;
5
+ exports.mayWriteKeychainItem = mayWriteKeychainItem;
5
6
  exports.mayHoldKeychainItem = mayHoldKeychainItem;
6
7
  exports.keychainAccessFromDefaultGroup = keychainAccessFromDefaultGroup;
7
8
  exports.resolveKeychainAccess = resolveKeychainAccess;
8
9
  exports.resetKeychainAccessForTests = resetKeychainAccessForTests;
9
10
  exports.keychainAccessGroupValue = keychainAccessGroupValue;
11
+ exports.setHolderSharedSecretFallback = setHolderSharedSecretFallback;
12
+ exports.closeWalletSharedSecretFallback = closeWalletSharedSecretFallback;
10
13
  exports.keychainReadPlan = keychainReadPlan;
11
14
  exports.keychainDeletePlan = keychainDeletePlan;
12
15
  exports.keychainWriteOption = keychainWriteOption;
@@ -65,6 +68,15 @@ exports.KEYCHAIN_ACCESS_GROUPS = {
65
68
  wallet: 'so.oxy.wallet',
66
69
  shared: 'group.so.oxy.shared',
67
70
  };
71
+ /**
72
+ * The suffix a DEVELOPMENT build's restricted groups carry (`so.oxy.commons.dev`,
73
+ * `to.peable.app.dev`: `so.oxy.identity.dev`, `so.oxy.wallet.dev`). A dev build
74
+ * sits beside the production app on the same phone, is signed by the same team
75
+ * and runs JavaScript from a dev server, so it never shares a restricted group
76
+ * with production (`@oxy.so/app-preset` enforces the entitlements). The shared
77
+ * group has no variant: it holds nothing secret.
78
+ */
79
+ exports.DEV_KEYCHAIN_GROUP_SUFFIX = '.dev';
68
80
  /** Thrown while the probe cannot answer (before first unlock). Transient. */
69
81
  class KeychainAccessUnavailableError extends Error {
70
82
  constructor(cause) {
@@ -86,6 +98,16 @@ function keychainGroupFor(item) {
86
98
  return 'app';
87
99
  }
88
100
  }
101
+ /**
102
+ * Whether `role` may WRITE `item`. Only Commons writes a secret: the Peable
103
+ * wallet reads, derives and signs with spending keys (and the retired ring)
104
+ * but never adds one — a key the wallet could add is one the wallet could
105
+ * choose. (The keychain itself lets every app in the wallet group write it;
106
+ * this is the SDK's half, and readers check what they find, ADR 0062.)
107
+ */
108
+ function mayWriteKeychainItem(role, item) {
109
+ return role === 'holder' || item === 'shared' || item === 'app';
110
+ }
89
111
  /** Whether `role` holds the group `item` lives in. */
90
112
  function mayHoldKeychainItem(role, item) {
91
113
  switch (role) {
@@ -109,14 +131,18 @@ function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
109
131
  }
110
132
  const prefix = defaultGroup.slice(0, dot + 1);
111
133
  const group = defaultGroup.slice(dot + 1);
112
- const role = group === exports.KEYCHAIN_ACCESS_GROUPS.identity
134
+ const dev = group.endsWith(exports.DEV_KEYCHAIN_GROUP_SUFFIX);
135
+ const base = dev ? group.slice(0, -exports.DEV_KEYCHAIN_GROUP_SUFFIX.length) : group;
136
+ const role = base === exports.KEYCHAIN_ACCESS_GROUPS.identity
113
137
  ? 'holder'
114
- : group === exports.KEYCHAIN_ACCESS_GROUPS.wallet
138
+ : base === exports.KEYCHAIN_ACCESS_GROUPS.wallet
115
139
  ? 'wallet'
116
140
  : 'member';
141
+ // A `.dev` suffix on anything but a restricted group is not a variant.
142
+ const variant = dev && role !== 'member' ? { variant: exports.DEV_KEYCHAIN_GROUP_SUFFIX } : {};
117
143
  return appGroup?.startsWith(prefix)
118
- ? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
119
- : { mode: 'grouped', prefix, defaultGroup, role };
144
+ ? { mode: 'grouped', prefix, defaultGroup, role, appGroup, ...variant }
145
+ : { mode: 'grouped', prefix, defaultGroup, role, ...variant };
120
146
  }
121
147
  let resolvedAccess = null;
122
148
  /**
@@ -169,9 +195,11 @@ async function resolveKeychainAccess() {
169
195
  resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
170
196
  return resolvedAccess;
171
197
  }
172
- /** Forget the memoized access. Tests only. @internal */
198
+ /** Forget the memoized access and the fallback state. Tests only. @internal */
173
199
  function resetKeychainAccessForTests() {
174
200
  resolvedAccess = null;
201
+ holderSharedFallback = false;
202
+ walletSharedFallbackClosed = false;
175
203
  }
176
204
  /**
177
205
  * The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
@@ -189,17 +217,54 @@ function keychainAccessGroupValue(access, group) {
189
217
  // default is the shared group: no group, the calls it always made.
190
218
  return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
191
219
  }
192
- return `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}`;
220
+ const variant = group === 'shared' ? '' : (access.variant ?? '');
221
+ return `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}${variant}`;
222
+ }
223
+ /**
224
+ * Whether a SECRET read may still fall back to the pre-split shared group
225
+ * (R4-7). Every Oxy app can write that group, and keychain items survive an
226
+ * uninstall, so a secret found there is one Commons did not necessarily write.
227
+ *
228
+ * - Commons (`holder`): CLOSED, except while its one-shot migration has proved
229
+ * the shared copies are its own (they carry the key its sandbox marker
230
+ * records) and could not move them all yet; the migration opens it for that
231
+ * launch only ({@link setHolderSharedSecretFallback}).
232
+ * - The Peable wallet: open until Commons' migration has finished (the
233
+ * completion record Commons mirrors into the wallet group), because until
234
+ * then an upgraded phone's keys are still in the shared group; closed for
235
+ * good once it has seen that record ({@link closeWalletSharedSecretFallback}).
236
+ */
237
+ let holderSharedFallback = false;
238
+ let walletSharedFallbackClosed = false;
239
+ /** The migration's switch for Commons' fallback. @internal */
240
+ function setHolderSharedSecretFallback(open) {
241
+ holderSharedFallback = open;
242
+ }
243
+ /** The wallet saw Commons' completion record: no shared fallback again in this process. @internal */
244
+ function closeWalletSharedSecretFallback() {
245
+ walletSharedFallbackClosed = true;
246
+ }
247
+ function sharedFallbackOpen(access) {
248
+ switch (access.role) {
249
+ case 'holder':
250
+ return holderSharedFallback;
251
+ case 'wallet':
252
+ return !walletSharedFallbackClosed;
253
+ case 'member':
254
+ return false;
255
+ }
193
256
  }
194
257
  function option(value) {
195
258
  return value === undefined ? {} : { accessGroup: value };
196
259
  }
197
260
  /**
198
- * The option bags a READ of `item` tries, in order: its own group, then, for
199
- * an identity secret or a spending key, the shared group, where those lived
200
- * before the split (release 4 of the doc drops that fallback). Empty when this
201
- * app may not hold the item: the caller answers `null` without touching the
202
- * keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
261
+ * The option bags a READ of `item` tries, in order: its own group, then — for
262
+ * an identity secret or a spending key, and only while
263
+ * {@link sharedFallbackOpen} says so — the shared group, where those lived
264
+ * before the split. Empty when this app may not hold the item: the caller
265
+ * answers `null` without touching the keychain. Evaluated per read: the
266
+ * fallback closes during a process's life. An app's own items (`app`) take
267
+ * {@link ownKeychainItemPlan}.
203
268
  */
204
269
  function keychainReadPlan(access, item) {
205
270
  if (access.mode === 'ungrouped') {
@@ -210,18 +275,29 @@ function keychainReadPlan(access, item) {
210
275
  }
211
276
  const own = keychainGroupFor(item);
212
277
  const plan = [option(keychainAccessGroupValue(access, own))];
213
- if (own === 'identity' || own === 'wallet') {
214
- // Where the split moved these items from.
278
+ if ((own === 'identity' || own === 'wallet') && sharedFallbackOpen(access)) {
215
279
  plan.push(option(keychainAccessGroupValue(access, 'shared')));
216
280
  }
217
281
  return plan;
218
282
  }
219
283
  /**
220
- * Every place a DELETE of `item` must reach: the read plan, so a delete never
221
- * leaves a pre-split copy behind in the shared group.
284
+ * Every place a DELETE of `item` must reach: its own group AND the shared
285
+ * group, whatever the read fallback says, so a delete never leaves a copy
286
+ * behind where every Oxy app can read it.
222
287
  */
223
288
  function keychainDeletePlan(access, item) {
224
- return keychainReadPlan(access, item);
289
+ if (access.mode === 'ungrouped') {
290
+ return [{}];
291
+ }
292
+ if (!mayHoldKeychainItem(access.role, item)) {
293
+ return [];
294
+ }
295
+ const own = keychainGroupFor(item);
296
+ const plan = [option(keychainAccessGroupValue(access, own))];
297
+ if (own === 'identity' || own === 'wallet') {
298
+ plan.push(option(keychainAccessGroupValue(access, 'shared')));
299
+ }
300
+ return plan;
225
301
  }
226
302
  /**
227
303
  * The option a WRITE of `item` carries. THROWS when this app may not hold the
@@ -232,8 +308,8 @@ function keychainWriteOption(access, item) {
232
308
  if (access.mode === 'ungrouped') {
233
309
  return {};
234
310
  }
235
- if (!mayHoldKeychainItem(access.role, item)) {
236
- throw new Error(`This app (keychain role "${access.role}") may not hold a ${item} keychain item; only Commons writes the identity.`);
311
+ if (!mayWriteKeychainItem(access.role, item)) {
312
+ throw new Error(`This app (keychain role "${access.role}") may not write a ${item} keychain item; only Commons writes the identity and the keys the wallet spends.`);
237
313
  }
238
314
  return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
239
315
  }
@@ -301,19 +377,29 @@ async function deleteThroughPlan(store, key, options, plan) {
301
377
  }
302
378
  /**
303
379
  * `store` with every call routed to `item`'s groups for this app: reads walk
304
- * the read plan, writes carry the write option (throwing for a role that may
305
- * not hold the item), deletes reach every location. The constants of the
306
- * wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
380
+ * the read plan (evaluated per read), writes carry the write option (throwing
381
+ * for a role that may not write the item), deletes reach every location. A
382
+ * secret WRITE also deletes the item's copy in the shared group (R4-6): a value
383
+ * Commons writes replaces whatever any app could read there, and a copy that
384
+ * cannot be removed fails the write instead of staying readable. The constants
385
+ * of the wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still
386
+ * reads.
307
387
  */
308
388
  function groupedKeychainStore(store, access, item) {
309
- const plan = keychainReadPlan(access, item);
389
+ const sharedCopy = access.mode === 'grouped' && (item === 'identity-secret' || item === 'spending-key')
390
+ ? option(keychainAccessGroupValue(access, 'shared'))
391
+ : null;
310
392
  const wrapped = {
311
- getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, plan),
393
+ getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, keychainReadPlan(access, item)),
312
394
  setItemAsync: async (key, value, options) => {
313
395
  const group = keychainWriteOption(access, item);
314
396
  await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
397
+ if (sharedCopy) {
398
+ const { keychainAccessible: _accessible, ...rest } = (options ?? {});
399
+ await store.deleteItemAsync(key, { ...rest, ...sharedCopy });
400
+ }
315
401
  },
316
- deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, plan),
402
+ deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, keychainDeletePlan(access, item)),
317
403
  };
318
404
  return Object.assign(Object.create(store), wrapped);
319
405
  }