@oxy.so/core 7.31.0 → 7.32.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 (32) hide show
  1. package/dist/cjs/api/identity.js +146 -23
  2. package/dist/cjs/crypto/identityMarker.js +15 -0
  3. package/dist/cjs/crypto/index.js +7 -2
  4. package/dist/cjs/crypto/keyManager.js +7 -10
  5. package/dist/cjs/crypto/keychainGroupMigration.js +327 -160
  6. package/dist/cjs/crypto/organizationKeys.js +33 -5
  7. package/dist/cjs/crypto/retiredKeys.js +80 -18
  8. package/dist/cjs/utils/keychainAccessGroups.js +111 -25
  9. package/dist/esm/api/identity.js +146 -23
  10. package/dist/esm/crypto/identityMarker.js +14 -0
  11. package/dist/esm/crypto/index.js +3 -3
  12. package/dist/esm/crypto/keyManager.js +9 -12
  13. package/dist/esm/crypto/keychainGroupMigration.js +327 -162
  14. package/dist/esm/crypto/organizationKeys.js +34 -6
  15. package/dist/esm/crypto/retiredKeys.js +76 -17
  16. package/dist/esm/utils/keychainAccessGroups.js +107 -24
  17. package/dist/types/api/identity.d.ts +36 -0
  18. package/dist/types/crypto/identityMarker.d.ts +8 -0
  19. package/dist/types/crypto/index.d.ts +3 -3
  20. package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
  21. package/dist/types/crypto/organizationKeys.d.ts +4 -2
  22. package/dist/types/crypto/retiredKeys.d.ts +30 -4
  23. package/dist/types/utils/keychainAccessGroups.d.ts +41 -11
  24. package/package.json +1 -1
  25. package/src/api/identity.ts +185 -40
  26. package/src/crypto/identityMarker.ts +15 -0
  27. package/src/crypto/index.ts +8 -1
  28. package/src/crypto/keyManager.ts +9 -15
  29. package/src/crypto/keychainGroupMigration.ts +390 -185
  30. package/src/crypto/organizationKeys.ts +38 -6
  31. package/src/crypto/retiredKeys.ts +87 -17
  32. package/src/utils/keychainAccessGroups.ts +121 -24
@@ -32,7 +32,8 @@ import { logger } from '../logger/index.js';
32
32
  import { groupedKeychainStore, resolveKeychainAccess, } from '../utils/keychainAccessGroups.js';
33
33
  import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
34
34
  import { registeredIdentitySignerStore } from './identitySigner.js';
35
- import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
35
+ import { prepareKeychainAccess } from './keychainGroupMigration.js';
36
+ import { retireIdentityKey } from './retiredKeys.js';
36
37
  import { ORGANIZATION_ANDROID_KEYCHAIN_SERVICE as ANDROID_KEYCHAIN_SERVICE, ORGANIZATION_INDEX_KEYCHAIN_SERVICE as INDEX_KEYCHAIN_SERVICE, ORGANIZATION_INDEX_STORAGE_KEY as INDEX_STORAGE_KEY, ORGANIZATION_MNEMONIC_KEYCHAIN_SERVICE as MNEMONIC_KEYCHAIN_SERVICE, isOrganizationAccountId, organizationMnemonicName as mnemonicName, organizationPrivateKeyName as privateKeyName, organizationPublicKeyName as publicKeyName, serializeOrganizationIndex, } from './keychainItems.js';
37
38
  export { isOrganizationAccountId };
38
39
  const ORGANIZATION_PHRASE_WORDS = 24;
@@ -102,9 +103,7 @@ export async function organizationKeyFromPhrase(phrase) {
102
103
  async function secureStore(item) {
103
104
  const raw = await loadSecureStore();
104
105
  const access = await resolveKeychainAccess();
105
- if (access.mode === 'grouped' && access.role === 'holder') {
106
- await ensureKeychainGroupsMigrated();
107
- }
106
+ await prepareKeychainAccess(access);
108
107
  return groupedKeychainStore(raw, access, item);
109
108
  }
110
109
  const pairStore = () => secureStore('spending-key');
@@ -195,8 +194,10 @@ async function readLocalPair(accountId) {
195
194
  * it; on iOS only Commons holds the groups it writes (any other app throws).
196
195
  *
197
196
  * Refuses a pair whose public key does not derive from its private key, and a
198
- * phrase that does not derive the pair. Overwrites whatever this device held for
199
- * the organization (a replaced key is the caller's decision, ADR 0061 D7).
197
+ * phrase that does not derive the pair. A DIFFERENT key this device already
198
+ * held for the organization is first kept in the organization's retired ring
199
+ * (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
200
+ * cannot be confirmed nothing is overwritten and this throws.
200
201
  */
201
202
  export async function storeOrganizationKey(accountId, key) {
202
203
  assertAccountId(accountId);
@@ -218,6 +219,18 @@ export async function storeOrganizationKey(accountId, key) {
218
219
  throw new Error('On Android only Commons holds organization keys; this app has no identity signer store for them.');
219
220
  }
220
221
  const store = await pairStore();
222
+ const previous = await heldPrivateKey(store, accountId);
223
+ if (previous) {
224
+ const previousPublic = deriveSecp256k1PublicKey(previous);
225
+ if (previousPublic !== publicKey) {
226
+ try {
227
+ await retireIdentityKey({ privateKey: previous, publicKey: previousPublic }, { accountId });
228
+ }
229
+ catch (error) {
230
+ throw new Error(`organization key: the key this device held could not be kept as a previous key, so it was not replaced (${error instanceof Error ? error.message : 'unknown'})`);
231
+ }
232
+ }
233
+ }
221
234
  await store.setItemAsync(privateKeyName(accountId), privateKey, pairOptions(store, true));
222
235
  await store.setItemAsync(publicKeyName(accountId), publicKey, pairOptions(store, false));
223
236
  if (signer?.writeOrganization &&
@@ -298,6 +311,21 @@ export async function listOrganizationKeys() {
298
311
  return [];
299
312
  return readIndex(await indexStore());
300
313
  }
314
+ /**
315
+ * The private key this device holds for `accountId`, whatever its public half
316
+ * says (an unhealthy pair is still a key coins may sit under): the slot, then
317
+ * on Android the signer store's copy. `null` when there is none.
318
+ */
319
+ async function heldPrivateKey(store, accountId) {
320
+ const local = await store.getItemAsync(privateKeyName(accountId), pairOptions(store, false));
321
+ if (local && isValidSecp256k1PrivateKey(local))
322
+ return normalizeSecp256k1PrivateKey(local);
323
+ const signer = isAndroid() ? registeredIdentitySignerStore() : null;
324
+ const mirrored = (await signer?.readOrganization?.(accountId)) ?? null;
325
+ return mirrored && isValidSecp256k1PrivateKey(mirrored.privateKey)
326
+ ? normalizeSecp256k1PrivateKey(mirrored.privateKey)
327
+ : null;
328
+ }
301
329
  /** Forget an organization key on this device: its pair, its phrase, its signer copy and its index entry. */
302
330
  export async function deleteOrganizationKey(accountId) {
303
331
  assertAccountId(accountId);
@@ -49,8 +49,8 @@ import { logger } from '../logger/index.js';
49
49
  import { groupedKeychainStore, resolveKeychainAccess } from '../utils/keychainAccessGroups.js';
50
50
  import { isAndroid, isIOS, isWeb } from '../utils/platform.js';
51
51
  import { registeredIdentitySignerStore } from './identitySigner.js';
52
- import { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
53
- import { isOrganizationAccountId } from './organizationKeys.js';
52
+ import { isOrganizationAccountId } from './keychainItems.js';
53
+ import { prepareKeychainAccess } from './keychainGroupMigration.js';
54
54
  const PERSONAL_SCOPE = 'personal';
55
55
  /** Android, Commons-local: its own Keystore-backed slot, shared with no other key. */
56
56
  const ANDROID_KEYCHAIN_SERVICE = 'oxy_retired_identity';
@@ -61,15 +61,15 @@ const PUBLIC_KEY = /^04[0-9a-f]{128}$/;
61
61
  * The secure store for the ring. On iOS every item is a `spending-key`
62
62
  * (ADR 0062): the private keys AND the index live in the wallet group, which
63
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.
64
+ * Commons every access first awaits the access-group migration (`gate: false`
65
+ * is the migration itself, keeping a pre-split copy). On Android the grouping
66
+ * is a no-op and Commons' own services below apply.
66
67
  */
67
- async function ringStore() {
68
+ async function ringStore(gate = true) {
68
69
  const raw = await loadSecureStore();
69
70
  const access = await resolveKeychainAccess();
70
- if (access.mode === 'grouped' && access.role === 'holder') {
71
- await ensureKeychainGroupsMigrated();
72
- }
71
+ if (gate)
72
+ await prepareKeychainAccess(access);
73
73
  return groupedKeychainStore(raw, access, 'spending-key');
74
74
  }
75
75
  /** The ring a selector names: `personal`, or the organization's account id. */
@@ -161,13 +161,24 @@ function toListed(entry) {
161
161
  ? { publicKey: entry.publicKey, retiredAt: entry.retiredAt }
162
162
  : { publicKey: entry.publicKey, accountId: entry.scope, retiredAt: entry.retiredAt };
163
163
  }
164
- /** Whether this app writes the ring: Commons (Android: the signer store it registers; iOS: native). */
165
- function assertMayWrite() {
164
+ /**
165
+ * Whether this app writes the ring: Commons only (Android: the signer store it
166
+ * registers; iOS: the keychain role `holder`). The Peable wallet holds the
167
+ * wallet group too, but only reads, derives and signs: a key the wallet could
168
+ * add to the ring is a key it could choose (R4-14).
169
+ */
170
+ async function assertMayWrite() {
166
171
  if (isWeb())
167
172
  throw new Error('retired key: only a native device holds one');
168
173
  if (isAndroid() && !registeredIdentitySignerStore()?.writeRetired) {
169
174
  throw new Error('On Android only Commons holds retired keys; this app has no identity signer store for them.');
170
175
  }
176
+ if (isIOS()) {
177
+ const access = await resolveKeychainAccess();
178
+ if (access.mode === 'grouped' && access.role !== 'holder') {
179
+ throw new Error('On iOS only Commons writes retired keys; this app may only read them.');
180
+ }
181
+ }
171
182
  }
172
183
  /**
173
184
  * Keep `key` in the ring of `selector`'s account. Commons only. Idempotent: a
@@ -177,16 +188,27 @@ function assertMayWrite() {
177
188
  * caller about to replace the key can stop before anything is lost.
178
189
  */
179
190
  export async function retireIdentityKey(key, selector) {
191
+ await keepRetired(key, selector, true);
192
+ }
193
+ /**
194
+ * {@link retireIdentityKey} from inside the iOS access-group migration, which
195
+ * keeps a replaced pre-split copy before deleting it from the shared group and
196
+ * cannot await itself. @internal
197
+ */
198
+ export async function retireIdentityKeyDuringMigration(key, selector) {
199
+ await keepRetired(key, selector, false);
200
+ }
201
+ async function keepRetired(key, selector, gate) {
180
202
  const scope = retiredKeyScope(selector);
181
203
  if (!scope)
182
204
  throw new Error('retired key: accountId must be a 24-hex or uuid account id');
183
- assertMayWrite();
205
+ await assertMayWrite();
184
206
  if (!isHealthyPair(key.privateKey, key.publicKey)) {
185
207
  throw new Error('retired key: the public key does not derive from the private key');
186
208
  }
187
209
  const privateKey = normalizeSecp256k1PrivateKey(key.privateKey);
188
210
  const publicKey = normalizeSecp256k1PublicKey(key.publicKey);
189
- const store = await ringStore();
211
+ const store = await ringStore(gate);
190
212
  await store.setItemAsync(privateKeyName(scope, publicKey), privateKey, privateOptions(store, true));
191
213
  const readBack = await store.getItemAsync(privateKeyName(scope, publicKey), privateOptions(store, false));
192
214
  if (readBack !== privateKey) {
@@ -284,7 +306,7 @@ export async function forgetRetiredIdentityKey(publicKey, selector) {
284
306
  const canonical = canonicalPublicKey(publicKey);
285
307
  if (!scope || !canonical)
286
308
  throw new Error('retired key: invalid selector or public key');
287
- assertMayWrite();
309
+ await assertMayWrite();
288
310
  const store = await ringStore();
289
311
  await store.deleteItemAsync(privateKeyName(scope, canonical), privateOptions(store, false));
290
312
  const signer = isAndroid() ? registeredIdentitySignerStore() : null;
@@ -292,10 +314,12 @@ export async function forgetRetiredIdentityKey(publicKey, selector) {
292
314
  await writeIndex(store, (await readIndex(store)).filter((entry) => !(entry.scope === scope && entry.publicKey === canonical)));
293
315
  }
294
316
  /**
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.
317
+ * Forget every retired key of one ring (the person's, or an organization's).
318
+ * Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
319
+ * delete-account flow), which tells them the coins go with it. Deleting the
320
+ * identity from this device — "Start over", a forced delete — never calls it:
321
+ * the ring is money, and the same person restoring their identity later must
322
+ * find it (R4-4). Resolves how many it removed.
299
323
  */
300
324
  export async function forgetRetiredIdentityKeys(selector) {
301
325
  const scope = retiredKeyScope(selector);
@@ -342,6 +366,41 @@ export async function syncRetiredKeys() {
342
366
  }
343
367
  return mirrored;
344
368
  }
369
+ /**
370
+ * Every retired key of `selector`'s ring WITH its private half, for the
371
+ * encrypted identity backup (`oxy.identity.backup.create`), which carries them
372
+ * under the CURRENT phrase so a lost phone costs no more than the current
373
+ * phrase does (R4-4). Commons only; an entry whose private half is missing or
374
+ * does not derive its public key is left out. Never log or transmit the result
375
+ * except inside that encrypted envelope.
376
+ */
377
+ export async function exportRetiredIdentityKeys(selector) {
378
+ const scope = retiredKeyScope(selector);
379
+ if (!scope || isWeb())
380
+ return [];
381
+ await assertMayWrite();
382
+ const out = [];
383
+ for (const entry of await listRetiredIdentityKeys(selector)) {
384
+ const privateKey = await getRetiredPrivateKey(entry.publicKey, selector);
385
+ if (privateKey)
386
+ out.push({ privateKey, publicKey: entry.publicKey, retiredAt: entry.retiredAt });
387
+ }
388
+ return out;
389
+ }
390
+ /**
391
+ * The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
392
+ * for a person whose old identity was imported as a private key and so has no
393
+ * phrase to re-enter. Stores nothing: the caller checks the public key against
394
+ * the account's key history and only then calls {@link retireIdentityKey}.
395
+ */
396
+ export function retiredKeyFromPrivateKey(privateKeyHex) {
397
+ const trimmed = privateKeyHex.trim().toLowerCase().replace(/^0x/, '');
398
+ if (!/^[0-9a-f]{64}$/.test(trimmed) || !isValidSecp256k1PrivateKey(trimmed)) {
399
+ throw new Error('retired key: not a valid private key');
400
+ }
401
+ const privateKey = normalizeSecp256k1PrivateKey(trimmed);
402
+ return { privateKey, publicKey: publicKeyOf(privateKey) };
403
+ }
345
404
  /**
346
405
  * The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
347
406
  * and organization-key derivation; 12–24 words). Stores nothing: the caller
@@ -49,6 +49,15 @@ export const KEYCHAIN_ACCESS_GROUPS = {
49
49
  wallet: 'so.oxy.wallet',
50
50
  shared: 'group.so.oxy.shared',
51
51
  };
52
+ /**
53
+ * The suffix a DEVELOPMENT build's restricted groups carry (`so.oxy.commons.dev`,
54
+ * `to.peable.app.dev`: `so.oxy.identity.dev`, `so.oxy.wallet.dev`). A dev build
55
+ * sits beside the production app on the same phone, is signed by the same team
56
+ * and runs JavaScript from a dev server, so it never shares a restricted group
57
+ * with production (`@oxy.so/app-preset` enforces the entitlements). The shared
58
+ * group has no variant: it holds nothing secret.
59
+ */
60
+ export const DEV_KEYCHAIN_GROUP_SUFFIX = '.dev';
52
61
  /** Thrown while the probe cannot answer (before first unlock). Transient. */
53
62
  export class KeychainAccessUnavailableError extends Error {
54
63
  constructor(cause) {
@@ -69,6 +78,16 @@ export function keychainGroupFor(item) {
69
78
  return 'app';
70
79
  }
71
80
  }
81
+ /**
82
+ * Whether `role` may WRITE `item`. Only Commons writes a secret: the Peable
83
+ * wallet reads, derives and signs with spending keys (and the retired ring)
84
+ * but never adds one — a key the wallet could add is one the wallet could
85
+ * choose. (The keychain itself lets every app in the wallet group write it;
86
+ * this is the SDK's half, and readers check what they find, ADR 0062.)
87
+ */
88
+ export function mayWriteKeychainItem(role, item) {
89
+ return role === 'holder' || item === 'shared' || item === 'app';
90
+ }
72
91
  /** Whether `role` holds the group `item` lives in. */
73
92
  export function mayHoldKeychainItem(role, item) {
74
93
  switch (role) {
@@ -92,14 +111,18 @@ export function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
92
111
  }
93
112
  const prefix = defaultGroup.slice(0, dot + 1);
94
113
  const group = defaultGroup.slice(dot + 1);
95
- const role = group === KEYCHAIN_ACCESS_GROUPS.identity
114
+ const dev = group.endsWith(DEV_KEYCHAIN_GROUP_SUFFIX);
115
+ const base = dev ? group.slice(0, -DEV_KEYCHAIN_GROUP_SUFFIX.length) : group;
116
+ const role = base === KEYCHAIN_ACCESS_GROUPS.identity
96
117
  ? 'holder'
97
- : group === KEYCHAIN_ACCESS_GROUPS.wallet
118
+ : base === KEYCHAIN_ACCESS_GROUPS.wallet
98
119
  ? 'wallet'
99
120
  : 'member';
121
+ // A `.dev` suffix on anything but a restricted group is not a variant.
122
+ const variant = dev && role !== 'member' ? { variant: DEV_KEYCHAIN_GROUP_SUFFIX } : {};
100
123
  return appGroup?.startsWith(prefix)
101
- ? { mode: 'grouped', prefix, defaultGroup, role, appGroup }
102
- : { mode: 'grouped', prefix, defaultGroup, role };
124
+ ? { mode: 'grouped', prefix, defaultGroup, role, appGroup, ...variant }
125
+ : { mode: 'grouped', prefix, defaultGroup, role, ...variant };
103
126
  }
104
127
  let resolvedAccess = null;
105
128
  /**
@@ -152,9 +175,11 @@ export async function resolveKeychainAccess() {
152
175
  resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
153
176
  return resolvedAccess;
154
177
  }
155
- /** Forget the memoized access. Tests only. @internal */
178
+ /** Forget the memoized access and the fallback state. Tests only. @internal */
156
179
  export function resetKeychainAccessForTests() {
157
180
  resolvedAccess = null;
181
+ holderSharedFallback = false;
182
+ walletSharedFallbackClosed = false;
158
183
  }
159
184
  /**
160
185
  * The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
@@ -172,17 +197,54 @@ export function keychainAccessGroupValue(access, group) {
172
197
  // default is the shared group: no group, the calls it always made.
173
198
  return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
174
199
  }
175
- return `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}`;
200
+ const variant = group === 'shared' ? '' : (access.variant ?? '');
201
+ return `${access.prefix}${KEYCHAIN_ACCESS_GROUPS[group]}${variant}`;
202
+ }
203
+ /**
204
+ * Whether a SECRET read may still fall back to the pre-split shared group
205
+ * (R4-7). Every Oxy app can write that group, and keychain items survive an
206
+ * uninstall, so a secret found there is one Commons did not necessarily write.
207
+ *
208
+ * - Commons (`holder`): CLOSED, except while its one-shot migration has proved
209
+ * the shared copies are its own (they carry the key its sandbox marker
210
+ * records) and could not move them all yet; the migration opens it for that
211
+ * launch only ({@link setHolderSharedSecretFallback}).
212
+ * - The Peable wallet: open until Commons' migration has finished (the
213
+ * completion record Commons mirrors into the wallet group), because until
214
+ * then an upgraded phone's keys are still in the shared group; closed for
215
+ * good once it has seen that record ({@link closeWalletSharedSecretFallback}).
216
+ */
217
+ let holderSharedFallback = false;
218
+ let walletSharedFallbackClosed = false;
219
+ /** The migration's switch for Commons' fallback. @internal */
220
+ export function setHolderSharedSecretFallback(open) {
221
+ holderSharedFallback = open;
222
+ }
223
+ /** The wallet saw Commons' completion record: no shared fallback again in this process. @internal */
224
+ export function closeWalletSharedSecretFallback() {
225
+ walletSharedFallbackClosed = true;
226
+ }
227
+ function sharedFallbackOpen(access) {
228
+ switch (access.role) {
229
+ case 'holder':
230
+ return holderSharedFallback;
231
+ case 'wallet':
232
+ return !walletSharedFallbackClosed;
233
+ case 'member':
234
+ return false;
235
+ }
176
236
  }
177
237
  function option(value) {
178
238
  return value === undefined ? {} : { accessGroup: value };
179
239
  }
180
240
  /**
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}.
241
+ * The option bags a READ of `item` tries, in order: its own group, then — for
242
+ * an identity secret or a spending key, and only while
243
+ * {@link sharedFallbackOpen} says so — the shared group, where those lived
244
+ * before the split. Empty when this app may not hold the item: the caller
245
+ * answers `null` without touching the keychain. Evaluated per read: the
246
+ * fallback closes during a process's life. An app's own items (`app`) take
247
+ * {@link ownKeychainItemPlan}.
186
248
  */
187
249
  export function keychainReadPlan(access, item) {
188
250
  if (access.mode === 'ungrouped') {
@@ -193,18 +255,29 @@ export function keychainReadPlan(access, item) {
193
255
  }
194
256
  const own = keychainGroupFor(item);
195
257
  const plan = [option(keychainAccessGroupValue(access, own))];
196
- if (own === 'identity' || own === 'wallet') {
197
- // Where the split moved these items from.
258
+ if ((own === 'identity' || own === 'wallet') && sharedFallbackOpen(access)) {
198
259
  plan.push(option(keychainAccessGroupValue(access, 'shared')));
199
260
  }
200
261
  return plan;
201
262
  }
202
263
  /**
203
- * Every place a DELETE of `item` must reach: the read plan, so a delete never
204
- * leaves a pre-split copy behind in the shared group.
264
+ * Every place a DELETE of `item` must reach: its own group AND the shared
265
+ * group, whatever the read fallback says, so a delete never leaves a copy
266
+ * behind where every Oxy app can read it.
205
267
  */
206
268
  export function keychainDeletePlan(access, item) {
207
- return keychainReadPlan(access, item);
269
+ if (access.mode === 'ungrouped') {
270
+ return [{}];
271
+ }
272
+ if (!mayHoldKeychainItem(access.role, item)) {
273
+ return [];
274
+ }
275
+ const own = keychainGroupFor(item);
276
+ const plan = [option(keychainAccessGroupValue(access, own))];
277
+ if (own === 'identity' || own === 'wallet') {
278
+ plan.push(option(keychainAccessGroupValue(access, 'shared')));
279
+ }
280
+ return plan;
208
281
  }
209
282
  /**
210
283
  * The option a WRITE of `item` carries. THROWS when this app may not hold the
@@ -215,8 +288,8 @@ export function keychainWriteOption(access, item) {
215
288
  if (access.mode === 'ungrouped') {
216
289
  return {};
217
290
  }
218
- if (!mayHoldKeychainItem(access.role, item)) {
219
- throw new Error(`This app (keychain role "${access.role}") may not hold a ${item} keychain item; only Commons writes the identity.`);
291
+ if (!mayWriteKeychainItem(access.role, item)) {
292
+ throw new Error(`This app (keychain role "${access.role}") may not write a ${item} keychain item; only Commons writes the identity and the keys the wallet spends.`);
220
293
  }
221
294
  return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
222
295
  }
@@ -284,19 +357,29 @@ export async function deleteThroughPlan(store, key, options, plan) {
284
357
  }
285
358
  /**
286
359
  * `store` with every call routed to `item`'s groups for this app: reads walk
287
- * the read plan, writes carry the write option (throwing for a role that may
288
- * not hold the item), deletes reach every location. The constants of the
289
- * wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still reads.
360
+ * the read plan (evaluated per read), writes carry the write option (throwing
361
+ * for a role that may not write the item), deletes reach every location. A
362
+ * secret WRITE also deletes the item's copy in the shared group (R4-6): a value
363
+ * Commons writes replaces whatever any app could read there, and a copy that
364
+ * cannot be removed fails the write instead of staying readable. The constants
365
+ * of the wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still
366
+ * reads.
290
367
  */
291
368
  export function groupedKeychainStore(store, access, item) {
292
- const plan = keychainReadPlan(access, item);
369
+ const sharedCopy = access.mode === 'grouped' && (item === 'identity-secret' || item === 'spending-key')
370
+ ? option(keychainAccessGroupValue(access, 'shared'))
371
+ : null;
293
372
  const wrapped = {
294
- getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, plan),
373
+ getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, keychainReadPlan(access, item)),
295
374
  setItemAsync: async (key, value, options) => {
296
375
  const group = keychainWriteOption(access, item);
297
376
  await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
377
+ if (sharedCopy) {
378
+ const { keychainAccessible: _accessible, ...rest } = (options ?? {});
379
+ await store.deleteItemAsync(key, { ...rest, ...sharedCopy });
380
+ }
298
381
  },
299
- deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, plan),
382
+ deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, keychainDeletePlan(access, item)),
300
383
  };
301
384
  return Object.assign(Object.create(store), wrapped);
302
385
  }
@@ -67,6 +67,14 @@ export interface RotateKeyResult {
67
67
  * should surface a recovery prompt. Omitted on full success.
68
68
  */
69
69
  localPersistFailed?: true;
70
+ /**
71
+ * Whether the encrypted identity backup was re-created under the NEW phrase,
72
+ * carrying the retired ring with the key just replaced (R4-4). The server
73
+ * deletes the old backup in the rotation's transaction; `false` (native, the
74
+ * upload failed) means the caller should offer "Create backup" again, or the
75
+ * replaced key lives only on this phone. Absent on web.
76
+ */
77
+ backupRefreshed?: boolean;
70
78
  }
71
79
  /**
72
80
  * Derive a user's Oxy DID from their stable account id.
@@ -268,6 +276,34 @@ export declare class IdentityBackupApi {
268
276
  * from SecureStore.
269
277
  */
270
278
  create(phrase: string): Promise<BackupStatusResponse>;
279
+ /**
280
+ * The retired ring this device holds for the person, limited to keys Oxy's
281
+ * key history names as THIS account's retired keys (the ring's iOS group is
282
+ * writable by the wallet app too, and a previous person's ring can outlive a
283
+ * "Start over"). Empty where no ring is held (web, any app but Commons).
284
+ * Throws when the history cannot be read while a ring exists, so a backup
285
+ * never silently drops keys it should carry.
286
+ */
287
+ private retiredKeysToCarry;
288
+ /**
289
+ * Fetch and open the backup the phrase locates, checking it is the phrase's
290
+ * own identity. With `missingIsNull`, `null` when there is none (404);
291
+ * otherwise the request's own error propagates.
292
+ */
293
+ private open;
294
+ /**
295
+ * Keep every retired key a backup carries in this device's ring. Each pair
296
+ * must be healthy and must not be the current key; the envelope itself is
297
+ * authenticated by the phrase. Commons only (the ring refuses elsewhere).
298
+ * Resolves how many keys are now held; a key that fails is skipped.
299
+ */
300
+ private keepCarriedRetiredKeys;
301
+ /**
302
+ * Restore ONLY the retired keys the phrase's backup carries into this
303
+ * device's ring (Commons, after a phrase import or a silent device-backup
304
+ * restore). Resolves how many were kept; 0 when there is no backup.
305
+ */
306
+ restoreRetiredKeys(phrase: string): Promise<number>;
271
307
  /**
272
308
  * Whether the signed-in user has a stored backup, plus its non-sensitive hint
273
309
  * and timestamp. Returns no ciphertext and no locator.
@@ -66,6 +66,14 @@ export interface WriteIdentityMarkerInput {
66
66
  * rest.
67
67
  */
68
68
  export declare function readIdentityMarker(): Promise<IdentityMarker | null>;
69
+ /**
70
+ * Read the marker, THROWING when the storage cannot answer (instead of the
71
+ * fail-open `null` of {@link readIdentityMarker}). For a caller that must not
72
+ * mistake "unreadable" for "never written": the iOS keychain migration, which
73
+ * adopts a pre-split secret only when this marker vouches for it, and records
74
+ * once that there is nothing to adopt.
75
+ */
76
+ export declare function readIdentityMarkerStrict(): Promise<IdentityMarker | null>;
69
77
  /**
70
78
  * Write (create/replace) the marker. Returns `true` when it durably landed,
71
79
  * `false` when storage was unavailable or the write threw. Callers treat a
@@ -19,11 +19,11 @@ export type { IdentityDeviceBackupStore } from './deviceBackup';
19
19
  export type { IdentitySignerStore } from './identitySigner';
20
20
  export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys';
21
21
  export type { OrganizationKeyPair, GeneratedOrganizationKey, StoredOrganizationKey, } from './organizationKeys';
22
- export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups';
22
+ export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, mayWriteKeychainItem, DEV_KEYCHAIN_GROUP_SUFFIX, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups';
23
23
  export type { KeychainAccess, KeychainAccessGroupName, KeychainGroup, KeychainGroupOption, KeychainItemClass, KeychainRole, KeychainStoreLike, OwnKeychainItemNames, } from '../utils/keychainAccessGroups';
24
- export { ensureKeychainGroupsMigrated } from './keychainGroupMigration';
24
+ export { ensureKeychainGroupsMigrated, getPreSplitExposure, } from './keychainGroupMigration';
25
25
  export type { KeychainGroupMigrationReport, KeychainItemMigrationOutcome, } from './keychainGroupMigration';
26
- export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, } from './retiredKeys';
26
+ export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, } from './retiredKeys';
27
27
  export type { RetiredIdentityKey, RetiredKeyPair, RetiredKeySelector } from './retiredKeys';
28
28
  export { SignatureService } from './signatureService';
29
29
  export type { SignedMessage, AuthChallenge } from './signatureService';
@@ -1,20 +1,42 @@
1
+ import { type KeychainAccess } from '../utils/keychainAccessGroups';
1
2
  /** What happened to one item (or pair) in a run. */
2
- export type KeychainItemMigrationOutcome = 'absent' | 'moved' | 'conflict' | 'unhealthy' | 'failed' | 'deferred';
3
+ export type KeychainItemMigrationOutcome = 'absent' | 'moved' | 'retired' | 'discarded' | 'failed' | 'deferred';
3
4
  export interface KeychainGroupMigrationReport {
4
5
  /**
5
6
  * - `not-applicable`: not Commons on a split-aware iOS build; nothing to move.
6
- * - `complete`: nothing secret is left in the shared group.
7
- * - `incomplete`: something stayed (`conflict`, `unhealthy` or `failed`);
8
- * retried next launch.
9
- * - `deferred`: the keychain could not be read (locked); retried on the next
10
- * storage access.
7
+ * - `complete`: the completion record exists; the shared group is never
8
+ * read again for a secret.
9
+ * - `incomplete`: a copy or delete failed; retried next launch.
10
+ * - `deferred`: the keychain or the marker could not be read (locked);
11
+ * retried on the next storage access.
11
12
  */
12
13
  state: 'not-applicable' | 'complete' | 'incomplete' | 'deferred';
13
14
  /** Outcome per item label (`primary`, `phrase`, `organization-1`, …). */
14
15
  items: Record<string, KeychainItemMigrationOutcome>;
16
+ /** Whether this device's marker vouched for the shared copies (absent before a run). */
17
+ proven?: boolean;
15
18
  }
16
19
  /** Run the migration once. Exported for tests; production code awaits the gate. */
17
20
  export declare function runKeychainGroupMigration(): Promise<KeychainGroupMigrationReport>;
21
+ /**
22
+ * What every keychain-backed identity access awaits first, by role: Commons
23
+ * runs (or has run) the migration; the Peable wallet checks once whether
24
+ * Commons' completion record is in the wallet group, and from then on never
25
+ * falls back to the shared group for a secret. Never throws.
26
+ */
27
+ export declare function prepareKeychainAccess(access: KeychainAccess): Promise<void>;
28
+ /**
29
+ * Commons: whether this device's identity keys sat in the shared keychain
30
+ * group before the split, where every Oxy iOS app could read them. `publicKey`
31
+ * is the personal key that was exposed (recommend a rotation while it is still
32
+ * the current key), `organizations` the organization accounts whose key was.
33
+ * `null` when nothing was exposed, the migration has not finished, or this is
34
+ * not Commons on iOS.
35
+ */
36
+ export declare function getPreSplitExposure(): Promise<{
37
+ publicKey: string | null;
38
+ organizations: string[];
39
+ } | null>;
18
40
  /**
19
41
  * The gate every Commons identity-storage access awaits. One run per process
20
42
  * (`complete`, `incomplete` and `not-applicable` are kept; `incomplete` is
@@ -60,8 +60,10 @@ export declare function organizationKeyFromPhrase(phrase: string): Promise<Organ
60
60
  * it; on iOS only Commons holds the groups it writes (any other app throws).
61
61
  *
62
62
  * Refuses a pair whose public key does not derive from its private key, and a
63
- * phrase that does not derive the pair. Overwrites whatever this device held for
64
- * the organization (a replaced key is the caller's decision, ADR 0061 D7).
63
+ * phrase that does not derive the pair. A DIFFERENT key this device already
64
+ * held for the organization is first kept in the organization's retired ring
65
+ * (ADR 0061 D7 as amended; R4-12): coins under it still need it. When that
66
+ * cannot be confirmed nothing is overwritten and this throws.
65
67
  */
66
68
  export declare function storeOrganizationKey(accountId: string, key: StoredOrganizationKey): Promise<void>;
67
69
  /**
@@ -70,6 +70,12 @@ export declare function retiredKeyScope(selector?: RetiredKeySelector): string |
70
70
  * caller about to replace the key can stop before anything is lost.
71
71
  */
72
72
  export declare function retireIdentityKey(key: RetiredKeyPair, selector?: RetiredKeySelector): Promise<void>;
73
+ /**
74
+ * {@link retireIdentityKey} from inside the iOS access-group migration, which
75
+ * keeps a replaced pre-split copy before deleting it from the shared group and
76
+ * cannot await itself. @internal
77
+ */
78
+ export declare function retireIdentityKeyDuringMigration(key: RetiredKeyPair, selector?: RetiredKeySelector): Promise<void>;
73
79
  /**
74
80
  * The retired keys of `selector`'s account this app can reach, oldest first.
75
81
  * iOS and Commons: the ring's index. Android elsewhere: Commons' identity host
@@ -93,10 +99,12 @@ export declare function getRetiredPrivateKey(publicKey: string, selector?: Retir
93
99
  */
94
100
  export declare function forgetRetiredIdentityKey(publicKey: string, selector?: RetiredKeySelector): Promise<void>;
95
101
  /**
96
- * Forget every retired key of one ring (the person's, or an organization's):
97
- * called when the identity itself is deleted from this device, so a phone
98
- * handed on keeps none of the person's old keys. Commons only. Resolves how
99
- * many it removed.
102
+ * Forget every retired key of one ring (the person's, or an organization's).
103
+ * Commons only, and only when the person deletes their Oxy ACCOUNT (Commons'
104
+ * delete-account flow), which tells them the coins go with it. Deleting the
105
+ * identity from this device — "Start over", a forced delete — never calls it:
106
+ * the ring is money, and the same person restoring their identity later must
107
+ * find it (R4-4). Resolves how many it removed.
100
108
  */
101
109
  export declare function forgetRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<number>;
102
110
  /**
@@ -106,6 +114,24 @@ export declare function forgetRetiredIdentityKeys(selector?: RetiredKeySelector)
106
114
  * Resolves how many it mirrored. Elsewhere 0.
107
115
  */
108
116
  export declare function syncRetiredKeys(): Promise<number>;
117
+ /**
118
+ * Every retired key of `selector`'s ring WITH its private half, for the
119
+ * encrypted identity backup (`oxy.identity.backup.create`), which carries them
120
+ * under the CURRENT phrase so a lost phone costs no more than the current
121
+ * phrase does (R4-4). Commons only; an entry whose private half is missing or
122
+ * does not derive its public key is left out. Never log or transmit the result
123
+ * except inside that encrypted envelope.
124
+ */
125
+ export declare function exportRetiredIdentityKeys(selector?: RetiredKeySelector): Promise<Array<RetiredKeyPair & {
126
+ retiredAt: number;
127
+ }>>;
128
+ /**
129
+ * The pair a previous key's raw PRIVATE KEY gives (64 hex, an optional `0x`),
130
+ * for a person whose old identity was imported as a private key and so has no
131
+ * phrase to re-enter. Stores nothing: the caller checks the public key against
132
+ * the account's key history and only then calls {@link retireIdentityKey}.
133
+ */
134
+ export declare function retiredKeyFromPrivateKey(privateKeyHex: string): RetiredKeyPair;
109
135
  /**
110
136
  * The pair a previous recovery phrase derives (`seed[0:32]`, the personal-root
111
137
  * and organization-key derivation; 12–24 words). Stores nothing: the caller