@oxy.so/core 7.30.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 (40) 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/session/authStateStore.js +13 -0
  9. package/dist/cjs/session/index.js +5 -2
  10. package/dist/cjs/utils/keychainAccessGroups.js +208 -51
  11. package/dist/esm/api/identity.js +146 -23
  12. package/dist/esm/crypto/identityMarker.js +14 -0
  13. package/dist/esm/crypto/index.js +3 -3
  14. package/dist/esm/crypto/keyManager.js +9 -12
  15. package/dist/esm/crypto/keychainGroupMigration.js +327 -162
  16. package/dist/esm/crypto/organizationKeys.js +34 -6
  17. package/dist/esm/crypto/retiredKeys.js +76 -17
  18. package/dist/esm/session/authStateStore.js +12 -0
  19. package/dist/esm/session/index.js +1 -1
  20. package/dist/esm/utils/keychainAccessGroups.js +205 -51
  21. package/dist/types/api/identity.d.ts +36 -0
  22. package/dist/types/crypto/identityMarker.d.ts +8 -0
  23. package/dist/types/crypto/index.d.ts +4 -4
  24. package/dist/types/crypto/keychainGroupMigration.d.ts +28 -6
  25. package/dist/types/crypto/organizationKeys.d.ts +4 -2
  26. package/dist/types/crypto/retiredKeys.d.ts +30 -4
  27. package/dist/types/session/authStateStore.d.ts +9 -0
  28. package/dist/types/session/index.d.ts +2 -2
  29. package/dist/types/utils/keychainAccessGroups.d.ts +108 -26
  30. package/package.json +2 -2
  31. package/src/api/identity.ts +185 -40
  32. package/src/crypto/identityMarker.ts +15 -0
  33. package/src/crypto/index.ts +10 -1
  34. package/src/crypto/keyManager.ts +9 -15
  35. package/src/crypto/keychainGroupMigration.ts +396 -186
  36. package/src/crypto/organizationKeys.ts +38 -6
  37. package/src/crypto/retiredKeys.ts +87 -17
  38. package/src/session/authStateStore.ts +14 -0
  39. package/src/session/index.ts +8 -1
  40. package/src/utils/keychainAccessGroups.ts +276 -52
@@ -1,12 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.KeychainAccessUnavailableError = exports.KEYCHAIN_ACCESS_GROUPS = void 0;
3
+ exports.KeychainAccessUnavailableError = exports.DEV_KEYCHAIN_GROUP_SUFFIX = exports.KEYCHAIN_ACCESS_GROUPS = void 0;
4
4
  exports.keychainGroupFor = keychainGroupFor;
5
+ exports.mayWriteKeychainItem = mayWriteKeychainItem;
5
6
  exports.mayHoldKeychainItem = mayHoldKeychainItem;
6
7
  exports.keychainAccessFromDefaultGroup = keychainAccessFromDefaultGroup;
7
8
  exports.resolveKeychainAccess = resolveKeychainAccess;
8
9
  exports.resetKeychainAccessForTests = resetKeychainAccessForTests;
9
10
  exports.keychainAccessGroupValue = keychainAccessGroupValue;
11
+ exports.setHolderSharedSecretFallback = setHolderSharedSecretFallback;
12
+ exports.closeWalletSharedSecretFallback = closeWalletSharedSecretFallback;
10
13
  exports.keychainReadPlan = keychainReadPlan;
11
14
  exports.keychainDeletePlan = keychainDeletePlan;
12
15
  exports.keychainWriteOption = keychainWriteOption;
@@ -23,6 +26,7 @@ exports.groupedKeychainStore = groupedKeychainStore;
23
26
  * | `<TEAM>.so.oxy.identity` | Commons only (listed FIRST) | `identity-secret` |
24
27
  * | `<TEAM>.so.oxy.wallet` | Commons, the Peable wallet | `spending-key` |
25
28
  * | `<TEAM>.group.so.oxy.shared` | every Oxy app | `shared` |
29
+ * | `<TEAM>.<bundle id>` | that app alone | `app` |
26
30
  *
27
31
  * - `identity-secret`: the personal root (primary and backup slots), the
28
32
  * personal and organization recovery phrases, the organization index. Only
@@ -32,14 +36,21 @@ exports.groupedKeychainStore = groupedKeychainStore;
32
36
  * (`deriveScopedSeed`, `signSocialReceive`). Commons writes them; the Peable
33
37
  * wallet reads them.
34
38
  * - `shared`: what every Oxy app may read: the shared public key and the
35
- * device-session credential.
39
+ * device-session credential (the one cross-app session join point).
40
+ * - `app`: an app's OWN items, readable by no other app: its session
41
+ * credential (`oxy.auth.v1` and the rest of the SDK's session store), its
42
+ * identity pin, its settings, the Peable wallet's imported phrases. The group
43
+ * is the app's application identifier, which Apple makes every app a member
44
+ * of; no app lists it and no other app can name it. Android's counterpart is
45
+ * the app's own storage (ADR 0062 D6).
36
46
  *
37
47
  * The ROLE of this binary comes from the keychain itself: an item written
38
48
  * without a group lands in the app's default group, the first entry of its
39
49
  * `keychain-access-groups` entitlement, and `@oxy.so/app-preset` lets only
40
50
  * Commons put the identity group first and only the Peable wallet the wallet
41
51
  * group. The same probe gives the team prefix, which `kSecAttrAccessGroup`
42
- * needs and JavaScript cannot otherwise see.
52
+ * needs and JavaScript cannot otherwise see; a second probe proves the app's
53
+ * own group (`<prefix><bundle id>`) is writable.
43
54
  *
44
55
  * Android and web have no access groups: there every helper here answers the
45
56
  * single-store behaviour (`ungrouped`), and Android's narrowing is Commons'
@@ -57,6 +68,15 @@ exports.KEYCHAIN_ACCESS_GROUPS = {
57
68
  wallet: 'so.oxy.wallet',
58
69
  shared: 'group.so.oxy.shared',
59
70
  };
71
+ /**
72
+ * The suffix a DEVELOPMENT build's restricted groups carry (`so.oxy.commons.dev`,
73
+ * `to.peable.app.dev`: `so.oxy.identity.dev`, `so.oxy.wallet.dev`). A dev build
74
+ * sits beside the production app on the same phone, is signed by the same team
75
+ * and runs JavaScript from a dev server, so it never shares a restricted group
76
+ * with production (`@oxy.so/app-preset` enforces the entitlements). The shared
77
+ * group has no variant: it holds nothing secret.
78
+ */
79
+ exports.DEV_KEYCHAIN_GROUP_SUFFIX = '.dev';
60
80
  /** Thrown while the probe cannot answer (before first unlock). Transient. */
61
81
  class KeychainAccessUnavailableError extends Error {
62
82
  constructor(cause) {
@@ -66,10 +86,6 @@ class KeychainAccessUnavailableError extends Error {
66
86
  }
67
87
  }
68
88
  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
89
  function keychainGroupFor(item) {
74
90
  switch (item) {
75
91
  case 'identity-secret':
@@ -78,8 +94,20 @@ function keychainGroupFor(item) {
78
94
  return 'wallet';
79
95
  case 'shared':
80
96
  return 'shared';
97
+ case 'app':
98
+ return 'app';
81
99
  }
82
100
  }
101
+ /**
102
+ * Whether `role` may WRITE `item`. Only Commons writes a secret: the Peable
103
+ * wallet reads, derives and signs with spending keys (and the retired ring)
104
+ * but never adds one — a key the wallet could add is one the wallet could
105
+ * choose. (The keychain itself lets every app in the wallet group write it;
106
+ * this is the SDK's half, and readers check what they find, ADR 0062.)
107
+ */
108
+ function mayWriteKeychainItem(role, item) {
109
+ return role === 'holder' || item === 'shared' || item === 'app';
110
+ }
83
111
  /** Whether `role` holds the group `item` lives in. */
84
112
  function mayHoldKeychainItem(role, item) {
85
113
  switch (role) {
@@ -88,11 +116,14 @@ function mayHoldKeychainItem(role, item) {
88
116
  case 'wallet':
89
117
  return item !== 'identity-secret';
90
118
  case 'member':
91
- return item === 'shared';
119
+ return item === 'shared' || item === 'app';
92
120
  }
93
121
  }
94
- /** Parse the default access group the probe reported. Exported for tests. */
95
- function keychainAccessFromDefaultGroup(defaultGroup) {
122
+ /**
123
+ * Parse the default access group the probe reported, and the app's own group
124
+ * when the second probe proved it. Exported for tests.
125
+ */
126
+ function keychainAccessFromDefaultGroup(defaultGroup, appGroup) {
96
127
  const dot = defaultGroup.indexOf('.');
97
128
  if (dot <= 0 || dot === defaultGroup.length - 1) {
98
129
  // Not `<prefix>.<group>`: an unsigned simulator build. Nothing to split.
@@ -100,12 +131,18 @@ function keychainAccessFromDefaultGroup(defaultGroup) {
100
131
  }
101
132
  const prefix = defaultGroup.slice(0, dot + 1);
102
133
  const group = defaultGroup.slice(dot + 1);
103
- const role = group === exports.KEYCHAIN_ACCESS_GROUPS.identity
134
+ const dev = group.endsWith(exports.DEV_KEYCHAIN_GROUP_SUFFIX);
135
+ const base = dev ? group.slice(0, -exports.DEV_KEYCHAIN_GROUP_SUFFIX.length) : group;
136
+ const role = base === exports.KEYCHAIN_ACCESS_GROUPS.identity
104
137
  ? 'holder'
105
- : group === exports.KEYCHAIN_ACCESS_GROUPS.wallet
138
+ : base === exports.KEYCHAIN_ACCESS_GROUPS.wallet
106
139
  ? 'wallet'
107
140
  : 'member';
108
- return { mode: 'grouped', prefix, defaultGroup, role };
141
+ // A `.dev` suffix on anything but a restricted group is not a variant.
142
+ const variant = dev && role !== 'member' ? { variant: exports.DEV_KEYCHAIN_GROUP_SUFFIX } : {};
143
+ return appGroup?.startsWith(prefix)
144
+ ? { mode: 'grouped', prefix, defaultGroup, role, appGroup, ...variant }
145
+ : { mode: 'grouped', prefix, defaultGroup, role, ...variant };
109
146
  }
110
147
  let resolvedAccess = null;
111
148
  /**
@@ -133,28 +170,101 @@ async function resolveKeychainAccess() {
133
170
  if (probe.state === 'unavailable') {
134
171
  throw new KeychainAccessUnavailableError(probe.cause);
135
172
  }
136
- resolvedAccess =
137
- probe.state === 'resolved'
138
- ? keychainAccessFromDefaultGroup(probe.accessGroup)
139
- : { mode: 'ungrouped' };
173
+ if (probe.state !== 'resolved') {
174
+ resolvedAccess = { mode: 'ungrouped' };
175
+ return resolvedAccess;
176
+ }
177
+ const access = keychainAccessFromDefaultGroup(probe.accessGroup);
178
+ if (access.mode === 'ungrouped') {
179
+ resolvedAccess = access;
180
+ return resolvedAccess;
181
+ }
182
+ // The app's own group. A thrown probe is NOT degraded to "no app group": this
183
+ // process would then write the app's items to the old place while the next
184
+ // one reads the new place first, and see a stale value.
185
+ let own;
186
+ try {
187
+ own = await (0, protocol_1.loadKeychainApplicationAccessGroup)(access.prefix);
188
+ }
189
+ catch (cause) {
190
+ throw new KeychainAccessUnavailableError(cause);
191
+ }
192
+ if (own.state === 'unavailable') {
193
+ throw new KeychainAccessUnavailableError(own.cause);
194
+ }
195
+ resolvedAccess = keychainAccessFromDefaultGroup(probe.accessGroup, own.state === 'resolved' ? own.accessGroup : undefined);
140
196
  return resolvedAccess;
141
197
  }
142
- /** Forget the memoized access. Tests only. @internal */
198
+ /** Forget the memoized access and the fallback state. Tests only. @internal */
143
199
  function resetKeychainAccessForTests() {
144
200
  resolvedAccess = null;
201
+ holderSharedFallback = false;
202
+ walletSharedFallbackClosed = false;
145
203
  }
146
- /** The full `kSecAttrAccessGroup` value of `group`, or `undefined` when ungrouped. */
204
+ /**
205
+ * The full `kSecAttrAccessGroup` value of `group`, or `undefined` for "no
206
+ * group" (the app's default): always when ungrouped, and for `app` on a build
207
+ * that cannot place items there (see {@link ownKeychainItemPlan}).
208
+ */
147
209
  function keychainAccessGroupValue(access, group) {
148
- return access.mode === 'grouped' ? `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}` : undefined;
210
+ if (access.mode === 'ungrouped') {
211
+ return undefined;
212
+ }
213
+ if (group === 'app') {
214
+ // Commons' and the Peable wallet's default groups are theirs (no other app
215
+ // writes its own items there), so a build without the app-group probe
216
+ // keeps its own items there, as ADR 0062 D3 put them. Every other app's
217
+ // default is the shared group: no group, the calls it always made.
218
+ return access.appGroup ?? (access.role === 'member' ? undefined : access.defaultGroup);
219
+ }
220
+ const variant = group === 'shared' ? '' : (access.variant ?? '');
221
+ return `${access.prefix}${exports.KEYCHAIN_ACCESS_GROUPS[group]}${variant}`;
222
+ }
223
+ /**
224
+ * Whether a SECRET read may still fall back to the pre-split shared group
225
+ * (R4-7). Every Oxy app can write that group, and keychain items survive an
226
+ * uninstall, so a secret found there is one Commons did not necessarily write.
227
+ *
228
+ * - Commons (`holder`): CLOSED, except while its one-shot migration has proved
229
+ * the shared copies are its own (they carry the key its sandbox marker
230
+ * records) and could not move them all yet; the migration opens it for that
231
+ * launch only ({@link setHolderSharedSecretFallback}).
232
+ * - The Peable wallet: open until Commons' migration has finished (the
233
+ * completion record Commons mirrors into the wallet group), because until
234
+ * then an upgraded phone's keys are still in the shared group; closed for
235
+ * good once it has seen that record ({@link closeWalletSharedSecretFallback}).
236
+ */
237
+ let holderSharedFallback = false;
238
+ let walletSharedFallbackClosed = false;
239
+ /** The migration's switch for Commons' fallback. @internal */
240
+ function setHolderSharedSecretFallback(open) {
241
+ holderSharedFallback = open;
242
+ }
243
+ /** The wallet saw Commons' completion record: no shared fallback again in this process. @internal */
244
+ function closeWalletSharedSecretFallback() {
245
+ walletSharedFallbackClosed = true;
246
+ }
247
+ function sharedFallbackOpen(access) {
248
+ switch (access.role) {
249
+ case 'holder':
250
+ return holderSharedFallback;
251
+ case 'wallet':
252
+ return !walletSharedFallbackClosed;
253
+ case 'member':
254
+ return false;
255
+ }
149
256
  }
150
257
  function option(value) {
151
258
  return value === undefined ? {} : { accessGroup: value };
152
259
  }
153
260
  /**
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.
261
+ * The option bags a READ of `item` tries, in order: its own group, then — for
262
+ * an identity secret or a spending key, and only while
263
+ * {@link sharedFallbackOpen} says so — the shared group, where those lived
264
+ * before the split. Empty when this app may not hold the item: the caller
265
+ * answers `null` without touching the keychain. Evaluated per read: the
266
+ * fallback closes during a process's life. An app's own items (`app`) take
267
+ * {@link ownKeychainItemPlan}.
158
268
  */
159
269
  function keychainReadPlan(access, item) {
160
270
  if (access.mode === 'ungrouped') {
@@ -165,17 +275,29 @@ function keychainReadPlan(access, item) {
165
275
  }
166
276
  const own = keychainGroupFor(item);
167
277
  const plan = [option(keychainAccessGroupValue(access, own))];
168
- if (own !== 'shared') {
278
+ if ((own === 'identity' || own === 'wallet') && sharedFallbackOpen(access)) {
169
279
  plan.push(option(keychainAccessGroupValue(access, 'shared')));
170
280
  }
171
281
  return plan;
172
282
  }
173
283
  /**
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.
284
+ * Every place a DELETE of `item` must reach: its own group AND the shared
285
+ * group, whatever the read fallback says, so a delete never leaves a copy
286
+ * behind where every Oxy app can read it.
176
287
  */
177
288
  function keychainDeletePlan(access, item) {
178
- return keychainReadPlan(access, item);
289
+ if (access.mode === 'ungrouped') {
290
+ return [{}];
291
+ }
292
+ if (!mayHoldKeychainItem(access.role, item)) {
293
+ return [];
294
+ }
295
+ const own = keychainGroupFor(item);
296
+ const plan = [option(keychainAccessGroupValue(access, own))];
297
+ if (own === 'identity' || own === 'wallet') {
298
+ plan.push(option(keychainAccessGroupValue(access, 'shared')));
299
+ }
300
+ return plan;
179
301
  }
180
302
  /**
181
303
  * The option a WRITE of `item` carries. THROWS when this app may not hold the
@@ -186,31 +308,56 @@ function keychainWriteOption(access, item) {
186
308
  if (access.mode === 'ungrouped') {
187
309
  return {};
188
310
  }
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.`);
311
+ if (!mayWriteKeychainItem(access.role, item)) {
312
+ 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.`);
191
313
  }
192
314
  return option(keychainAccessGroupValue(access, keychainGroupFor(item)));
193
315
  }
194
316
  /**
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.
317
+ * The plan for an app's OWN items (class `app`, ADR 0062 D6): written to the
318
+ * app's own group (`<prefix><bundle id>`), which no other app can read, and
319
+ * read from it FIRST. Then:
320
+ *
321
+ * - from where this app kept them before: Commons' and the Peable wallet's
322
+ * default group (D3), which no other app writes its own items to;
323
+ * - for `this-app` names, from every group (`{}`), where they were before the
324
+ * split; deletes reach every group;
325
+ * - for `every-oxy-app` names, nowhere else (see {@link OwnKeychainItemNames}),
326
+ * and deletes reach exactly the read locations, earlier ones first, so a
327
+ * failed delete never leaves an older copy as the only one;
328
+ * - for `every-oxy-app-withholding` names, the shared group last on read and
329
+ * first on delete.
330
+ *
331
+ * Nothing is copied or deleted to migrate: the next write lands in the app
332
+ * group and the read order makes it win, so a crash or a failure at any step
333
+ * leaves the value the app had.
202
334
  *
203
- * Every other app (`member`) and Android/web keep the calls they always made.
335
+ * A native build whose module predates the app-group probe (`appGroup`
336
+ * absent) keeps its items where that build put them: Commons and the wallet in
337
+ * their default group, every other app the calls it always made (the one
338
+ * shared item, until that app's next native build). Android and web: no
339
+ * options.
204
340
  */
205
- function ownKeychainItemPlan(access) {
206
- if (access.mode === 'ungrouped' || access.role === 'member') {
341
+ function ownKeychainItemPlan(access, names = 'this-app') {
342
+ if (access.mode === 'ungrouped') {
207
343
  return { read: [{}], write: {}, delete: [{}] };
208
344
  }
209
- return {
210
- read: [option(access.defaultGroup), {}],
211
- write: option(access.defaultGroup),
212
- delete: [{}],
213
- };
345
+ const own = keychainAccessGroupValue(access, keychainGroupFor('app'));
346
+ if (own === undefined) {
347
+ return { read: [{}], write: {}, delete: [{}] };
348
+ }
349
+ const earlier = access.role !== 'member' && own !== access.defaultGroup ? [option(access.defaultGroup)] : [];
350
+ const mine = [option(own), ...earlier];
351
+ switch (names) {
352
+ case 'this-app':
353
+ return { read: [...mine, {}], write: option(own), delete: [{}] };
354
+ case 'every-oxy-app':
355
+ return { read: mine, write: option(own), delete: [...mine].reverse() };
356
+ case 'every-oxy-app-withholding': {
357
+ const read = [...mine, option(keychainAccessGroupValue(access, 'shared'))];
358
+ return { read, write: option(own), delete: [...read].reverse() };
359
+ }
360
+ }
214
361
  }
215
362
  /** Read `key` through `plan`: the first non-null answer. A thrown read propagates. */
216
363
  async function readThroughPlan(store, key, options, plan) {
@@ -230,19 +377,29 @@ async function deleteThroughPlan(store, key, options, plan) {
230
377
  }
231
378
  /**
232
379
  * `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.
380
+ * the read plan (evaluated per read), writes carry the write option (throwing
381
+ * for a role that may not write the item), deletes reach every location. A
382
+ * secret WRITE also deletes the item's copy in the shared group (R4-6): a value
383
+ * Commons writes replaces whatever any app could read there, and a copy that
384
+ * cannot be removed fails the write instead of staying readable. The constants
385
+ * of the wrapped store are kept, so `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY` still
386
+ * reads.
236
387
  */
237
388
  function groupedKeychainStore(store, access, item) {
238
- const plan = keychainReadPlan(access, item);
389
+ const sharedCopy = access.mode === 'grouped' && (item === 'identity-secret' || item === 'spending-key')
390
+ ? option(keychainAccessGroupValue(access, 'shared'))
391
+ : null;
239
392
  const wrapped = {
240
- getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, plan),
393
+ getItemAsync: (key, options) => readThroughPlan(store, key, options ?? {}, keychainReadPlan(access, item)),
241
394
  setItemAsync: async (key, value, options) => {
242
395
  const group = keychainWriteOption(access, item);
243
396
  await store.setItemAsync(key, value, { ...(options ?? {}), ...group });
397
+ if (sharedCopy) {
398
+ const { keychainAccessible: _accessible, ...rest } = (options ?? {});
399
+ await store.deleteItemAsync(key, { ...rest, ...sharedCopy });
400
+ }
244
401
  },
245
- deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, plan),
402
+ deleteItemAsync: (key, options) => deleteThroughPlan(store, key, options ?? {}, keychainDeletePlan(access, item)),
246
403
  };
247
404
  return Object.assign(Object.create(store), wrapped);
248
405
  }
@@ -21,6 +21,7 @@ import { checkContract, IDENTITY_PROOF_ACTIONS, identityLinkCreateResponseCheck,
21
21
  import { identityKeyHistoryCheck } from '@oxy.so/contracts/client';
22
22
  import { isAndroid, isWeb } from '../utils/platform.js';
23
23
  import { logger } from '../logger/index.js';
24
+ import { extractErrorStatus } from '../utils/errorUtils.js';
24
25
  /** Short-TTL read cache for public identity reads. */
25
26
  const SHORT_TTL = 60 * 1000;
26
27
  /**
@@ -316,10 +317,31 @@ export class IdentityApi {
316
317
  }
317
318
  }
318
319
  }
320
+ // 8. The rotation deleted the encrypted backup (it was sealed under the
321
+ // replaced phrase). Seal a new one under the new phrase, carrying the
322
+ // retired ring — now with the key just replaced — so the coins under it
323
+ // survive a lost phone as long as the NEW phrase does (R4-4). Always,
324
+ // not only when one existed: after a rotation it is the only copy of the
325
+ // replaced key off this phone. Best-effort: the rotation already succeeded.
326
+ let backupRefreshed;
327
+ if (!isWeb() && !localPersistFailed) {
328
+ try {
329
+ await this.backup.create(pending.phrase);
330
+ backupRefreshed = true;
331
+ }
332
+ catch (backupError) {
333
+ backupRefreshed = false;
334
+ logger.warn('Key rotated, but the encrypted backup could not be re-created under the new phrase.', { component: 'oxy.identity', method: 'rotateKey' }, backupError);
335
+ }
336
+ }
319
337
  this.invalidate(userId);
320
- return localPersistFailed
321
- ? { newPublicKey, newPhrase: pending.phrase, words: pending.words, localPersistFailed: true }
322
- : { newPublicKey, newPhrase: pending.phrase, words: pending.words };
338
+ return {
339
+ newPublicKey,
340
+ newPhrase: pending.phrase,
341
+ words: pending.words,
342
+ ...(localPersistFailed ? { localPersistFailed: true } : {}),
343
+ ...(backupRefreshed === undefined ? {} : { backupRefreshed }),
344
+ };
323
345
  }
324
346
  /**
325
347
  * The signed-in user's signed, open-format data-export bundle
@@ -508,6 +530,10 @@ const BACKUP_ALGORITHM = 'xchacha20poly1305';
508
530
  * to recognise WHICH identity a backup belongs to, but only a prefix.
509
531
  */
510
532
  const PUBLIC_KEY_HINT_LENGTH = 16;
533
+ /** Whether a request error is the restore lookup's constant-shape 404. */
534
+ function isNotFound(error) {
535
+ return extractErrorStatus(error) === 404;
536
+ }
511
537
  /** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
512
538
  function toHex(bytes) {
513
539
  let out = '';
@@ -567,7 +593,13 @@ export class IdentityBackupApi {
567
593
  const publicKey = KeyManager.derivePublicKey(privateKey);
568
594
  const createdAt = new Date().toISOString();
569
595
  const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
570
- const payload = { privateKey, publicKey, createdAt };
596
+ const retiredKeys = await this.retiredKeysToCarry(publicKey);
597
+ const payload = {
598
+ privateKey,
599
+ publicKey,
600
+ createdAt,
601
+ ...(retiredKeys.length > 0 ? { retiredKeys } : {}),
602
+ };
571
603
  const plaintext = new TextEncoder().encode(JSON.stringify(payload));
572
604
  const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
573
605
  const { nonce, ciphertext } = encryptAead(backupKey, plaintext, aad);
@@ -585,6 +617,107 @@ export class IdentityBackupApi {
585
617
  cache: false,
586
618
  });
587
619
  }
620
+ /**
621
+ * The retired ring this device holds for the person, limited to keys Oxy's
622
+ * key history names as THIS account's retired keys (the ring's iOS group is
623
+ * writable by the wallet app too, and a previous person's ring can outlive a
624
+ * "Start over"). Empty where no ring is held (web, any app but Commons).
625
+ * Throws when the history cannot be read while a ring exists, so a backup
626
+ * never silently drops keys it should carry.
627
+ */
628
+ async retiredKeysToCarry(currentPublicKey) {
629
+ if (isWeb())
630
+ return [];
631
+ const { exportRetiredIdentityKeys } = await import('../crypto/index.js');
632
+ let held;
633
+ try {
634
+ held = await exportRetiredIdentityKeys();
635
+ }
636
+ catch {
637
+ return []; // Not Commons: this app holds no ring to carry.
638
+ }
639
+ if (held.length === 0)
640
+ return [];
641
+ const history = await this.ctx.request('GET', '/identity/key-history', undefined, {
642
+ cache: false,
643
+ });
644
+ const parsed = checkContract(identityKeyHistoryCheck, history);
645
+ if (!parsed)
646
+ throw new Error('identity/key-history returned an unexpected response shape');
647
+ const retired = new Set(parsed.retired.map((entry) => entry.publicKey.toLowerCase()));
648
+ const current = currentPublicKey.toLowerCase();
649
+ return held.filter((key) => key.publicKey.toLowerCase() !== current && retired.has(key.publicKey.toLowerCase()));
650
+ }
651
+ /**
652
+ * Fetch and open the backup the phrase locates, checking it is the phrase's
653
+ * own identity. With `missingIsNull`, `null` when there is none (404);
654
+ * otherwise the request's own error propagates.
655
+ */
656
+ async open(phrase, missingIsNull = false) {
657
+ const { KeyManager, RecoveryPhraseService, decryptAead } = await import('../crypto/index.js');
658
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
659
+ let envelope;
660
+ try {
661
+ envelope = await this.ctx.request('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
662
+ }
663
+ catch (error) {
664
+ if (missingIsNull && isNotFound(error))
665
+ return null;
666
+ throw error;
667
+ }
668
+ if (envelope.algorithm !== BACKUP_ALGORITHM) {
669
+ throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
670
+ }
671
+ const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
672
+ const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
673
+ const payload = JSON.parse(new TextDecoder().decode(plaintext));
674
+ if (!payload.privateKey || !payload.publicKey) {
675
+ throw new Error('Backup payload is missing key material');
676
+ }
677
+ const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
678
+ const payloadPk = payload.publicKey.toLowerCase();
679
+ const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
680
+ if (phrasePk !== payloadPk || privatePk !== payloadPk) {
681
+ throw new Error('Backup payload does not match the recovery phrase');
682
+ }
683
+ return payload;
684
+ }
685
+ /**
686
+ * Keep every retired key a backup carries in this device's ring. Each pair
687
+ * must be healthy and must not be the current key; the envelope itself is
688
+ * authenticated by the phrase. Commons only (the ring refuses elsewhere).
689
+ * Resolves how many keys are now held; a key that fails is skipped.
690
+ */
691
+ async keepCarriedRetiredKeys(payload) {
692
+ const { KeyManager, retireIdentityKey } = await import('../crypto/index.js');
693
+ let kept = 0;
694
+ for (const key of Array.isArray(payload.retiredKeys) ? payload.retiredKeys : []) {
695
+ try {
696
+ if (typeof key?.privateKey !== 'string' || typeof key.publicKey !== 'string')
697
+ continue;
698
+ if (key.publicKey.toLowerCase() === payload.publicKey.toLowerCase())
699
+ continue;
700
+ if (KeyManager.derivePublicKey(key.privateKey).toLowerCase() !== key.publicKey.toLowerCase()) {
701
+ continue;
702
+ }
703
+ await retireIdentityKey({ privateKey: key.privateKey, publicKey: key.publicKey });
704
+ kept += 1;
705
+ }
706
+ catch (error) {
707
+ logger.warn('Could not keep a retired key from the encrypted backup', { component: 'oxy.identity', method: 'backup.restoreRetiredKeys' }, error);
708
+ }
709
+ }
710
+ return kept;
711
+ }
712
+ /**
713
+ * Restore ONLY the retired keys the phrase's backup carries into this
714
+ * device's ring (Commons, after a phrase import or a silent device-backup
715
+ * restore). Resolves how many were kept; 0 when there is no backup.
716
+ */
717
+ async restoreRetiredKeys(phrase) {
718
+ const payload = await this.open(phrase, true);
719
+ return payload ? this.keepCarriedRetiredKeys(payload) : 0;
720
+ }
588
721
  /**
589
722
  * Whether the signed-in user has a stored backup, plus its non-sensitive hint
590
723
  * and timestamp. Returns no ciphertext and no locator.
@@ -615,24 +748,14 @@ export class IdentityBackupApi {
615
748
  * fails authentication (tamper), or an existing identity blocks the import.
616
749
  */
617
750
  async restore(phrase, options) {
618
- const { KeyManager, RecoveryPhraseService, decryptAead } = await import('../crypto/index.js');
619
- const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
620
- const envelope = await this.ctx.request('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
621
- if (envelope.algorithm !== BACKUP_ALGORITHM) {
622
- throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
623
- }
624
- const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
625
- const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
626
- const payload = JSON.parse(new TextDecoder().decode(plaintext));
627
- if (!payload.privateKey || !payload.publicKey) {
628
- throw new Error('Backup payload is missing key material');
629
- }
630
- const phrasePk = (await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase)).toLowerCase();
631
- const payloadPk = payload.publicKey.toLowerCase();
632
- const privatePk = KeyManager.derivePublicKey(payload.privateKey).toLowerCase();
633
- if (phrasePk !== payloadPk || privatePk !== payloadPk) {
634
- throw new Error('Backup payload does not match the recovery phrase');
635
- }
636
- return KeyManager.importKeyPair(payload.privateKey, { overwrite: options?.overwrite === true });
751
+ const { KeyManager } = await import('../crypto/index.js');
752
+ const payload = await this.open(phrase);
753
+ if (!payload)
754
+ throw new Error('No encrypted backup was found for this recovery phrase.');
755
+ const publicKey = await KeyManager.importKeyPair(payload.privateKey, {
756
+ overwrite: options?.overwrite === true,
757
+ });
758
+ await this.keepCarriedRetiredKeys(payload);
759
+ return publicKey;
637
760
  }
638
761
  }
@@ -145,6 +145,20 @@ export async function readIdentityMarker() {
145
145
  return null;
146
146
  }
147
147
  }
148
+ /**
149
+ * Read the marker, THROWING when the storage cannot answer (instead of the
150
+ * fail-open `null` of {@link readIdentityMarker}). For a caller that must not
151
+ * mistake "unreadable" for "never written": the iOS keychain migration, which
152
+ * adopts a pre-split secret only when this marker vouches for it, and records
153
+ * once that there is nothing to adopt.
154
+ */
155
+ export async function readIdentityMarkerStrict() {
156
+ const storage = await getStorage();
157
+ if (!storage) {
158
+ throw new Error('The identity marker storage is unavailable.');
159
+ }
160
+ return deserialize(await storage.getItem(IDENTITY_MARKER_STORAGE_KEY));
161
+ }
148
162
  /**
149
163
  * Write (create/replace) the marker. Returns `true` when it durably landed,
150
164
  * `false` when storage was unavailable or the write threw. Callers treat a
@@ -18,11 +18,11 @@ export { readIdentityMarker, updateIdentityMarker } from './identityMarker.js';
18
18
  // Organization keys on the device (docs/adr/0061-organization-identity-key.md D6)
19
19
  export { generateOrganizationKey, organizationKeyFromPhrase, storeOrganizationKey, getOrganizationPublicKey, getOrganizationPrivateKey, getOrganizationRecoveryPhrase, listOrganizationKeys, deleteOrganizationKey, syncOrganizationKeys, isOrganizationAccountId, isHealthyOrganizationKeyPair, } from './organizationKeys.js';
20
20
  // iOS keychain access groups (docs/identity/ios-keychain-access-groups.md, ADR 0062)
21
- export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
22
- export { ensureKeychainGroupsMigrated } from './keychainGroupMigration.js';
21
+ export { KEYCHAIN_ACCESS_GROUPS, KeychainAccessUnavailableError, keychainGroupFor, mayHoldKeychainItem, mayWriteKeychainItem, DEV_KEYCHAIN_GROUP_SUFFIX, resolveKeychainAccess, keychainAccessGroupValue, keychainReadPlan, keychainDeletePlan, keychainWriteOption, ownKeychainItemPlan, readThroughPlan, deleteThroughPlan, groupedKeychainStore, } from '../utils/keychainAccessGroups.js';
22
+ export { ensureKeychainGroupsMigrated, getPreSplitExposure, } from './keychainGroupMigration.js';
23
23
  // Retired identity keys: kept for spending after a rotation or replacement
24
24
  // (docs/adr/0061-organization-identity-key.md D7 as amended 2026-11-06)
25
- export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, } from './retiredKeys.js';
25
+ export { retireIdentityKey, listRetiredIdentityKeys, forgetRetiredIdentityKey, forgetRetiredIdentityKeys, syncRetiredKeys, retiredKeyFromPhrase, retiredKeyFromPrivateKey, exportRetiredIdentityKeys, } from './retiredKeys.js';
26
26
  export { SignatureService } from './signatureService.js';
27
27
  export { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from './recoveryPhrase.js';
28
28
  // Low-level primitives (encrypted backup, device transfer)