@oxy.so/core 7.30.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.
- package/dist/cjs/api/identity.js +146 -23
- package/dist/cjs/crypto/identityMarker.js +15 -0
- package/dist/cjs/crypto/index.js +7 -2
- package/dist/cjs/crypto/keyManager.js +7 -10
- package/dist/cjs/crypto/keychainGroupMigration.js +327 -160
- package/dist/cjs/crypto/organizationKeys.js +33 -5
- package/dist/cjs/crypto/retiredKeys.js +80 -18
- package/dist/cjs/session/authStateStore.js +13 -0
- package/dist/cjs/session/index.js +5 -2
- package/dist/cjs/utils/keychainAccessGroups.js +208 -51
- package/dist/esm/api/identity.js +146 -23
- package/dist/esm/crypto/identityMarker.js +14 -0
- package/dist/esm/crypto/index.js +3 -3
- package/dist/esm/crypto/keyManager.js +9 -12
- package/dist/esm/crypto/keychainGroupMigration.js +327 -162
- package/dist/esm/crypto/organizationKeys.js +34 -6
- package/dist/esm/crypto/retiredKeys.js +76 -17
- package/dist/esm/session/authStateStore.js +12 -0
- package/dist/esm/session/index.js +1 -1
- package/dist/esm/utils/keychainAccessGroups.js +205 -51
- package/dist/types/api/identity.d.ts +36 -0
- package/dist/types/crypto/identityMarker.d.ts +8 -0
- package/dist/types/crypto/index.d.ts +4 -4
- package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
- package/dist/types/crypto/organizationKeys.d.ts +4 -2
- package/dist/types/crypto/retiredKeys.d.ts +30 -4
- package/dist/types/session/authStateStore.d.ts +9 -0
- package/dist/types/session/index.d.ts +2 -2
- package/dist/types/utils/keychainAccessGroups.d.ts +108 -26
- package/package.json +2 -2
- package/src/api/identity.ts +185 -40
- package/src/crypto/identityMarker.ts +15 -0
- package/src/crypto/index.ts +10 -1
- package/src/crypto/keyManager.ts +9 -15
- package/src/crypto/keychainGroupMigration.ts +396 -186
- package/src/crypto/organizationKeys.ts +38 -6
- package/src/crypto/retiredKeys.ts +87 -17
- package/src/session/authStateStore.ts +14 -0
- package/src/session/index.ts +8 -1
- package/src/utils/keychainAccessGroups.ts +276 -52
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
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
|
-
*
|
|
23
|
-
* -
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
| '
|
|
76
|
-
| '
|
|
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`:
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* - `deferred`: the keychain could not be read (locked);
|
|
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,38 +143,54 @@ interface Slot {
|
|
|
99
143
|
deviceOnly: boolean;
|
|
100
144
|
}
|
|
101
145
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 => ({
|
|
146
|
+
const slot = (
|
|
147
|
+
key: string,
|
|
148
|
+
item: Exclude<KeychainItemClass, 'app'>,
|
|
149
|
+
service?: string,
|
|
150
|
+
deviceOnly = true,
|
|
151
|
+
): Slot => ({
|
|
114
152
|
key,
|
|
115
153
|
service,
|
|
116
154
|
group: keychainGroupFor(item),
|
|
117
155
|
deviceOnly,
|
|
118
156
|
});
|
|
119
157
|
|
|
120
|
-
function
|
|
121
|
-
if (!privateKey || !
|
|
158
|
+
function publicKeyOf(privateKey: string | null): string | null {
|
|
159
|
+
if (!privateKey || !isValidSecp256k1PrivateKey(privateKey)) return null;
|
|
122
160
|
try {
|
|
123
|
-
return (
|
|
124
|
-
deriveSecp256k1PublicKey(normalizeSecp256k1PrivateKey(privateKey)) ===
|
|
125
|
-
normalizeSecp256k1PublicKey(publicKey)
|
|
126
|
-
);
|
|
161
|
+
return deriveSecp256k1PublicKey(normalizeSecp256k1PrivateKey(privateKey));
|
|
127
162
|
} catch {
|
|
128
|
-
return
|
|
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;
|
|
129
172
|
}
|
|
130
173
|
}
|
|
131
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
|
+
|
|
132
189
|
class Deferred extends Error {}
|
|
133
190
|
|
|
191
|
+
/** Which ring a retired copy goes to: the person's, or an organization's. */
|
|
192
|
+
type Ring = { accountId?: string };
|
|
193
|
+
|
|
134
194
|
class Mover {
|
|
135
195
|
constructor(
|
|
136
196
|
private readonly store: ExpoSecureStoreLike,
|
|
@@ -156,98 +216,133 @@ class Mover {
|
|
|
156
216
|
}
|
|
157
217
|
}
|
|
158
218
|
|
|
159
|
-
/**
|
|
160
|
-
|
|
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> {
|
|
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> {
|
|
166
221
|
try {
|
|
167
222
|
await this.store.setItemAsync(s.key, value, this.options(s, s.group, true));
|
|
168
|
-
|
|
169
|
-
return true;
|
|
170
|
-
}
|
|
223
|
+
return (await this.store.getItemAsync(s.key, this.options(s, s.group))) === value;
|
|
171
224
|
} catch {
|
|
172
225
|
return false;
|
|
173
226
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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;
|
|
178
236
|
try {
|
|
179
237
|
await this.store.deleteItemAsync(s.key, this.options(s, s.group));
|
|
180
238
|
} catch {
|
|
181
|
-
// The
|
|
239
|
+
// The shared copy is intact; the next run tries again.
|
|
182
240
|
}
|
|
183
241
|
return false;
|
|
184
242
|
}
|
|
185
243
|
|
|
186
|
-
async deleteShared(
|
|
244
|
+
async deleteShared(slots: Slot[]): Promise<boolean> {
|
|
187
245
|
try {
|
|
188
|
-
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'));
|
|
189
247
|
return true;
|
|
190
248
|
} catch {
|
|
191
249
|
return false;
|
|
192
250
|
}
|
|
193
251
|
}
|
|
194
252
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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';
|
|
205
266
|
}
|
|
206
|
-
|
|
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';
|
|
267
|
+
return (await this.deleteShared(shared)) ? 'retired' : 'failed';
|
|
210
268
|
}
|
|
211
269
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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';
|
|
224
291
|
}
|
|
225
|
-
const
|
|
226
|
-
if (!
|
|
227
|
-
|
|
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';
|
|
292
|
+
const targetPrivate = await this.read(privateSlot, privateSlot.group);
|
|
293
|
+
if (!keep(derived) || (targetPrivate !== null && targetPrivate !== sharedPrivate)) {
|
|
294
|
+
return this.retireThenDelete(sharedPrivate, ring, movable);
|
|
231
295
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
|
|
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
|
+
}
|
|
237
305
|
}
|
|
238
|
-
|
|
239
|
-
|
|
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]);
|
|
240
323
|
}
|
|
241
|
-
return '
|
|
324
|
+
if (target === null && !(await this.copy(s, shared))) return 'failed';
|
|
325
|
+
return (await this.deleteShared([s])) ? 'moved' : 'failed';
|
|
242
326
|
}
|
|
243
327
|
|
|
244
|
-
|
|
245
|
-
|
|
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';
|
|
246
342
|
}
|
|
247
343
|
}
|
|
248
344
|
|
|
249
|
-
|
|
250
|
-
function personalUnits(): Unit[] {
|
|
345
|
+
const personalPairs = () => {
|
|
251
346
|
const v2 = (key: string, service: string, deviceOnly: boolean) =>
|
|
252
347
|
slot(key, 'identity-secret', service, deviceOnly);
|
|
253
348
|
const legacy = (key: string, deviceOnly: boolean) =>
|
|
@@ -255,56 +350,54 @@ function personalUnits(): Unit[] {
|
|
|
255
350
|
return [
|
|
256
351
|
{
|
|
257
352
|
label: 'primary',
|
|
258
|
-
kind: 'pair',
|
|
259
353
|
privateSlot: v2(V2_STORAGE_KEYS.PRIVATE_KEY, V2_PRIMARY_KEYCHAIN_SERVICE, true),
|
|
260
354
|
publicSlot: v2(V2_STORAGE_KEYS.PUBLIC_KEY, V2_PRIMARY_KEYCHAIN_SERVICE, false),
|
|
261
355
|
},
|
|
262
356
|
{
|
|
263
357
|
label: 'backup',
|
|
264
|
-
kind: 'pair',
|
|
265
358
|
privateSlot: v2(V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, V2_BACKUP_KEYCHAIN_SERVICE, true),
|
|
266
359
|
publicSlot: v2(V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, V2_BACKUP_KEYCHAIN_SERVICE, false),
|
|
267
360
|
},
|
|
268
|
-
{
|
|
269
|
-
label: 'backup-timestamp',
|
|
270
|
-
kind: 'value',
|
|
271
|
-
slot: v2(V2_STORAGE_KEYS.BACKUP_TIMESTAMP, V2_BACKUP_KEYCHAIN_SERVICE, false),
|
|
272
|
-
},
|
|
273
361
|
{
|
|
274
362
|
label: 'legacy-primary',
|
|
275
|
-
kind: 'pair',
|
|
276
363
|
privateSlot: legacy(STORAGE_KEYS.PRIVATE_KEY, true),
|
|
277
364
|
publicSlot: legacy(STORAGE_KEYS.PUBLIC_KEY, false),
|
|
278
365
|
},
|
|
279
366
|
{
|
|
280
367
|
label: 'legacy-backup',
|
|
281
|
-
kind: 'pair',
|
|
282
368
|
privateSlot: legacy(STORAGE_KEYS.BACKUP_PRIVATE_KEY, true),
|
|
283
369
|
publicSlot: legacy(STORAGE_KEYS.BACKUP_PUBLIC_KEY, false),
|
|
284
370
|
},
|
|
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
371
|
{
|
|
300
372
|
// The private key moves to the wallet group; its public key stays shared.
|
|
301
373
|
label: 'shared-slot',
|
|
302
|
-
kind: 'pair',
|
|
303
374
|
privateSlot: slot(STORAGE_KEYS.SHARED_PRIVATE_KEY, 'spending-key'),
|
|
304
375
|
publicSlot: slot(STORAGE_KEYS.SHARED_PUBLIC_KEY, 'shared', undefined, false),
|
|
305
376
|
},
|
|
306
377
|
];
|
|
307
|
-
}
|
|
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
|
+
);
|
|
308
401
|
|
|
309
402
|
const INDEX_SLOT = slot(
|
|
310
403
|
ORGANIZATION_INDEX_STORAGE_KEY,
|
|
@@ -313,14 +406,70 @@ const INDEX_SLOT = slot(
|
|
|
313
406
|
false,
|
|
314
407
|
);
|
|
315
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
|
+
|
|
316
412
|
/** Union of two indexes; never drops an id either side lists. */
|
|
317
|
-
function mergeIndexes(shared: string, target: string): string
|
|
413
|
+
function mergeIndexes(shared: string, target: string): string {
|
|
318
414
|
return serializeOrganizationIndex([
|
|
319
415
|
...parseOrganizationIndex(shared),
|
|
320
416
|
...parseOrganizationIndex(target),
|
|
321
417
|
]);
|
|
322
418
|
}
|
|
323
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
|
+
|
|
324
473
|
/** Run the migration once. Exported for tests; production code awaits the gate. */
|
|
325
474
|
export async function runKeychainGroupMigration(): Promise<KeychainGroupMigrationReport> {
|
|
326
475
|
let access: KeychainAccess;
|
|
@@ -340,62 +489,122 @@ export async function runKeychainGroupMigration(): Promise<KeychainGroupMigratio
|
|
|
340
489
|
}
|
|
341
490
|
const mover = new Mover(store, access);
|
|
342
491
|
const items: Record<string, KeychainItemMigrationOutcome> = {};
|
|
492
|
+
setHolderSharedSecretFallback(false);
|
|
493
|
+
let record: CompletionRecord;
|
|
494
|
+
let proven = false;
|
|
343
495
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
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 };
|
|
346
503
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
});
|
|
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');
|
|
370
509
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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.
|
|
378
520
|
} catch (error) {
|
|
379
521
|
if (error instanceof Deferred) {
|
|
380
|
-
return { state: 'deferred', items };
|
|
522
|
+
return { state: 'deferred', items, proven };
|
|
381
523
|
}
|
|
382
524
|
throw error;
|
|
383
525
|
}
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
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);
|
|
390
531
|
logger.warn('Keychain access-group migration left items in the shared group', {
|
|
391
532
|
component: 'KeychainGroupMigration',
|
|
392
|
-
items:
|
|
393
|
-
.filter(([, o]) => o !== 'absent' && o !== 'moved')
|
|
394
|
-
.map(([label, o]) => `${label}:${o}`)
|
|
395
|
-
.join(','),
|
|
533
|
+
items: failed.map(([label]) => label).join(','),
|
|
396
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;
|
|
397
607
|
}
|
|
398
|
-
return report;
|
|
399
608
|
}
|
|
400
609
|
|
|
401
610
|
let settledReport: KeychainGroupMigrationReport | null = null;
|
|
@@ -437,4 +646,5 @@ export function ensureKeychainGroupsMigrated(): Promise<KeychainGroupMigrationRe
|
|
|
437
646
|
export function resetKeychainGroupMigrationForTests(): void {
|
|
438
647
|
settledReport = null;
|
|
439
648
|
inFlight = null;
|
|
649
|
+
walletSawCompletion = false;
|
|
440
650
|
}
|