@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
@@ -11,6 +11,9 @@ import { isDev, logger } from '../logger/index.js';
11
11
  import { deriveScopedSeedFromKey, signSocialReceiveDigest } from './identityDerivations.js';
12
12
  import { registerIdentitySignerStore } from './identitySigner.js';
13
13
  import { getOrganizationPrivateKey, isOrganizationAccountId } from './organizationKeys.js';
14
+ import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
15
+ import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
16
+ import { RECOVERY_MNEMONIC_KEYCHAIN_SERVICE, RECOVERY_MNEMONIC_STORAGE_KEY, STORAGE_KEYS, V2_BACKUP_KEYCHAIN_SERVICE, V2_PRIMARY_KEYCHAIN_SERVICE, V2_STORAGE_KEYS, } from './keychainItems.js';
14
17
  import { clearIdentityMarker, readIdentityMarker, updateIdentityMarker, writeIdentityMarker, } from './identityMarker.js';
15
18
  import { DEVICE_BACKUP_VERSION, parseDeviceBackup, serializeDeviceBackup, } from './deviceBackup.js';
16
19
  /**
@@ -68,16 +71,6 @@ export class IdentityUnavailableError extends Error {
68
71
  this.name = 'IdentityUnavailableError';
69
72
  }
70
73
  }
71
- const STORAGE_KEYS = {
72
- PRIVATE_KEY: 'oxy_identity_private_key',
73
- PUBLIC_KEY: 'oxy_identity_public_key',
74
- BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key',
75
- BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key',
76
- BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp',
77
- // The cross-app identity in the iOS keychain access group (iOS only).
78
- SHARED_PRIVATE_KEY: 'oxy_shared_identity_private_key',
79
- SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
80
- };
81
74
  /**
82
75
  * v2 identity slot layout — blast-radius isolation.
83
76
  *
@@ -93,15 +86,8 @@ const STORAGE_KEYS = {
93
86
  * Migration from the legacy layout is lazy + verify-before-delete — see
94
87
  * {@link KeyManager._runSlotMigration}.
95
88
  */
96
- const V2_PRIMARY_KEYCHAIN_SERVICE = 'oxy_identity';
97
- const V2_BACKUP_KEYCHAIN_SERVICE = 'oxy_identity_backup';
98
- const V2_STORAGE_KEYS = {
99
- PRIVATE_KEY: 'oxy_identity_private_key_v2',
100
- PUBLIC_KEY: 'oxy_identity_public_key_v2',
101
- BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
102
- BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
103
- BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
104
- };
89
+ // The names (V2_PRIMARY_KEYCHAIN_SERVICE, V2_BACKUP_KEYCHAIN_SERVICE,
90
+ // V2_STORAGE_KEYS) live in `./keychainItems`, beside every other identity item.
105
91
  /**
106
92
  * Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
107
93
  *
@@ -118,8 +104,7 @@ const V2_STORAGE_KEYS = {
118
104
  * lives ONLY in this slot: it is never mirrored into the identity marker,
119
105
  * {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
120
106
  */
121
- const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
122
- const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
107
+ // RECOVERY_MNEMONIC_KEYCHAIN_SERVICE / RECOVERY_MNEMONIC_STORAGE_KEY: `./keychainItems`.
123
108
  /**
124
109
  * Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
125
110
  * Re-derivable (its loss just re-runs the cheap slot check), so it lives in
@@ -154,12 +139,6 @@ const LEGACY_SLOT_LAYOUT = {
154
139
  backupPublicKeyName: STORAGE_KEYS.BACKUP_PUBLIC_KEY,
155
140
  backupTimestampName: STORAGE_KEYS.BACKUP_TIMESTAMP,
156
141
  };
157
- /**
158
- * iOS Keychain Access Group for sharing identities across Oxy apps
159
- * All Oxy apps must have this access group enabled in their entitlements
160
- * Format: [Team ID].so.oxy.shared or group.so.oxy.shared
161
- */
162
- const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
163
142
  /**
164
143
  * Initialize React Native specific modules
165
144
  *
@@ -169,7 +148,7 @@ const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
169
148
  * `expo-secure-store`; off RN it throws (and is never called because every
170
149
  * caller is gated by `isWebPlatform()` / native-only paths).
171
150
  */
172
- async function initSecureStore() {
151
+ async function loadRawSecureStore() {
173
152
  try {
174
153
  return await loadSecureStore();
175
154
  }
@@ -178,6 +157,29 @@ async function initSecureStore() {
178
157
  throw new Error(`Failed to load expo-secure-store: ${errorMessage}. Make sure expo-secure-store is installed and properly configured.`);
179
158
  }
180
159
  }
160
+ /**
161
+ * The secure store routed to the keychain group of `item`
162
+ * (`../utils/keychainAccessGroups`): on iOS reads walk the item's group then
163
+ * the pre-split shared group, writes carry the item's group and THROW in an app
164
+ * that may not hold it, and in Commons every access first awaits the
165
+ * access-group migration. Android and web are unchanged (`ungrouped`).
166
+ *
167
+ * Throws when the store cannot load or the keychain cannot say which groups
168
+ * this app holds; every caller already treats a throw here as "storage
169
+ * unavailable".
170
+ */
171
+ async function keychainItemStore(item) {
172
+ const raw = await loadRawSecureStore();
173
+ const access = await resolveKeychainAccess();
174
+ if (access.mode === 'grouped' && access.role === 'holder') {
175
+ await ensureKeychainGroupsMigrated();
176
+ }
177
+ return groupedKeychainStore(raw, access, item);
178
+ }
179
+ /** The store for the identity slots (primary, backup, legacy, phrase): Commons only on iOS. */
180
+ function initSecureStore() {
181
+ return keychainItemStore('identity-secret');
182
+ }
181
183
  /**
182
184
  * Check if we're on web platform
183
185
  * Identity storage is only available on native platforms (iOS/Android)
@@ -568,12 +570,12 @@ export class KeyManager {
568
570
  * rung. Best-effort — the shared slot is a redundant convenience copy. The
569
571
  * Android signer store is cleared on every delete, in {@link deleteIdentity}.
570
572
  */
571
- static async _clearSharedSlot(store) {
573
+ static async _clearSharedSlot(_identityStore) {
572
574
  try {
573
575
  if (isIOS()) {
574
- const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
575
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
576
- await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
576
+ // The shared slot lives in two groups of its own, not the identity slots'.
577
+ await (await keychainItemStore('spending-key')).deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
578
+ await (await keychainItemStore('shared')).deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
577
579
  }
578
580
  KeyManager.invalidateSharedCache();
579
581
  }
@@ -640,6 +642,20 @@ export class KeyManager {
640
642
  KeyManager.cachedSharedPublicKey = null;
641
643
  KeyManager.cachedHasSharedIdentity = null;
642
644
  }
645
+ /**
646
+ * iOS: write the personal key's cross-app slot — the private key to the
647
+ * wallet group (Commons and the Peable wallet), the public key to the shared
648
+ * group (every Oxy app). Throws in any app that may not hold the private key.
649
+ * @internal
650
+ */
651
+ static async _writeIosSharedSlot(privateKey, publicKey) {
652
+ const privateStore = await keychainItemStore('spending-key');
653
+ const privateOpts = {
654
+ keychainAccessible: privateStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
655
+ };
656
+ await privateStore.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
657
+ await (await keychainItemStore('shared')).setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, {});
658
+ }
643
659
  /**
644
660
  * Lowercase and pad to canonical 64-hex-char form.
645
661
  *
@@ -675,8 +691,10 @@ export class KeyManager {
675
691
  // ==================== SHARED IDENTITY METHODS ====================
676
692
  // One Oxy identity across every Oxy app on a device.
677
693
  //
678
- // iOS: the keychain access group `group.so.oxy.shared`. Every Oxy app reads
679
- // the same keychain item, and no app can wipe another's keychain.
694
+ // iOS: keychain access groups (docs/identity/ios-keychain-access-groups.md).
695
+ // The private key is in `<TEAM>.so.oxy.wallet` (Commons and the Peable
696
+ // wallet only), the public key in `<TEAM>.group.so.oxy.shared`, which every
697
+ // Oxy app reads. Every other app signs in through Commons (ADR 0029).
680
698
  // Android: every app has its own UID, so nothing is shared through storage.
681
699
  // Commons is the ONLY holder of the private key. It keeps the identity
682
700
  // signer store (`setIdentitySignerStore`) for its identity host, and every
@@ -701,21 +719,11 @@ export class KeyManager {
701
719
  }
702
720
  const { privateKey, publicKey } = await KeyManager.generateKeyPair();
703
721
  if (isIOS()) {
704
- const store = await initSecureStore();
705
- // Note: keychainAccessGroup requires Keychain Sharing capability in Xcode
706
722
  try {
707
- const privateOpts = {
708
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
709
- keychainAccessGroup: IOS_KEYCHAIN_GROUP, // Enables sharing across apps
710
- };
711
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
712
- const publicOpts = {
713
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
714
- };
715
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
723
+ await KeyManager._writeIosSharedSlot(privateKey, publicKey);
716
724
  }
717
725
  catch (error) {
718
- throw new Error(`Failed to create shared identity on iOS. Ensure your app has the Keychain Sharing capability enabled with access group "${IOS_KEYCHAIN_GROUP}". Error: ${error}`);
726
+ throw new Error(`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}`);
719
727
  }
720
728
  }
721
729
  else if (isAndroid()) {
@@ -755,9 +763,8 @@ export class KeyManager {
755
763
  try {
756
764
  let publicKey = null;
757
765
  if (isIOS()) {
758
- const store = await initSecureStore();
759
- const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
760
- publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
766
+ const store = await keychainItemStore('shared');
767
+ publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, {});
761
768
  }
762
769
  else if (isAndroid()) {
763
770
  const signer = KeyManager.identitySignerStore;
@@ -797,9 +804,10 @@ export class KeyManager {
797
804
  return null;
798
805
  }
799
806
  try {
800
- const store = await initSecureStore();
801
- const opts = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
802
- return await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
807
+ // The wallet group: Commons and the Peable wallet. Every other app's plan
808
+ // is empty, so this is `null` there without touching the keychain.
809
+ const store = await keychainItemStore('spending-key');
810
+ return await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, {});
803
811
  }
804
812
  catch (error) {
805
813
  if (isDev()) {
@@ -893,16 +901,7 @@ export class KeyManager {
893
901
  const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
894
902
  const publicKey = deriveSecp256k1PublicKey(canonicalPrivate);
895
903
  if (isIOS()) {
896
- const store = await initSecureStore();
897
- const privateOpts = {
898
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
899
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
900
- };
901
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, privateOpts);
902
- const publicOpts = {
903
- keychainAccessGroup: IOS_KEYCHAIN_GROUP,
904
- };
905
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
904
+ await KeyManager._writeIosSharedSlot(canonicalPrivate, publicKey);
906
905
  }
907
906
  else if (isAndroid()) {
908
907
  await KeyManager._writeIdentitySigner(canonicalPrivate, publicKey);
@@ -2567,7 +2566,8 @@ export class KeyManager {
2567
2566
  * `HDKey.fromMasterSeed` and never touches the identity key.
2568
2567
  *
2569
2568
  * Key source (native only):
2570
- * - iOS: the shared identity in `group.so.oxy.shared`, then this device's
2569
+ * - iOS: the shared slot's private key in the wallet keychain group (Commons
2570
+ * and the Peable wallet only; `null` in every other app), then this device's
2571
2571
  * primary identity.
2572
2572
  * - Android: Commons (the one app with a signer store) uses its own primary
2573
2573
  * identity; every other app asks Commons over IPC and never reads a local
@@ -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
+ }