@oxy.so/core 7.28.0 → 7.30.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 (44) hide show
  1. package/dist/cjs/api/identity.js +40 -1
  2. package/dist/cjs/crypto/index.js +28 -1
  3. package/dist/cjs/crypto/keyManager.js +175 -107
  4. package/dist/cjs/crypto/keychainGroupMigration.js +353 -0
  5. package/dist/cjs/crypto/keychainItems.js +70 -0
  6. package/dist/cjs/crypto/organizationKeys.js +80 -72
  7. package/dist/cjs/crypto/retiredKeys.js +371 -0
  8. package/dist/cjs/crypto/secureStoreOptions.js +2 -0
  9. package/dist/cjs/session/index.js +11 -1
  10. package/dist/cjs/utils/keychainAccessGroups.js +248 -0
  11. package/dist/esm/api/identity.js +40 -1
  12. package/dist/esm/crypto/index.js +6 -0
  13. package/dist/esm/crypto/keyManager.js +132 -64
  14. package/dist/esm/crypto/keychainGroupMigration.js +348 -0
  15. package/dist/esm/crypto/keychainItems.js +61 -0
  16. package/dist/esm/crypto/organizationKeys.js +54 -46
  17. package/dist/esm/crypto/retiredKeys.js +361 -0
  18. package/dist/esm/crypto/secureStoreOptions.js +1 -0
  19. package/dist/esm/session/index.js +3 -0
  20. package/dist/esm/utils/keychainAccessGroups.js +231 -0
  21. package/dist/types/api/identity.d.ts +10 -0
  22. package/dist/types/crypto/identitySigner.d.ts +23 -0
  23. package/dist/types/crypto/index.d.ts +6 -0
  24. package/dist/types/crypto/keyManager.d.ts +32 -2
  25. package/dist/types/crypto/keychainGroupMigration.d.ts +27 -0
  26. package/dist/types/crypto/keychainItems.d.ts +41 -0
  27. package/dist/types/crypto/organizationKeys.d.ts +11 -9
  28. package/dist/types/crypto/retiredKeys.d.ts +116 -0
  29. package/dist/types/crypto/secureStoreOptions.d.ts +15 -0
  30. package/dist/types/session/index.d.ts +2 -0
  31. package/dist/types/utils/keychainAccessGroups.d.ts +104 -0
  32. package/package.json +3 -3
  33. package/src/api/identity.ts +46 -3
  34. package/src/crypto/identitySigner.ts +23 -0
  35. package/src/crypto/index.ts +40 -0
  36. package/src/crypto/keyManager.ts +165 -102
  37. package/src/crypto/keychainGroupMigration.ts +440 -0
  38. package/src/crypto/keychainItems.ts +73 -0
  39. package/src/crypto/organizationKeys.ts +72 -57
  40. package/src/crypto/retiredKeys.ts +453 -0
  41. package/src/crypto/secureStoreOptions.ts +32 -0
  42. package/src/session/index.ts +11 -0
  43. package/src/utils/keychainAccessGroups.ts +308 -0
  44. package/src/types/expo-secure-store.d.ts +0 -21
@@ -0,0 +1,348 @@
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 { deriveSecp256k1PublicKey, normalizeSecp256k1PrivateKey, normalizeSecp256k1PublicKey, } from '@oxy.so/protocol/secp256k1';
40
+ import { loadSecureStore } from '@oxy.so/protocol';
41
+ import { logger } from '../logger/index.js';
42
+ import { keychainAccessGroupValue, keychainGroupFor, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
43
+ import { ORGANIZATION_INDEX_KEYCHAIN_SERVICE, ORGANIZATION_INDEX_STORAGE_KEY, ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE, RECOVERY_MNEMONIC_STORAGE_KEY, STORAGE_KEYS, V2_BACKUP_KEYCHAIN_SERVICE, V2_PRIMARY_KEYCHAIN_SERVICE, V2_STORAGE_KEYS, organizationMnemonicName, organizationPrivateKeyName, organizationPublicKeyName, parseOrganizationIndex, serializeOrganizationIndex, } from './keychainItems.js';
44
+ const slot = (key, item, service, deviceOnly = true) => ({
45
+ key,
46
+ service,
47
+ group: keychainGroupFor(item),
48
+ deviceOnly,
49
+ });
50
+ function isHealthyPair(privateKey, publicKey) {
51
+ if (!privateKey || !publicKey)
52
+ return false;
53
+ try {
54
+ return (deriveSecp256k1PublicKey(normalizeSecp256k1PrivateKey(privateKey)) ===
55
+ normalizeSecp256k1PublicKey(publicKey));
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ class Deferred extends Error {
62
+ }
63
+ class Mover {
64
+ constructor(store, access) {
65
+ this.store = store;
66
+ this.access = access;
67
+ }
68
+ options(s, group, write = false) {
69
+ return {
70
+ ...(s.service ? { keychainService: s.service } : {}),
71
+ accessGroup: keychainAccessGroupValue(this.access, group),
72
+ ...(write && s.deviceOnly
73
+ ? { keychainAccessible: this.store.WHEN_UNLOCKED_THIS_DEVICE_ONLY }
74
+ : {}),
75
+ };
76
+ }
77
+ /** A read that throws means the keychain is unavailable: the whole run defers. */
78
+ async read(s, group) {
79
+ try {
80
+ return (await this.store.getItemAsync(s.key, this.options(s, group))) ?? null;
81
+ }
82
+ catch (error) {
83
+ throw new Deferred(error instanceof Error ? error.message : 'keychain read threw');
84
+ }
85
+ }
86
+ /**
87
+ * Write `value` where the new group had nothing, then read it back; `true`
88
+ * only when the new group holds exactly `value`. A copy that reads back as
89
+ * something else is removed again (best effort), because reads look in the
90
+ * new group FIRST and must not find it there.
91
+ */
92
+ async copy(s, value, replacing = false) {
93
+ try {
94
+ await this.store.setItemAsync(s.key, value, this.options(s, s.group, true));
95
+ if ((await this.store.getItemAsync(s.key, this.options(s, s.group))) === value) {
96
+ return true;
97
+ }
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ if (replacing) {
103
+ // A merge overwrote what was there; keep whatever is there now.
104
+ return false;
105
+ }
106
+ try {
107
+ await this.store.deleteItemAsync(s.key, this.options(s, s.group));
108
+ }
109
+ catch {
110
+ // The next run reads it as a conflict and leaves both; the shared copy is intact.
111
+ }
112
+ return false;
113
+ }
114
+ async deleteShared(s) {
115
+ try {
116
+ await this.store.deleteItemAsync(s.key, this.options(s, 'shared'));
117
+ return true;
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
123
+ async moveValue(unit) {
124
+ const { slot: s } = unit;
125
+ const shared = await this.read(s, 'shared');
126
+ if (shared === null)
127
+ return 'absent';
128
+ const target = await this.read(s, s.group);
129
+ let keep = target ?? shared;
130
+ if (target !== null && target !== shared) {
131
+ const merged = unit.merge?.(shared, target) ?? null;
132
+ if (merged === null)
133
+ return 'conflict';
134
+ keep = merged;
135
+ }
136
+ if (target !== keep && !(await this.copy(s, keep, target !== null)))
137
+ return 'failed';
138
+ // Verify the copy that is about to become the only one.
139
+ if ((await this.read(s, s.group)) !== keep)
140
+ return 'failed';
141
+ return (await this.deleteShared(s)) ? 'moved' : 'failed';
142
+ }
143
+ async movePair(unit) {
144
+ const halves = [unit.privateSlot, unit.publicSlot];
145
+ // A half whose group IS the shared group stays: it is read, never moved.
146
+ const movable = halves.filter((h) => h.group !== 'shared');
147
+ const shared = await Promise.all(halves.map((h) => this.read(h, 'shared')));
148
+ if (movable.every((h) => shared[halves.indexOf(h)] === null))
149
+ return 'absent';
150
+ const target = await Promise.all(halves.map((h, i) => (h.group === 'shared' ? shared[i] : this.read(h, h.group))));
151
+ for (const h of movable) {
152
+ const i = halves.indexOf(h);
153
+ if (shared[i] !== null && target[i] !== null && shared[i] !== target[i])
154
+ return 'conflict';
155
+ }
156
+ const finalPair = halves.map((_, i) => target[i] ?? shared[i]);
157
+ if (!isHealthyPair(finalPair[0], finalPair[1]))
158
+ return 'unhealthy';
159
+ for (const h of movable) {
160
+ const i = halves.indexOf(h);
161
+ const value = shared[i];
162
+ if (value !== null && target[i] === null && !(await this.copy(h, value)))
163
+ return 'failed';
164
+ }
165
+ const readBack = await Promise.all(halves.map((h, i) => (h.group === 'shared' ? shared[i] : this.read(h, h.group))));
166
+ if (readBack.some((v, i) => v !== finalPair[i]) || !isHealthyPair(readBack[0], readBack[1])) {
167
+ return 'failed';
168
+ }
169
+ for (const h of movable) {
170
+ if (shared[halves.indexOf(h)] !== null && !(await this.deleteShared(h)))
171
+ return 'failed';
172
+ }
173
+ return 'moved';
174
+ }
175
+ move(unit) {
176
+ return unit.kind === 'pair' ? this.movePair(unit) : this.moveValue(unit);
177
+ }
178
+ }
179
+ /** The personal items, in the order they move. */
180
+ function personalUnits() {
181
+ const v2 = (key, service, deviceOnly) => slot(key, 'identity-secret', service, deviceOnly);
182
+ const legacy = (key, deviceOnly) => slot(key, 'identity-secret', undefined, deviceOnly);
183
+ return [
184
+ {
185
+ label: 'primary',
186
+ kind: 'pair',
187
+ privateSlot: v2(V2_STORAGE_KEYS.PRIVATE_KEY, V2_PRIMARY_KEYCHAIN_SERVICE, true),
188
+ publicSlot: v2(V2_STORAGE_KEYS.PUBLIC_KEY, V2_PRIMARY_KEYCHAIN_SERVICE, false),
189
+ },
190
+ {
191
+ label: 'backup',
192
+ kind: 'pair',
193
+ privateSlot: v2(V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, V2_BACKUP_KEYCHAIN_SERVICE, true),
194
+ publicSlot: v2(V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, V2_BACKUP_KEYCHAIN_SERVICE, false),
195
+ },
196
+ {
197
+ label: 'backup-timestamp',
198
+ kind: 'value',
199
+ slot: v2(V2_STORAGE_KEYS.BACKUP_TIMESTAMP, V2_BACKUP_KEYCHAIN_SERVICE, false),
200
+ },
201
+ {
202
+ label: 'legacy-primary',
203
+ kind: 'pair',
204
+ privateSlot: legacy(STORAGE_KEYS.PRIVATE_KEY, true),
205
+ publicSlot: legacy(STORAGE_KEYS.PUBLIC_KEY, false),
206
+ },
207
+ {
208
+ label: 'legacy-backup',
209
+ kind: 'pair',
210
+ privateSlot: legacy(STORAGE_KEYS.BACKUP_PRIVATE_KEY, true),
211
+ publicSlot: legacy(STORAGE_KEYS.BACKUP_PUBLIC_KEY, false),
212
+ },
213
+ {
214
+ label: 'legacy-backup-timestamp',
215
+ kind: 'value',
216
+ slot: legacy(STORAGE_KEYS.BACKUP_TIMESTAMP, false),
217
+ },
218
+ {
219
+ label: 'phrase',
220
+ kind: 'value',
221
+ slot: slot(RECOVERY_MNEMONIC_STORAGE_KEY, 'identity-secret', RECOVERY_MNEMONIC_KEYCHAIN_SERVICE),
222
+ },
223
+ {
224
+ // The private key moves to the wallet group; its public key stays shared.
225
+ label: 'shared-slot',
226
+ kind: 'pair',
227
+ privateSlot: slot(STORAGE_KEYS.SHARED_PRIVATE_KEY, 'spending-key'),
228
+ publicSlot: slot(STORAGE_KEYS.SHARED_PUBLIC_KEY, 'shared', undefined, false),
229
+ },
230
+ ];
231
+ }
232
+ const INDEX_SLOT = slot(ORGANIZATION_INDEX_STORAGE_KEY, 'identity-secret', ORGANIZATION_INDEX_KEYCHAIN_SERVICE, false);
233
+ /** Union of two indexes; never drops an id either side lists. */
234
+ function mergeIndexes(shared, target) {
235
+ return serializeOrganizationIndex([
236
+ ...parseOrganizationIndex(shared),
237
+ ...parseOrganizationIndex(target),
238
+ ]);
239
+ }
240
+ /** Run the migration once. Exported for tests; production code awaits the gate. */
241
+ export async function runKeychainGroupMigration() {
242
+ let access;
243
+ try {
244
+ access = await resolveKeychainAccess();
245
+ }
246
+ catch {
247
+ return { state: 'deferred', items: {} };
248
+ }
249
+ if (access.mode !== 'grouped' || access.role !== 'holder') {
250
+ return { state: 'not-applicable', items: {} };
251
+ }
252
+ let store;
253
+ try {
254
+ store = await loadSecureStore();
255
+ }
256
+ catch {
257
+ return { state: 'deferred', items: {} };
258
+ }
259
+ const mover = new Mover(store, access);
260
+ const items = {};
261
+ try {
262
+ for (const unit of personalUnits()) {
263
+ items[unit.label] = await mover.move(unit);
264
+ }
265
+ // Organizations: every id either copy of the index names.
266
+ const ids = new Set([
267
+ ...parseOrganizationIndex(await mover.read(INDEX_SLOT, 'shared')),
268
+ ...parseOrganizationIndex(await mover.read(INDEX_SLOT, INDEX_SLOT.group)),
269
+ ]);
270
+ let n = 0;
271
+ for (const accountId of [...ids].sort()) {
272
+ n += 1;
273
+ items[`organization-${n}`] = await mover.move({
274
+ label: `organization-${n}`,
275
+ kind: 'pair',
276
+ privateSlot: slot(organizationPrivateKeyName(accountId), 'spending-key'),
277
+ publicSlot: slot(organizationPublicKeyName(accountId), 'spending-key', undefined, false),
278
+ });
279
+ items[`organization-${n}-phrase`] = await mover.move({
280
+ label: `organization-${n}-phrase`,
281
+ kind: 'value',
282
+ slot: slot(organizationMnemonicName(accountId), 'identity-secret', ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE),
283
+ });
284
+ }
285
+ // The index last: it is how a later run finds the organizations above.
286
+ items['organization-index'] = await mover.move({
287
+ label: 'organization-index',
288
+ kind: 'value',
289
+ slot: INDEX_SLOT,
290
+ merge: mergeIndexes,
291
+ });
292
+ }
293
+ catch (error) {
294
+ if (error instanceof Deferred) {
295
+ return { state: 'deferred', items };
296
+ }
297
+ throw error;
298
+ }
299
+ const settled = Object.values(items).every((o) => o === 'absent' || o === 'moved');
300
+ const report = {
301
+ state: settled ? 'complete' : 'incomplete',
302
+ items,
303
+ };
304
+ if (!settled) {
305
+ logger.warn('Keychain access-group migration left items in the shared group', {
306
+ component: 'KeychainGroupMigration',
307
+ items: Object.entries(items)
308
+ .filter(([, o]) => o !== 'absent' && o !== 'moved')
309
+ .map(([label, o]) => `${label}:${o}`)
310
+ .join(','),
311
+ });
312
+ }
313
+ return report;
314
+ }
315
+ let settledReport = null;
316
+ let inFlight = null;
317
+ /**
318
+ * The gate every Commons identity-storage access awaits. One run per process
319
+ * (`complete`, `incomplete` and `not-applicable` are kept; `incomplete` is
320
+ * retried on the next launch); a `deferred` run is forgotten, so the next
321
+ * access tries again once the keychain unlocks. Concurrent callers share one
322
+ * run. Never throws.
323
+ */
324
+ export function ensureKeychainGroupsMigrated() {
325
+ if (settledReport) {
326
+ return Promise.resolve(settledReport);
327
+ }
328
+ if (!inFlight) {
329
+ inFlight = runKeychainGroupMigration()
330
+ .catch((error) => {
331
+ logger.warn('Keychain access-group migration threw; retrying on the next access', { component: 'KeychainGroupMigration' }, error);
332
+ return { state: 'deferred', items: {} };
333
+ })
334
+ .then((report) => {
335
+ if (report.state !== 'deferred') {
336
+ settledReport = report;
337
+ }
338
+ inFlight = null;
339
+ return report;
340
+ });
341
+ }
342
+ return inFlight;
343
+ }
344
+ /** Forget the memoized run. Tests only. @internal */
345
+ export function resetKeychainGroupMigrationForTests() {
346
+ settledReport = null;
347
+ inFlight = null;
348
+ }
@@ -0,0 +1,61 @@
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
+ /** Legacy (pre-v2) and shared-slot keys; the legacy ones have no keychain service. */
8
+ export const STORAGE_KEYS = {
9
+ PRIVATE_KEY: 'oxy_identity_private_key',
10
+ PUBLIC_KEY: 'oxy_identity_public_key',
11
+ BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key',
12
+ BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key',
13
+ BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp',
14
+ // The personal key's cross-app slot: the private key in the wallet group, the
15
+ // public key in the shared group (iOS only), no keychain service.
16
+ SHARED_PRIVATE_KEY: 'oxy_shared_identity_private_key',
17
+ SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
18
+ };
19
+ export const V2_PRIMARY_KEYCHAIN_SERVICE = 'oxy_identity';
20
+ export const V2_BACKUP_KEYCHAIN_SERVICE = 'oxy_identity_backup';
21
+ export const V2_STORAGE_KEYS = {
22
+ PRIVATE_KEY: 'oxy_identity_private_key_v2',
23
+ PUBLIC_KEY: 'oxy_identity_public_key_v2',
24
+ BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
25
+ BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
26
+ BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
27
+ };
28
+ export const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
29
+ export const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
30
+ /** Organization keys (ADR 0061 D6). On iOS the pair has no keychain service. */
31
+ export const ORGANIZATION_ANDROID_KEYCHAIN_SERVICE = 'oxy_org_identity';
32
+ export const ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_org_identity_mnemonic';
33
+ export const ORGANIZATION_INDEX_KEYCHAIN_SERVICE = 'oxy_org_identity_index';
34
+ export const ORGANIZATION_INDEX_STORAGE_KEY = 'oxy_org_identity_index_v1';
35
+ export const organizationPrivateKeyName = (accountId) => `oxy_org_identity_private_key.${accountId}`;
36
+ export const organizationPublicKeyName = (accountId) => `oxy_org_identity_public_key.${accountId}`;
37
+ export const organizationMnemonicName = (accountId) => `oxy_org_identity_mnemonic.${accountId}`;
38
+ /** Same set the server's account ids come from: a legacy 24-hex id or a lowercase uuid. */
39
+ const ACCOUNT_ID = /^(?:[0-9a-f]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
40
+ /** Whether `value` is an account id an organization key can be stored under. */
41
+ export function isOrganizationAccountId(value) {
42
+ return ACCOUNT_ID.test(value);
43
+ }
44
+ /** The account ids in a stored organization index; anything malformed is skipped. */
45
+ export function parseOrganizationIndex(raw) {
46
+ if (!raw)
47
+ return [];
48
+ try {
49
+ const parsed = JSON.parse(raw);
50
+ return Array.isArray(parsed)
51
+ ? parsed.filter((id) => typeof id === 'string' && isOrganizationAccountId(id))
52
+ : [];
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ }
58
+ /** The stored form of an organization index: unique, sorted. */
59
+ export function serializeOrganizationIndex(ids) {
60
+ return JSON.stringify([...new Set(ids)].sort());
61
+ }
@@ -8,8 +8,10 @@
8
8
  * other Oxy apps reach it the way they reach the personal identity, by
9
9
  * derivation and never by the key:
10
10
  *
11
- * - iOS: the keychain access group `group.so.oxy.shared`, like the personal
12
- * shared slot. Only Commons writes it.
11
+ * - iOS: the pair in the wallet keychain group (`<TEAM>.so.oxy.wallet`), which
12
+ * only Commons and the Peable wallet hold; the phrase and the index in the
13
+ * identity group, Commons only (docs/identity/ios-keychain-access-groups.md).
14
+ * Only Commons writes them; every other app reads nothing.
13
15
  * - Android: Commons' own expo-secure-store slot, mirrored into the identity
14
16
  * signer store (`./identitySigner`) that Commons' identity host answers
15
17
  * `accountId` requests from. Every other app asks Commons over IPC
@@ -17,7 +19,7 @@
17
19
  *
18
20
  * The phrase is kept only in Commons, on the device that created or imported
19
21
  * it, so an owner can show it to another owner (ADR 0061 D5); it never reaches
20
- * the keychain group, an IPC answer or a log.
22
+ * the wallet or shared group, an IPC answer or a log.
21
23
  *
22
24
  * This module imports nothing from `./keyManager` (which imports it): the
23
25
  * signer store comes from `./identitySigner`'s registry.
@@ -27,29 +29,18 @@ import { wordlist } from '@scure/bip39/wordlists/english.js';
27
29
  import { loadCommonsIdentityBridge, loadSecureStore, } from '@oxy.so/protocol';
28
30
  import { deriveSecp256k1PublicKey, isValidSecp256k1PrivateKey, isValidSecp256k1PublicKey, normalizeSecp256k1PrivateKey, normalizeSecp256k1PublicKey, } from '@oxy.so/protocol/secp256k1';
29
31
  import { logger } from '../logger/index.js';
32
+ import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
30
33
  import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
31
34
  import { registeredIdentitySignerStore } from './identitySigner.js';
32
- /** Same set the server's account ids come from: a legacy 24-hex id or a lowercase uuid. */
33
- const ACCOUNT_ID = /^(?:[0-9a-f]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
34
- const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
35
- /** Android, Commons-local: its own Keystore key, shared with no personal slot. */
36
- const ANDROID_KEYCHAIN_SERVICE = 'oxy_org_identity';
37
- const MNEMONIC_KEYCHAIN_SERVICE = 'oxy_org_identity_mnemonic';
38
- const INDEX_KEYCHAIN_SERVICE = 'oxy_org_identity_index';
39
- const INDEX_STORAGE_KEY = 'oxy_org_identity_index_v1';
35
+ import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
36
+ import { ORGANIZATION_ANDROID_KEYCHAIN_SERVICE as ANDROID_KEYCHAIN_SERVICE, ORGANIZATION_INDEX_KEYCHAIN_SERVICE as INDEX_KEYCHAIN_SERVICE, ORGANIZATION_INDEX_STORAGE_KEY as INDEX_STORAGE_KEY, ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE as MNEMONIC_KEYCHAIN_SERVICE, isOrganizationAccountId, organizationMnemonicName as mnemonicName, organizationPrivateKeyName as privateKeyName, organizationPublicKeyName as publicKeyName, serializeOrganizationIndex, } from './keychainItems.js';
37
+ export { isOrganizationAccountId };
40
38
  const ORGANIZATION_PHRASE_WORDS = 24;
41
- /** Whether `value` is an account id an organization key can be stored under. */
42
- export function isOrganizationAccountId(value) {
43
- return ACCOUNT_ID.test(value);
44
- }
45
39
  function assertAccountId(accountId) {
46
40
  if (!isOrganizationAccountId(accountId)) {
47
41
  throw new Error('organization key: accountId must be a 24-hex or uuid account id');
48
42
  }
49
43
  }
50
- const privateKeyName = (accountId) => `oxy_org_identity_private_key.${accountId}`;
51
- const publicKeyName = (accountId) => `oxy_org_identity_public_key.${accountId}`;
52
- const mnemonicName = (accountId) => `oxy_org_identity_mnemonic.${accountId}`;
53
44
  function toHex(bytes) {
54
45
  return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
55
46
  }
@@ -102,9 +93,23 @@ export async function generateOrganizationKey() {
102
93
  export async function organizationKeyFromPhrase(phrase) {
103
94
  return pairFromPhrase(phrase);
104
95
  }
105
- async function secureStore() {
106
- return loadSecureStore();
96
+ /**
97
+ * The secure store for one class of organization item: on iOS the pair is a
98
+ * `spending-key` (wallet group), the phrase and the index `identity-secret`
99
+ * (identity group); see `../utils/keychainAccessGroups`. In Commons every
100
+ * access first awaits the access-group migration. Android is unchanged.
101
+ */
102
+ async function secureStore(item) {
103
+ const raw = await loadSecureStore();
104
+ const access = await resolveKeychainAccess();
105
+ if (access.mode === 'grouped' && access.role === 'holder') {
106
+ await ensureKeychainGroupsMigrated();
107
+ }
108
+ return groupedKeychainStore(raw, access, item);
107
109
  }
110
+ const pairStore = () => secureStore('spending-key');
111
+ const phraseStore = () => secureStore('identity-secret');
112
+ const indexStore = () => secureStore('identity-secret');
108
113
  async function readIndex(store) {
109
114
  const raw = await store.getItemAsync(INDEX_STORAGE_KEY, {
110
115
  keychainService: INDEX_KEYCHAIN_SERVICE,
@@ -126,19 +131,17 @@ async function readIndex(store) {
126
131
  }
127
132
  }
128
133
  async function writeIndex(store, ids) {
129
- await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify([...new Set(ids)].sort()), {
134
+ await store.setItemAsync(INDEX_STORAGE_KEY, serializeOrganizationIndex(ids), {
130
135
  keychainService: INDEX_KEYCHAIN_SERVICE,
131
136
  });
132
137
  }
133
- /** Where this platform keeps the pair: the iOS keychain group, or Commons' own Android slot. */
138
+ /**
139
+ * The options of the pair on this platform: on iOS no keychain service (the
140
+ * wallet group comes from {@link pairStore}), on Android Commons' own slot.
141
+ */
134
142
  function pairOptions(store, forPrivateWrite) {
135
143
  if (isIOS()) {
136
- return forPrivateWrite
137
- ? {
138
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
139
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
140
- }
141
- : { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
144
+ return forPrivateWrite ? { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY } : {};
142
145
  }
143
146
  return forPrivateWrite
144
147
  ? {
@@ -155,7 +158,7 @@ async function readLocalPair(accountId) {
155
158
  const signer = registeredIdentitySignerStore();
156
159
  if (!signer)
157
160
  return null; // Only Commons holds organization keys on Android.
158
- const store = await secureStore();
161
+ const store = await pairStore();
159
162
  const options = pairOptions(store, false);
160
163
  const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
161
164
  const publicKey = await store.getItemAsync(publicKeyName(accountId), options);
@@ -173,7 +176,9 @@ async function readLocalPair(accountId) {
173
176
  }
174
177
  if (!isIOS())
175
178
  return null;
176
- const store = await secureStore();
179
+ // The wallet group: `null` without a keychain read in every app but Commons
180
+ // and the Peable wallet.
181
+ const store = await pairStore();
177
182
  const options = pairOptions(store, false);
178
183
  const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
179
184
  const publicKey = await store.getItemAsync(publicKeyName(accountId), options);
@@ -187,7 +192,7 @@ async function readLocalPair(accountId) {
187
192
  /**
188
193
  * Keep an organization key on this device. Commons only: on Android it needs
189
194
  * the identity signer store Commons registers, so the identity host can serve
190
- * it; on iOS only Commons calls it (the keychain group is shared).
195
+ * it; on iOS only Commons holds the groups it writes (any other app throws).
191
196
  *
192
197
  * Refuses a pair whose public key does not derive from its private key, and a
193
198
  * phrase that does not derive the pair. Overwrites whatever this device held for
@@ -212,29 +217,31 @@ export async function storeOrganizationKey(accountId, key) {
212
217
  if (isAndroid() && !signer?.writeOrganization) {
213
218
  throw new Error('On Android only Commons holds organization keys; this app has no identity signer store for them.');
214
219
  }
215
- const store = await secureStore();
220
+ const store = await pairStore();
216
221
  await store.setItemAsync(privateKeyName(accountId), privateKey, pairOptions(store, true));
217
222
  await store.setItemAsync(publicKeyName(accountId), publicKey, pairOptions(store, false));
218
223
  if (signer?.writeOrganization &&
219
224
  !(await signer.writeOrganization(accountId, privateKey, publicKey))) {
220
225
  throw new Error(`The identity signer store (${signer.name}) did not confirm the organization key.`);
221
226
  }
227
+ const phrases = await phraseStore();
222
228
  if (key.mnemonic !== undefined) {
223
- await store.setItemAsync(mnemonicName(accountId), normalizePhrase(key.mnemonic), {
229
+ await phrases.setItemAsync(mnemonicName(accountId), normalizePhrase(key.mnemonic), {
224
230
  keychainService: MNEMONIC_KEYCHAIN_SERVICE,
225
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
231
+ keychainAccessible: phrases.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
226
232
  });
227
233
  }
228
234
  else {
229
- await store.deleteItemAsync(mnemonicName(accountId), {
235
+ await phrases.deleteItemAsync(mnemonicName(accountId), {
230
236
  keychainService: MNEMONIC_KEYCHAIN_SERVICE,
231
237
  });
232
238
  }
233
- await writeIndex(store, [...(await readIndex(store)), accountId]);
239
+ const index = await indexStore();
240
+ await writeIndex(index, [...(await readIndex(index)), accountId]);
234
241
  }
235
242
  /**
236
243
  * The organization's public key as THIS device can reach it, or `null`.
237
- * iOS: the keychain group. Android: Commons' own slot inside Commons, and
244
+ * iOS: the wallet keychain group (Commons, the Peable wallet). Android: Commons' own slot inside Commons, and
238
245
  * Commons' identity host (`describe` with `accountId`) everywhere else. Compare
239
246
  * it with the organization's DID `#key-1` before receiving (Peable ADR 0002).
240
247
  */
@@ -259,8 +266,8 @@ export async function getOrganizationPublicKey(accountId) {
259
266
  }
260
267
  /**
261
268
  * The organization's private key, where this app may hold it: Commons (both
262
- * platforms) and, on iOS, the keychain group. `null` on Android outside
263
- * Commons. Use it to sign within the app only; never transmit it.
269
+ * platforms) and, on iOS, the Peable wallet through the wallet keychain group.
270
+ * `null` everywhere else. Use it to sign within the app only; never transmit it.
264
271
  */
265
272
  export async function getOrganizationPrivateKey(accountId) {
266
273
  if (!isOrganizationAccountId(accountId))
@@ -280,7 +287,7 @@ export async function getOrganizationPrivateKey(accountId) {
280
287
  export async function getOrganizationRecoveryPhrase(accountId) {
281
288
  if (!isOrganizationAccountId(accountId) || isWeb())
282
289
  return null;
283
- const store = await secureStore();
290
+ const store = await phraseStore();
284
291
  return store.getItemAsync(mnemonicName(accountId), {
285
292
  keychainService: MNEMONIC_KEYCHAIN_SERVICE,
286
293
  });
@@ -289,23 +296,24 @@ export async function getOrganizationRecoveryPhrase(accountId) {
289
296
  export async function listOrganizationKeys() {
290
297
  if (isWeb())
291
298
  return [];
292
- return readIndex(await secureStore());
299
+ return readIndex(await indexStore());
293
300
  }
294
301
  /** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
295
302
  export async function deleteOrganizationKey(accountId) {
296
303
  assertAccountId(accountId);
297
304
  if (isWeb())
298
305
  return;
299
- const store = await secureStore();
306
+ const store = await pairStore();
300
307
  const options = pairOptions(store, false);
301
308
  await store.deleteItemAsync(privateKeyName(accountId), options);
302
309
  await store.deleteItemAsync(publicKeyName(accountId), options);
303
- await store.deleteItemAsync(mnemonicName(accountId), {
310
+ await (await phraseStore()).deleteItemAsync(mnemonicName(accountId), {
304
311
  keychainService: MNEMONIC_KEYCHAIN_SERVICE,
305
312
  });
306
313
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
307
314
  await signer?.clearOrganization?.(accountId);
308
- await writeIndex(store, (await readIndex(store)).filter((id) => id !== accountId));
315
+ const index = await indexStore();
316
+ await writeIndex(index, (await readIndex(index)).filter((id) => id !== accountId));
309
317
  }
310
318
  /**
311
319
  * Commons on Android: copy every held organization key into the identity
@@ -317,9 +325,9 @@ export async function syncOrganizationKeys() {
317
325
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
318
326
  if (!signer?.writeOrganization || !signer.readOrganization)
319
327
  return 0;
320
- const store = await secureStore();
328
+ const store = await pairStore();
321
329
  let mirrored = 0;
322
- for (const accountId of await readIndex(store)) {
330
+ for (const accountId of await readIndex(await indexStore())) {
323
331
  const options = pairOptions(store, false);
324
332
  const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
325
333
  const publicKey = await store.getItemAsync(publicKeyName(accountId), options);