@oxy.so/core 7.28.0 → 7.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/cjs/api/identity.js +40 -1
  2. package/dist/cjs/crypto/index.js +28 -1
  3. package/dist/cjs/crypto/keyManager.js +175 -107
  4. package/dist/cjs/crypto/keychainGroupMigration.js +353 -0
  5. package/dist/cjs/crypto/keychainItems.js +70 -0
  6. package/dist/cjs/crypto/organizationKeys.js +80 -72
  7. package/dist/cjs/crypto/retiredKeys.js +371 -0
  8. package/dist/cjs/crypto/secureStoreOptions.js +2 -0
  9. package/dist/cjs/session/index.js +11 -1
  10. package/dist/cjs/utils/keychainAccessGroups.js +248 -0
  11. package/dist/esm/api/identity.js +40 -1
  12. package/dist/esm/crypto/index.js +6 -0
  13. package/dist/esm/crypto/keyManager.js +132 -64
  14. package/dist/esm/crypto/keychainGroupMigration.js +348 -0
  15. package/dist/esm/crypto/keychainItems.js +61 -0
  16. package/dist/esm/crypto/organizationKeys.js +54 -46
  17. package/dist/esm/crypto/retiredKeys.js +361 -0
  18. package/dist/esm/crypto/secureStoreOptions.js +1 -0
  19. package/dist/esm/session/index.js +3 -0
  20. package/dist/esm/utils/keychainAccessGroups.js +231 -0
  21. package/dist/types/api/identity.d.ts +10 -0
  22. package/dist/types/crypto/identitySigner.d.ts +23 -0
  23. package/dist/types/crypto/index.d.ts +6 -0
  24. package/dist/types/crypto/keyManager.d.ts +32 -2
  25. package/dist/types/crypto/keychainGroupMigration.d.ts +27 -0
  26. package/dist/types/crypto/keychainItems.d.ts +41 -0
  27. package/dist/types/crypto/organizationKeys.d.ts +11 -9
  28. package/dist/types/crypto/retiredKeys.d.ts +116 -0
  29. package/dist/types/crypto/secureStoreOptions.d.ts +15 -0
  30. package/dist/types/session/index.d.ts +2 -0
  31. package/dist/types/utils/keychainAccessGroups.d.ts +104 -0
  32. package/package.json +3 -3
  33. package/src/api/identity.ts +46 -3
  34. package/src/crypto/identitySigner.ts +23 -0
  35. package/src/crypto/index.ts +40 -0
  36. package/src/crypto/keyManager.ts +165 -102
  37. package/src/crypto/keychainGroupMigration.ts +440 -0
  38. package/src/crypto/keychainItems.ts +73 -0
  39. package/src/crypto/organizationKeys.ts +72 -57
  40. package/src/crypto/retiredKeys.ts +453 -0
  41. package/src/crypto/secureStoreOptions.ts +32 -0
  42. package/src/session/index.ts +11 -0
  43. package/src/utils/keychainAccessGroups.ts +308 -0
  44. package/src/types/expo-secure-store.d.ts +0 -21
@@ -0,0 +1,371 @@
1
+ "use strict";
2
+ /**
3
+ * Retired identity keys: the keys an account HAD, kept on the device for
4
+ * spending only (oxy ADR 0061 D7 as amended 2026-11-06, Peable ADR 0002).
5
+ *
6
+ * The Peable wallet derives from the account's identity key (`deriveScopedSeed`
7
+ * for its HD wallet, `signSocialReceive` for its social-receive addresses), and
8
+ * coins keep arriving under a key after it is replaced: payments already
9
+ * broadcast settle against the key they were reserved under, and late payers
10
+ * and `@username` senders hold old addresses. So a personal rotation
11
+ * (`oxy.identity.rotateKey`) and an organization replacement keep the replaced
12
+ * private key here, in a ring per account — `personal`, and one per
13
+ * organization account id — instead of deleting it.
14
+ *
15
+ * A retired key is NEVER a way to sign in, publish, receive a new reservation or
16
+ * rotate: nothing here signs a challenge, and the identity host refuses
17
+ * `proveIdentity` / `describe` for one. It is reached exactly as the current key
18
+ * is, by derivation and never by export, through the `publicKey` selector of
19
+ * `KeyManager.deriveScopedSeed` / `signSocialReceive`:
20
+ *
21
+ * - iOS: the private key in the wallet keychain group (ADR 0062's
22
+ * `spending-key`: Commons and the Peable wallet only), beside the current
23
+ * private keys; the ring's index (public keys and dates only) in the same
24
+ * group, so the wallet can list it. Only Commons writes either.
25
+ * - Android: Commons' own expo-secure-store slot, mirrored into the identity
26
+ * signer store (`./identitySigner`) that Commons' identity host answers
27
+ * `keyPublicKey` requests from (wallet only). Every other app asks over IPC
28
+ * (`loadCommonsIdentityBridge`) and never gets the key.
29
+ *
30
+ * A retired phrase is not kept: the person wrote it down, and a phrase is a
31
+ * second copy of the key that buys the wallet nothing.
32
+ *
33
+ * A key enters the ring only from Commons: the key being replaced (rotation,
34
+ * organization replacement), or a previous recovery phrase the person adds,
35
+ * which Commons accepts only when Oxy's key history names it as one of the
36
+ * account's retired keys. Never into the current slot.
37
+ *
38
+ * Security: a retired key that was compromised is also the attacker's. The
39
+ * ring makes the coins under it spendable from Peable again; it cannot make
40
+ * them safe. A rotation forced by a compromise must be followed at once by the
41
+ * wallet's "move to your current key" sweep, which races the attacker.
42
+ *
43
+ * This module imports nothing from `./keyManager` (which imports it).
44
+ */
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.retiredKeyScope = retiredKeyScope;
47
+ exports.retireIdentityKey = retireIdentityKey;
48
+ exports.listRetiredIdentityKeys = listRetiredIdentityKeys;
49
+ exports.getRetiredPrivateKey = getRetiredPrivateKey;
50
+ exports.forgetRetiredIdentityKey = forgetRetiredIdentityKey;
51
+ exports.forgetRetiredIdentityKeys = forgetRetiredIdentityKeys;
52
+ exports.syncRetiredKeys = syncRetiredKeys;
53
+ exports.retiredKeyFromPhrase = retiredKeyFromPhrase;
54
+ const bip39_1 = require("@scure/bip39");
55
+ const english_js_1 = require("@scure/bip39/wordlists/english.js");
56
+ const protocol_1 = require("@oxy.so/protocol");
57
+ const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
58
+ const logger_1 = require("../logger");
59
+ const keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
60
+ const platform_1 = require("../utils/platform");
61
+ const identitySigner_1 = require("./identitySigner");
62
+ const keychainGroupMigration_1 = require("./keychainGroupMigration");
63
+ const organizationKeys_1 = require("./organizationKeys");
64
+ const PERSONAL_SCOPE = 'personal';
65
+ /** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
66
+ const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
67
+ const INDEX_KEYCHAIN_SERVICE = 'oxy_retired_identity_index';
68
+ const INDEX_STORAGE_KEY = 'oxy_retired_identity_index_v1';
69
+ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
70
+ /**
71
+ * The secure store for the ring. On iOS every item is a `spending-key`
72
+ * (ADR 0062): the private keys AND the index live in the wallet group, which
73
+ * only Commons and the Peable wallet can read, never the shared one. In
74
+ * Commons every access first awaits the access-group migration. On Android
75
+ * the grouping is a no-op and Commons' own services below apply.
76
+ */
77
+ async function ringStore() {
78
+ const raw = await (0, protocol_1.loadSecureStore)();
79
+ const access = await (0, keychainAccessGroups_1.resolveKeychainAccess)();
80
+ if (access.mode === 'grouped' && access.role === 'holder') {
81
+ await (0, keychainGroupMigration_1.ensureKeychainGroupsMigrated)();
82
+ }
83
+ return (0, keychainAccessGroups_1.groupedKeychainStore)(raw, access, 'spending-key');
84
+ }
85
+ /** The ring a selector names: `personal`, or the organization's account id. */
86
+ function retiredKeyScope(selector) {
87
+ const accountId = selector?.accountId;
88
+ if (accountId === undefined)
89
+ return PERSONAL_SCOPE;
90
+ return (0, organizationKeys_1.isOrganizationAccountId)(accountId) ? accountId : null;
91
+ }
92
+ function canonicalPublicKey(publicKey) {
93
+ try {
94
+ const normalized = (0, secp256k1_1.normalizeSecp256k1PublicKey)(publicKey.trim());
95
+ return PUBLIC_KEY.test(normalized) ? normalized : null;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ function publicKeyOf(privateKey) {
102
+ return (0, secp256k1_1.deriveSecp256k1PublicKey)((0, secp256k1_1.normalizeSecp256k1PrivateKey)(privateKey));
103
+ }
104
+ function isHealthyPair(privateKey, publicKey) {
105
+ if (!(0, secp256k1_1.isValidSecp256k1PrivateKey)(privateKey) || !(0, secp256k1_1.isValidSecp256k1PublicKey)(publicKey)) {
106
+ return false;
107
+ }
108
+ try {
109
+ return publicKeyOf(privateKey) === (0, secp256k1_1.normalizeSecp256k1PublicKey)(publicKey);
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
115
+ const privateKeyName = (scope, publicKey) => `oxy_retired_identity_private_key.${scope}.${publicKey}`;
116
+ /** The options of a private half: on iOS none but accessibility (the group comes from {@link ringStore}). */
117
+ function privateOptions(store, forWrite) {
118
+ if ((0, platform_1.isIOS)()) {
119
+ return forWrite ? { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY } : {};
120
+ }
121
+ return forWrite
122
+ ? {
123
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
124
+ keychainService: ANDROID_KEYCHAIN_SERVICE,
125
+ }
126
+ : { keychainService: ANDROID_KEYCHAIN_SERVICE };
127
+ }
128
+ /** The index: public keys and dates, beside the private halves. */
129
+ function indexOptions() {
130
+ return (0, platform_1.isIOS)() ? {} : { keychainService: INDEX_KEYCHAIN_SERVICE };
131
+ }
132
+ function isIndexEntry(value) {
133
+ if (typeof value !== 'object' || value === null)
134
+ return false;
135
+ const entry = value;
136
+ return (typeof entry.scope === 'string' &&
137
+ (entry.scope === PERSONAL_SCOPE || (0, organizationKeys_1.isOrganizationAccountId)(entry.scope)) &&
138
+ typeof entry.publicKey === 'string' &&
139
+ PUBLIC_KEY.test(entry.publicKey) &&
140
+ typeof entry.retiredAt === 'number' &&
141
+ Number.isFinite(entry.retiredAt));
142
+ }
143
+ async function readIndex(store) {
144
+ const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
145
+ if (!raw)
146
+ return [];
147
+ try {
148
+ const parsed = JSON.parse(raw);
149
+ return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
150
+ }
151
+ catch (error) {
152
+ logger_1.logger.warn('Retired key index unreadable; treating as empty', {
153
+ component: 'RetiredKeys',
154
+ error: error instanceof Error ? error.name : 'unknown',
155
+ });
156
+ return [];
157
+ }
158
+ }
159
+ async function writeIndex(store, entries) {
160
+ const unique = new Map();
161
+ for (const entry of entries) {
162
+ const id = `${entry.scope}.${entry.publicKey}`;
163
+ if (!unique.has(id))
164
+ unique.set(id, entry);
165
+ }
166
+ const sorted = [...unique.values()].sort((a, b) => a.retiredAt - b.retiredAt || a.publicKey.localeCompare(b.publicKey));
167
+ await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify(sorted), indexOptions());
168
+ }
169
+ function toListed(entry) {
170
+ return entry.scope === PERSONAL_SCOPE
171
+ ? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
172
+ : { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
173
+ }
174
+ /** Whether this app writes the ring: Commons (Android: the signer store it registers; iOS: native). */
175
+ function assertMayWrite() {
176
+ if ((0, platform_1.isWeb)())
177
+ throw new Error('retired key: only a native device holds one');
178
+ if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()?.writeRetired) {
179
+ throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
180
+ }
181
+ }
182
+ /**
183
+ * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
184
+ * key already kept stays, with its first date. Refuses a pair whose public key
185
+ * does not derive from its private key. Resolves once every copy confirmed it
186
+ * (Android: the signer store's read-back too), and throws otherwise, so a
187
+ * caller about to replace the key can stop before anything is lost.
188
+ */
189
+ async function retireIdentityKey(key, selector) {
190
+ const scope = retiredKeyScope(selector);
191
+ if (!scope)
192
+ throw new Error('retired key: accountId must be a 24-hex or uuid account id');
193
+ assertMayWrite();
194
+ if (!isHealthyPair(key.privateKey, key.publicKey)) {
195
+ throw new Error('retired key: the public key does not derive from the private key');
196
+ }
197
+ const privateKey = (0, secp256k1_1.normalizeSecp256k1PrivateKey)(key.privateKey);
198
+ const publicKey = (0, secp256k1_1.normalizeSecp256k1PublicKey)(key.publicKey);
199
+ const store = await ringStore();
200
+ await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
201
+ const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
202
+ if (readBack !== privateKey) {
203
+ throw new Error('retired key: the keychain did not confirm the key');
204
+ }
205
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
206
+ if (signer?.writeRetired && !(await signer.writeRetired(scope, privateKey, publicKey))) {
207
+ throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
208
+ }
209
+ const index = await readIndex(store);
210
+ if (!index.some((entry) => entry.scope === scope && entry.publicKey === publicKey)) {
211
+ await writeIndex(store, [...index, { scope, publicKey, retiredAt: Date.now() }]);
212
+ }
213
+ }
214
+ /**
215
+ * The retired keys of `selector`'s account this app can reach, oldest first.
216
+ * iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
217
+ * (the wallet only), which leaves out the scope's current key. After an
218
+ * interrupted rotation the local list can include the current key: compare
219
+ * with the DID's `#key-1` before treating an entry as retired.
220
+ */
221
+ async function listRetiredIdentityKeys(selector) {
222
+ const scope = retiredKeyScope(selector);
223
+ if (!scope || (0, platform_1.isWeb)())
224
+ return [];
225
+ try {
226
+ if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)()) {
227
+ const bridge = await (0, protocol_1.loadCommonsIdentityBridge)();
228
+ const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
229
+ return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
230
+ ? { publicKey, retiredAt: 0 }
231
+ : { publicKey, accountId: scope, retiredAt: 0 });
232
+ }
233
+ if (!(0, platform_1.isIOS)() && !(0, platform_1.isAndroid)())
234
+ return [];
235
+ const store = await ringStore();
236
+ return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
237
+ }
238
+ catch (error) {
239
+ logger_1.logger.warn('Failed to list retired keys', {
240
+ component: 'RetiredKeys',
241
+ error: error instanceof Error ? error.name : 'unknown',
242
+ });
243
+ return [];
244
+ }
245
+ }
246
+ /**
247
+ * The private half of a retired key, where this app may hold it: Commons (both
248
+ * platforms) and, on iOS, the keychain group. `null` on Android outside Commons
249
+ * (ask the identity host instead) and for a key not in the ring. Sign within
250
+ * the app only; never transmit it. @internal — `KeyManager` reads it for the
251
+ * `publicKey` selector.
252
+ */
253
+ async function getRetiredPrivateKey(publicKey, selector) {
254
+ const scope = retiredKeyScope(selector);
255
+ const canonical = canonicalPublicKey(publicKey);
256
+ if (!scope || !canonical || (0, platform_1.isWeb)())
257
+ return null;
258
+ try {
259
+ if ((0, platform_1.isAndroid)()) {
260
+ const signer = (0, identitySigner_1.registeredIdentitySignerStore)();
261
+ if (!signer)
262
+ return null;
263
+ const store = await ringStore();
264
+ const local = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
265
+ if (local && isHealthyPair(local, canonical))
266
+ return (0, secp256k1_1.normalizeSecp256k1PrivateKey)(local);
267
+ // The signer store is a rung: a wiped expo-secure-store slot still has it.
268
+ const mirrored = (await signer.readRetired?.(scope, canonical)) ?? null;
269
+ return mirrored && isHealthyPair(mirrored.privateKey, canonical)
270
+ ? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(mirrored.privateKey)
271
+ : null;
272
+ }
273
+ if (!(0, platform_1.isIOS)())
274
+ return null;
275
+ const store = await ringStore();
276
+ const value = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
277
+ return value && isHealthyPair(value, canonical) ? (0, secp256k1_1.normalizeSecp256k1PrivateKey)(value) : null;
278
+ }
279
+ catch (error) {
280
+ logger_1.logger.warn('Failed to read a retired key', {
281
+ component: 'RetiredKeys',
282
+ error: error instanceof Error ? error.name : 'unknown',
283
+ });
284
+ return null;
285
+ }
286
+ }
287
+ /**
288
+ * Forget a retired key on this device: its private half, its signer copy and
289
+ * its index entry. Commons only, and only on the person's explicit request
290
+ * (their coins under it are then spendable only with its phrase again).
291
+ */
292
+ async function forgetRetiredIdentityKey(publicKey, selector) {
293
+ const scope = retiredKeyScope(selector);
294
+ const canonical = canonicalPublicKey(publicKey);
295
+ if (!scope || !canonical)
296
+ throw new Error('retired key: invalid selector or public key');
297
+ assertMayWrite();
298
+ const store = await ringStore();
299
+ await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
300
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
301
+ await signer?.clearRetired?.(scope, canonical);
302
+ await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
303
+ }
304
+ /**
305
+ * Forget every retired key of one ring (the person's, or an organization's):
306
+ * called when the identity itself is deleted from this device, so a phone
307
+ * handed on keeps none of the person's old keys. Commons only. Resolves how
308
+ * many it removed.
309
+ */
310
+ async function forgetRetiredIdentityKeys(selector) {
311
+ const scope = retiredKeyScope(selector);
312
+ if (!scope || (0, platform_1.isWeb)())
313
+ return 0;
314
+ if ((0, platform_1.isAndroid)() && !(0, identitySigner_1.registeredIdentitySignerStore)())
315
+ return 0;
316
+ const store = await ringStore();
317
+ const index = await readIndex(store);
318
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
319
+ let removed = 0;
320
+ for (const entry of index) {
321
+ if (entry.scope !== scope)
322
+ continue;
323
+ await store.deleteItemAsync(privateKeyName(scope, entry.publicKey), privateOptions(store, false));
324
+ await signer?.clearRetired?.(scope, entry.publicKey);
325
+ removed += 1;
326
+ }
327
+ await writeIndex(store, index.filter((entry) => entry.scope !== scope));
328
+ return removed;
329
+ }
330
+ /**
331
+ * Commons on Android: copy every retired key into the identity signer store
332
+ * again, so the identity host answers for it after that file was rebuilt (its
333
+ * self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
334
+ * Resolves how many it mirrored. Elsewhere 0.
335
+ */
336
+ async function syncRetiredKeys() {
337
+ const signer = (0, platform_1.isAndroid)() ? (0, identitySigner_1.registeredIdentitySignerStore)() : null;
338
+ if (!signer?.writeRetired || !signer.readRetired)
339
+ return 0;
340
+ const store = await ringStore();
341
+ let mirrored = 0;
342
+ for (const entry of await readIndex(store)) {
343
+ const privateKey = await store.getItemAsync(privateKeyName(entry.scope, entry.publicKey), privateOptions(store, false));
344
+ if (!privateKey || !isHealthyPair(privateKey, entry.publicKey))
345
+ continue;
346
+ const current = await signer.readRetired(entry.scope, entry.publicKey);
347
+ if (current?.publicKey.toLowerCase() === entry.publicKey)
348
+ continue;
349
+ if (await signer.writeRetired(entry.scope, (0, secp256k1_1.normalizeSecp256k1PrivateKey)(privateKey), entry.publicKey)) {
350
+ mirrored += 1;
351
+ }
352
+ }
353
+ return mirrored;
354
+ }
355
+ /**
356
+ * The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
357
+ * and organization-key derivation; 12–24 words). Stores nothing: the caller
358
+ * checks the public key against the account's key history (personal:
359
+ * `oxy.identity.keyHistory()`; organization: `oxy.accounts.identityKey.get`)
360
+ * and only then calls {@link retireIdentityKey}.
361
+ */
362
+ async function retiredKeyFromPhrase(phrase) {
363
+ const normalized = phrase.trim().toLowerCase().split(/\s+/).join(' ');
364
+ const words = normalized.split(' ').length;
365
+ if (words < 12 || words > 24 || words % 3 !== 0 || !(0, bip39_1.validateMnemonic)(normalized, english_js_1.wordlist)) {
366
+ throw new Error('retired key: not a valid recovery phrase');
367
+ }
368
+ const seed = await (0, bip39_1.mnemonicToSeed)(normalized);
369
+ const privateKey = Array.from(seed.subarray(0, 32), (b) => b.toString(16).padStart(2, '0')).join('');
370
+ return { privateKey, publicKey: publicKeyOf(privateKey) };
371
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -8,7 +8,8 @@
8
8
  * the API with a token it already has never needs it.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.runColdBoot = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.establishIdentitySession = exports.resolveIdentityPin = exports.IDENTITY_PIN_STORAGE_KEY = exports.identityPinMatches = exports.createMemoryIdentityPinStore = exports.createNativeIdentityPinStore = exports.createWebIdentityPinStore = exports.readLocalDeviceCredential = exports.publishProvenDeviceCredential = exports.normalizeSharedDeviceSessionRead = exports.decideSharedDevicePublish = exports.decideSharedDeviceJoin = exports.createSharedMirroringAuthStateStore = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.resolveAccountDelegationAccess = exports.canSwitchIntoAccount = exports.isSwitchTargetAccount = exports.deviceComparisonCode = exports.showsPrincipalHeaders = exports.buildSwitcherRows = exports.resolveDeviceContext = exports.resolveActiveContext = exports.projectDevicePrincipals = exports.directoryHandle = exports.directoryDisplayName = exports.canActivateContext = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.DeviceManager = void 0;
11
+ exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.establishIdentitySession = exports.resolveIdentityPin = exports.IDENTITY_PIN_STORAGE_KEY = exports.identityPinMatches = exports.createMemoryIdentityPinStore = exports.createNativeIdentityPinStore = exports.createWebIdentityPinStore = exports.resolveKeychainAccess = exports.deleteThroughPlan = exports.readThroughPlan = exports.ownKeychainItemPlan = exports.keychainAccessGroupValue = exports.KeychainAccessUnavailableError = exports.readLocalDeviceCredential = exports.publishProvenDeviceCredential = exports.normalizeSharedDeviceSessionRead = exports.decideSharedDevicePublish = exports.decideSharedDeviceJoin = exports.createSharedMirroringAuthStateStore = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.resolveAccountDelegationAccess = exports.canSwitchIntoAccount = exports.isSwitchTargetAccount = exports.deviceComparisonCode = exports.showsPrincipalHeaders = exports.buildSwitcherRows = exports.resolveDeviceContext = exports.resolveActiveContext = exports.projectDevicePrincipals = exports.directoryHandle = exports.directoryDisplayName = exports.canActivateContext = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.DeviceManager = void 0;
12
+ exports.runColdBoot = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = void 0;
12
13
  // Session code reaches the identity key (identity-bound sessions), so the
13
14
  // crypto shim goes first — see `../crypto/polyfill`.
14
15
  require("../crypto/polyfill");
@@ -107,6 +108,15 @@ Object.defineProperty(exports, "decideSharedDevicePublish", { enumerable: true,
107
108
  Object.defineProperty(exports, "normalizeSharedDeviceSessionRead", { enumerable: true, get: function () { return sharedDeviceCredential_1.normalizeSharedDeviceSessionRead; } });
108
109
  Object.defineProperty(exports, "publishProvenDeviceCredential", { enumerable: true, get: function () { return sharedDeviceCredential_1.publishProvenDeviceCredential; } });
109
110
  Object.defineProperty(exports, "readLocalDeviceCredential", { enumerable: true, get: function () { return sharedDeviceCredential_1.readLocalDeviceCredential; } });
111
+ // Where the shared device credential (and an app's own items) live on iOS:
112
+ // docs/identity/ios-keychain-access-groups.md, ADR 0062.
113
+ var keychainAccessGroups_1 = require("../utils/keychainAccessGroups");
114
+ Object.defineProperty(exports, "KeychainAccessUnavailableError", { enumerable: true, get: function () { return keychainAccessGroups_1.KeychainAccessUnavailableError; } });
115
+ Object.defineProperty(exports, "keychainAccessGroupValue", { enumerable: true, get: function () { return keychainAccessGroups_1.keychainAccessGroupValue; } });
116
+ Object.defineProperty(exports, "ownKeychainItemPlan", { enumerable: true, get: function () { return keychainAccessGroups_1.ownKeychainItemPlan; } });
117
+ Object.defineProperty(exports, "readThroughPlan", { enumerable: true, get: function () { return keychainAccessGroups_1.readThroughPlan; } });
118
+ Object.defineProperty(exports, "deleteThroughPlan", { enumerable: true, get: function () { return keychainAccessGroups_1.deleteThroughPlan; } });
119
+ Object.defineProperty(exports, "resolveKeychainAccess", { enumerable: true, get: function () { return keychainAccessGroups_1.resolveKeychainAccess; } });
110
120
  // Identity-bound sessions (the identity vault). The pin is the durable
111
121
  // `{publicKey, accountId}` binding between this device's PRIMARY identity key
112
122
  // and the account it authenticates as; it is what keeps such a client from
@@ -0,0 +1,248 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KeychainAccessUnavailableError = exports.KEYCHAIN_ACCESS_GROUPS = void 0;
4
+ exports.keychainGroupFor = keychainGroupFor;
5
+ exports.mayHoldKeychainItem = mayHoldKeychainItem;
6
+ exports.keychainAccessFromDefaultGroup = keychainAccessFromDefaultGroup;
7
+ exports.resolveKeychainAccess = resolveKeychainAccess;
8
+ exports.resetKeychainAccessForTests = resetKeychainAccessForTests;
9
+ exports.keychainAccessGroupValue = keychainAccessGroupValue;
10
+ exports.keychainReadPlan = keychainReadPlan;
11
+ exports.keychainDeletePlan = keychainDeletePlan;
12
+ exports.keychainWriteOption = keychainWriteOption;
13
+ exports.ownKeychainItemPlan = ownKeychainItemPlan;
14
+ exports.readThroughPlan = readThroughPlan;
15
+ exports.deleteThroughPlan = deleteThroughPlan;
16
+ exports.groupedKeychainStore = groupedKeychainStore;
17
+ /**
18
+ * iOS keychain access groups: which group each Oxy keychain item lives in, and
19
+ * which apps may read it (docs/identity/ios-keychain-access-groups.md, ADR 0062).
20
+ *
21
+ * | Group | Held by | Item class |
22
+ * |-------------------------------|------------------------------|-------------------|
23
+ * | `<TEAM>.so.oxy.identity` | Commons only (listed FIRST) | `identity-secret` |
24
+ * | `<TEAM>.so.oxy.wallet` | Commons, the Peable wallet | `spending-key` |
25
+ * | `<TEAM>.group.so.oxy.shared` | every Oxy app | `shared` |
26
+ *
27
+ * - `identity-secret`: the personal root (primary and backup slots), the
28
+ * personal and organization recovery phrases, the organization index. Only
29
+ * Commons may read or write them.
30
+ * - `spending-key`: private keys an app other than Commons signs or derives
31
+ * with in process — the personal key's shared slot and organization pairs
32
+ * (`deriveScopedSeed`, `signSocialReceive`). Commons writes them; the Peable
33
+ * wallet reads them.
34
+ * - `shared`: what every Oxy app may read: the shared public key and the
35
+ * device-session credential.
36
+ *
37
+ * The ROLE of this binary comes from the keychain itself: an item written
38
+ * without a group lands in the app's default group, the first entry of its
39
+ * `keychain-access-groups` entitlement, and `@oxy.so/app-preset` lets only
40
+ * Commons put the identity group first and only the Peable wallet the wallet
41
+ * group. The same probe gives the team prefix, which `kSecAttrAccessGroup`
42
+ * needs and JavaScript cannot otherwise see.
43
+ *
44
+ * Android and web have no access groups: there every helper here answers the
45
+ * single-store behaviour (`ungrouped`), and Android's narrowing is Commons'
46
+ * identity host. An iOS build whose native module predates the probe is also
47
+ * `ungrouped` — exactly what that build's entitlements (one shared group) do.
48
+ *
49
+ * This module never imports the identity stack: services' device-session store
50
+ * and Peable's wallet storage use it too.
51
+ */
52
+ const protocol_1 = require("@oxy.so/protocol");
53
+ const platform_1 = require("./platform");
54
+ /** The group names after the team prefix, exactly as the entitlements list them. */
55
+ exports.KEYCHAIN_ACCESS_GROUPS = {
56
+ identity: 'so.oxy.identity',
57
+ wallet: 'so.oxy.wallet',
58
+ shared: 'group.so.oxy.shared',
59
+ };
60
+ /** Thrown while the probe cannot answer (before first unlock). Transient. */
61
+ class KeychainAccessUnavailableError extends Error {
62
+ constructor(cause) {
63
+ super('The keychain cannot say which access groups this app holds right now.');
64
+ this.cause = cause;
65
+ this.name = 'KeychainAccessUnavailableError';
66
+ }
67
+ }
68
+ exports.KeychainAccessUnavailableError = KeychainAccessUnavailableError;
69
+ /**
70
+ * The single place each item class is assigned a group. A new kind of item
71
+ * (F1's retained keys, for one) picks its class and reuses everything below.
72
+ */
73
+ function keychainGroupFor(item) {
74
+ switch (item) {
75
+ case 'identity-secret':
76
+ return 'identity';
77
+ case 'spending-key':
78
+ return 'wallet';
79
+ case 'shared':
80
+ return 'shared';
81
+ }
82
+ }
83
+ /** Whether `role` holds the group `item` lives in. */
84
+ function mayHoldKeychainItem(role, item) {
85
+ switch (role) {
86
+ case 'holder':
87
+ return true;
88
+ case 'wallet':
89
+ return item !== 'identity-secret';
90
+ case 'member':
91
+ return item === 'shared';
92
+ }
93
+ }
94
+ /** Parse the default access group the probe reported. Exported for tests. */
95
+ function keychainAccessFromDefaultGroup(defaultGroup) {
96
+ const dot = defaultGroup.indexOf('.');
97
+ if (dot <= 0 || dot === defaultGroup.length - 1) {
98
+ // Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
99
+ return { mode: 'ungrouped' };
100
+ }
101
+ const prefix = defaultGroup.slice(0, dot + 1);
102
+ const group = defaultGroup.slice(dot + 1);
103
+ const role = group === exports.KEYCHAIN_ACCESS_GROUPS.identity
104
+ ? 'holder'
105
+ : group === exports.KEYCHAIN_ACCESS_GROUPS.wallet
106
+ ? 'wallet'
107
+ : 'member';
108
+ return { mode: 'grouped', prefix, defaultGroup, role };
109
+ }
110
+ let resolvedAccess = null;
111
+ /**
112
+ * This binary's keychain access, memoized once known. THROWS
113
+ * {@link KeychainAccessUnavailableError} while the probe cannot answer, which
114
+ * every caller already treats as "storage unavailable, retry".
115
+ */
116
+ async function resolveKeychainAccess() {
117
+ if (resolvedAccess) {
118
+ return resolvedAccess;
119
+ }
120
+ if (!(0, platform_1.isIOS)()) {
121
+ // Not memoized: until `@oxy.so/services` registers the platform, a phone
122
+ // reads as "not iOS", and caching that would keep the whole process on the
123
+ // pre-split behaviour.
124
+ return { mode: 'ungrouped' };
125
+ }
126
+ let probe;
127
+ try {
128
+ probe = await (0, protocol_1.loadKeychainDefaultAccessGroup)();
129
+ }
130
+ catch (cause) {
131
+ throw new KeychainAccessUnavailableError(cause);
132
+ }
133
+ if (probe.state === 'unavailable') {
134
+ throw new KeychainAccessUnavailableError(probe.cause);
135
+ }
136
+ resolvedAccess =
137
+ probe.state === 'resolved'
138
+ ? keychainAccessFromDefaultGroup(probe.accessGroup)
139
+ : { mode: 'ungrouped' };
140
+ return resolvedAccess;
141
+ }
142
+ /** Forget the memoized access. Tests only. @internal */
143
+ function resetKeychainAccessForTests() {
144
+ resolvedAccess = null;
145
+ }
146
+ /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
147
+ function keychainAccessGroupValue(access, group) {
148
+ return access.mode === 'grouped' ? `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
149
+ }
150
+ function option(value) {
151
+ return value === undefined ? {} : { accessGroup: value };
152
+ }
153
+ /**
154
+ * The option bags a READ of `item` tries, in order: its own group, then the
155
+ * shared group, where every item lived before the split (release 4 of the doc
156
+ * drops that fallback). Empty when this app may not hold the item: the caller
157
+ * answers `null` without touching the keychain.
158
+ */
159
+ function keychainReadPlan(access, item) {
160
+ if (access.mode === 'ungrouped') {
161
+ return [{}];
162
+ }
163
+ if (!mayHoldKeychainItem(access.role, item)) {
164
+ return [];
165
+ }
166
+ const own = keychainGroupFor(item);
167
+ const plan = [option(keychainAccessGroupValue(access, own))];
168
+ if (own !== 'shared') {
169
+ plan.push(option(keychainAccessGroupValue(access, 'shared')));
170
+ }
171
+ return plan;
172
+ }
173
+ /**
174
+ * Every place a DELETE of `item` must reach: the read plan, so a delete never
175
+ * leaves a pre-split copy behind in the shared group.
176
+ */
177
+ function keychainDeletePlan(access, item) {
178
+ return keychainReadPlan(access, item);
179
+ }
180
+ /**
181
+ * The option a WRITE of `item` carries. THROWS when this app may not hold the
182
+ * item: an identity secret written by any app but Commons would land in that
183
+ * app's default group, a second holder nobody knows about.
184
+ */
185
+ function keychainWriteOption(access, item) {
186
+ if (access.mode === 'ungrouped') {
187
+ return {};
188
+ }
189
+ if (!mayHoldKeychainItem(access.role, item)) {
190
+ throw new Error(`This app (keychain role "${access.role}") may not hold a ${item} keychain item; only Commons writes the identity.`);
191
+ }
192
+ return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
193
+ }
194
+ /**
195
+ * The plan for an app's OWN items (its session credential, its settings, an
196
+ * imported wallet's phrase) in Commons and the Peable wallet, whose default
197
+ * group the split changed: written to the default group explicitly — where a
198
+ * write without a group goes anyway — and read from it FIRST, then from every
199
+ * group (`{}`), where all of them were written before. Without the explicit
200
+ * order a read could return whichever of two copies the keychain met first.
201
+ * Deletes reach every group, as they always did.
202
+ *
203
+ * Every other app (`member`) and Android/web keep the calls they always made.
204
+ */
205
+ function ownKeychainItemPlan(access) {
206
+ if (access.mode === 'ungrouped' || access.role === 'member') {
207
+ return { read: [{}], write: {}, delete: [{}] };
208
+ }
209
+ return {
210
+ read: [option(access.defaultGroup), {}],
211
+ write: option(access.defaultGroup),
212
+ delete: [{}],
213
+ };
214
+ }
215
+ /** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
216
+ async function readThroughPlan(store, key, options, plan) {
217
+ for (const group of plan) {
218
+ const value = await store.getItemAsync(key, { ...options, ...group });
219
+ if (value !== null && value !== undefined) {
220
+ return value;
221
+ }
222
+ }
223
+ return null;
224
+ }
225
+ /** Delete `key` from every location in `plan`. A thrown delete propagates. */
226
+ async function deleteThroughPlan(store, key, options, plan) {
227
+ for (const group of plan) {
228
+ await store.deleteItemAsync(key, { ...options, ...group });
229
+ }
230
+ }
231
+ /**
232
+ * `store` with every call routed to `item`'s groups for this app: reads walk
233
+ * the read plan, writes carry the write option (throwing for a role that may
234
+ * not hold the item), deletes reach every location. The constants of the
235
+ * wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
236
+ */
237
+ function groupedKeychainStore(store, access, item) {
238
+ const plan = keychainReadPlan(access, item);
239
+ const wrapped = {
240
+ getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, plan),
241
+ setItemAsync: async (key, value, options) => {
242
+ const group = keychainWriteOption(access, item);
243
+ await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
244
+ },
245
+ deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, plan),
246
+ };
247
+ return Object.assign(Object.create(store), wrapped);
248
+ }