@oxy.so/core 7.27.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 (47) 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/i18n/locales/compact/es-ES.generated.js +1 -1
  8. package/dist/cjs/i18n/locales/en-US.js +14 -0
  9. package/dist/cjs/i18n/locales/es-ES.js +14 -0
  10. package/dist/cjs/session/index.js +11 -1
  11. package/dist/cjs/utils/keychainAccessGroups.js +248 -0
  12. package/dist/esm/crypto/index.js +3 -0
  13. package/dist/esm/crypto/keyManager.js +63 -63
  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/secureStoreOptions.js +1 -0
  18. package/dist/esm/i18n/locales/compact/es-ES.generated.js +1 -1
  19. package/dist/esm/i18n/locales/en-US.js +14 -0
  20. package/dist/esm/i18n/locales/es-ES.js +14 -0
  21. package/dist/esm/session/index.js +3 -0
  22. package/dist/esm/utils/keychainAccessGroups.js +231 -0
  23. package/dist/types/crypto/index.d.ts +4 -0
  24. package/dist/types/crypto/keyManager.d.ts +9 -1
  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/secureStoreOptions.d.ts +15 -0
  29. package/dist/types/i18n/locales/en-US.d.ts +14 -0
  30. package/dist/types/i18n/locales/es-ES.d.ts +14 -0
  31. package/dist/types/models/interfaces.d.ts +6 -1
  32. package/dist/types/session/index.d.ts +2 -0
  33. package/dist/types/utils/keychainAccessGroups.d.ts +104 -0
  34. package/package.json +2 -2
  35. package/src/crypto/index.ts +29 -0
  36. package/src/crypto/keyManager.ts +84 -99
  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/secureStoreOptions.ts +32 -0
  41. package/src/i18n/locales/compact/es-ES.generated.ts +1 -1
  42. package/src/i18n/locales/en-US.ts +14 -0
  43. package/src/i18n/locales/es-ES.ts +14 -0
  44. package/src/models/interfaces.ts +6 -0
  45. package/src/session/index.ts +11 -0
  46. package/src/utils/keychainAccessGroups.ts +308 -0
  47. 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.
@@ -38,8 +40,28 @@ import {
38
40
  normalizeSecp256k1PublicKey,
39
41
  } from '@oxy.so/protocol/secp256k1';
40
42
  import { logger } from '../logger';
43
+ import {
44
+ type KeychainItemClass,
45
+ groupedKeychainStore,
46
+ resolveKeychainAccess,
47
+ } from '../utils/keychainAccessGroups';
41
48
  import { isAndroid, isIOS, isWeb } from '../utils/platform';
42
49
  import { registeredIdentitySignerStore } from './identitySigner';
50
+ import { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
51
+ import {
52
+ ORGANIZATION_ANDROID_KEYCHAIN_SERVICE as ANDROID_KEYCHAIN_SERVICE,
53
+ ORGANIZATION_INDEX_KEYCHAIN_SERVICE as INDEX_KEYCHAIN_SERVICE,
54
+ ORGANIZATION_INDEX_STORAGE_KEY as INDEX_STORAGE_KEY,
55
+ ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE as MNEMONIC_KEYCHAIN_SERVICE,
56
+ isOrganizationAccountId,
57
+ organizationMnemonicName as mnemonicName,
58
+ organizationPrivateKeyName as privateKeyName,
59
+ organizationPublicKeyName as publicKeyName,
60
+ serializeOrganizationIndex,
61
+ } from './keychainItems';
62
+ import type { OxySecureStoreOptions as SecureStoreOptions } from './secureStoreOptions';
63
+
64
+ export { isOrganizationAccountId };
43
65
 
44
66
  /** A secp256k1 pair: private key 64 hex, public key lowercase uncompressed (`04…`, 130 hex). */
45
67
  export interface OrganizationKeyPair {
@@ -57,39 +79,14 @@ export interface StoredOrganizationKey extends OrganizationKeyPair {
57
79
  mnemonic?: string;
58
80
  }
59
81
 
60
- /** Same set the server's account ids come from: a legacy 24-hex id or a lowercase uuid. */
61
- const ACCOUNT_ID =
62
- /^(?:[0-9a-f]{24}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
63
-
64
- const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
65
- /** Android, Commons-local: its own Keystore key, shared with no personal slot. */
66
- const ANDROID_KEYCHAIN_SERVICE = 'oxy_org_identity';
67
- const MNEMONIC_KEYCHAIN_SERVICE = 'oxy_org_identity_mnemonic';
68
- const INDEX_KEYCHAIN_SERVICE = 'oxy_org_identity_index';
69
- const INDEX_STORAGE_KEY = 'oxy_org_identity_index_v1';
70
82
  const ORGANIZATION_PHRASE_WORDS = 24;
71
83
 
72
- interface SecureStoreOptions {
73
- keychainService?: string;
74
- keychainAccessible?: number;
75
- keychainAccessGroup?: string;
76
- }
77
-
78
- /** Whether `value` is an account id an organization key can be stored under. */
79
- export function isOrganizationAccountId(value: string): boolean {
80
- return ACCOUNT_ID.test(value);
81
- }
82
-
83
84
  function assertAccountId(accountId: string): void {
84
85
  if (!isOrganizationAccountId(accountId)) {
85
86
  throw new Error('organization key: accountId must be a 24-hex or uuid account id');
86
87
  }
87
88
  }
88
89
 
89
- const privateKeyName = (accountId: string) => `oxy_org_identity_private_key.${accountId}`;
90
- const publicKeyName = (accountId: string) => `oxy_org_identity_public_key.${accountId}`;
91
- const mnemonicName = (accountId: string) => `oxy_org_identity_mnemonic.${accountId}`;
92
-
93
90
  function toHex(bytes: Uint8Array): string {
94
91
  return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
95
92
  }
@@ -152,10 +149,25 @@ export async function organizationKeyFromPhrase(phrase: string): Promise<Organiz
152
149
  return pairFromPhrase(phrase);
153
150
  }
154
151
 
155
- async function secureStore(): Promise<ExpoSecureStoreLike> {
156
- return loadSecureStore();
152
+ /**
153
+ * The secure store for one class of organization item: on iOS the pair is a
154
+ * `spending-key` (wallet group), the phrase and the index `identity-secret`
155
+ * (identity group); see `../utils/keychainAccessGroups`. In Commons every
156
+ * access first awaits the access-group migration. Android is unchanged.
157
+ */
158
+ async function secureStore(item: KeychainItemClass): Promise<ExpoSecureStoreLike> {
159
+ const raw = await loadSecureStore();
160
+ const access = await resolveKeychainAccess();
161
+ if (access.mode === 'grouped' && access.role === 'holder') {
162
+ await ensureKeychainGroupsMigrated();
163
+ }
164
+ return groupedKeychainStore(raw, access, item);
157
165
  }
158
166
 
167
+ const pairStore = () => secureStore('spending-key');
168
+ const phraseStore = () => secureStore('identity-secret');
169
+ const indexStore = () => secureStore('identity-secret');
170
+
159
171
  async function readIndex(store: ExpoSecureStoreLike): Promise<string[]> {
160
172
  const raw = await store.getItemAsync(INDEX_STORAGE_KEY, {
161
173
  keychainService: INDEX_KEYCHAIN_SERVICE,
@@ -176,20 +188,18 @@ async function readIndex(store: ExpoSecureStoreLike): Promise<string[]> {
176
188
  }
177
189
 
178
190
  async function writeIndex(store: ExpoSecureStoreLike, ids: string[]): Promise<void> {
179
- await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify([...new Set(ids)].sort()), {
191
+ await store.setItemAsync(INDEX_STORAGE_KEY, serializeOrganizationIndex(ids), {
180
192
  keychainService: INDEX_KEYCHAIN_SERVICE,
181
193
  } satisfies SecureStoreOptions);
182
194
  }
183
195
 
184
- /** Where this platform keeps the pair: the iOS keychain group, or Commons' own Android slot. */
196
+ /**
197
+ * The options of the pair on this platform: on iOS no keychain service (the
198
+ * wallet group comes from {@link pairStore}), on Android Commons' own slot.
199
+ */
185
200
  function pairOptions(store: ExpoSecureStoreLike, forPrivateWrite: boolean): SecureStoreOptions {
186
201
  if (isIOS()) {
187
- return forPrivateWrite
188
- ? {
189
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
190
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
191
- }
192
- : { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
202
+ return forPrivateWrite ? { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY } : {};
193
203
  }
194
204
  return forPrivateWrite
195
205
  ? {
@@ -205,7 +215,7 @@ async function readLocalPair(accountId: string): Promise<OrganizationKeyPair | n
205
215
  if (isAndroid()) {
206
216
  const signer = registeredIdentitySignerStore();
207
217
  if (!signer) return null; // Only Commons holds organization keys on Android.
208
- const store = await secureStore();
218
+ const store = await pairStore();
209
219
  const options = pairOptions(store, false);
210
220
  const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
211
221
  const publicKey = await store.getItemAsync(publicKeyName(accountId), options);
@@ -222,7 +232,9 @@ async function readLocalPair(accountId: string): Promise<OrganizationKeyPair | n
222
232
  : null;
223
233
  }
224
234
  if (!isIOS()) return null;
225
- const store = await secureStore();
235
+ // The wallet group: `null` without a keychain read in every app but Commons
236
+ // and the Peable wallet.
237
+ const store = await pairStore();
226
238
  const options = pairOptions(store, false);
227
239
  const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
228
240
  const publicKey = await store.getItemAsync(publicKeyName(accountId), options);
@@ -237,7 +249,7 @@ async function readLocalPair(accountId: string): Promise<OrganizationKeyPair | n
237
249
  /**
238
250
  * Keep an organization key on this device. Commons only: on Android it needs
239
251
  * the identity signer store Commons registers, so the identity host can serve
240
- * it; on iOS only Commons calls it (the keychain group is shared).
252
+ * it; on iOS only Commons holds the groups it writes (any other app throws).
241
253
  *
242
254
  * Refuses a pair whose public key does not derive from its private key, and a
243
255
  * phrase that does not derive the pair. Overwrites whatever this device held for
@@ -267,7 +279,7 @@ export async function storeOrganizationKey(
267
279
  'On Android only Commons holds organization keys; this app has no identity signer store for them.',
268
280
  );
269
281
  }
270
- const store = await secureStore();
282
+ const store = await pairStore();
271
283
  await store.setItemAsync(privateKeyName(accountId), privateKey, pairOptions(store, true));
272
284
  await store.setItemAsync(publicKeyName(accountId), publicKey, pairOptions(store, false));
273
285
  if (
@@ -278,22 +290,24 @@ export async function storeOrganizationKey(
278
290
  `The identity signer store (${signer.name}) did not confirm the organization key.`,
279
291
  );
280
292
  }
293
+ const phrases = await phraseStore();
281
294
  if (key.mnemonic !== undefined) {
282
- await store.setItemAsync(mnemonicName(accountId), normalizePhrase(key.mnemonic), {
295
+ await phrases.setItemAsync(mnemonicName(accountId), normalizePhrase(key.mnemonic), {
283
296
  keychainService: MNEMONIC_KEYCHAIN_SERVICE,
284
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
297
+ keychainAccessible: phrases.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
285
298
  } satisfies SecureStoreOptions);
286
299
  } else {
287
- await store.deleteItemAsync(mnemonicName(accountId), {
300
+ await phrases.deleteItemAsync(mnemonicName(accountId), {
288
301
  keychainService: MNEMONIC_KEYCHAIN_SERVICE,
289
302
  } satisfies SecureStoreOptions);
290
303
  }
291
- await writeIndex(store, [...(await readIndex(store)), accountId]);
304
+ const index = await indexStore();
305
+ await writeIndex(index, [...(await readIndex(index)), accountId]);
292
306
  }
293
307
 
294
308
  /**
295
309
  * The organization's public key as THIS device can reach it, or `null`.
296
- * iOS: the keychain group. Android: Commons' own slot inside Commons, and
310
+ * iOS: the wallet keychain group (Commons, the Peable wallet). Android: Commons' own slot inside Commons, and
297
311
  * Commons' identity host (`describe` with `accountId`) everywhere else. Compare
298
312
  * it with the organization's DID `#key-1` before receiving (Peable ADR 0002).
299
313
  */
@@ -317,8 +331,8 @@ export async function getOrganizationPublicKey(accountId: string): Promise<strin
317
331
 
318
332
  /**
319
333
  * The organization's private key, where this app may hold it: Commons (both
320
- * platforms) and, on iOS, the keychain group. `null` on Android outside
321
- * Commons. Use it to sign within the app only; never transmit it.
334
+ * platforms) and, on iOS, the Peable wallet through the wallet keychain group.
335
+ * `null` everywhere else. Use it to sign within the app only; never transmit it.
322
336
  */
323
337
  export async function getOrganizationPrivateKey(accountId: string): Promise<string | null> {
324
338
  if (!isOrganizationAccountId(accountId)) return null;
@@ -336,7 +350,7 @@ export async function getOrganizationPrivateKey(accountId: string): Promise<stri
336
350
  /** The organization's phrase, if this device created or imported it with one. Commons only. */
337
351
  export async function getOrganizationRecoveryPhrase(accountId: string): Promise<string | null> {
338
352
  if (!isOrganizationAccountId(accountId) || isWeb()) return null;
339
- const store = await secureStore();
353
+ const store = await phraseStore();
340
354
  return store.getItemAsync(mnemonicName(accountId), {
341
355
  keychainService: MNEMONIC_KEYCHAIN_SERVICE,
342
356
  } satisfies SecureStoreOptions);
@@ -345,25 +359,26 @@ export async function getOrganizationRecoveryPhrase(accountId: string): Promise<
345
359
  /** The organizations this device keeps a key for (Commons' own index). */
346
360
  export async function listOrganizationKeys(): Promise<string[]> {
347
361
  if (isWeb()) return [];
348
- return readIndex(await secureStore());
362
+ return readIndex(await indexStore());
349
363
  }
350
364
 
351
365
  /** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
352
366
  export async function deleteOrganizationKey(accountId: string): Promise<void> {
353
367
  assertAccountId(accountId);
354
368
  if (isWeb()) return;
355
- const store = await secureStore();
369
+ const store = await pairStore();
356
370
  const options = pairOptions(store, false);
357
371
  await store.deleteItemAsync(privateKeyName(accountId), options);
358
372
  await store.deleteItemAsync(publicKeyName(accountId), options);
359
- await store.deleteItemAsync(mnemonicName(accountId), {
373
+ await (await phraseStore()).deleteItemAsync(mnemonicName(accountId), {
360
374
  keychainService: MNEMONIC_KEYCHAIN_SERVICE,
361
375
  } satisfies SecureStoreOptions);
362
376
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
363
377
  await signer?.clearOrganization?.(accountId);
378
+ const index = await indexStore();
364
379
  await writeIndex(
365
- store,
366
- (await readIndex(store)).filter((id) => id !== accountId),
380
+ index,
381
+ (await readIndex(index)).filter((id) => id !== accountId),
367
382
  );
368
383
  }
369
384
 
@@ -376,9 +391,9 @@ export async function deleteOrganizationKey(accountId: string): Promise<void> {
376
391
  export async function syncOrganizationKeys(): Promise<number> {
377
392
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
378
393
  if (!signer?.writeOrganization || !signer.readOrganization) return 0;
379
- const store = await secureStore();
394
+ const store = await pairStore();
380
395
  let mirrored = 0;
381
- for (const accountId of await readIndex(store)) {
396
+ for (const accountId of await readIndex(await indexStore())) {
382
397
  const options = pairOptions(store, false);
383
398
  const privateKey = await store.getItemAsync(privateKeyName(accountId), options);
384
399
  const publicKey = await store.getItemAsync(publicKeyName(accountId), options);
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The `expo-secure-store` options `@oxy.so/core` passes.
3
+ *
4
+ * Standalone, not `SecureStoreOptions` itself, so the PUBLISHED declarations
5
+ * never name expo-secure-store: under NodeNext its types pull in
6
+ * expo-modules-core and turn every Node timer into a DOM `number` in server
7
+ * consumers. The check below still makes `tsc` refuse a field name the real
8
+ * package does not have — `keychainAccessGroup` instead of `accessGroup` sat
9
+ * here unnoticed for years behind a hand-written ambient shim, so no item was
10
+ * ever written to the group the code named (docs/identity/ios-keychain-access-groups.md).
11
+ */
12
+ import type { SecureStoreOptions } from 'expo-secure-store';
13
+
14
+ export interface OxySecureStoreOptions {
15
+ /** Keychain service name (Android keystore alias / iOS `kSecAttrService`). */
16
+ keychainService?: string;
17
+ /** Item accessibility: the `ExpoSecureStoreLike` constant, e.g. `WHEN_UNLOCKED_THIS_DEVICE_ONLY`. */
18
+ keychainAccessible?: number;
19
+ /** Prompt shown for biometric-protected items. */
20
+ authenticationPrompt?: string;
21
+ /** Whether Face ID / Touch ID is required to read the item. */
22
+ requireAuthentication?: boolean;
23
+ /**
24
+ * iOS `kSecAttrAccessGroup`: the FULL, team-prefixed entitlement value. Set
25
+ * only through `../utils/keychainAccessGroups`, never by hand.
26
+ */
27
+ accessGroup?: string;
28
+ }
29
+
30
+ /** Every key above must be one `expo-secure-store` reads; a misspelt one fails `tsc` here. */
31
+ type FieldsExpoReads = Pick<SecureStoreOptions, keyof OxySecureStoreOptions>;
32
+ type AssertExpoReadsEveryField = FieldsExpoReads;