@oxy.so/core 7.29.0 → 7.31.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.
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Retired identity keys: the keys an account HAD, kept on the device for
3
+ * spending only (oxy ADR 0061 D7 as amended 2026-11-06, Peable ADR 0002).
4
+ *
5
+ * The Peable wallet derives from the account's identity key (`deriveScopedSeed`
6
+ * for its HD wallet, `signSocialReceive` for its social-receive addresses), and
7
+ * coins keep arriving under a key after it is replaced: payments already
8
+ * broadcast settle against the key they were reserved under, and late payers
9
+ * and `@username` senders hold old addresses. So a personal rotation
10
+ * (`oxy.identity.rotateKey`) and an organization replacement keep the replaced
11
+ * private key here, in a ring per account — `personal`, and one per
12
+ * organization account id — instead of deleting it.
13
+ *
14
+ * A retired key is NEVER a way to sign in, publish, receive a new reservation or
15
+ * rotate: nothing here signs a challenge, and the identity host refuses
16
+ * `proveIdentity` / `describe` for one. It is reached exactly as the current key
17
+ * is, by derivation and never by export, through the `publicKey` selector of
18
+ * `KeyManager.deriveScopedSeed` / `signSocialReceive`:
19
+ *
20
+ * - iOS: the private key in the wallet keychain group (ADR 0062's
21
+ * `spending-key`: Commons and the Peable wallet only), beside the current
22
+ * private keys; the ring's index (public keys and dates only) in the same
23
+ * group, so the wallet can list it. Only Commons writes either.
24
+ * - Android: Commons' own expo-secure-store slot, mirrored into the identity
25
+ * signer store (`./identitySigner`) that Commons' identity host answers
26
+ * `keyPublicKey` requests from (wallet only). Every other app asks over IPC
27
+ * (`loadCommonsIdentityBridge`) and never gets the key.
28
+ *
29
+ * A retired phrase is not kept: the person wrote it down, and a phrase is a
30
+ * second copy of the key that buys the wallet nothing.
31
+ *
32
+ * A key enters the ring only from Commons: the key being replaced (rotation,
33
+ * organization replacement), or a previous recovery phrase the person adds,
34
+ * which Commons accepts only when Oxy's key history names it as one of the
35
+ * account's retired keys. Never into the current slot.
36
+ *
37
+ * Security: a retired key that was compromised is also the attacker's. The
38
+ * ring makes the coins under it spendable from Peable again; it cannot make
39
+ * them safe. A rotation forced by a compromise must be followed at once by the
40
+ * wallet's "move to your current key" sweep, which races the attacker.
41
+ *
42
+ * This module imports nothing from `./keyManager` (which imports it).
43
+ */
44
+ import { mnemonicToSeed, validateMnemonic } from '@scure/bip39';
45
+ import { wordlist } from '@scure/bip39/wordlists/english.js';
46
+ import { loadCommonsIdentityBridge, loadSecureStore, } from '@oxy.so/protocol';
47
+ import { deriveSecp256k1PublicKey, isValidSecp256k1PrivateKey, isValidSecp256k1PublicKey, normalizeSecp256k1PrivateKey, normalizeSecp256k1PublicKey, } from '@oxy.so/protocol/secp256k1';
48
+ import { logger } from '../logger/index.js';
49
+ import { groupedKeychainStore, resolveKeychainAccess } from '../utils/keychainAccessGroups.js';
50
+ import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
51
+ import { registeredIdentitySignerStore } from './identitySigner.js';
52
+ import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
53
+ import { isOrganizationAccountId } from './organizationKeys.js';
54
+ const PERSONAL_SCOPE = 'personal';
55
+ /** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
56
+ const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
57
+ const INDEX_KEYCHAIN_SERVICE = 'oxy_retired_identity_index';
58
+ const INDEX_STORAGE_KEY = 'oxy_retired_identity_index_v1';
59
+ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
60
+ /**
61
+ * The secure store for the ring. On iOS every item is a `spending-key`
62
+ * (ADR 0062): the private keys AND the index live in the wallet group, which
63
+ * only Commons and the Peable wallet can read, never the shared one. In
64
+ * Commons every access first awaits the access-group migration. On Android
65
+ * the grouping is a no-op and Commons' own services below apply.
66
+ */
67
+ async function ringStore() {
68
+ const raw = await loadSecureStore();
69
+ const access = await resolveKeychainAccess();
70
+ if (access.mode === 'grouped' && access.role === 'holder') {
71
+ await ensureKeychainGroupsMigrated();
72
+ }
73
+ return groupedKeychainStore(raw, access, 'spending-key');
74
+ }
75
+ /** The ring a selector names: `personal`, or the organization's account id. */
76
+ export function retiredKeyScope(selector) {
77
+ const accountId = selector?.accountId;
78
+ if (accountId === undefined)
79
+ return PERSONAL_SCOPE;
80
+ return isOrganizationAccountId(accountId) ? accountId : null;
81
+ }
82
+ function canonicalPublicKey(publicKey) {
83
+ try {
84
+ const normalized = normalizeSecp256k1PublicKey(publicKey.trim());
85
+ return PUBLIC_KEY.test(normalized) ? normalized : null;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ function publicKeyOf(privateKey) {
92
+ return deriveSecp256k1PublicKey(normalizeSecp256k1PrivateKey(privateKey));
93
+ }
94
+ function isHealthyPair(privateKey, publicKey) {
95
+ if (!isValidSecp256k1PrivateKey(privateKey) || !isValidSecp256k1PublicKey(publicKey)) {
96
+ return false;
97
+ }
98
+ try {
99
+ return publicKeyOf(privateKey) === normalizeSecp256k1PublicKey(publicKey);
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ }
105
+ const privateKeyName = (scope, publicKey) => `oxy_retired_identity_private_key.${scope}.${publicKey}`;
106
+ /** The options of a private half: on iOS none but accessibility (the group comes from {@link ringStore}). */
107
+ function privateOptions(store, forWrite) {
108
+ if (isIOS()) {
109
+ return forWrite ? { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY } : {};
110
+ }
111
+ return forWrite
112
+ ? {
113
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
114
+ keychainService: ANDROID_KEYCHAIN_SERVICE,
115
+ }
116
+ : { keychainService: ANDROID_KEYCHAIN_SERVICE };
117
+ }
118
+ /** The index: public keys and dates, beside the private halves. */
119
+ function indexOptions() {
120
+ return isIOS() ? {} : { keychainService: INDEX_KEYCHAIN_SERVICE };
121
+ }
122
+ function isIndexEntry(value) {
123
+ if (typeof value !== 'object' || value === null)
124
+ return false;
125
+ const entry = value;
126
+ return (typeof entry.scope === 'string' &&
127
+ (entry.scope === PERSONAL_SCOPE || isOrganizationAccountId(entry.scope)) &&
128
+ typeof entry.publicKey === 'string' &&
129
+ PUBLIC_KEY.test(entry.publicKey) &&
130
+ typeof entry.retiredAt === 'number' &&
131
+ Number.isFinite(entry.retiredAt));
132
+ }
133
+ async function readIndex(store) {
134
+ const raw = await store.getItemAsync(INDEX_STORAGE_KEY, indexOptions());
135
+ if (!raw)
136
+ return [];
137
+ try {
138
+ const parsed = JSON.parse(raw);
139
+ return Array.isArray(parsed) ? parsed.filter(isIndexEntry) : [];
140
+ }
141
+ catch (error) {
142
+ logger.warn('Retired key index unreadable; treating as empty', {
143
+ component: 'RetiredKeys',
144
+ error: error instanceof Error ? error.name : 'unknown',
145
+ });
146
+ return [];
147
+ }
148
+ }
149
+ async function writeIndex(store, entries) {
150
+ const unique = new Map();
151
+ for (const entry of entries) {
152
+ const id = `${entry.scope}.${entry.publicKey}`;
153
+ if (!unique.has(id))
154
+ unique.set(id, entry);
155
+ }
156
+ const sorted = [...unique.values()].sort((a, b) => a.retiredAt - b.retiredAt || a.publicKey.localeCompare(b.publicKey));
157
+ await store.setItemAsync(INDEX_STORAGE_KEY, JSON.stringify(sorted), indexOptions());
158
+ }
159
+ function toListed(entry) {
160
+ return entry.scope === PERSONAL_SCOPE
161
+ ? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
162
+ : { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
163
+ }
164
+ /** Whether this app writes the ring: Commons (Android: the signer store it registers; iOS: native). */
165
+ function assertMayWrite() {
166
+ if (isWeb())
167
+ throw new Error('retired key: only a native device holds one');
168
+ if (isAndroid() && !registeredIdentitySignerStore()?.writeRetired) {
169
+ throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
170
+ }
171
+ }
172
+ /**
173
+ * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
174
+ * key already kept stays, with its first date. Refuses a pair whose public key
175
+ * does not derive from its private key. Resolves once every copy confirmed it
176
+ * (Android: the signer store's read-back too), and throws otherwise, so a
177
+ * caller about to replace the key can stop before anything is lost.
178
+ */
179
+ export async function retireIdentityKey(key, selector) {
180
+ const scope = retiredKeyScope(selector);
181
+ if (!scope)
182
+ throw new Error('retired key: accountId must be a 24-hex or uuid account id');
183
+ assertMayWrite();
184
+ if (!isHealthyPair(key.privateKey, key.publicKey)) {
185
+ throw new Error('retired key: the public key does not derive from the private key');
186
+ }
187
+ const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
188
+ const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
189
+ const store = await ringStore();
190
+ await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
191
+ const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
192
+ if (readBack !== privateKey) {
193
+ throw new Error('retired key: the keychain did not confirm the key');
194
+ }
195
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
196
+ if (signer?.writeRetired && !(await signer.writeRetired(scope, privateKey, publicKey))) {
197
+ throw new Error(`The identity signer store (${signer.name}) did not confirm the retired key.`);
198
+ }
199
+ const index = await readIndex(store);
200
+ if (!index.some((entry) => entry.scope === scope && entry.publicKey === publicKey)) {
201
+ await writeIndex(store, [...index, { scope, publicKey, retiredAt: Date.now() }]);
202
+ }
203
+ }
204
+ /**
205
+ * The retired keys of `selector`'s account this app can reach, oldest first.
206
+ * iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
207
+ * (the wallet only), which leaves out the scope's current key. After an
208
+ * interrupted rotation the local list can include the current key: compare
209
+ * with the DID's `#key-1` before treating an entry as retired.
210
+ */
211
+ export async function listRetiredIdentityKeys(selector) {
212
+ const scope = retiredKeyScope(selector);
213
+ if (!scope || isWeb())
214
+ return [];
215
+ try {
216
+ if (isAndroid() && !registeredIdentitySignerStore()) {
217
+ const bridge = await loadCommonsIdentityBridge();
218
+ const keys = (await bridge?.listRetiredKeys?.(selector?.accountId)) ?? null;
219
+ return (keys ?? []).map((publicKey) => scope === PERSONAL_SCOPE
220
+ ? { publicKey, retiredAt: 0 }
221
+ : { publicKey, accountId: scope, retiredAt: 0 });
222
+ }
223
+ if (!isIOS() && !isAndroid())
224
+ return [];
225
+ const store = await ringStore();
226
+ return (await readIndex(store)).filter((entry) => entry.scope === scope).map(toListed);
227
+ }
228
+ catch (error) {
229
+ logger.warn('Failed to list retired keys', {
230
+ component: 'RetiredKeys',
231
+ error: error instanceof Error ? error.name : 'unknown',
232
+ });
233
+ return [];
234
+ }
235
+ }
236
+ /**
237
+ * The private half of a retired key, where this app may hold it: Commons (both
238
+ * platforms) and, on iOS, the keychain group. `null` on Android outside Commons
239
+ * (ask the identity host instead) and for a key not in the ring. Sign within
240
+ * the app only; never transmit it. @internal — `KeyManager` reads it for the
241
+ * `publicKey` selector.
242
+ */
243
+ export async function getRetiredPrivateKey(publicKey, selector) {
244
+ const scope = retiredKeyScope(selector);
245
+ const canonical = canonicalPublicKey(publicKey);
246
+ if (!scope || !canonical || isWeb())
247
+ return null;
248
+ try {
249
+ if (isAndroid()) {
250
+ const signer = registeredIdentitySignerStore();
251
+ if (!signer)
252
+ return null;
253
+ const store = await ringStore();
254
+ const local = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
255
+ if (local && isHealthyPair(local, canonical))
256
+ return normalizeSecp256k1PrivateKey(local);
257
+ // The signer store is a rung: a wiped expo-secure-store slot still has it.
258
+ const mirrored = (await signer.readRetired?.(scope, canonical)) ?? null;
259
+ return mirrored && isHealthyPair(mirrored.privateKey, canonical)
260
+ ? normalizeSecp256k1PrivateKey(mirrored.privateKey)
261
+ : null;
262
+ }
263
+ if (!isIOS())
264
+ return null;
265
+ const store = await ringStore();
266
+ const value = await store.getItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
267
+ return value && isHealthyPair(value, canonical) ? normalizeSecp256k1PrivateKey(value) : null;
268
+ }
269
+ catch (error) {
270
+ logger.warn('Failed to read a retired key', {
271
+ component: 'RetiredKeys',
272
+ error: error instanceof Error ? error.name : 'unknown',
273
+ });
274
+ return null;
275
+ }
276
+ }
277
+ /**
278
+ * Forget a retired key on this device: its private half, its signer copy and
279
+ * its index entry. Commons only, and only on the person's explicit request
280
+ * (their coins under it are then spendable only with its phrase again).
281
+ */
282
+ export async function forgetRetiredIdentityKey(publicKey, selector) {
283
+ const scope = retiredKeyScope(selector);
284
+ const canonical = canonicalPublicKey(publicKey);
285
+ if (!scope || !canonical)
286
+ throw new Error('retired key: invalid selector or public key');
287
+ assertMayWrite();
288
+ const store = await ringStore();
289
+ await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
290
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
291
+ await signer?.clearRetired?.(scope, canonical);
292
+ await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
293
+ }
294
+ /**
295
+ * Forget every retired key of one ring (the person's, or an organization's):
296
+ * called when the identity itself is deleted from this device, so a phone
297
+ * handed on keeps none of the person's old keys. Commons only. Resolves how
298
+ * many it removed.
299
+ */
300
+ export async function forgetRetiredIdentityKeys(selector) {
301
+ const scope = retiredKeyScope(selector);
302
+ if (!scope || isWeb())
303
+ return 0;
304
+ if (isAndroid() && !registeredIdentitySignerStore())
305
+ return 0;
306
+ const store = await ringStore();
307
+ const index = await readIndex(store);
308
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
309
+ let removed = 0;
310
+ for (const entry of index) {
311
+ if (entry.scope !== scope)
312
+ continue;
313
+ await store.deleteItemAsync(privateKeyName(scope, entry.publicKey), privateOptions(store, false));
314
+ await signer?.clearRetired?.(scope, entry.publicKey);
315
+ removed += 1;
316
+ }
317
+ await writeIndex(store, index.filter((entry) => entry.scope !== scope));
318
+ return removed;
319
+ }
320
+ /**
321
+ * Commons on Android: copy every retired key into the identity signer store
322
+ * again, so the identity host answers for it after that file was rebuilt (its
323
+ * self-heal leaves it empty). Call on every boot, beside `syncOrganizationKeys`.
324
+ * Resolves how many it mirrored. Elsewhere 0.
325
+ */
326
+ export async function syncRetiredKeys() {
327
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
328
+ if (!signer?.writeRetired || !signer.readRetired)
329
+ return 0;
330
+ const store = await ringStore();
331
+ let mirrored = 0;
332
+ for (const entry of await readIndex(store)) {
333
+ const privateKey = await store.getItemAsync(privateKeyName(entry.scope, entry.publicKey), privateOptions(store, false));
334
+ if (!privateKey || !isHealthyPair(privateKey, entry.publicKey))
335
+ continue;
336
+ const current = await signer.readRetired(entry.scope, entry.publicKey);
337
+ if (current?.publicKey.toLowerCase() === entry.publicKey)
338
+ continue;
339
+ if (await signer.writeRetired(entry.scope, normalizeSecp256k1PrivateKey(privateKey), entry.publicKey)) {
340
+ mirrored += 1;
341
+ }
342
+ }
343
+ return mirrored;
344
+ }
345
+ /**
346
+ * The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
347
+ * and organization-key derivation; 12–24 words). Stores nothing: the caller
348
+ * checks the public key against the account's key history (personal:
349
+ * `oxy.identity.keyHistory()`; organization: `oxy.accounts.identityKey.get`)
350
+ * and only then calls {@link retireIdentityKey}.
351
+ */
352
+ export async function retiredKeyFromPhrase(phrase) {
353
+ const normalized = phrase.trim().toLowerCase().split(/\s+/).join(' ');
354
+ const words = normalized.split(' ').length;
355
+ if (words < 12 || words > 24 || words % 3 !== 0 || !validateMnemonic(normalized, wordlist)) {
356
+ throw new Error('retired key: not a valid recovery phrase');
357
+ }
358
+ const seed = await mnemonicToSeed(normalized);
359
+ const privateKey = Array.from(seed.subarray(0, 32), (b) => b.toString(16).padStart(2, '0')).join('');
360
+ return { privateKey, publicKey: publicKeyOf(privateKey) };
361
+ }
@@ -46,6 +46,18 @@ export function matchesAuthState(actual, expected) {
46
46
  */
47
47
  export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
48
48
  export const AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = 'oxy.auth.automatic-identity-signin-suppressed.v1';
49
+ /**
50
+ * What `ownKeychainItemPlan` (ADR 0062 D6) must know about a key a native
51
+ * session store keeps — this module's three and the identity pin: every Oxy app
52
+ * writes it under the same name, so on iOS a copy in the shared group may be
53
+ * another app's session and is never read as this app's. The sign-out marker
54
+ * can only withhold, so a shared copy of it still counts.
55
+ */
56
+ export function nativeSessionItemNames(key) {
57
+ return key === AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY
58
+ ? 'every-oxy-app-withholding'
59
+ : 'every-oxy-app';
60
+ }
49
61
  /**
50
62
  * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
51
63
  * `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
@@ -65,7 +65,7 @@ export { AccountDialogController, createAccountDialogController, } from './accou
65
65
  // device credential is `deviceId` + `deviceSecret`; the access token is re-minted
66
66
  // via `POST /session/device/token`.
67
67
  // ---------------------------------------------------------------------------
68
- export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './authStateStore.js';
68
+ export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, nativeSessionItemNames, AUTH_STATE_STORAGE_KEY, AUTH_STATE_TOKEN_STORAGE_KEY, AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY, } from './authStateStore.js';
69
69
  // The shared NATIVE DeviceSession credential — how several official apps on one
70
70
  // device end up on ONE `DeviceSession` and therefore one active context. It is an
71
71
  // ordinary revocable `deviceId` + `deviceSecret`, deliberately NOT the
@@ -7,6 +7,7 @@
7
7
  * | `<TEAM>.so.oxy.identity` | Commons only (listed FIRST) | `identity-secret` |
8
8
  * | `<TEAM>.so.oxy.wallet` | Commons, the Peable wallet | `spending-key` |
9
9
  * | `<TEAM>.group.so.oxy.shared` | every Oxy app | `shared` |
10
+ * | `<TEAM>.<bundle id>` | that app alone | `app` |
10
11
  *
11
12
  * - `identity-secret`: the personal root (primary and backup slots), the
12
13
  * personal and organization recovery phrases, the organization index. Only
@@ -16,14 +17,21 @@
16
17
  * (`deriveScopedSeed`, `signSocialReceive`). Commons writes them; the Peable
17
18
  * wallet reads them.
18
19
  * - `shared`: what every Oxy app may read: the shared public key and the
19
- * device-session credential.
20
+ * device-session credential (the one cross-app session join point).
21
+ * - `app`: an app's OWN items, readable by no other app: its session
22
+ * credential (`oxy.auth.v1` and the rest of the SDK's session store), its
23
+ * identity pin, its settings, the Peable wallet's imported phrases. The group
24
+ * is the app's application identifier, which Apple makes every app a member
25
+ * of; no app lists it and no other app can name it. Android's counterpart is
26
+ * the app's own storage (ADR 0062 D6).
20
27
  *
21
28
  * The ROLE of this binary comes from the keychain itself: an item written
22
29
  * without a group lands in the app's default group, the first entry of its
23
30
  * `keychain-access-groups` entitlement, and `@oxy.so/app-preset` lets only
24
31
  * Commons put the identity group first and only the Peable wallet the wallet
25
32
  * group. The same probe gives the team prefix, which `kSecAttrAccessGroup`
26
- * needs and JavaScript cannot otherwise see.
33
+ * needs and JavaScript cannot otherwise see; a second probe proves the app's
34
+ * own group (`<prefix><bundle id>`) is writable.
27
35
  *
28
36
  * Android and web have no access groups: there every helper here answers the
29
37
  * single-store behaviour (`ungrouped`), and Android's narrowing is Commons'
@@ -33,7 +41,7 @@
33
41
  * This module never imports the identity stack: services' device-session store
34
42
  * and Peable's wallet storage use it too.
35
43
  */
36
- import { loadKeychainDefaultAccessGroup } from '@oxy.so/protocol';
44
+ import { loadKeychainApplicationAccessGroup, loadKeychainDefaultAccessGroup, } from '@oxy.so/protocol';
37
45
  import { isIOS } from './platform.js';
38
46
  /** The group names after the team prefix, exactly as the entitlements list them. */
39
47
  export const KEYCHAIN_ACCESS_GROUPS = {
@@ -49,10 +57,6 @@ export class KeychainAccessUnavailableError extends Error {
49
57
  this.name = 'KeychainAccessUnavailableError';
50
58
  }
51
59
  }
52
- /**
53
- * The single place each item class is assigned a group. A new kind of item
54
- * (F1's retained keys, for one) picks its class and reuses everything below.
55
- */
56
60
  export function keychainGroupFor(item) {
57
61
  switch (item) {
58
62
  case 'identity-secret':
@@ -61,6 +65,8 @@ export function keychainGroupFor(item) {
61
65
  return 'wallet';
62
66
  case 'shared':
63
67
  return 'shared';
68
+ case 'app':
69
+ return 'app';
64
70
  }
65
71
  }
66
72
  /** Whether `role` holds the group `item` lives in. */
@@ -71,11 +77,14 @@ export function mayHoldKeychainItem(role, item) {
71
77
  case 'wallet':
72
78
  return item !== 'identity-secret';
73
79
  case 'member':
74
- return item === 'shared';
80
+ return item === 'shared' || item === 'app';
75
81
  }
76
82
  }
77
- /** Parse the default access group the probe reported. Exported for tests. */
78
- export function keychainAccessFromDefaultGroup(defaultGroup) {
83
+ /**
84
+ * Parse the default access group the probe reported, and the app's own group
85
+ * when the second probe proved it. Exported for tests.
86
+ */
87
+ export function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
79
88
  const dot = defaultGroup.indexOf('.');
80
89
  if (dot <= 0 || dot === defaultGroup.length - 1) {
81
90
  // Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
@@ -88,7 +97,9 @@ export function keychainAccessFromDefaultGroup(defaultGroup) {
88
97
  : group === KEYCHAIN_ACCESS_GROUPS.wallet
89
98
  ? 'wallet'
90
99
  : 'member';
91
- return { mode: 'grouped', prefix, defaultGroup, role };
100
+ return appGroup?.startsWith(prefix)
101
+ ? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
102
+ : { mode: 'grouped', prefix, defaultGroup, role };
92
103
  }
93
104
  let resolvedAccess = null;
94
105
  /**
@@ -116,28 +127,62 @@ export async function resolveKeychainAccess() {
116
127
  if (probe.state === 'unavailable') {
117
128
  throw new KeychainAccessUnavailableError(probe.cause);
118
129
  }
119
- resolvedAccess =
120
- probe.state === 'resolved'
121
- ? keychainAccessFromDefaultGroup(probe.accessGroup)
122
- : { mode: 'ungrouped' };
130
+ if (probe.state !== 'resolved') {
131
+ resolvedAccess = { mode: 'ungrouped' };
132
+ return resolvedAccess;
133
+ }
134
+ const access = keychainAccessFromDefaultGroup(probe.accessGroup);
135
+ if (access.mode === 'ungrouped') {
136
+ resolvedAccess = access;
137
+ return resolvedAccess;
138
+ }
139
+ // The app's own group. A thrown probe is NOT degraded to "no app group": this
140
+ // process would then write the app's items to the old place while the next
141
+ // one reads the new place first, and see a stale value.
142
+ let own;
143
+ try {
144
+ own = await loadKeychainApplicationAccessGroup(access.prefix);
145
+ }
146
+ catch (cause) {
147
+ throw new KeychainAccessUnavailableError(cause);
148
+ }
149
+ if (own.state === 'unavailable') {
150
+ throw new KeychainAccessUnavailableError(own.cause);
151
+ }
152
+ resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
123
153
  return resolvedAccess;
124
154
  }
125
155
  /** Forget the memoized access. Tests only. @internal */
126
156
  export function resetKeychainAccessForTests() {
127
157
  resolvedAccess = null;
128
158
  }
129
- /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
159
+ /**
160
+ * The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
161
+ * group" (the app's default): always when ungrouped, and for `app` on a build
162
+ * that cannot place items there (see {@link ownKeychainItemPlan}).
163
+ */
130
164
  export function keychainAccessGroupValue(access, group) {
131
- return access.mode === 'grouped' ? `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
165
+ if (access.mode === 'ungrouped') {
166
+ return undefined;
167
+ }
168
+ if (group === 'app') {
169
+ // Commons' and the Peable wallet's default groups are theirs (no other app
170
+ // writes its own items there), so a build without the app-group probe
171
+ // keeps its own items there, as ADR 0062 D3 put them. Every other app's
172
+ // default is the shared group: no group, the calls it always made.
173
+ return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
174
+ }
175
+ return `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}`;
132
176
  }
133
177
  function option(value) {
134
178
  return value === undefined ? {} : { accessGroup: value };
135
179
  }
136
180
  /**
137
- * The option bags a READ of `item` tries, in order: its own group, then the
138
- * shared group, where every item lived before the split (release 4 of the doc
139
- * drops that fallback). Empty when this app may not hold the item: the caller
140
- * answers `null` without touching the keychain.
181
+ * The option bags a READ of `item` tries, in order: its own group, then, for
182
+ * an identity secret or a spending key, the shared group, where those lived
183
+ * before the split (release 4 of the doc drops that fallback). Empty when this
184
+ * app may not hold the item: the caller answers `null` without touching the
185
+ * keychain. An app's own items (`app`) take {@link ownKeychainItemPlan}.
141
186
  */
142
187
  export function keychainReadPlan(access, item) {
143
188
  if (access.mode === 'ungrouped') {
@@ -148,7 +193,8 @@ export function keychainReadPlan(access, item) {
148
193
  }
149
194
  const own = keychainGroupFor(item);
150
195
  const plan = [option(keychainAccessGroupValue(access, own))];
151
- if (own !== 'shared') {
196
+ if (own === 'identity' || own === 'wallet') {
197
+ // Where the split moved these items from.
152
198
  plan.push(option(keychainAccessGroupValue(access, 'shared')));
153
199
  }
154
200
  return plan;
@@ -175,25 +221,50 @@ export function keychainWriteOption(access, item) {
175
221
  return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
176
222
  }
177
223
  /**
178
- * The plan for an app's OWN items (its session credential, its settings, an
179
- * imported wallet's phrase) in Commons and the Peable wallet, whose default
180
- * group the split changed: written to the default group explicitly — where a
181
- * write without a group goes anyway — and read from it FIRST, then from every
182
- * group (`{}`), where all of them were written before. Without the explicit
183
- * order a read could return whichever of two copies the keychain met first.
184
- * Deletes reach every group, as they always did.
224
+ * The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
225
+ * app's own group (`<prefix><bundle id>`), which no other app can read, and
226
+ * read from it FIRST. Then:
227
+ *
228
+ * - from where this app kept them before: Commons' and the Peable wallet's
229
+ * default group (D3), which no other app writes its own items to;
230
+ * - for `this-app` names, from every group (`{}`), where they were before the
231
+ * split; deletes reach every group;
232
+ * - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
233
+ * and deletes reach exactly the read locations, earlier ones first, so a
234
+ * failed delete never leaves an older copy as the only one;
235
+ * - for `every-oxy-app-withholding` names, the shared group last on read and
236
+ * first on delete.
185
237
  *
186
- * Every other app (`member`) and Android/web keep the calls they always made.
238
+ * Nothing is copied or deleted to migrate: the next write lands in the app
239
+ * group and the read order makes it win, so a crash or a failure at any step
240
+ * leaves the value the app had.
241
+ *
242
+ * A native build whose module predates the app-group probe (`appGroup`
243
+ * absent) keeps its items where that build put them: Commons and the wallet in
244
+ * their default group, every other app the calls it always made (the one
245
+ * shared item, until that app's next native build). Android and web: no
246
+ * options.
187
247
  */
188
- export function ownKeychainItemPlan(access) {
189
- if (access.mode === 'ungrouped' || access.role === 'member') {
248
+ export function ownKeychainItemPlan(access, names = 'this-app') {
249
+ if (access.mode === 'ungrouped') {
190
250
  return { read: [{}], write: {}, delete: [{}] };
191
251
  }
192
- return {
193
- read: [option(access.defaultGroup), {}],
194
- write: option(access.defaultGroup),
195
- delete: [{}],
196
- };
252
+ const own = keychainAccessGroupValue(access, keychainGroupFor('app'));
253
+ if (own === undefined) {
254
+ return { read: [{}], write: {}, delete: [{}] };
255
+ }
256
+ const earlier = access.role !== 'member' && own !== access.defaultGroup ? [option(access.defaultGroup)] : [];
257
+ const mine = [option(own), ...earlier];
258
+ switch (names) {
259
+ case 'this-app':
260
+ return { read: [...mine, {}], write: option(own), delete: [{}] };
261
+ case 'every-oxy-app':
262
+ return { read: mine, write: option(own), delete: [...mine].reverse() };
263
+ case 'every-oxy-app-withholding': {
264
+ const read = [...mine, option(keychainAccessGroupValue(access, 'shared'))];
265
+ return { read, write: option(own), delete: [...read].reverse() };
266
+ }
267
+ }
197
268
  }
198
269
  /** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
199
270
  export async function readThroughPlan(store, key, options, plan) {
@@ -1,3 +1,4 @@
1
+ import { type IdentityKeyHistory } from '@oxy.so/contracts/client';
1
2
  import type { AuthMethodsResponse, BackupStatusResponse, DidDocument, DomainVerificationInstructions, ExportBundle, IdentityLinkCreateResponse, IdentityLinkState, IdentityRootStatus, OxySignedRecordType, ReauthProof, SignedRecordEnvelope, VerifiedDomain } from '@oxy.so/contracts';
2
3
  import type { OxyContext } from '../client/context';
3
4
  import type { PendingIdentityResult } from '../crypto/recoveryPhrase';
@@ -176,6 +177,15 @@ export declare class IdentityApi {
176
177
  * user's derived DID document (which embeds auth methods + verified domains).
177
178
  */
178
179
  private invalidate;
180
+ /**
181
+ * The signed-in personal account's current root (`null` without one) and
182
+ * every root a rotation replaced, oldest first, all canonical (uncompressed,
183
+ * lowercase). Commons reads it to accept a previous recovery phrase only when
184
+ * it derives a key that WAS this account's root. A retired root is never any
185
+ * account's again. First-party only; a non-personal account is refused
186
+ * (`IDENTITY_NOT_PERSONAL_ACCOUNT`).
187
+ */
188
+ keyHistory(): Promise<IdentityKeyHistory>;
179
189
  }
180
190
  /** `oxy.identity.links` — joining Commons' root to an account (ADR 0029 D3). */
181
191
  export declare class IdentityLinksApi {