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