@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
@@ -1,68 +1,117 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runKeychainGroupMigration = runKeychainGroupMigration;
4
+ exports.prepareKeychainAccess = prepareKeychainAccess;
5
+ exports.getPreSplitExposure = getPreSplitExposure;
4
6
  exports.ensureKeychainGroupsMigrated = ensureKeychainGroupsMigrated;
5
7
  exports.resetKeychainGroupMigrationForTests = resetKeychainGroupMigrationForTests;
6
8
  /**
7
9
  * Commons, iOS: move every identity item out of the shared keychain group
8
- * into the group it belongs to (docs/identity/ios-keychain-access-groups.md
9
- * "Migrating existing items", ADR 0062).
10
+ * into the group it belongs to, ONCE (docs/identity/ios-keychain-access-groups.md
11
+ * "Migrating existing items", ADR 0062; round-4 review R4-6, R4-7).
10
12
  *
11
13
  * Before the split Commons listed one access group, the shared one, so every
12
14
  * item it wrote landed there — the root key, both phrases, organization keys.
13
- * Each item is moved by VERIFY-BEFORE-DELETE:
15
+ * But every Oxy app can WRITE that group too, and keychain items survive an
16
+ * uninstall. So a value found there is adopted only when Commons can prove it
17
+ * wrote it:
14
18
  *
15
- * 1. read it from the shared group (an explicit `accessGroup`);
16
- * 2. write it to its new group;
17
- * 3. read it back from there and check it: a pair must be healthy (the
18
- * public key derives from the private one), any value byte-identical;
19
- * 4. only then delete it from the shared group.
19
+ * - **Provenance.** Commons' identity marker (`./identityMarker`) lives in its
20
+ * own sandbox (AsyncStorage): no other app can write it and an uninstall
21
+ * removes it. A personal key is adopted only when it IS the key that marker
22
+ * records; a phrase only when it derives it. Organization items are adopted
23
+ * only on such a proven device (before the split every Oxy app could already
24
+ * read and replace them, so a proven upgrade gains nothing by doubting them).
25
+ * No marker — a fresh install, a reinstall, a device that never ran a
26
+ * marker-aware Commons — adopts NOTHING from the shared group, ever.
27
+ * - **One shot.** When a run settles, a completion record is written to the
28
+ * identity group (Commons only) and mirrored into the wallet group (for the
29
+ * Peable wallet). With it, no later run looks at the shared group and no
30
+ * read falls back to it (`setHolderSharedSecretFallback`,
31
+ * `closeWalletSharedSecretFallback`).
32
+ * - **A different key is retired, then deleted (R4-6).** On a proven device a
33
+ * shared copy that is a valid key but not the one being kept (a replaced
34
+ * root, an old backup slot, an organization's previous key, a phrase of
35
+ * either) is kept in the retired ring (`./retiredKeys`, spending only), read
36
+ * back, and only then deleted from the shared group. Nothing that could
37
+ * hold money is destroyed, and nothing stays where every app can read it.
20
38
  *
21
- * INVARIANT: at every instant each item has at least one readable copy, and a
22
- * copy is never overwritten with a different value. A failure at any step
23
- * leaves the shared copy where it is; the next run (the next accessor call
24
- * for a thrown read, the next launch otherwise) resumes from whatever state
25
- * the last one reached:
39
+ * Each adopted item moves by VERIFY-BEFORE-DELETE: read it from the shared
40
+ * group, write it to its group, read it back (a pair must be healthy, a value
41
+ * byte-identical), and only then delete the shared copy. A failure at any step
42
+ * leaves the shared copy; the run is `incomplete`, no record is written, and
43
+ * on a proven device Commons' reads may still fall back to the shared group
44
+ * for that launch; the next launch resumes.
26
45
  *
27
- * - shared only → copy, verify, delete;
28
- * - shared and new, equal → (a crash after the copy) verify, delete;
29
- * - new only → done;
30
- * - shared and new DIFFER → `conflict`: both stay. The new group was written
31
- * after the split, so it is current, but the shared copy may be a replaced
32
- * key that still receives money, and no step here may destroy one.
33
- * - a pair that is not healthy → `unhealthy`: left in place, never moved.
34
- *
35
- * Every read the rest of the SDK makes goes through `keychainReadPlan` (its
36
- * group, then the shared group), so it finds an item wherever this run left it.
37
- * `ensureKeychainGroupsMigrated` is awaited by every KeyManager and
38
- * organization-key storage access in Commons, so no write of theirs can
39
- * interleave with a copy here (a copy racing a rotation could otherwise put
40
- * the replaced key back).
41
- *
42
- * Nothing is logged but item labels and outcomes; never a value.
46
+ * `ensureKeychainGroupsMigrated` is awaited by every KeyManager, organization
47
+ * key and retired-ring access in Commons, so no write of theirs can interleave
48
+ * with a copy here. Nothing is logged but item labels and outcomes; never a
49
+ * value.
43
50
  */
44
51
  const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
45
52
  const protocol_1 = require("@oxy.so/protocol");
53
+ const bip39_1 = require("@scure/bip39");
54
+ const english_js_1 = require("@scure/bip39/wordlists/english.js");
46
55
  const logger_1 = require("../logger");
47
56
  const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
57
+ const identityMarker_1 = require("./identityMarker");
48
58
  const keychainItems_1 = require("./keychainItems");
59
+ const retiredKeys_1 = require("./retiredKeys");
60
+ /**
61
+ * The completion record (identity group) and its mirror (wallet group). The
62
+ * record also remembers whether the CURRENT personal key, and which
63
+ * organization keys, were found in the shared group: every Oxy iOS app could
64
+ * read those before the split ({@link getPreSplitExposure}).
65
+ */
66
+ const COMPLETION_KEY = 'oxy_keychain_group_migration_v1';
67
+ const COMPLETION_SERVICE = 'oxy_keychain_group_migration';
68
+ function parseRecord(raw) {
69
+ if (!raw)
70
+ return null;
71
+ try {
72
+ const parsed = JSON.parse(raw);
73
+ return parsed && parsed.v === 1 && typeof parsed.completedAt === 'number'
74
+ ? parsed
75
+ : null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
49
81
  const slot = (key, item, service, deviceOnly = true) => ({
50
82
  key,
51
83
  service,
52
84
  group: (0, keychainAccessGroups_1.keychainGroupFor)(item),
53
85
  deviceOnly,
54
86
  });
55
- function isHealthyPair(privateKey, publicKey) {
56
- if (!privateKey || !publicKey)
57
- return false;
87
+ function publicKeyOf(privateKey) {
88
+ if (!privateKey || !(0, secp256k1_1.isValidSecp256k1PrivateKey)(privateKey))
89
+ return null;
58
90
  try {
59
- return ((0, secp256k1_1.deriveSecp256k1PublicKey)((0, secp256k1_1.normalizeSecp256k1PrivateKey)(privateKey)) ===
60
- (0, secp256k1_1.normalizeSecp256k1PublicKey)(publicKey));
91
+ return (0, secp256k1_1.deriveSecp256k1PublicKey)((0, secp256k1_1.normalizeSecp256k1PrivateKey)(privateKey));
61
92
  }
62
93
  catch {
63
- return false;
94
+ return null;
95
+ }
96
+ }
97
+ function canonicalPublic(publicKey) {
98
+ try {
99
+ return (0, secp256k1_1.normalizeSecp256k1PublicKey)(publicKey.trim());
100
+ }
101
+ catch {
102
+ return null;
64
103
  }
65
104
  }
105
+ /** The pair a phrase derives (`seed[0:32]`), or `null` for anything that is not a phrase. */
106
+ async function pairOfPhrase(phrase) {
107
+ const normalized = phrase.trim().toLowerCase().split(/\s+/).join(' ');
108
+ if (!(0, bip39_1.validateMnemonic)(normalized, english_js_1.wordlist))
109
+ return null;
110
+ const seed = await (0, bip39_1.mnemonicToSeed)(normalized);
111
+ const privateKey = Array.from(seed.subarray(0, 32), (b) => b.toString(16).padStart(2, '0')).join('');
112
+ const publicKey = publicKeyOf(privateKey);
113
+ return publicKey ? { privateKey, publicKey } : null;
114
+ }
66
115
  class Deferred extends Error {
67
116
  }
68
117
  class Mover {
@@ -88,153 +137,169 @@ class Mover {
88
137
  throw new Deferred(error instanceof Error ? error.message : 'keychain read threw');
89
138
  }
90
139
  }
91
- /**
92
- * Write `value` where the new group had nothing, then read it back; `true`
93
- * only when the new group holds exactly `value`. A copy that reads back as
94
- * something else is removed again (best effort), because reads look in the
95
- * new group FIRST and must not find it there.
96
- */
97
- async copy(s, value, replacing = false) {
140
+ /** Write `value` to the slot's group and read it back; `true` only when it holds exactly `value`. */
141
+ async write(s, value) {
98
142
  try {
99
143
  await this.store.setItemAsync(s.key, value, this.options(s, s.group, true));
100
- if ((await this.store.getItemAsync(s.key, this.options(s, s.group))) === value) {
101
- return true;
102
- }
144
+ return (await this.store.getItemAsync(s.key, this.options(s, s.group))) === value;
103
145
  }
104
146
  catch {
105
147
  return false;
106
148
  }
107
- if (replacing) {
108
- // A merge overwrote what was there; keep whatever is there now.
109
- return false;
110
- }
149
+ }
150
+ /**
151
+ * Write `value` where the slot's group had nothing, then read it back. A
152
+ * copy that reads back as something else is removed again (best effort):
153
+ * reads look in the slot's group first and must not find it there.
154
+ */
155
+ async copy(s, value) {
156
+ if (await this.write(s, value))
157
+ return true;
111
158
  try {
112
159
  await this.store.deleteItemAsync(s.key, this.options(s, s.group));
113
160
  }
114
161
  catch {
115
- // The next run reads it as a conflict and leaves both; the shared copy is intact.
162
+ // The shared copy is intact; the next run tries again.
116
163
  }
117
164
  return false;
118
165
  }
119
- async deleteShared(s) {
166
+ async deleteShared(slots) {
120
167
  try {
121
- await this.store.deleteItemAsync(s.key, this.options(s, 'shared'));
168
+ for (const s of slots)
169
+ await this.store.deleteItemAsync(s.key, this.options(s, 'shared'));
122
170
  return true;
123
171
  }
124
172
  catch {
125
173
  return false;
126
174
  }
127
175
  }
128
- async moveValue(unit) {
129
- const { slot: s } = unit;
130
- const shared = await this.read(s, 'shared');
131
- if (shared === null)
132
- return 'absent';
133
- const target = await this.read(s, s.group);
134
- let keep = target ?? shared;
135
- if (target !== null && target !== shared) {
136
- const merged = unit.merge?.(shared, target) ?? null;
137
- if (merged === null)
138
- return 'conflict';
139
- keep = merged;
176
+ /** Keep a valid private key in a ring, then delete the shared copies. */
177
+ async retireThenDelete(privateKey, ring, shared) {
178
+ const publicKey = publicKeyOf(privateKey);
179
+ if (!publicKey)
180
+ return (await this.deleteShared(shared)) ? 'discarded' : 'failed';
181
+ try {
182
+ // Resolves only once the ring read it back.
183
+ await (0, retiredKeys_1.retireIdentityKeyDuringMigration)({ privateKey, publicKey }, ring);
140
184
  }
141
- if (target !== keep && !(await this.copy(s, keep, target !== null)))
142
- return 'failed';
143
- // Verify the copy that is about to become the only one.
144
- if ((await this.read(s, s.group)) !== keep)
185
+ catch {
145
186
  return 'failed';
146
- return (await this.deleteShared(s)) ? 'moved' : 'failed';
147
- }
148
- async movePair(unit) {
149
- const halves = [unit.privateSlot, unit.publicSlot];
150
- // A half whose group IS the shared group stays: it is read, never moved.
151
- const movable = halves.filter((h) => h.group !== 'shared');
152
- const shared = await Promise.all(halves.map((h) => this.read(h, 'shared')));
153
- if (movable.every((h) => shared[halves.indexOf(h)] === null))
187
+ }
188
+ return (await this.deleteShared(shared)) ? 'retired' : 'failed';
189
+ }
190
+ /**
191
+ * A personal or organization pair. `keep` decides, from the shared private
192
+ * key, whether it is the key to adopt (`true`) or one to retire (`false`).
193
+ * A half whose own group IS the shared group (the shared slot's public key)
194
+ * is never moved or deleted here.
195
+ */
196
+ async movePair(privateSlot, publicSlot, keep, ring) {
197
+ const movable = [privateSlot, publicSlot].filter((h) => h.group !== 'shared');
198
+ const sharedPrivate = await this.read(privateSlot, 'shared');
199
+ const sharedPublic = publicSlot.group === 'shared' ? null : await this.read(publicSlot, 'shared');
200
+ if (sharedPrivate === null && sharedPublic === null)
154
201
  return 'absent';
155
- const target = await Promise.all(halves.map((h, i) => (h.group === 'shared' ? shared[i] : this.read(h, h.group))));
156
- for (const h of movable) {
157
- const i = halves.indexOf(h);
158
- if (shared[i] !== null && target[i] !== null && shared[i] !== target[i])
159
- return 'conflict';
202
+ const derived = publicKeyOf(sharedPrivate);
203
+ if (!sharedPrivate || !derived) {
204
+ // A public half alone, or a private half that is not a key: nothing to keep.
205
+ return (await this.deleteShared(movable)) ? 'discarded' : 'failed';
160
206
  }
161
- const finalPair = halves.map((_, i) => target[i] ?? shared[i]);
162
- if (!isHealthyPair(finalPair[0], finalPair[1]))
163
- return 'unhealthy';
164
- for (const h of movable) {
165
- const i = halves.indexOf(h);
166
- const value = shared[i];
167
- if (value !== null && target[i] === null && !(await this.copy(h, value)))
168
- return 'failed';
207
+ const targetPrivate = await this.read(privateSlot, privateSlot.group);
208
+ if (!keep(derived) || (targetPrivate !== null && targetPrivate !== sharedPrivate)) {
209
+ return this.retireThenDelete(sharedPrivate, ring, movable);
169
210
  }
170
- const readBack = await Promise.all(halves.map((h, i) => (h.group === 'shared' ? shared[i] : this.read(h, h.group))));
171
- if (readBack.some((v, i) => v !== finalPair[i]) || !isHealthyPair(readBack[0], readBack[1])) {
211
+ // Adopt (or finish adopting, after a run that died half-way): the private
212
+ // key, and (when its group is not shared) the public key it derives —
213
+ // never a stored public half that disagrees with it.
214
+ if (targetPrivate === null && !(await this.copy(privateSlot, sharedPrivate)))
172
215
  return 'failed';
216
+ if (publicSlot.group !== 'shared') {
217
+ const targetPublic = await this.read(publicSlot, publicSlot.group);
218
+ if (targetPublic === null || canonicalPublic(targetPublic) !== derived) {
219
+ if (!(await this.write(publicSlot, derived)))
220
+ return 'failed';
221
+ }
173
222
  }
174
- for (const h of movable) {
175
- if (shared[halves.indexOf(h)] !== null && !(await this.deleteShared(h)))
176
- return 'failed';
223
+ if (publicKeyOf(await this.read(privateSlot, privateSlot.group)) !== derived)
224
+ return 'failed';
225
+ return (await this.deleteShared(movable)) ? 'moved' : 'failed';
226
+ }
227
+ /** A phrase: adopted when it derives the key being kept, retired otherwise. */
228
+ async movePhrase(s, keep, ring) {
229
+ const shared = await this.read(s, 'shared');
230
+ if (shared === null)
231
+ return 'absent';
232
+ const pair = await pairOfPhrase(shared);
233
+ if (!pair)
234
+ return (await this.deleteShared([s])) ? 'discarded' : 'failed';
235
+ const target = await this.read(s, s.group);
236
+ if (!keep(pair.publicKey) || (target !== null && target !== shared)) {
237
+ return this.retireThenDelete(pair.privateKey, ring, [s]);
177
238
  }
178
- return 'moved';
239
+ if (target === null && !(await this.copy(s, shared)))
240
+ return 'failed';
241
+ return (await this.deleteShared([s])) ? 'moved' : 'failed';
179
242
  }
180
- move(unit) {
181
- return unit.kind === 'pair' ? this.movePair(unit) : this.moveValue(unit);
243
+ /** A non-secret value (a timestamp, the index): copied when the group has none, merged by `merge`. */
244
+ async moveValue(s, merge) {
245
+ const shared = await this.read(s, 'shared');
246
+ if (shared === null)
247
+ return 'absent';
248
+ const target = await this.read(s, s.group);
249
+ const keep = target === null ? shared : merge ? merge(shared, target) : target;
250
+ if (keep !== target) {
251
+ if (!(target === null ? await this.copy(s, keep) : await this.write(s, keep)))
252
+ return 'failed';
253
+ }
254
+ return (await this.deleteShared([s])) ? 'moved' : 'failed';
182
255
  }
183
256
  }
184
- /** The personal items, in the order they move. */
185
- function personalUnits() {
257
+ const personalPairs = () => {
186
258
  const v2 = (key, service, deviceOnly) => slot(key, 'identity-secret', service, deviceOnly);
187
259
  const legacy = (key, deviceOnly) => slot(key, 'identity-secret', undefined, deviceOnly);
188
260
  return [
189
261
  {
190
262
  label: 'primary',
191
- kind: 'pair',
192
263
  privateSlot: v2(keychainItems_1.V2_STORAGE_KEYS.PRIVATE_KEY, keychainItems_1.V2_PRIMARY_KEYCHAIN_SERVICE, true),
193
264
  publicSlot: v2(keychainItems_1.V2_STORAGE_KEYS.PUBLIC_KEY, keychainItems_1.V2_PRIMARY_KEYCHAIN_SERVICE, false),
194
265
  },
195
266
  {
196
267
  label: 'backup',
197
- kind: 'pair',
198
268
  privateSlot: v2(keychainItems_1.V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, keychainItems_1.V2_BACKUP_KEYCHAIN_SERVICE, true),
199
269
  publicSlot: v2(keychainItems_1.V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, keychainItems_1.V2_BACKUP_KEYCHAIN_SERVICE, false),
200
270
  },
201
- {
202
- label: 'backup-timestamp',
203
- kind: 'value',
204
- slot: v2(keychainItems_1.V2_STORAGE_KEYS.BACKUP_TIMESTAMP, keychainItems_1.V2_BACKUP_KEYCHAIN_SERVICE, false),
205
- },
206
271
  {
207
272
  label: 'legacy-primary',
208
- kind: 'pair',
209
273
  privateSlot: legacy(keychainItems_1.STORAGE_KEYS.PRIVATE_KEY, true),
210
274
  publicSlot: legacy(keychainItems_1.STORAGE_KEYS.PUBLIC_KEY, false),
211
275
  },
212
276
  {
213
277
  label: 'legacy-backup',
214
- kind: 'pair',
215
278
  privateSlot: legacy(keychainItems_1.STORAGE_KEYS.BACKUP_PRIVATE_KEY, true),
216
279
  publicSlot: legacy(keychainItems_1.STORAGE_KEYS.BACKUP_PUBLIC_KEY, false),
217
280
  },
218
- {
219
- label: 'legacy-backup-timestamp',
220
- kind: 'value',
221
- slot: legacy(keychainItems_1.STORAGE_KEYS.BACKUP_TIMESTAMP, false),
222
- },
223
- {
224
- label: 'phrase',
225
- kind: 'value',
226
- slot: slot(keychainItems_1.RECOVERY_MNEMONIC_STORAGE_KEY, 'identity-secret', keychainItems_1.RECOVERY_MNEMONIC_KEYCHAIN_SERVICE),
227
- },
228
281
  {
229
282
  // The private key moves to the wallet group; its public key stays shared.
230
283
  label: 'shared-slot',
231
- kind: 'pair',
232
284
  privateSlot: slot(keychainItems_1.STORAGE_KEYS.SHARED_PRIVATE_KEY, 'spending-key'),
233
285
  publicSlot: slot(keychainItems_1.STORAGE_KEYS.SHARED_PUBLIC_KEY, 'shared', undefined, false),
234
286
  },
235
287
  ];
236
- }
288
+ };
289
+ const TIMESTAMPS = [
290
+ {
291
+ label: 'backup-timestamp',
292
+ slot: slot(keychainItems_1.V2_STORAGE_KEYS.BACKUP_TIMESTAMP, 'identity-secret', keychainItems_1.V2_BACKUP_KEYCHAIN_SERVICE, false),
293
+ },
294
+ {
295
+ label: 'legacy-backup-timestamp',
296
+ slot: slot(keychainItems_1.STORAGE_KEYS.BACKUP_TIMESTAMP, 'identity-secret', undefined, false),
297
+ },
298
+ ];
299
+ const PHRASE_SLOT = slot(keychainItems_1.RECOVERY_MNEMONIC_STORAGE_KEY, 'identity-secret', keychainItems_1.RECOVERY_MNEMONIC_KEYCHAIN_SERVICE);
237
300
  const INDEX_SLOT = slot(keychainItems_1.ORGANIZATION_INDEX_STORAGE_KEY, 'identity-secret', keychainItems_1.ORGANIZATION_INDEX_KEYCHAIN_SERVICE, false);
301
+ const RECORD_SLOT = slot(COMPLETION_KEY, 'identity-secret', COMPLETION_SERVICE, false);
302
+ const MIRROR_SLOT = slot(COMPLETION_KEY, 'spending-key', COMPLETION_SERVICE, false);
238
303
  /** Union of two indexes; never drops an id either side lists. */
239
304
  function mergeIndexes(shared, target) {
240
305
  return (0, keychainItems_1.serializeOrganizationIndex)([
@@ -242,6 +307,45 @@ function mergeIndexes(shared, target) {
242
307
  ...(0, keychainItems_1.parseOrganizationIndex)(target),
243
308
  ]);
244
309
  }
310
+ const ADOPTED = (o) => o === 'moved';
311
+ /** The proven run: everything the marker vouches for, every organization item. */
312
+ async function migrateProven(mover, owner, items) {
313
+ const isOwner = (publicKey) => publicKey === owner;
314
+ let personalExposed = false;
315
+ for (const pair of personalPairs()) {
316
+ items[pair.label] = await mover.movePair(pair.privateSlot, pair.publicSlot, isOwner, {});
317
+ personalExposed || (personalExposed = ADOPTED(items[pair.label]));
318
+ }
319
+ for (const t of TIMESTAMPS)
320
+ items[t.label] = await mover.moveValue(t.slot);
321
+ items.phrase = await mover.movePhrase(PHRASE_SLOT, isOwner, {});
322
+ personalExposed || (personalExposed = ADOPTED(items.phrase));
323
+ // Organizations: every id either copy of the index names.
324
+ const ids = new Set([
325
+ ...(0, keychainItems_1.parseOrganizationIndex)(await mover.read(INDEX_SLOT, 'shared')),
326
+ ...(0, keychainItems_1.parseOrganizationIndex)(await mover.read(INDEX_SLOT, INDEX_SLOT.group)),
327
+ ]);
328
+ const organizations = [];
329
+ let n = 0;
330
+ for (const accountId of [...ids].sort()) {
331
+ n += 1;
332
+ const ring = { accountId };
333
+ const privateSlot = slot((0, keychainItems_1.organizationPrivateKeyName)(accountId), 'spending-key');
334
+ const publicSlot = slot((0, keychainItems_1.organizationPublicKeyName)(accountId), 'spending-key', undefined, false);
335
+ // The organization's key is whichever this device keeps after the move:
336
+ // the wallet group's when it has one, else the shared copy.
337
+ const kept = publicKeyOf(await mover.read(privateSlot, privateSlot.group)) ??
338
+ publicKeyOf(await mover.read(privateSlot, 'shared'));
339
+ const isKept = (publicKey) => publicKey === kept;
340
+ items[`organization-${n}`] = await mover.movePair(privateSlot, publicSlot, isKept, ring);
341
+ items[`organization-${n}-phrase`] = await mover.movePhrase(slot((0, keychainItems_1.organizationMnemonicName)(accountId), 'identity-secret', keychainItems_1.ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE), isKept, ring);
342
+ if (ADOPTED(items[`organization-${n}`]))
343
+ organizations.push(accountId);
344
+ }
345
+ // The index last: it is how a later run finds the organizations above.
346
+ items['organization-index'] = await mover.moveValue(INDEX_SLOT, mergeIndexes);
347
+ return { personalExposed, organizations };
348
+ }
245
349
  /** Run the migration once. Exported for tests; production code awaits the gate. */
246
350
  async function runKeychainGroupMigration() {
247
351
  let access;
@@ -263,59 +367,121 @@ async function runKeychainGroupMigration() {
263
367
  }
264
368
  const mover = new Mover(store, access);
265
369
  const items = {};
370
+ (0, keychainAccessGroups_1.setHolderSharedSecretFallback)(false);
371
+ let record;
372
+ let proven = false;
266
373
  try {
267
- for (const unit of personalUnits()) {
268
- items[unit.label] = await mover.move(unit);
374
+ const existing = parseRecord(await mover.read(RECORD_SLOT, RECORD_SLOT.group));
375
+ if (existing) {
376
+ // Done before. Re-mirror (the wallet group may have been cleared).
377
+ if ((await mover.read(MIRROR_SLOT, MIRROR_SLOT.group)) === null) {
378
+ await mover.write(MIRROR_SLOT, JSON.stringify({ v: 1, completedAt: existing.completedAt }));
379
+ }
380
+ return { state: 'complete', items };
269
381
  }
270
- // Organizations: every id either copy of the index names.
271
- const ids = new Set([
272
- ...(0, keychainItems_1.parseOrganizationIndex)(await mover.read(INDEX_SLOT, 'shared')),
273
- ...(0, keychainItems_1.parseOrganizationIndex)(await mover.read(INDEX_SLOT, INDEX_SLOT.group)),
274
- ]);
275
- let n = 0;
276
- for (const accountId of [...ids].sort()) {
277
- n += 1;
278
- items[`organization-${n}`] = await mover.move({
279
- label: `organization-${n}`,
280
- kind: 'pair',
281
- privateSlot: slot((0, keychainItems_1.organizationPrivateKeyName)(accountId), 'spending-key'),
282
- publicSlot: slot((0, keychainItems_1.organizationPublicKeyName)(accountId), 'spending-key', undefined, false),
283
- });
284
- items[`organization-${n}-phrase`] = await mover.move({
285
- label: `organization-${n}-phrase`,
286
- kind: 'value',
287
- slot: slot((0, keychainItems_1.organizationMnemonicName)(accountId), 'identity-secret', keychainItems_1.ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE),
288
- });
382
+ let marker;
383
+ try {
384
+ marker = await (0, identityMarker_1.readIdentityMarkerStrict)();
289
385
  }
290
- // The index last: it is how a later run finds the organizations above.
291
- items['organization-index'] = await mover.move({
292
- label: 'organization-index',
293
- kind: 'value',
294
- slot: INDEX_SLOT,
295
- merge: mergeIndexes,
296
- });
386
+ catch (error) {
387
+ throw new Deferred(error instanceof Error ? error.message : 'marker unreadable');
388
+ }
389
+ const owner = marker ? canonicalPublic(marker.publicKey) : null;
390
+ record = { v: 1, completedAt: Date.now() };
391
+ if (owner) {
392
+ proven = true;
393
+ const exposure = await migrateProven(mover, owner, items);
394
+ if (exposure.personalExposed)
395
+ record.exposedPublicKey = owner;
396
+ if (exposure.organizations.length > 0)
397
+ record.exposedOrganizations = exposure.organizations;
398
+ }
399
+ // No marker: this Commons wrote nothing that is in the shared group (or
400
+ // cannot prove it did). Nothing there is adopted, now or later.
297
401
  }
298
402
  catch (error) {
299
403
  if (error instanceof Deferred) {
300
- return { state: 'deferred', items };
404
+ return { state: 'deferred', items, proven };
301
405
  }
302
406
  throw error;
303
407
  }
304
- const settled = Object.values(items).every((o) => o === 'absent' || o === 'moved');
305
- const report = {
306
- state: settled ? 'complete' : 'incomplete',
307
- items,
308
- };
309
- if (!settled) {
408
+ const failed = Object.entries(items).filter(([, o]) => o === 'failed');
409
+ if (failed.length > 0) {
410
+ // Proven copies are still in the shared group: let Commons read them for
411
+ // this launch, and try again on the next one.
412
+ (0, keychainAccessGroups_1.setHolderSharedSecretFallback)(true);
310
413
  logger_1.logger.warn('Keychain access-group migration left items in the shared group', {
311
414
  component: 'KeychainGroupMigration',
312
- items: Object.entries(items)
313
- .filter(([, o]) => o !== 'absent' && o !== 'moved')
314
- .map(([label, o]) => `${label}:${o}`)
315
- .join(','),
415
+ items: failed.map(([label]) => label).join(','),
316
416
  });
417
+ return { state: 'incomplete', items, proven };
418
+ }
419
+ if (!(await mover.write(RECORD_SLOT, JSON.stringify(record))) ||
420
+ !(await mover.write(MIRROR_SLOT, JSON.stringify({ v: 1, completedAt: record.completedAt })))) {
421
+ return { state: 'incomplete', items, proven };
422
+ }
423
+ return { state: 'complete', items, proven };
424
+ }
425
+ /**
426
+ * What every keychain-backed identity access awaits first, by role: Commons
427
+ * runs (or has run) the migration; the Peable wallet checks once whether
428
+ * Commons' completion record is in the wallet group, and from then on never
429
+ * falls back to the shared group for a secret. Never throws.
430
+ */
431
+ async function prepareKeychainAccess(access) {
432
+ if (access.mode !== 'grouped')
433
+ return;
434
+ if (access.role === 'holder') {
435
+ await ensureKeychainGroupsMigrated();
436
+ return;
437
+ }
438
+ if (access.role === 'wallet' && !walletSawCompletion) {
439
+ try {
440
+ const store = await (0, protocol_1.loadSecureStore)();
441
+ const mirror = await store.getItemAsync(COMPLETION_KEY, {
442
+ keychainService: COMPLETION_SERVICE,
443
+ accessGroup: (0, keychainAccessGroups_1.keychainAccessGroupValue)(access, 'wallet'),
444
+ });
445
+ if (parseRecord(mirror)) {
446
+ walletSawCompletion = true;
447
+ (0, keychainAccessGroups_1.closeWalletSharedSecretFallback)();
448
+ }
449
+ }
450
+ catch {
451
+ // Locked: the next access asks again.
452
+ }
453
+ }
454
+ }
455
+ let walletSawCompletion = false;
456
+ /**
457
+ * Commons: whether this device's identity keys sat in the shared keychain
458
+ * group before the split, where every Oxy iOS app could read them. `publicKey`
459
+ * is the personal key that was exposed (recommend a rotation while it is still
460
+ * the current key), `organizations` the organization accounts whose key was.
461
+ * `null` when nothing was exposed, the migration has not finished, or this is
462
+ * not Commons on iOS.
463
+ */
464
+ async function getPreSplitExposure() {
465
+ try {
466
+ const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
467
+ if (access.mode !== 'grouped' || access.role !== 'holder')
468
+ return null;
469
+ await ensureKeychainGroupsMigrated();
470
+ const store = await (0, protocol_1.loadSecureStore)();
471
+ const record = parseRecord(await store.getItemAsync(COMPLETION_KEY, {
472
+ keychainService: COMPLETION_SERVICE,
473
+ accessGroup: (0, keychainAccessGroups_1.keychainAccessGroupValue)(access, 'identity'),
474
+ }));
475
+ if (!record || (!record.exposedPublicKey && !record.exposedOrganizations?.length))
476
+ return null;
477
+ return {
478
+ publicKey: record.exposedPublicKey ?? null,
479
+ organizations: record.exposedOrganizations ?? [],
480
+ };
481
+ }
482
+ catch {
483
+ return null;
317
484
  }
318
- return report;
319
485
  }
320
486
  let settledReport = null;
321
487
  let inFlight = null;
@@ -350,4 +516,5 @@ function ensureKeychainGroupsMigrated() {
350
516
  function resetKeychainGroupMigrationForTests() {
351
517
  settledReport = null;
352
518
  inFlight = null;
519
+ walletSawCompletion = false;
353
520
  }