@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.
Files changed (34) hide show
  1. package/dist/cjs/crypto/index.js +19 -1
  2. package/dist/cjs/crypto/keyManager.js +106 -106
  3. package/dist/cjs/crypto/keychainGroupMigration.js +353 -0
  4. package/dist/cjs/crypto/keychainItems.js +70 -0
  5. package/dist/cjs/crypto/organizationKeys.js +80 -72
  6. package/dist/cjs/crypto/secureStoreOptions.js +2 -0
  7. package/dist/cjs/session/index.js +11 -1
  8. package/dist/cjs/utils/keychainAccessGroups.js +248 -0
  9. package/dist/esm/crypto/index.js +3 -0
  10. package/dist/esm/crypto/keyManager.js +63 -63
  11. package/dist/esm/crypto/keychainGroupMigration.js +348 -0
  12. package/dist/esm/crypto/keychainItems.js +61 -0
  13. package/dist/esm/crypto/organizationKeys.js +54 -46
  14. package/dist/esm/crypto/secureStoreOptions.js +1 -0
  15. package/dist/esm/session/index.js +3 -0
  16. package/dist/esm/utils/keychainAccessGroups.js +231 -0
  17. package/dist/types/crypto/index.d.ts +4 -0
  18. package/dist/types/crypto/keyManager.d.ts +9 -1
  19. package/dist/types/crypto/keychainGroupMigration.d.ts +27 -0
  20. package/dist/types/crypto/keychainItems.d.ts +41 -0
  21. package/dist/types/crypto/organizationKeys.d.ts +11 -9
  22. package/dist/types/crypto/secureStoreOptions.d.ts +15 -0
  23. package/dist/types/session/index.d.ts +2 -0
  24. package/dist/types/utils/keychainAccessGroups.d.ts +104 -0
  25. package/package.json +2 -2
  26. package/src/crypto/index.ts +29 -0
  27. package/src/crypto/keyManager.ts +84 -99
  28. package/src/crypto/keychainGroupMigration.ts +440 -0
  29. package/src/crypto/keychainItems.ts +73 -0
  30. package/src/crypto/organizationKeys.ts +72 -57
  31. package/src/crypto/secureStoreOptions.ts +32 -0
  32. package/src/session/index.ts +11 -0
  33. package/src/utils/keychainAccessGroups.ts +308 -0
  34. package/src/types/expo-secure-store.d.ts +0 -21
@@ -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);
@@ -0,0 +1 @@
1
+ export {};
@@ -72,6 +72,9 @@ export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthSt
72
72
  // Commons private identity key: an app that only needs a session must never be
73
73
  // handed the key that signs identity approvals.
74
74
  export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './sharedDeviceCredential.js';
75
+ // Where the shared device credential (and an app's own items) live on iOS:
76
+ // docs/identity/ios-keychain-access-groups.md, ADR 0062.
77
+ export { KeychainAccessUnavailableError, keychainAccessGroupValue, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
75
78
  // Identity-bound sessions (the identity vault). The pin is the durable
76
79
  // `{publicKey, accountId}` binding between this device's PRIMARY identity key
77
80
  // and the account it authenticates as; it is what keeps such a client from
@@ -0,0 +1,231 @@
1
+ /**
2
+ * iOS keychain access groups: which group each Oxy keychain item lives in, and
3
+ * which apps may read it (docs/identity/ios-keychain-access-groups.md, ADR 0062).
4
+ *
5
+ * | Group | Held by | Item class |
6
+ * |-------------------------------|------------------------------|-------------------|
7
+ * | `<TEAM>.so.oxy.identity` | Commons only (listed FIRST) | `identity-secret` |
8
+ * | `<TEAM>.so.oxy.wallet` | Commons, the Peable wallet | `spending-key` |
9
+ * | `<TEAM>.group.so.oxy.shared` | every Oxy app | `shared` |
10
+ *
11
+ * - `identity-secret`: the personal root (primary and backup slots), the
12
+ * personal and organization recovery phrases, the organization index. Only
13
+ * Commons may read or write them.
14
+ * - `spending-key`: private keys an app other than Commons signs or derives
15
+ * with in process — the personal key's shared slot and organization pairs
16
+ * (`deriveScopedSeed`, `signSocialReceive`). Commons writes them; the Peable
17
+ * wallet reads them.
18
+ * - `shared`: what every Oxy app may read: the shared public key and the
19
+ * device-session credential.
20
+ *
21
+ * The ROLE of this binary comes from the keychain itself: an item written
22
+ * without a group lands in the app's default group, the first entry of its
23
+ * `keychain-access-groups` entitlement, and `@oxy.so/app-preset` lets only
24
+ * Commons put the identity group first and only the Peable wallet the wallet
25
+ * group. The same probe gives the team prefix, which `kSecAttrAccessGroup`
26
+ * needs and JavaScript cannot otherwise see.
27
+ *
28
+ * Android and web have no access groups: there every helper here answers the
29
+ * single-store behaviour (`ungrouped`), and Android's narrowing is Commons'
30
+ * identity host. An iOS build whose native module predates the probe is also
31
+ * `ungrouped` — exactly what that build's entitlements (one shared group) do.
32
+ *
33
+ * This module never imports the identity stack: services' device-session store
34
+ * and Peable's wallet storage use it too.
35
+ */
36
+ import { loadKeychainDefaultAccessGroup } from '@oxy.so/protocol';
37
+ import { isIOS } from './platform.js';
38
+ /** The group names after the team prefix, exactly as the entitlements list them. */
39
+ export const KEYCHAIN_ACCESS_GROUPS = {
40
+ identity: 'so.oxy.identity',
41
+ wallet: 'so.oxy.wallet',
42
+ shared: 'group.so.oxy.shared',
43
+ };
44
+ /** Thrown while the probe cannot answer (before first unlock). Transient. */
45
+ export class KeychainAccessUnavailableError extends Error {
46
+ constructor(cause) {
47
+ super('The keychain cannot say which access groups this app holds right now.');
48
+ this.cause = cause;
49
+ this.name = 'KeychainAccessUnavailableError';
50
+ }
51
+ }
52
+ /**
53
+ * The single place each item class is assigned a group. A new kind of item
54
+ * (F1's retained keys, for one) picks its class and reuses everything below.
55
+ */
56
+ export function keychainGroupFor(item) {
57
+ switch (item) {
58
+ case 'identity-secret':
59
+ return 'identity';
60
+ case 'spending-key':
61
+ return 'wallet';
62
+ case 'shared':
63
+ return 'shared';
64
+ }
65
+ }
66
+ /** Whether `role` holds the group `item` lives in. */
67
+ export function mayHoldKeychainItem(role, item) {
68
+ switch (role) {
69
+ case 'holder':
70
+ return true;
71
+ case 'wallet':
72
+ return item !== 'identity-secret';
73
+ case 'member':
74
+ return item === 'shared';
75
+ }
76
+ }
77
+ /** Parse the default access group the probe reported. Exported for tests. */
78
+ export function keychainAccessFromDefaultGroup(defaultGroup) {
79
+ const dot = defaultGroup.indexOf('.');
80
+ if (dot <= 0 || dot === defaultGroup.length - 1) {
81
+ // Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
82
+ return { mode: 'ungrouped' };
83
+ }
84
+ const prefix = defaultGroup.slice(0, dot + 1);
85
+ const group = defaultGroup.slice(dot + 1);
86
+ const role = group === KEYCHAIN_ACCESS_GROUPS.identity
87
+ ? 'holder'
88
+ : group === KEYCHAIN_ACCESS_GROUPS.wallet
89
+ ? 'wallet'
90
+ : 'member';
91
+ return { mode: 'grouped', prefix, defaultGroup, role };
92
+ }
93
+ let resolvedAccess = null;
94
+ /**
95
+ * This binary's keychain access, memoized once known. THROWS
96
+ * {@link KeychainAccessUnavailableError} while the probe cannot answer, which
97
+ * every caller already treats as "storage unavailable, retry".
98
+ */
99
+ export async function resolveKeychainAccess() {
100
+ if (resolvedAccess) {
101
+ return resolvedAccess;
102
+ }
103
+ if (!isIOS()) {
104
+ // Not memoized: until `@oxy.so/services` registers the platform, a phone
105
+ // reads as "not iOS", and caching that would keep the whole process on the
106
+ // pre-split behaviour.
107
+ return { mode: 'ungrouped' };
108
+ }
109
+ let probe;
110
+ try {
111
+ probe = await loadKeychainDefaultAccessGroup();
112
+ }
113
+ catch (cause) {
114
+ throw new KeychainAccessUnavailableError(cause);
115
+ }
116
+ if (probe.state === 'unavailable') {
117
+ throw new KeychainAccessUnavailableError(probe.cause);
118
+ }
119
+ resolvedAccess =
120
+ probe.state === 'resolved'
121
+ ? keychainAccessFromDefaultGroup(probe.accessGroup)
122
+ : { mode: 'ungrouped' };
123
+ return resolvedAccess;
124
+ }
125
+ /** Forget the memoized access. Tests only. @internal */
126
+ export function resetKeychainAccessForTests() {
127
+ resolvedAccess = null;
128
+ }
129
+ /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
130
+ export function keychainAccessGroupValue(access, group) {
131
+ return access.mode === 'grouped' ? `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
132
+ }
133
+ function option(value) {
134
+ return value === undefined ? {} : { accessGroup: value };
135
+ }
136
+ /**
137
+ * The option bags a READ of `item` tries, in order: its own group, then the
138
+ * shared group, where every item lived before the split (release 4 of the doc
139
+ * drops that fallback). Empty when this app may not hold the item: the caller
140
+ * answers `null` without touching the keychain.
141
+ */
142
+ export function keychainReadPlan(access, item) {
143
+ if (access.mode === 'ungrouped') {
144
+ return [{}];
145
+ }
146
+ if (!mayHoldKeychainItem(access.role, item)) {
147
+ return [];
148
+ }
149
+ const own = keychainGroupFor(item);
150
+ const plan = [option(keychainAccessGroupValue(access, own))];
151
+ if (own !== 'shared') {
152
+ plan.push(option(keychainAccessGroupValue(access, 'shared')));
153
+ }
154
+ return plan;
155
+ }
156
+ /**
157
+ * Every place a DELETE of `item` must reach: the read plan, so a delete never
158
+ * leaves a pre-split copy behind in the shared group.
159
+ */
160
+ export function keychainDeletePlan(access, item) {
161
+ return keychainReadPlan(access, item);
162
+ }
163
+ /**
164
+ * The option a WRITE of `item` carries. THROWS when this app may not hold the
165
+ * item: an identity secret written by any app but Commons would land in that
166
+ * app's default group, a second holder nobody knows about.
167
+ */
168
+ export function keychainWriteOption(access, item) {
169
+ if (access.mode === 'ungrouped') {
170
+ return {};
171
+ }
172
+ if (!mayHoldKeychainItem(access.role, item)) {
173
+ throw new Error(`This app (keychain role "${access.role}") may not hold a ${item} keychain item; only Commons writes the identity.`);
174
+ }
175
+ return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
176
+ }
177
+ /**
178
+ * The plan for an app's OWN items (its session credential, its settings, an
179
+ * imported wallet's phrase) in Commons and the Peable wallet, whose default
180
+ * group the split changed: written to the default group explicitly — where a
181
+ * write without a group goes anyway — and read from it FIRST, then from every
182
+ * group (`{}`), where all of them were written before. Without the explicit
183
+ * order a read could return whichever of two copies the keychain met first.
184
+ * Deletes reach every group, as they always did.
185
+ *
186
+ * Every other app (`member`) and Android/web keep the calls they always made.
187
+ */
188
+ export function ownKeychainItemPlan(access) {
189
+ if (access.mode === 'ungrouped' || access.role === 'member') {
190
+ return { read: [{}], write: {}, delete: [{}] };
191
+ }
192
+ return {
193
+ read: [option(access.defaultGroup), {}],
194
+ write: option(access.defaultGroup),
195
+ delete: [{}],
196
+ };
197
+ }
198
+ /** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
199
+ export async function readThroughPlan(store, key, options, plan) {
200
+ for (const group of plan) {
201
+ const value = await store.getItemAsync(key, { ...options, ...group });
202
+ if (value !== null && value !== undefined) {
203
+ return value;
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+ /** Delete `key` from every location in `plan`. A thrown delete propagates. */
209
+ export async function deleteThroughPlan(store, key, options, plan) {
210
+ for (const group of plan) {
211
+ await store.deleteItemAsync(key, { ...options, ...group });
212
+ }
213
+ }
214
+ /**
215
+ * `store` with every call routed to `item`'s groups for this app: reads walk
216
+ * the read plan, writes carry the write option (throwing for a role that may
217
+ * not hold the item), deletes reach every location. The constants of the
218
+ * wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
219
+ */
220
+ export function groupedKeychainStore(store, access, item) {
221
+ const plan = keychainReadPlan(access, item);
222
+ const wrapped = {
223
+ getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, plan),
224
+ setItemAsync: async (key, value, options) => {
225
+ const group = keychainWriteOption(access, item);
226
+ await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
227
+ },
228
+ deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, plan),
229
+ };
230
+ return Object.assign(Object.create(store), wrapped);
231
+ }
@@ -19,6 +19,10 @@ export type { IdentityDeviceBackupStore } from './deviceBackup';
19
19
  export type { IdentitySignerStore } from './identitySigner';
20
20
  export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys';
21
21
  export type { OrganizationKeyPair, GeneratedOrganizationKey, StoredOrganizationKey, } from './organizationKeys';
22
+ export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups';
23
+ export type { KeychainAccess, KeychainAccessGroupName, KeychainGroupOption, KeychainItemClass, KeychainRole, KeychainStoreLike, } from '../utils/keychainAccessGroups';
24
+ export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
25
+ export type { KeychainGroupMigrationReport, KeychainItemMigrationOutcome, } from './keychainGroupMigration';
22
26
  export { SignatureService } from './signatureService';
23
27
  export type { SignedMessage, AuthChallenge } from './signatureService';
24
28
  export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase';
@@ -238,6 +238,13 @@ export declare class KeyManager {
238
238
  * Called internally when shared identity is created/deleted/imported
239
239
  */
240
240
  private static invalidateSharedCache;
241
+ /**
242
+ * iOS: write the personal key's cross-app slot — the private key to the
243
+ * wallet group (Commons and the Peable wallet), the public key to the shared
244
+ * group (every Oxy app). Throws in any app that may not hold the private key.
245
+ * @internal
246
+ */
247
+ private static _writeIosSharedSlot;
241
248
  /**
242
249
  * Lowercase and pad to canonical 64-hex-char form.
243
250
  *
@@ -717,7 +724,8 @@ export declare class KeyManager {
717
724
  * `HDKey.fromMasterSeed` and never touches the identity key.
718
725
  *
719
726
  * Key source (native only):
720
- * - iOS: the shared identity in `group.so.oxy.shared`, then this device's
727
+ * - iOS: the shared slot's private key in the wallet keychain group (Commons
728
+ * and the Peable wallet only; `null` in every other app), then this device's
721
729
  * primary identity.
722
730
  * - Android: Commons (the one app with a signer store) uses its own primary
723
731
  * identity; every other app asks Commons over IPC and never reads a local
@@ -0,0 +1,27 @@
1
+ /** What happened to one item (or pair) in a run. */
2
+ export type KeychainItemMigrationOutcome = 'absent' | 'moved' | 'conflict' | 'unhealthy' | 'failed' | 'deferred';
3
+ export interface KeychainGroupMigrationReport {
4
+ /**
5
+ * - `not-applicable`: not Commons on a split-aware iOS build; nothing to move.
6
+ * - `complete`: nothing secret is left in the shared group.
7
+ * - `incomplete`: something stayed (`conflict`, `unhealthy` or `failed`);
8
+ * retried next launch.
9
+ * - `deferred`: the keychain could not be read (locked); retried on the next
10
+ * storage access.
11
+ */
12
+ state: 'not-applicable' | 'complete' | 'incomplete' | 'deferred';
13
+ /** Outcome per item label (`primary`, `phrase`, `organization-1`, …). */
14
+ items: Record<string, KeychainItemMigrationOutcome>;
15
+ }
16
+ /** Run the migration once. Exported for tests; production code awaits the gate. */
17
+ export declare function runKeychainGroupMigration(): Promise<KeychainGroupMigrationReport>;
18
+ /**
19
+ * The gate every Commons identity-storage access awaits. One run per process
20
+ * (`complete`, `incomplete` and `not-applicable` are kept; `incomplete` is
21
+ * retried on the next launch); a `deferred` run is forgotten, so the next
22
+ * access tries again once the keychain unlocks. Concurrent callers share one
23
+ * run. Never throws.
24
+ */
25
+ export declare function ensureKeychainGroupsMigrated(): Promise<KeychainGroupMigrationReport>;
26
+ /** Forget the memoized run. Tests only. @internal */
27
+ export declare function resetKeychainGroupMigrationForTests(): void;
@@ -0,0 +1,41 @@
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 declare const STORAGE_KEYS: {
9
+ readonly PRIVATE_KEY: "oxy_identity_private_key";
10
+ readonly PUBLIC_KEY: "oxy_identity_public_key";
11
+ readonly BACKUP_PRIVATE_KEY: "oxy_identity_backup_private_key";
12
+ readonly BACKUP_PUBLIC_KEY: "oxy_identity_backup_public_key";
13
+ readonly BACKUP_TIMESTAMP: "oxy_identity_backup_timestamp";
14
+ readonly SHARED_PRIVATE_KEY: "oxy_shared_identity_private_key";
15
+ readonly SHARED_PUBLIC_KEY: "oxy_shared_identity_public_key";
16
+ };
17
+ export declare const V2_PRIMARY_KEYCHAIN_SERVICE = "oxy_identity";
18
+ export declare const V2_BACKUP_KEYCHAIN_SERVICE = "oxy_identity_backup";
19
+ export declare const V2_STORAGE_KEYS: {
20
+ readonly PRIVATE_KEY: "oxy_identity_private_key_v2";
21
+ readonly PUBLIC_KEY: "oxy_identity_public_key_v2";
22
+ readonly BACKUP_PRIVATE_KEY: "oxy_identity_backup_private_key_v2";
23
+ readonly BACKUP_PUBLIC_KEY: "oxy_identity_backup_public_key_v2";
24
+ readonly BACKUP_TIMESTAMP: "oxy_identity_backup_timestamp_v2";
25
+ };
26
+ export declare const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = "oxy_identity_mnemonic";
27
+ export declare const RECOVERY_MNEMONIC_STORAGE_KEY = "oxy_identity_mnemonic_v1";
28
+ /** Organization keys (ADR 0061 D6). On iOS the pair has no keychain service. */
29
+ export declare const ORGANIZATION_ANDROID_KEYCHAIN_SERVICE = "oxy_org_identity";
30
+ export declare const ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE = "oxy_org_identity_mnemonic";
31
+ export declare const ORGANIZATION_INDEX_KEYCHAIN_SERVICE = "oxy_org_identity_index";
32
+ export declare const ORGANIZATION_INDEX_STORAGE_KEY = "oxy_org_identity_index_v1";
33
+ export declare const organizationPrivateKeyName: (accountId: string) => string;
34
+ export declare const organizationPublicKeyName: (accountId: string) => string;
35
+ export declare const organizationMnemonicName: (accountId: string) => string;
36
+ /** Whether `value` is an account id an organization key can be stored under. */
37
+ export declare function isOrganizationAccountId(value: string): boolean;
38
+ /** The account ids in a stored organization index; anything malformed is skipped. */
39
+ export declare function parseOrganizationIndex(raw: string | null): string[];
40
+ /** The stored form of an organization index: unique, sorted. */
41
+ export declare function serializeOrganizationIndex(ids: readonly string[]): string;
@@ -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,11 +19,13 @@
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.
24
26
  */
27
+ import { isOrganizationAccountId } from './keychainItems';
28
+ export { isOrganizationAccountId };
25
29
  /** A secp256k1 pair: private key 64 hex, public key lowercase uncompressed (`04…`, 130 hex). */
26
30
  export interface OrganizationKeyPair {
27
31
  privateKey: string;
@@ -35,8 +39,6 @@ export interface GeneratedOrganizationKey extends OrganizationKeyPair {
35
39
  export interface StoredOrganizationKey extends OrganizationKeyPair {
36
40
  mnemonic?: string;
37
41
  }
38
- /** Whether `value` is an account id an organization key can be stored under. */
39
- export declare function isOrganizationAccountId(value: string): boolean;
40
42
  /** True only when both halves are well-formed AND the public key derives from the private one. */
41
43
  export declare function isHealthyOrganizationKeyPair(privateKey: string | null | undefined, publicKey: string | null | undefined): boolean;
42
44
  /**
@@ -55,7 +57,7 @@ export declare function organizationKeyFromPhrase(phrase: string): Promise<Organ
55
57
  /**
56
58
  * Keep an organization key on this device. Commons only: on Android it needs
57
59
  * the identity signer store Commons registers, so the identity host can serve
58
- * it; on iOS only Commons calls it (the keychain group is shared).
60
+ * it; on iOS only Commons holds the groups it writes (any other app throws).
59
61
  *
60
62
  * Refuses a pair whose public key does not derive from its private key, and a
61
63
  * phrase that does not derive the pair. Overwrites whatever this device held for
@@ -64,15 +66,15 @@ export declare function organizationKeyFromPhrase(phrase: string): Promise<Organ
64
66
  export declare function storeOrganizationKey(accountId: string, key: StoredOrganizationKey): Promise<void>;
65
67
  /**
66
68
  * The organization's public key as THIS device can reach it, or `null`.
67
- * iOS: the keychain group. Android: Commons' own slot inside Commons, and
69
+ * iOS: the wallet keychain group (Commons, the Peable wallet). Android: Commons' own slot inside Commons, and
68
70
  * Commons' identity host (`describe` with `accountId`) everywhere else. Compare
69
71
  * it with the organization's DID `#key-1` before receiving (Peable ADR 0002).
70
72
  */
71
73
  export declare function getOrganizationPublicKey(accountId: string): Promise<string | null>;
72
74
  /**
73
75
  * The organization's private key, where this app may hold it: Commons (both
74
- * platforms) and, on iOS, the keychain group. `null` on Android outside
75
- * Commons. Use it to sign within the app only; never transmit it.
76
+ * platforms) and, on iOS, the Peable wallet through the wallet keychain group.
77
+ * `null` everywhere else. Use it to sign within the app only; never transmit it.
76
78
  */
77
79
  export declare function getOrganizationPrivateKey(accountId: string): Promise<string | null>;
78
80
  /** The organization's phrase, if this device created or imported it with one. Commons only. */