@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
@@ -0,0 +1,15 @@
1
+ export interface OxySecureStoreOptions {
2
+ /** Keychain service name (Android keystore alias / iOS `kSecAttrService`). */
3
+ keychainService?: string;
4
+ /** Item accessibility: the `ExpoSecureStoreLike` constant, e.g. `WHEN_UNLOCKED_THIS_DEVICE_ONLY`. */
5
+ keychainAccessible?: number;
6
+ /** Prompt shown for biometric-protected items. */
7
+ authenticationPrompt?: string;
8
+ /** Whether Face ID / Touch ID is required to read the item. */
9
+ requireAuthentication?: boolean;
10
+ /**
11
+ * iOS `kSecAttrAccessGroup`: the FULL, team-prefixed entitlement value. Set
12
+ * only through `../utils/keychainAccessGroups`, never by hand.
13
+ */
14
+ accessGroup?: string;
15
+ }
@@ -29,6 +29,8 @@ export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthSt
29
29
  export type { PersistedAuthState, AuthStateStore, AuthStateWriteGuard, NativeKeyValueStorage, } from './authStateStore';
30
30
  export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './sharedDeviceCredential';
31
31
  export type { SharedDeviceCredential, SharedDeviceCredentialRead, SharedDeviceCredentialStore, SharedDeviceJoinDecision, SharedDeviceJoinSkipReason, SharedDevicePublishDecision, SharedDevicePublishOutcome, SharedDevicePublishSkipReason, } from './sharedDeviceCredential';
32
+ export { KeychainAccessUnavailableError, keychainAccessGroupValue, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, resolveKeychainAccess, } from '../utils/keychainAccessGroups';
33
+ export type { KeychainAccess, KeychainGroupOption } from '../utils/keychainAccessGroups';
32
34
  export { createWebIdentityPinStore, createNativeIdentityPinStore, createMemoryIdentityPinStore, identityPinMatches, IDENTITY_PIN_STORAGE_KEY, } from './identityPin';
33
35
  export type { IdentityPin, IdentityPinStore } from './identityPin';
34
36
  export { resolveIdentityPin, establishIdentitySession, } from './identitySession';
@@ -0,0 +1,104 @@
1
+ /** The group names after the team prefix, exactly as the entitlements list them. */
2
+ export declare const KEYCHAIN_ACCESS_GROUPS: {
3
+ readonly identity: "so.oxy.identity";
4
+ readonly wallet: "so.oxy.wallet";
5
+ readonly shared: "group.so.oxy.shared";
6
+ };
7
+ export type KeychainAccessGroupName = keyof typeof KEYCHAIN_ACCESS_GROUPS;
8
+ /** What a keychain item IS, which decides its group. See the table above. */
9
+ export type KeychainItemClass = 'identity-secret' | 'spending-key' | 'shared';
10
+ /** Which holder this binary is, from its default access group. */
11
+ export type KeychainRole = 'holder' | 'wallet' | 'member';
12
+ /**
13
+ * - `grouped`: iOS with the probe answered. `prefix` ends with its dot
14
+ * (`ABCDE12345.`); `defaultGroup` is the full default group string.
15
+ * - `ungrouped`: Android, web, or an iOS native build without the probe.
16
+ */
17
+ export type KeychainAccess = {
18
+ mode: 'grouped';
19
+ prefix: string;
20
+ defaultGroup: string;
21
+ role: KeychainRole;
22
+ } | {
23
+ mode: 'ungrouped';
24
+ };
25
+ /** The `expo-secure-store` option this module sets (its real name since 58). */
26
+ export interface KeychainGroupOption {
27
+ accessGroup?: string;
28
+ }
29
+ /** Thrown while the probe cannot answer (before first unlock). Transient. */
30
+ export declare class KeychainAccessUnavailableError extends Error {
31
+ readonly cause?: unknown | undefined;
32
+ readonly name = "KeychainAccessUnavailableError";
33
+ constructor(cause?: unknown | undefined);
34
+ }
35
+ /**
36
+ * The single place each item class is assigned a group. A new kind of item
37
+ * (F1's retained keys, for one) picks its class and reuses everything below.
38
+ */
39
+ export declare function keychainGroupFor(item: KeychainItemClass): KeychainAccessGroupName;
40
+ /** Whether `role` holds the group `item` lives in. */
41
+ export declare function mayHoldKeychainItem(role: KeychainRole, item: KeychainItemClass): boolean;
42
+ /** Parse the default access group the probe reported. Exported for tests. */
43
+ export declare function keychainAccessFromDefaultGroup(defaultGroup: string): KeychainAccess;
44
+ /**
45
+ * This binary's keychain access, memoized once known. THROWS
46
+ * {@link KeychainAccessUnavailableError} while the probe cannot answer, which
47
+ * every caller already treats as "storage unavailable, retry".
48
+ */
49
+ export declare function resolveKeychainAccess(): Promise<KeychainAccess>;
50
+ /** Forget the memoized access. Tests only. @internal */
51
+ export declare function resetKeychainAccessForTests(): void;
52
+ /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
53
+ export declare function keychainAccessGroupValue(access: KeychainAccess, group: KeychainAccessGroupName): string | undefined;
54
+ /**
55
+ * The option bags a READ of `item` tries, in order: its own group, then the
56
+ * shared group, where every item lived before the split (release 4 of the doc
57
+ * drops that fallback). Empty when this app may not hold the item: the caller
58
+ * answers `null` without touching the keychain.
59
+ */
60
+ export declare function keychainReadPlan(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption[];
61
+ /**
62
+ * Every place a DELETE of `item` must reach: the read plan, so a delete never
63
+ * leaves a pre-split copy behind in the shared group.
64
+ */
65
+ export declare function keychainDeletePlan(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption[];
66
+ /**
67
+ * The option a WRITE of `item` carries. THROWS when this app may not hold the
68
+ * item: an identity secret written by any app but Commons would land in that
69
+ * app's default group, a second holder nobody knows about.
70
+ */
71
+ export declare function keychainWriteOption(access: KeychainAccess, item: KeychainItemClass): KeychainGroupOption;
72
+ /**
73
+ * The plan for an app's OWN items (its session credential, its settings, an
74
+ * imported wallet's phrase) in Commons and the Peable wallet, whose default
75
+ * group the split changed: written to the default group explicitly — where a
76
+ * write without a group goes anyway — and read from it FIRST, then from every
77
+ * group (`{}`), where all of them were written before. Without the explicit
78
+ * order a read could return whichever of two copies the keychain met first.
79
+ * Deletes reach every group, as they always did.
80
+ *
81
+ * Every other app (`member`) and Android/web keep the calls they always made.
82
+ */
83
+ export declare function ownKeychainItemPlan(access: KeychainAccess): {
84
+ read: KeychainGroupOption[];
85
+ write: KeychainGroupOption;
86
+ delete: KeychainGroupOption[];
87
+ };
88
+ /** The subset of `expo-secure-store` a grouped store wraps. */
89
+ export interface KeychainStoreLike {
90
+ getItemAsync(key: string, options?: object): Promise<string | null>;
91
+ setItemAsync(key: string, value: string, options?: object): Promise<void>;
92
+ deleteItemAsync(key: string, options?: object): Promise<void>;
93
+ }
94
+ /** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
95
+ export declare function readThroughPlan(store: KeychainStoreLike, key: string, options: object, plan: readonly KeychainGroupOption[]): Promise<string | null>;
96
+ /** Delete `key` from every location in `plan`. A thrown delete propagates. */
97
+ export declare function deleteThroughPlan(store: KeychainStoreLike, key: string, options: object, plan: readonly KeychainGroupOption[]): Promise<void>;
98
+ /**
99
+ * `store` with every call routed to `item`'s groups for this app: reads walk
100
+ * the read plan, writes carry the write option (throwing for a role that may
101
+ * not hold the item), deletes reach every location. The constants of the
102
+ * wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
103
+ */
104
+ export declare function groupedKeychainStore<S extends KeychainStoreLike>(store: S, access: KeychainAccess, item: KeychainItemClass): S;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.28.0",
3
+ "version": "7.29.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -182,7 +182,7 @@
182
182
  "@noble/ciphers": "^2.4.0",
183
183
  "@noble/hashes": "^2.4.0",
184
184
  "@oxy.so/contracts": "^6.11.0",
185
- "@oxy.so/protocol": "^1.7.0",
185
+ "@oxy.so/protocol": "^1.8.0",
186
186
  "@oxy.so/telemetry": "^2.1.0",
187
187
  "@scure/bip39": "^2.4.0",
188
188
  "buffer": "^6.0.3",
@@ -45,6 +45,35 @@ export type {
45
45
  GeneratedOrganizationKey,
46
46
  StoredOrganizationKey,
47
47
  } from './organizationKeys';
48
+ // iOS keychain access groups (docs/identity/ios-keychain-access-groups.md, ADR 0062)
49
+ export {
50
+ KEYCHAIN_ACCESS_GROUPS,
51
+ KeychainAccessUnavailableError,
52
+ keychainGroupFor,
53
+ mayHoldKeychainItem,
54
+ resolveKeychainAccess,
55
+ keychainAccessGroupValue,
56
+ keychainReadPlan,
57
+ keychainDeletePlan,
58
+ keychainWriteOption,
59
+ ownKeychainItemPlan,
60
+ readThroughPlan,
61
+ deleteThroughPlan,
62
+ groupedKeychainStore,
63
+ } from '../utils/keychainAccessGroups';
64
+ export type {
65
+ KeychainAccess,
66
+ KeychainAccessGroupName,
67
+ KeychainGroupOption,
68
+ KeychainItemClass,
69
+ KeychainRole,
70
+ KeychainStoreLike,
71
+ } from '../utils/keychainAccessGroups';
72
+ export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
73
+ export type {
74
+ KeychainGroupMigrationReport,
75
+ KeychainItemMigrationOutcome,
76
+ } from './keychainGroupMigration';
48
77
  export { SignatureService } from './signatureService';
49
78
  export type { SignedMessage, AuthChallenge } from './signatureService';
50
79
  export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase';
@@ -32,6 +32,21 @@ import { isDev, logger } from '../logger';
32
32
  import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations';
33
33
  import { type IdentitySignerStore, registerIdentitySignerStore } from './identitySigner';
34
34
  import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys';
35
+ import {
36
+ type KeychainItemClass,
37
+ groupedKeychainStore,
38
+ resolveKeychainAccess,
39
+ } from '../utils/keychainAccessGroups';
40
+ import { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
41
+ import {
42
+ RECOVERY_MNEMONIC_KEYCHAIN_SERVICE,
43
+ RECOVERY_MNEMONIC_STORAGE_KEY,
44
+ STORAGE_KEYS,
45
+ V2_BACKUP_KEYCHAIN_SERVICE,
46
+ V2_PRIMARY_KEYCHAIN_SERVICE,
47
+ V2_STORAGE_KEYS,
48
+ } from './keychainItems';
49
+ import type { OxySecureStoreOptions } from './secureStoreOptions';
35
50
  import {
36
51
  type IdentityMarker,
37
52
  clearIdentityMarker,
@@ -53,37 +68,6 @@ import {
53
68
  */
54
69
  const DEVICE_BACKUP_TOMBSTONE = JSON.stringify({ deleted: true });
55
70
 
56
- /**
57
- * Options for expo-secure-store calls made by KeyManager.
58
- *
59
- * Defined as a standalone interface (not extending expo-secure-store's
60
- * `SecureStoreOptions`) so that server / Node.js consumers of @oxy.so/core
61
- * do not transitively pull in expo-modules-core's type declarations under
62
- * NodeNext module resolution (which would cause NodeJS.Timeout / number
63
- * pollution across all timer APIs). The fields mirror SecureStoreOptions
64
- * exactly — any object satisfying this interface is safe to pass to the
65
- * real expo-secure-store methods (which accept `options?: object`).
66
- */
67
- interface OxySecureStoreOptions {
68
- /** Keychain service name (Android keystore / iOS keychain tag). */
69
- keychainService?: string;
70
- /**
71
- * Keychain / Keystore item accessibility. Use the constants from the
72
- * `ExpoSecureStoreLike` handle returned by `initSecureStore()`, e.g.
73
- * `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY`.
74
- */
75
- keychainAccessible?: number;
76
- /** Prompt shown to the user for biometric/auth-protected items. */
77
- authenticationPrompt?: string;
78
- /** Whether Face ID / Touch ID / biometric auth is required to read the item. */
79
- requireAuthentication?: boolean;
80
- /**
81
- * iOS Keychain access group. Required for sharing identity material across
82
- * apps in the Oxy ecosystem via Keychain Sharing entitlements.
83
- */
84
- keychainAccessGroup?: string;
85
- }
86
-
87
71
  /**
88
72
  * Thrown when an identity-mutating operation (createIdentity / importKeyPair)
89
73
  * is invoked while a valid identity already exists on the device.
@@ -181,17 +165,6 @@ export type IdentityRecoveryResult =
181
165
  | { recovered: true; source: 'backup' | 'shared' | 'device-backup'; publicKey: string }
182
166
  | { recovered: false; reason: 'not-lost' | 'no-sources' | 'mismatch' | 'unavailable' };
183
167
 
184
- const STORAGE_KEYS = {
185
- PRIVATE_KEY: 'oxy_identity_private_key',
186
- PUBLIC_KEY: 'oxy_identity_public_key',
187
- BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key',
188
- BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key',
189
- BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp',
190
- // The cross-app identity in the iOS keychain access group (iOS only).
191
- SHARED_PRIVATE_KEY: 'oxy_shared_identity_private_key',
192
- SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
193
- } as const;
194
-
195
168
  /**
196
169
  * v2 identity slot layout — blast-radius isolation.
197
170
  *
@@ -207,16 +180,8 @@ const STORAGE_KEYS = {
207
180
  * Migration from the legacy layout is lazy + verify-before-delete — see
208
181
  * {@link KeyManager._runSlotMigration}.
209
182
  */
210
- const V2_PRIMARY_KEYCHAIN_SERVICE = 'oxy_identity';
211
- const V2_BACKUP_KEYCHAIN_SERVICE = 'oxy_identity_backup';
212
-
213
- const V2_STORAGE_KEYS = {
214
- PRIVATE_KEY: 'oxy_identity_private_key_v2',
215
- PUBLIC_KEY: 'oxy_identity_public_key_v2',
216
- BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
217
- BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
218
- BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
219
- } as const;
183
+ // The names (V2_PRIMARY_KEYCHAIN_SERVICE, V2_BACKUP_KEYCHAIN_SERVICE,
184
+ // V2_STORAGE_KEYS) live in `./keychainItems`, beside every other identity item.
220
185
 
221
186
  /**
222
187
  * Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
@@ -234,8 +199,7 @@ const V2_STORAGE_KEYS = {
234
199
  * lives ONLY in this slot: it is never mirrored into the identity marker,
235
200
  * {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
236
201
  */
237
- const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
238
- const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
202
+ // RECOVERY_MNEMONIC_KEYCHAIN_SERVICE / RECOVERY_MNEMONIC_STORAGE_KEY: `./keychainItems`.
239
203
 
240
204
  /**
241
205
  * Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
@@ -301,13 +265,6 @@ type SlotMigrationResult =
301
265
  | { mode: 'legacy'; layout: ResolvedSlotLayout }
302
266
  | { mode: 'deferred'; cause: unknown };
303
267
 
304
- /**
305
- * iOS Keychain Access Group for sharing identities across Oxy apps
306
- * All Oxy apps must have this access group enabled in their entitlements
307
- * Format: [Team ID].so.oxy.shared or group.so.oxy.shared
308
- */
309
- const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
310
-
311
268
  /**
312
269
  * Initialize React Native specific modules
313
270
  *
@@ -317,7 +274,7 @@ const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
317
274
  * `expo-secure-store`; off RN it throws (and is never called because every
318
275
  * caller is gated by `isWebPlatform()` / native-only paths).
319
276
  */
320
- async function initSecureStore(): Promise<ExpoSecureStoreLike> {
277
+ async function loadRawSecureStore(): Promise<ExpoSecureStoreLike> {
321
278
  try {
322
279
  return await loadSecureStore();
323
280
  } catch (error) {
@@ -328,6 +285,31 @@ async function initSecureStore(): Promise<ExpoSecureStoreLike> {
328
285
  }
329
286
  }
330
287
 
288
+ /**
289
+ * The secure store routed to the keychain group of `item`
290
+ * (`../utils/keychainAccessGroups`): on iOS reads walk the item's group then
291
+ * the pre-split shared group, writes carry the item's group and THROW in an app
292
+ * that may not hold it, and in Commons every access first awaits the
293
+ * access-group migration. Android and web are unchanged (`ungrouped`).
294
+ *
295
+ * Throws when the store cannot load or the keychain cannot say which groups
296
+ * this app holds; every caller already treats a throw here as "storage
297
+ * unavailable".
298
+ */
299
+ async function keychainItemStore(item: KeychainItemClass): Promise<ExpoSecureStoreLike> {
300
+ const raw = await loadRawSecureStore();
301
+ const access = await resolveKeychainAccess();
302
+ if (access.mode === 'grouped' && access.role === 'holder') {
303
+ await ensureKeychainGroupsMigrated();
304
+ }
305
+ return groupedKeychainStore(raw, access, item);
306
+ }
307
+
308
+ /** The store for the identity slots (primary, backup, legacy, phrase): Commons only on iOS. */
309
+ function initSecureStore(): Promise<ExpoSecureStoreLike> {
310
+ return keychainItemStore('identity-secret');
311
+ }
312
+
331
313
  /**
332
314
  * Check if we're on web platform
333
315
  * Identity storage is only available on native platforms (iOS/Android)
@@ -875,13 +857,15 @@ export class KeyManager {
875
857
  * Android signer store is cleared on every delete, in {@link deleteIdentity}.
876
858
  */
877
859
  private static async _clearSharedSlot(
878
- store: Awaited<ReturnType<typeof initSecureStore>>,
860
+ _identityStore: Awaited<ReturnType<typeof initSecureStore>>,
879
861
  ): Promise<void> {
880
862
  try {
881
863
  if (isIOS()) {
882
- const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
883
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
884
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
864
+ // The shared slot lives in two groups of its own, not the identity slots'.
865
+ await (await keychainItemStore('spending-key')).deleteItemAsync(
866
+ STORAGE_KEYS.SHARED_PRIVATE_KEY,
867
+ );
868
+ await (await keychainItemStore('shared')).deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
885
869
  }
886
870
  KeyManager.invalidateSharedCache();
887
871
  } catch (error) {
@@ -964,6 +948,25 @@ export class KeyManager {
964
948
  KeyManager.cachedHasSharedIdentity = null;
965
949
  }
966
950
 
951
+ /**
952
+ * iOS: write the personal key's cross-app slot — the private key to the
953
+ * wallet group (Commons and the Peable wallet), the public key to the shared
954
+ * group (every Oxy app). Throws in any app that may not hold the private key.
955
+ * @internal
956
+ */
957
+ private static async _writeIosSharedSlot(privateKey: string, publicKey: string): Promise<void> {
958
+ const privateStore = await keychainItemStore('spending-key');
959
+ const privateOpts: OxySecureStoreOptions = {
960
+ keychainAccessible: privateStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
961
+ };
962
+ await privateStore.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
963
+ await (await keychainItemStore('shared')).setItemAsync(
964
+ STORAGE_KEYS.SHARED_PUBLIC_KEY,
965
+ publicKey,
966
+ {},
967
+ );
968
+ }
969
+
967
970
  /**
968
971
  * Lowercase and pad to canonical 64-hex-char form.
969
972
  *
@@ -1002,8 +1005,10 @@ export class KeyManager {
1002
1005
  // ==================== SHARED IDENTITY METHODS ====================
1003
1006
  // One Oxy identity across every Oxy app on a device.
1004
1007
  //
1005
- // iOS: the keychain access group `group.so.oxy.shared`. Every Oxy app reads
1006
- // the same keychain item, and no app can wipe another's keychain.
1008
+ // iOS: keychain access groups (docs/identity/ios-keychain-access-groups.md).
1009
+ // The private key is in `<TEAM>.so.oxy.wallet` (Commons and the Peable
1010
+ // wallet only), the public key in `<TEAM>.group.so.oxy.shared`, which every
1011
+ // Oxy app reads. Every other app signs in through Commons (ADR 0029).
1007
1012
  // Android: every app has its own UID, so nothing is shared through storage.
1008
1013
  // Commons is the ONLY holder of the private key. It keeps the identity
1009
1014
  // signer store (`setIdentitySignerStore`) for its identity host, and every
@@ -1031,22 +1036,11 @@ export class KeyManager {
1031
1036
  const { privateKey, publicKey } = await KeyManager.generateKeyPair();
1032
1037
 
1033
1038
  if (isIOS()) {
1034
- const store = await initSecureStore();
1035
- // Note: keychainAccessGroup requires Keychain Sharing capability in Xcode
1036
1039
  try {
1037
- const privateOpts: OxySecureStoreOptions = {
1038
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
1039
- keychainAccessGroup: IOS_KEYCHAIN_GROUP, // Enables sharing across apps
1040
- };
1041
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
1042
-
1043
- const publicOpts: OxySecureStoreOptions = {
1044
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
1045
- };
1046
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
1040
+ await KeyManager._writeIosSharedSlot(privateKey, publicKey);
1047
1041
  } catch (error) {
1048
1042
  throw new Error(
1049
- `Failed to create shared identity on iOS. Ensure your app has the Keychain Sharing capability enabled with access group "${IOS_KEYCHAIN_GROUP}". Error: ${error}`,
1043
+ `Failed to create shared identity on iOS. Only Commons may write it, with the keychain access groups of docs/identity/ios-keychain-access-groups.md. Error: ${error}`,
1050
1044
  );
1051
1045
  }
1052
1046
  } else if (isAndroid()) {
@@ -1093,9 +1087,8 @@ export class KeyManager {
1093
1087
  let publicKey: string | null = null;
1094
1088
 
1095
1089
  if (isIOS()) {
1096
- const store = await initSecureStore();
1097
- const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
1098
- publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
1090
+ const store = await keychainItemStore('shared');
1091
+ publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, {});
1099
1092
  } else if (isAndroid()) {
1100
1093
  const signer = KeyManager.identitySignerStore;
1101
1094
  if (signer) {
@@ -1136,9 +1129,10 @@ export class KeyManager {
1136
1129
  }
1137
1130
 
1138
1131
  try {
1139
- const store = await initSecureStore();
1140
- const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
1141
- return await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
1132
+ // The wallet group: Commons and the Peable wallet. Every other app's plan
1133
+ // is empty, so this is `null` there without touching the keychain.
1134
+ const store = await keychainItemStore('spending-key');
1135
+ return await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, {});
1142
1136
  } catch (error) {
1143
1137
  if (isDev()) {
1144
1138
  logger.warn('Failed to get shared private key', { component: 'KeyManager' }, error);
@@ -1245,17 +1239,7 @@ export class KeyManager {
1245
1239
  const publicKey = deriveSecp256k1PublicKey(canonicalPrivate);
1246
1240
 
1247
1241
  if (isIOS()) {
1248
- const store = await initSecureStore();
1249
- const privateOpts: OxySecureStoreOptions = {
1250
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
1251
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
1252
- };
1253
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, privateOpts);
1254
-
1255
- const publicOpts: OxySecureStoreOptions = {
1256
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
1257
- };
1258
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
1242
+ await KeyManager._writeIosSharedSlot(canonicalPrivate, publicKey);
1259
1243
  } else if (isAndroid()) {
1260
1244
  await KeyManager._writeIdentitySigner(canonicalPrivate, publicKey);
1261
1245
  }
@@ -3230,7 +3214,8 @@ export class KeyManager {
3230
3214
  * `HDKey.fromMasterSeed` and never touches the identity key.
3231
3215
  *
3232
3216
  * Key source (native only):
3233
- * - iOS: the shared identity in `group.so.oxy.shared`, then this device's
3217
+ * - iOS: the shared slot's private key in the wallet keychain group (Commons
3218
+ * and the Peable wallet only; `null` in every other app), then this device's
3234
3219
  * primary identity.
3235
3220
  * - Android: Commons (the one app with a signer store) uses its own primary
3236
3221
  * identity; every other app asks Commons over IPC and never reads a local