@oxy.so/core 7.28.0 → 7.29.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.
- package/dist/cjs/crypto/index.js +19 -1
- package/dist/cjs/crypto/keyManager.js +106 -106
- package/dist/cjs/crypto/keychainGroupMigration.js +353 -0
- package/dist/cjs/crypto/keychainItems.js +70 -0
- package/dist/cjs/crypto/organizationKeys.js +80 -72
- package/dist/cjs/crypto/secureStoreOptions.js +2 -0
- package/dist/cjs/session/index.js +11 -1
- package/dist/cjs/utils/keychainAccessGroups.js +248 -0
- package/dist/esm/crypto/index.js +3 -0
- package/dist/esm/crypto/keyManager.js +63 -63
- package/dist/esm/crypto/keychainGroupMigration.js +348 -0
- package/dist/esm/crypto/keychainItems.js +61 -0
- package/dist/esm/crypto/organizationKeys.js +54 -46
- package/dist/esm/crypto/secureStoreOptions.js +1 -0
- package/dist/esm/session/index.js +3 -0
- package/dist/esm/utils/keychainAccessGroups.js +231 -0
- package/dist/types/crypto/index.d.ts +4 -0
- package/dist/types/crypto/keyManager.d.ts +9 -1
- package/dist/types/crypto/keychainGroupMigration.d.ts +27 -0
- package/dist/types/crypto/keychainItems.d.ts +41 -0
- package/dist/types/crypto/organizationKeys.d.ts +11 -9
- package/dist/types/crypto/secureStoreOptions.d.ts +15 -0
- package/dist/types/session/index.d.ts +2 -0
- package/dist/types/utils/keychainAccessGroups.d.ts +104 -0
- package/package.json +2 -2
- package/src/crypto/index.ts +29 -0
- package/src/crypto/keyManager.ts +84 -99
- package/src/crypto/keychainGroupMigration.ts +440 -0
- package/src/crypto/keychainItems.ts +73 -0
- package/src/crypto/organizationKeys.ts +72 -57
- package/src/crypto/secureStoreOptions.ts +32 -0
- package/src/session/index.ts +11 -0
- package/src/utils/keychainAccessGroups.ts +308 -0
- package/src/types/expo-secure-store.d.ts +0 -21
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
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).
|
|
5
|
+
*
|
|
6
|
+
* Before the split Commons listed one access group, the shared one, so every
|
|
7
|
+
* item it wrote landed there — the root key, both phrases, organization keys.
|
|
8
|
+
* Each item is moved by VERIFY-BEFORE-DELETE:
|
|
9
|
+
*
|
|
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.
|
|
15
|
+
*
|
|
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:
|
|
21
|
+
*
|
|
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.
|
|
38
|
+
*/
|
|
39
|
+
import {
|
|
40
|
+
deriveSecp256k1PublicKey,
|
|
41
|
+
normalizeSecp256k1PrivateKey,
|
|
42
|
+
normalizeSecp256k1PublicKey,
|
|
43
|
+
} from '@oxy.so/protocol/secp256k1';
|
|
44
|
+
import { type ExpoSecureStoreLike, loadSecureStore } from '@oxy.so/protocol';
|
|
45
|
+
import { logger } from '../logger';
|
|
46
|
+
import {
|
|
47
|
+
type KeychainAccess,
|
|
48
|
+
type KeychainAccessGroupName,
|
|
49
|
+
type KeychainItemClass,
|
|
50
|
+
keychainAccessGroupValue,
|
|
51
|
+
keychainGroupFor,
|
|
52
|
+
resolveKeychainAccess,
|
|
53
|
+
} from '../utils/keychainAccessGroups';
|
|
54
|
+
import {
|
|
55
|
+
ORGANIZATION_INDEX_KEYCHAIN_SERVICE,
|
|
56
|
+
ORGANIZATION_INDEX_STORAGE_KEY,
|
|
57
|
+
ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE,
|
|
58
|
+
RECOVERY_MNEMONIC_KEYCHAIN_SERVICE,
|
|
59
|
+
RECOVERY_MNEMONIC_STORAGE_KEY,
|
|
60
|
+
STORAGE_KEYS,
|
|
61
|
+
V2_BACKUP_KEYCHAIN_SERVICE,
|
|
62
|
+
V2_PRIMARY_KEYCHAIN_SERVICE,
|
|
63
|
+
V2_STORAGE_KEYS,
|
|
64
|
+
organizationMnemonicName,
|
|
65
|
+
organizationPrivateKeyName,
|
|
66
|
+
organizationPublicKeyName,
|
|
67
|
+
parseOrganizationIndex,
|
|
68
|
+
serializeOrganizationIndex,
|
|
69
|
+
} from './keychainItems';
|
|
70
|
+
|
|
71
|
+
/** What happened to one item (or pair) in a run. */
|
|
72
|
+
export type KeychainItemMigrationOutcome =
|
|
73
|
+
| 'absent'
|
|
74
|
+
| 'moved'
|
|
75
|
+
| 'conflict'
|
|
76
|
+
| 'unhealthy'
|
|
77
|
+
| 'failed'
|
|
78
|
+
| 'deferred';
|
|
79
|
+
|
|
80
|
+
export interface KeychainGroupMigrationReport {
|
|
81
|
+
/**
|
|
82
|
+
* - `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.
|
|
88
|
+
*/
|
|
89
|
+
state: 'not-applicable' | 'complete' | 'incomplete' | 'deferred';
|
|
90
|
+
/** Outcome per item label (`primary`, `phrase`, `organization-1`, …). */
|
|
91
|
+
items: Record<string, KeychainItemMigrationOutcome>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One stored value, and the group it belongs in. `deviceOnly` for secrets. */
|
|
95
|
+
interface Slot {
|
|
96
|
+
key: string;
|
|
97
|
+
service?: string;
|
|
98
|
+
group: KeychainAccessGroupName;
|
|
99
|
+
deviceOnly: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
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
|
+
const slot = (key: string, item: KeychainItemClass, service?: string, deviceOnly = true): Slot => ({
|
|
114
|
+
key,
|
|
115
|
+
service,
|
|
116
|
+
group: keychainGroupFor(item),
|
|
117
|
+
deviceOnly,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function isHealthyPair(privateKey: string | null, publicKey: string | null): boolean {
|
|
121
|
+
if (!privateKey || !publicKey) return false;
|
|
122
|
+
try {
|
|
123
|
+
return (
|
|
124
|
+
deriveSecp256k1PublicKey(normalizeSecp256k1PrivateKey(privateKey)) ===
|
|
125
|
+
normalizeSecp256k1PublicKey(publicKey)
|
|
126
|
+
);
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
class Deferred extends Error {}
|
|
133
|
+
|
|
134
|
+
class Mover {
|
|
135
|
+
constructor(
|
|
136
|
+
private readonly store: ExpoSecureStoreLike,
|
|
137
|
+
private readonly access: KeychainAccess,
|
|
138
|
+
) {}
|
|
139
|
+
|
|
140
|
+
private options(s: Slot, group: KeychainAccessGroupName, write = false): object {
|
|
141
|
+
return {
|
|
142
|
+
...(s.service ? { keychainService: s.service } : {}),
|
|
143
|
+
accessGroup: keychainAccessGroupValue(this.access, group),
|
|
144
|
+
...(write && s.deviceOnly
|
|
145
|
+
? { keychainAccessible: this.store.WHEN_UNLOCKED_THIS_DEVICE_ONLY }
|
|
146
|
+
: {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A read that throws means the keychain is unavailable: the whole run defers. */
|
|
151
|
+
async read(s: Slot, group: KeychainAccessGroupName): Promise<string | null> {
|
|
152
|
+
try {
|
|
153
|
+
return (await this.store.getItemAsync(s.key, this.options(s, group))) ?? null;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw new Deferred(error instanceof Error ? error.message : 'keychain read threw');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Write `value` where the new group had nothing, then read it back; `true`
|
|
161
|
+
* only when the new group holds exactly `value`. A copy that reads back as
|
|
162
|
+
* something else is removed again (best effort), because reads look in the
|
|
163
|
+
* new group FIRST and must not find it there.
|
|
164
|
+
*/
|
|
165
|
+
async copy(s: Slot, value: string, replacing = false): Promise<boolean> {
|
|
166
|
+
try {
|
|
167
|
+
await this.store.setItemAsync(s.key, value, this.options(s, s.group, true));
|
|
168
|
+
if ((await this.store.getItemAsync(s.key, this.options(s, s.group))) === value) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
if (replacing) {
|
|
175
|
+
// A merge overwrote what was there; keep whatever is there now.
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await this.store.deleteItemAsync(s.key, this.options(s, s.group));
|
|
180
|
+
} catch {
|
|
181
|
+
// The next run reads it as a conflict and leaves both; the shared copy is intact.
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async deleteShared(s: Slot): Promise<boolean> {
|
|
187
|
+
try {
|
|
188
|
+
await this.store.deleteItemAsync(s.key, this.options(s, 'shared'));
|
|
189
|
+
return true;
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async moveValue(unit: Extract<Unit, { kind: 'value' }>): Promise<KeychainItemMigrationOutcome> {
|
|
196
|
+
const { slot: s } = unit;
|
|
197
|
+
const shared = await this.read(s, 'shared');
|
|
198
|
+
if (shared === null) return 'absent';
|
|
199
|
+
const target = await this.read(s, s.group);
|
|
200
|
+
let keep = target ?? shared;
|
|
201
|
+
if (target !== null && target !== shared) {
|
|
202
|
+
const merged = unit.merge?.(shared, target) ?? null;
|
|
203
|
+
if (merged === null) return 'conflict';
|
|
204
|
+
keep = merged;
|
|
205
|
+
}
|
|
206
|
+
if (target !== keep && !(await this.copy(s, keep, target !== null))) return 'failed';
|
|
207
|
+
// Verify the copy that is about to become the only one.
|
|
208
|
+
if ((await this.read(s, s.group)) !== keep) return 'failed';
|
|
209
|
+
return (await this.deleteShared(s)) ? 'moved' : 'failed';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async movePair(unit: Extract<Unit, { kind: 'pair' }>): Promise<KeychainItemMigrationOutcome> {
|
|
213
|
+
const halves = [unit.privateSlot, unit.publicSlot];
|
|
214
|
+
// A half whose group IS the shared group stays: it is read, never moved.
|
|
215
|
+
const movable = halves.filter((h) => h.group !== 'shared');
|
|
216
|
+
const shared = await Promise.all(halves.map((h) => this.read(h, 'shared')));
|
|
217
|
+
if (movable.every((h) => shared[halves.indexOf(h)] === null)) return 'absent';
|
|
218
|
+
const target = await Promise.all(
|
|
219
|
+
halves.map((h, i) => (h.group === 'shared' ? shared[i] : this.read(h, h.group))),
|
|
220
|
+
);
|
|
221
|
+
for (const h of movable) {
|
|
222
|
+
const i = halves.indexOf(h);
|
|
223
|
+
if (shared[i] !== null && target[i] !== null && shared[i] !== target[i]) return 'conflict';
|
|
224
|
+
}
|
|
225
|
+
const finalPair = halves.map((_, i) => target[i] ?? shared[i]);
|
|
226
|
+
if (!isHealthyPair(finalPair[0], finalPair[1])) return 'unhealthy';
|
|
227
|
+
for (const h of movable) {
|
|
228
|
+
const i = halves.indexOf(h);
|
|
229
|
+
const value = shared[i];
|
|
230
|
+
if (value !== null && target[i] === null && !(await this.copy(h, value))) return 'failed';
|
|
231
|
+
}
|
|
232
|
+
const readBack = await Promise.all(
|
|
233
|
+
halves.map((h, i) => (h.group === 'shared' ? shared[i] : this.read(h, h.group))),
|
|
234
|
+
);
|
|
235
|
+
if (readBack.some((v, i) => v !== finalPair[i]) || !isHealthyPair(readBack[0], readBack[1])) {
|
|
236
|
+
return 'failed';
|
|
237
|
+
}
|
|
238
|
+
for (const h of movable) {
|
|
239
|
+
if (shared[halves.indexOf(h)] !== null && !(await this.deleteShared(h))) return 'failed';
|
|
240
|
+
}
|
|
241
|
+
return 'moved';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
move(unit: Unit): Promise<KeychainItemMigrationOutcome> {
|
|
245
|
+
return unit.kind === 'pair' ? this.movePair(unit) : this.moveValue(unit);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The personal items, in the order they move. */
|
|
250
|
+
function personalUnits(): Unit[] {
|
|
251
|
+
const v2 = (key: string, service: string, deviceOnly: boolean) =>
|
|
252
|
+
slot(key, 'identity-secret', service, deviceOnly);
|
|
253
|
+
const legacy = (key: string, deviceOnly: boolean) =>
|
|
254
|
+
slot(key, 'identity-secret', undefined, deviceOnly);
|
|
255
|
+
return [
|
|
256
|
+
{
|
|
257
|
+
label: 'primary',
|
|
258
|
+
kind: 'pair',
|
|
259
|
+
privateSlot: v2(V2_STORAGE_KEYS.PRIVATE_KEY, V2_PRIMARY_KEYCHAIN_SERVICE, true),
|
|
260
|
+
publicSlot: v2(V2_STORAGE_KEYS.PUBLIC_KEY, V2_PRIMARY_KEYCHAIN_SERVICE, false),
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
label: 'backup',
|
|
264
|
+
kind: 'pair',
|
|
265
|
+
privateSlot: v2(V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, V2_BACKUP_KEYCHAIN_SERVICE, true),
|
|
266
|
+
publicSlot: v2(V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, V2_BACKUP_KEYCHAIN_SERVICE, false),
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
label: 'backup-timestamp',
|
|
270
|
+
kind: 'value',
|
|
271
|
+
slot: v2(V2_STORAGE_KEYS.BACKUP_TIMESTAMP, V2_BACKUP_KEYCHAIN_SERVICE, false),
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
label: 'legacy-primary',
|
|
275
|
+
kind: 'pair',
|
|
276
|
+
privateSlot: legacy(STORAGE_KEYS.PRIVATE_KEY, true),
|
|
277
|
+
publicSlot: legacy(STORAGE_KEYS.PUBLIC_KEY, false),
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
label: 'legacy-backup',
|
|
281
|
+
kind: 'pair',
|
|
282
|
+
privateSlot: legacy(STORAGE_KEYS.BACKUP_PRIVATE_KEY, true),
|
|
283
|
+
publicSlot: legacy(STORAGE_KEYS.BACKUP_PUBLIC_KEY, false),
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
label: 'legacy-backup-timestamp',
|
|
287
|
+
kind: 'value',
|
|
288
|
+
slot: legacy(STORAGE_KEYS.BACKUP_TIMESTAMP, false),
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
label: 'phrase',
|
|
292
|
+
kind: 'value',
|
|
293
|
+
slot: slot(
|
|
294
|
+
RECOVERY_MNEMONIC_STORAGE_KEY,
|
|
295
|
+
'identity-secret',
|
|
296
|
+
RECOVERY_MNEMONIC_KEYCHAIN_SERVICE,
|
|
297
|
+
),
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
// The private key moves to the wallet group; its public key stays shared.
|
|
301
|
+
label: 'shared-slot',
|
|
302
|
+
kind: 'pair',
|
|
303
|
+
privateSlot: slot(STORAGE_KEYS.SHARED_PRIVATE_KEY, 'spending-key'),
|
|
304
|
+
publicSlot: slot(STORAGE_KEYS.SHARED_PUBLIC_KEY, 'shared', undefined, false),
|
|
305
|
+
},
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const INDEX_SLOT = slot(
|
|
310
|
+
ORGANIZATION_INDEX_STORAGE_KEY,
|
|
311
|
+
'identity-secret',
|
|
312
|
+
ORGANIZATION_INDEX_KEYCHAIN_SERVICE,
|
|
313
|
+
false,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
/** Union of two indexes; never drops an id either side lists. */
|
|
317
|
+
function mergeIndexes(shared: string, target: string): string | null {
|
|
318
|
+
return serializeOrganizationIndex([
|
|
319
|
+
...parseOrganizationIndex(shared),
|
|
320
|
+
...parseOrganizationIndex(target),
|
|
321
|
+
]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Run the migration once. Exported for tests; production code awaits the gate. */
|
|
325
|
+
export async function runKeychainGroupMigration(): Promise<KeychainGroupMigrationReport> {
|
|
326
|
+
let access: KeychainAccess;
|
|
327
|
+
try {
|
|
328
|
+
access = await resolveKeychainAccess();
|
|
329
|
+
} catch {
|
|
330
|
+
return { state: 'deferred', items: {} };
|
|
331
|
+
}
|
|
332
|
+
if (access.mode !== 'grouped' || access.role !== 'holder') {
|
|
333
|
+
return { state: 'not-applicable', items: {} };
|
|
334
|
+
}
|
|
335
|
+
let store: ExpoSecureStoreLike;
|
|
336
|
+
try {
|
|
337
|
+
store = await loadSecureStore();
|
|
338
|
+
} catch {
|
|
339
|
+
return { state: 'deferred', items: {} };
|
|
340
|
+
}
|
|
341
|
+
const mover = new Mover(store, access);
|
|
342
|
+
const items: Record<string, KeychainItemMigrationOutcome> = {};
|
|
343
|
+
try {
|
|
344
|
+
for (const unit of personalUnits()) {
|
|
345
|
+
items[unit.label] = await mover.move(unit);
|
|
346
|
+
}
|
|
347
|
+
// Organizations: every id either copy of the index names.
|
|
348
|
+
const ids = new Set([
|
|
349
|
+
...parseOrganizationIndex(await mover.read(INDEX_SLOT, 'shared')),
|
|
350
|
+
...parseOrganizationIndex(await mover.read(INDEX_SLOT, INDEX_SLOT.group)),
|
|
351
|
+
]);
|
|
352
|
+
let n = 0;
|
|
353
|
+
for (const accountId of [...ids].sort()) {
|
|
354
|
+
n += 1;
|
|
355
|
+
items[`organization-${n}`] = await mover.move({
|
|
356
|
+
label: `organization-${n}`,
|
|
357
|
+
kind: 'pair',
|
|
358
|
+
privateSlot: slot(organizationPrivateKeyName(accountId), 'spending-key'),
|
|
359
|
+
publicSlot: slot(organizationPublicKeyName(accountId), 'spending-key', undefined, false),
|
|
360
|
+
});
|
|
361
|
+
items[`organization-${n}-phrase`] = await mover.move({
|
|
362
|
+
label: `organization-${n}-phrase`,
|
|
363
|
+
kind: 'value',
|
|
364
|
+
slot: slot(
|
|
365
|
+
organizationMnemonicName(accountId),
|
|
366
|
+
'identity-secret',
|
|
367
|
+
ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE,
|
|
368
|
+
),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
// The index last: it is how a later run finds the organizations above.
|
|
372
|
+
items['organization-index'] = await mover.move({
|
|
373
|
+
label: 'organization-index',
|
|
374
|
+
kind: 'value',
|
|
375
|
+
slot: INDEX_SLOT,
|
|
376
|
+
merge: mergeIndexes,
|
|
377
|
+
});
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (error instanceof Deferred) {
|
|
380
|
+
return { state: 'deferred', items };
|
|
381
|
+
}
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
const settled = Object.values(items).every((o) => o === 'absent' || o === 'moved');
|
|
385
|
+
const report: KeychainGroupMigrationReport = {
|
|
386
|
+
state: settled ? 'complete' : 'incomplete',
|
|
387
|
+
items,
|
|
388
|
+
};
|
|
389
|
+
if (!settled) {
|
|
390
|
+
logger.warn('Keychain access-group migration left items in the shared group', {
|
|
391
|
+
component: 'KeychainGroupMigration',
|
|
392
|
+
items: Object.entries(items)
|
|
393
|
+
.filter(([, o]) => o !== 'absent' && o !== 'moved')
|
|
394
|
+
.map(([label, o]) => `${label}:${o}`)
|
|
395
|
+
.join(','),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return report;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let settledReport: KeychainGroupMigrationReport | null = null;
|
|
402
|
+
let inFlight: Promise<KeychainGroupMigrationReport> | null = null;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The gate every Commons identity-storage access awaits. One run per process
|
|
406
|
+
* (`complete`, `incomplete` and `not-applicable` are kept; `incomplete` is
|
|
407
|
+
* retried on the next launch); a `deferred` run is forgotten, so the next
|
|
408
|
+
* access tries again once the keychain unlocks. Concurrent callers share one
|
|
409
|
+
* run. Never throws.
|
|
410
|
+
*/
|
|
411
|
+
export function ensureKeychainGroupsMigrated(): Promise<KeychainGroupMigrationReport> {
|
|
412
|
+
if (settledReport) {
|
|
413
|
+
return Promise.resolve(settledReport);
|
|
414
|
+
}
|
|
415
|
+
if (!inFlight) {
|
|
416
|
+
inFlight = runKeychainGroupMigration()
|
|
417
|
+
.catch((error: unknown): KeychainGroupMigrationReport => {
|
|
418
|
+
logger.warn(
|
|
419
|
+
'Keychain access-group migration threw; retrying on the next access',
|
|
420
|
+
{ component: 'KeychainGroupMigration' },
|
|
421
|
+
error,
|
|
422
|
+
);
|
|
423
|
+
return { state: 'deferred', items: {} };
|
|
424
|
+
})
|
|
425
|
+
.then((report) => {
|
|
426
|
+
if (report.state !== 'deferred') {
|
|
427
|
+
settledReport = report;
|
|
428
|
+
}
|
|
429
|
+
inFlight = null;
|
|
430
|
+
return report;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return inFlight;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Forget the memoized run. Tests only. @internal */
|
|
437
|
+
export function resetKeychainGroupMigrationForTests(): void {
|
|
438
|
+
settledReport = null;
|
|
439
|
+
inFlight = null;
|
|
440
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The names of every identity item `KeyManager` and `./organizationKeys` keep
|
|
3
|
+
* in `expo-secure-store`, in one place so the access-group migration
|
|
4
|
+
* (`./keychainGroupMigration`) moves exactly what those modules read. Values
|
|
5
|
+
* are stable on-device identifiers: renaming one strands what is stored.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Legacy (pre-v2) and shared-slot keys; the legacy ones have no keychain service. */
|
|
9
|
+
export const STORAGE_KEYS = {
|
|
10
|
+
PRIVATE_KEY: 'oxy_identity_private_key',
|
|
11
|
+
PUBLIC_KEY: 'oxy_identity_public_key',
|
|
12
|
+
BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key',
|
|
13
|
+
BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key',
|
|
14
|
+
BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp',
|
|
15
|
+
// The personal key's cross-app slot: the private key in the wallet group, the
|
|
16
|
+
// public key in the shared group (iOS only), no keychain service.
|
|
17
|
+
SHARED_PRIVATE_KEY: 'oxy_shared_identity_private_key',
|
|
18
|
+
SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export const V2_PRIMARY_KEYCHAIN_SERVICE = 'oxy_identity';
|
|
22
|
+
export const V2_BACKUP_KEYCHAIN_SERVICE = 'oxy_identity_backup';
|
|
23
|
+
|
|
24
|
+
export const V2_STORAGE_KEYS = {
|
|
25
|
+
PRIVATE_KEY: 'oxy_identity_private_key_v2',
|
|
26
|
+
PUBLIC_KEY: 'oxy_identity_public_key_v2',
|
|
27
|
+
BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
|
|
28
|
+
BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
|
|
29
|
+
BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
|
|
33
|
+
export const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
|
|
34
|
+
|
|
35
|
+
/** Organization keys (ADR 0061 D6). On iOS the pair has no keychain service. */
|
|
36
|
+
export const ORGANIZATION_ANDROID_KEYCHAIN_SERVICE = 'oxy_org_identity';
|
|
37
|
+
export const ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_org_identity_mnemonic';
|
|
38
|
+
export const ORGANIZATION_INDEX_KEYCHAIN_SERVICE = 'oxy_org_identity_index';
|
|
39
|
+
export const ORGANIZATION_INDEX_STORAGE_KEY = 'oxy_org_identity_index_v1';
|
|
40
|
+
|
|
41
|
+
export const organizationPrivateKeyName = (accountId: string) =>
|
|
42
|
+
`oxy_org_identity_private_key.${accountId}`;
|
|
43
|
+
export const organizationPublicKeyName = (accountId: string) =>
|
|
44
|
+
`oxy_org_identity_public_key.${accountId}`;
|
|
45
|
+
export const organizationMnemonicName = (accountId: string) =>
|
|
46
|
+
`oxy_org_identity_mnemonic.${accountId}`;
|
|
47
|
+
|
|
48
|
+
/** Same set the server's account ids come from: a legacy 24-hex id or a lowercase uuid. */
|
|
49
|
+
const ACCOUNT_ID =
|
|
50
|
+
/^(?:[0-9a-f]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
|
|
51
|
+
|
|
52
|
+
/** Whether `value` is an account id an organization key can be stored under. */
|
|
53
|
+
export function isOrganizationAccountId(value: string): boolean {
|
|
54
|
+
return ACCOUNT_ID.test(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The account ids in a stored organization index; anything malformed is skipped. */
|
|
58
|
+
export function parseOrganizationIndex(raw: string | null): string[] {
|
|
59
|
+
if (!raw) return [];
|
|
60
|
+
try {
|
|
61
|
+
const parsed: unknown = JSON.parse(raw);
|
|
62
|
+
return Array.isArray(parsed)
|
|
63
|
+
? parsed.filter((id): id is string => typeof id === 'string' && isOrganizationAccountId(id))
|
|
64
|
+
: [];
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The stored form of an organization index: unique, sorted. */
|
|
71
|
+
export function serializeOrganizationIndex(ids: readonly string[]): string {
|
|
72
|
+
return JSON.stringify([...new Set(ids)].sort());
|
|
73
|
+
}
|