@plane31/blind-relay 0.1.2 → 0.2.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 (81) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/index.d.ts +3 -3
  3. package/dist/adapters/index.d.ts.map +1 -1
  4. package/dist/adapters/index.js +1 -1
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/audience.d.ts +1 -1
  7. package/dist/audience.d.ts.map +1 -1
  8. package/dist/audience.js +7 -6
  9. package/dist/audience.js.map +1 -1
  10. package/dist/client.d.ts +78 -3
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +903 -85
  13. package/dist/client.js.map +1 -1
  14. package/dist/codec.d.ts +25 -1
  15. package/dist/codec.d.ts.map +1 -1
  16. package/dist/codec.js +44 -0
  17. package/dist/codec.js.map +1 -1
  18. package/dist/crypto.d.ts +1 -1
  19. package/dist/crypto.d.ts.map +1 -1
  20. package/dist/crypto.js.map +1 -1
  21. package/dist/errors.d.ts +9 -1
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +2 -1
  24. package/dist/errors.js.map +1 -1
  25. package/dist/identity.d.ts +8 -2
  26. package/dist/identity.d.ts.map +1 -1
  27. package/dist/identity.js +7 -3
  28. package/dist/identity.js.map +1 -1
  29. package/dist/index.d.ts +4 -4
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/policy.d.ts +13 -0
  34. package/dist/policy.d.ts.map +1 -1
  35. package/dist/policy.js +12 -1
  36. package/dist/policy.js.map +1 -1
  37. package/dist/rows.d.ts.map +1 -1
  38. package/dist/rows.js +2 -0
  39. package/dist/rows.js.map +1 -1
  40. package/dist/store.d.ts +114 -3
  41. package/dist/store.d.ts.map +1 -1
  42. package/dist/store.js.map +1 -1
  43. package/dist/succession.d.ts +108 -0
  44. package/dist/succession.d.ts.map +1 -0
  45. package/dist/succession.js +205 -0
  46. package/dist/succession.js.map +1 -0
  47. package/dist/testing/clock.d.ts.map +1 -1
  48. package/dist/testing/clock.js +1 -0
  49. package/dist/testing/clock.js.map +1 -1
  50. package/dist/testing/fakeHost.d.ts +60 -1
  51. package/dist/testing/fakeHost.d.ts.map +1 -1
  52. package/dist/testing/fakeHost.js +201 -31
  53. package/dist/testing/fakeHost.js.map +1 -1
  54. package/dist/testing/faults.d.ts +5 -3
  55. package/dist/testing/faults.d.ts.map +1 -1
  56. package/dist/testing/faults.js +24 -3
  57. package/dist/testing/faults.js.map +1 -1
  58. package/dist/transactions.d.ts +6 -0
  59. package/dist/transactions.d.ts.map +1 -1
  60. package/dist/transactions.js +11 -5
  61. package/dist/transactions.js.map +1 -1
  62. package/dist/transport.d.ts +66 -2
  63. package/dist/transport.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/adapters/index.ts +13 -0
  66. package/src/audience.ts +7 -6
  67. package/src/client.ts +881 -90
  68. package/src/codec.ts +64 -1
  69. package/src/crypto.ts +1 -0
  70. package/src/errors.ts +12 -1
  71. package/src/identity.ts +13 -3
  72. package/src/index.ts +5 -3
  73. package/src/policy.ts +23 -1
  74. package/src/rows.ts +2 -0
  75. package/src/store.ts +110 -3
  76. package/src/succession.ts +270 -0
  77. package/src/testing/clock.ts +1 -0
  78. package/src/testing/fakeHost.ts +204 -28
  79. package/src/testing/faults.ts +28 -7
  80. package/src/transactions.ts +13 -6
  81. package/src/transport.ts +69 -2
package/dist/client.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import { concat, equalBytes, fromHex, toHex } from "./bytes.js";
7
7
  import { resolveAudience } from "./audience.js";
8
8
  import { acceptSnapshot, checkMemberResolution, createCircleSnapshot, newCircleId, pinCircle, successorSnapshot } from "./circle.js";
9
- import { DocumentType, expectType, parseVaultHeader, verifyDocument } from "./codec.js";
9
+ import { DocumentType, buildCircleSnapshot, expectType, parseDocument, parseVaultHeader, verifyDocument } from "./codec.js";
10
10
  import { randomBytes, ready } from "./crypto.js";
11
11
  import { prepareEpoch, resolvePublish } from "./epoch.js";
12
12
  import { KavachError, isKavachError } from "./errors.js";
@@ -14,10 +14,11 @@ import { acceptCertificate, certify, deviceFromSeed, newAccountSeed, newDevice,
14
14
  import { ensureHistoryIndex } from "./historyIndex.js";
15
15
  import { ensureSlots, forgetAuthor, forgetAuthorIn, history, historyPage, newest, newestSlot, purgeHistory, receive as receiveRecord, syncStream } from "./inbox.js";
16
16
  import { drain, enqueue, outboxCount, outboxCounts, purgeOutbox } from "./outbox.js";
17
- import { kindOf, validatePolicy } from "./policy.js";
17
+ import { acceptsSuccession, allowsContinuity, kindOf, validatePolicy } from "./policy.js";
18
18
  import { ResetTrust } from "./reset.js";
19
19
  import { big, circleToRow, pinToRow, rowToCircle, rowToPin, rowToPrepared, unbig } from "./rows.js";
20
20
  import { META, REVISION, STREAM, VAULT, openStore, t1Activate, t1Header, t1Secrets, t2PrepareActivation, t3ApplyChange, t3Circle, t4PersistPrepared, t5Committed, t8OwnerSnapshot, t9MarkVault, t9Vault } from "./transactions.js";
21
+ import { applySuccession, authenticateSuccession, pinnedState, recordChainIn, rootIdentityIn, rootState, sameTransition, transitionOf, validateTransition, verifyChain } from "./succession.js";
21
22
  import { buildVault, encodeRecoveryCode, newRecoveryKey, openVault, recoverAccount, removeEntry, upsertEntry } from "./vault.js";
22
23
  /** accountSeed, deviceSeed, recoveryKey, instanceId and deviceId of a pending reset, in the secret store. */
23
24
  const RESET_ROOTS_BYTES = 32 * 3 + 16 + 16;
@@ -37,14 +38,27 @@ function sameEntries(a, b) {
37
38
  const key = (es) => es.map((e) => `${toHex(e.circleId)}:${toHex(e.ownerIdentityId)}:${e.sharingEnabled ? 1 : 0}`).sort().join(",");
38
39
  return key(a) === key(b);
39
40
  }
41
+ function proposalOf(row) {
42
+ return { successionId: fromHex(row.successionId), accountName: row.accountName, accountId: fromHex(row.accountId), expectedCertificateHash: fromHex(row.expectedCertificateHash), expectedGeneration: unbig(row.expectedGeneration), certificate: fromHex(row.certificate) };
43
+ }
44
+ /** The account id a directory answer names: the root of its succession chain, else the identity of its key. */
45
+ export function resolvedAccountId(r) {
46
+ const first = r.successions?.[0];
47
+ if (first)
48
+ return expectType(parseDocument(first.wire), DocumentType.AccountSuccession, "resolve").accountId;
49
+ const doc = expectType(verifyDocument(r.certificate), DocumentType.AccountDevice, "resolve");
50
+ return pinFromCertificate(doc).identityId;
51
+ }
40
52
  export const DEFAULT_CATCH_UP = { maxRecords: 500, maxMs: 5_000 };
41
- function syncError(operation, e) {
42
- return e instanceof KavachError ? { operation, code: e.code, stage: e.stage, retryable: e.retryable } : { operation, code: "UNEXPECTED", stage: operation, retryable: false };
53
+ function syncError(operation, e, extra = {}) {
54
+ return e instanceof KavachError ? { operation, code: e.code, stage: e.stage, retryable: e.retryable, ...extra } : { operation, code: "UNEXPECTED", stage: operation, retryable: false, ...extra };
43
55
  }
44
56
  const RESET_LIST_RECHECK_MS = 86400000n;
45
57
  export async function open(options) {
46
58
  await ready();
47
59
  const policy = validatePolicy(options.retentionPolicy);
60
+ if (!options.recoveryProvider && allowsContinuity(policy))
61
+ throw new KavachError("MALFORMED", "open", "recoveryProvider is required when the profile enables identity continuity");
48
62
  const impl = new ClientImpl(options, policy);
49
63
  await impl.load();
50
64
  return impl;
@@ -57,13 +71,21 @@ class ClientImpl {
57
71
  secrets;
58
72
  transport;
59
73
  provider;
74
+ /** A vault exists only where a recovery provider does: a recovery-only profile without one has no path that reads it. */
75
+ get vaultEnabled() {
76
+ return this.provider !== undefined;
77
+ }
60
78
  now;
61
79
  listeners = new Set();
62
80
  account;
81
+ /** Retired by a succession or a later device: nothing is written from here again. */
82
+ retired = false;
63
83
  device;
64
84
  selfPin;
65
85
  trust;
66
86
  syncing;
87
+ /** The current sync's reading budget: records, and the links of a succession chain, count against it. */
88
+ syncBudget;
67
89
  backupChecked = false;
68
90
  circles;
69
91
  constructor(options, policy) {
@@ -93,14 +115,16 @@ class ClientImpl {
93
115
  const opened = await openStore(this.store, this.secrets, this.accountName);
94
116
  if (opened.kind === "fresh")
95
117
  return;
118
+ this.retired = opened.kind === "RETIRED";
96
119
  await ensureHistoryIndex(this.store);
97
120
  await ensureSlots(this.store, this.policy);
98
121
  this.account = opened.account;
99
122
  const deviceSeed = await this.secrets.get("deviceSeed");
100
123
  const self = await this.store.read((tx) => tx.get("self", "self"));
101
- if (deviceSeed && self) {
124
+ if (deviceSeed && self && opened.account) {
125
+ const accountId = opened.account.accountId;
102
126
  this.device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
103
- const pinRow = await this.store.read((tx) => tx.get("pins", toHex(opened.account.identityId)));
127
+ const pinRow = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
104
128
  if (pinRow)
105
129
  this.selfPin = rowToPin(pinRow);
106
130
  }
@@ -140,13 +164,15 @@ class ClientImpl {
140
164
  return () => this.listeners.delete(listener);
141
165
  }
142
166
  requireAccount(stage) {
167
+ if (this.retired)
168
+ throw new KavachError("SUPERSEDED_DEVICE", stage, "this device is retired");
143
169
  if (!this.account || !this.device || !this.selfPin)
144
170
  throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no account on this device");
145
171
  return { account: this.account, device: this.device, pin: this.selfPin };
146
172
  }
147
173
  selfView() {
148
174
  const { device, pin } = this.requireAccount("self");
149
- return { identityId: pin.identityId, certificateHash: pin.certificateHash, box: device.box };
175
+ return { identityId: pin.accountId, certificateHash: pin.certificateHash, box: device.box };
150
176
  }
151
177
  outboxDeps() {
152
178
  const { device, pin } = this.requireAccount("outbox");
@@ -155,7 +181,7 @@ class ClientImpl {
155
181
  relay: this.transport.relay,
156
182
  policy: this.policy,
157
183
  now: this.now,
158
- author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.identityId }),
184
+ author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.accountId }),
159
185
  events: {
160
186
  queued: (recordId, recordKind) => this.emit({ kind: "queued", recordId, recordKind }),
161
187
  accepted: (recordId) => this.emit({ kind: "accepted", recordId }),
@@ -185,9 +211,11 @@ class ClientImpl {
185
211
  // State -----------------------------------------------------------------------------------------
186
212
  async getState() {
187
213
  const meta = await this.store.read((tx) => tx.get("meta", META));
188
- const base = { identityId: this.selfPin?.identityId, certificateHash: this.selfPin?.certificateHash, generation: this.selfPin?.generation, outbox: 0, waitingFor: [], circles: [] };
189
- if (!meta || !this.account)
190
- return { status: "fresh", paused: undefined, ...base };
214
+ const base = { identityId: this.selfPin?.accountId, keyIdentityId: this.selfPin?.identityId, certificateHash: this.selfPin?.certificateHash, generation: this.selfPin?.generation, outbox: 0, waitingFor: [], circles: [] };
215
+ if (!meta || (!this.account && meta.state !== "RETIRED")) {
216
+ const proposal = await this.store.read((tx) => tx.get("replacement", "pending"));
217
+ return { status: proposal && !proposal.stale ? "replacementPrepared" : "fresh", paused: undefined, ...base };
218
+ }
191
219
  const circles = await this.listCircles();
192
220
  const outbox = await outboxCount(this.store);
193
221
  const waitingFor = await this.waitingFor();
@@ -209,7 +237,7 @@ class ClientImpl {
209
237
  async waitingFor() {
210
238
  if (!this.selfPin)
211
239
  return [];
212
- const selfHex = toHex(this.selfPin.identityId);
240
+ const selfHex = toHex(this.selfPin.accountId);
213
241
  return this.store.read((tx) => {
214
242
  const authors = new Set();
215
243
  for (const [, c] of tx.list("circles"))
@@ -235,9 +263,15 @@ class ClientImpl {
235
263
  const stream = tx.get("stream", STREAM);
236
264
  const revision = tx.get("revision", REVISION);
237
265
  const vault = tx.get("vault", VAULT);
266
+ const meta = tx.get("meta", META);
267
+ const proposal = tx.get("replacement", "pending");
238
268
  const pendingEpoch = tx.list("epochs").find(([, e]) => e.status === "prepared" || e.status === "committed")?.[1].epoch;
239
269
  return {
240
- identityId: this.selfPin?.identityId,
270
+ identityId: this.selfPin?.accountId,
271
+ keyIdentityId: this.selfPin?.identityId,
272
+ replacementModel: this.policy.replacement,
273
+ replacement: proposal ? { successionId: fromHex(proposal.successionId), expectedGeneration: unbig(proposal.expectedGeneration), stale: proposal.stale, conflictGeneration: proposal.conflictGeneration === undefined ? undefined : unbig(proposal.conflictGeneration) } : undefined,
274
+ bootstrap: meta?.bootstrap ? { cursor: meta.bootstrap.cursor, complete: meta.bootstrap.complete } : undefined,
241
275
  certificateHash: this.selfPin?.certificateHash,
242
276
  generation: this.selfPin?.generation,
243
277
  desiredRevision: revision ? unbig(revision.desiredRevision) : 1n,
@@ -272,8 +306,8 @@ class ClientImpl {
272
306
  const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
273
307
  const iid = instanceId;
274
308
  await this.store.transaction((tx) => t1Header(tx, { account, device, certificate, instanceId: iid, generation: 1n }));
275
- const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey);
276
- const outcome = await this.transport.directory.registerAccount({ certificate, vault: vault.wire });
309
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
310
+ const outcome = await this.transport.directory.registerAccount({ certificate, vault: vault?.wire });
277
311
  if (outcome.status === "CONFLICT") {
278
312
  // A restart or a lost response after the host committed: the name is ours if the host holds
279
313
  // exactly the certificate this device saved; anything else is somebody else's account.
@@ -285,7 +319,8 @@ class ClientImpl {
285
319
  throw new KavachError("NOT_AUTHORIZED", stage, `host refused registration: ${outcome.status}`);
286
320
  }
287
321
  await this.store.transaction((tx) => {
288
- t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
322
+ if (vault)
323
+ t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
289
324
  t1Activate(tx);
290
325
  });
291
326
  this.account = account;
@@ -293,7 +328,7 @@ class ClientImpl {
293
328
  this.selfPin = pin;
294
329
  // The recovery key goes to the provider; enrollment never blocks on it or on the code.
295
330
  try {
296
- await this.provider.save(this.accountName, pin.identityId, recoveryKey);
331
+ await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
297
332
  }
298
333
  catch {
299
334
  /* status is reported by recoveryStatus() */
@@ -302,9 +337,15 @@ class ClientImpl {
302
337
  return { identityId: pin.identityId };
303
338
  }
304
339
  async recoveryStatus() {
340
+ if (!this.provider)
341
+ return { status: "unsupported", backupState: "unavailable", reason: "no recovery provider on a recovery-only profile" };
305
342
  return this.provider.status();
306
343
  }
307
344
  async recoveryCode() {
345
+ // A code opens a vault for identity continuity; where the profile disables that, or where no
346
+ // vault exists, the code would open nothing and is not issued.
347
+ if (!allowsContinuity(this.policy) || !this.vaultEnabled)
348
+ throw new KavachError("NOT_AUTHORIZED", "recoveryCode", "this deployment does not restore accounts by recovery code");
308
349
  const key = await this.secrets.get("recoveryKey");
309
350
  if (!key)
310
351
  throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", "recoveryCode");
@@ -324,6 +365,8 @@ class ClientImpl {
324
365
  // moved under us): prepare again with the same device keys. Nothing was allocated remotely.
325
366
  pending = resumed;
326
367
  }
368
+ if (!allowsContinuity(this.policy))
369
+ throw new KavachError("NOT_AUTHORIZED", stage, "this deployment replaces devices through successions, not the recovery key");
327
370
  const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
328
371
  if (!resolved)
329
372
  throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
@@ -331,7 +374,13 @@ class ClientImpl {
331
374
  const vaultWire = await this.transport.directory.getVault(this.accountName);
332
375
  if (!vaultWire)
333
376
  throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no vault");
377
+ const accountId = resolvedAccountId(resolved);
334
378
  const identity = pinFromCertificate(current).identityId;
379
+ if (!this.provider)
380
+ throw new KavachError("NOT_AUTHORIZED", stage, "identity continuity needs a recovery provider");
381
+ // An account that succeeded: the served chain is verified link by link under the current list,
382
+ // from the root to the key this device is about to hold, before anything is written.
383
+ const verified = resolved.successions?.length ? (await this.followChain({ accountId, accountName: this.accountName, finalIdentityId: identity, firstPage: resolved.successions, next: resolved.successionsNext })).verified : [];
335
384
  const recovered = await recoverAccount({
336
385
  accountName: this.accountName,
337
386
  identityId: identity,
@@ -340,12 +389,12 @@ class ClientImpl {
340
389
  provider: this.provider,
341
390
  ...(recoveryCode === undefined ? {} : { recoveryCode })
342
391
  });
343
- const account = openAccount(this.accountName, recovered.vault.accountSeed);
392
+ const account = openAccount(this.accountName, recovered.vault.accountSeed, accountId);
344
393
  // Fresh device keys and a fresh local store. Nothing from the old device is copied.
345
394
  const device = pending?.device ?? newDevice();
346
395
  const generation = current.generation + 1n;
347
396
  const certificate = certify(account, device, generation);
348
- const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
397
+ const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice), undefined, accountId);
349
398
  const header = parseVaultHeader(vaultWire);
350
399
  const nextVault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: header.vaultRevision + 1n }, { accountSeed: recovered.vault.accountSeed, lastDeviceGeneration: generation, lastCertificateHash: pin.certificateHash, entries: recovered.vault.entries }, recovered.recoveryKey);
351
400
  const instanceId = pending?.instanceId ?? randomBytes(16);
@@ -353,6 +402,8 @@ class ClientImpl {
353
402
  await t1Secrets(this.secrets, { accountSeed: recovered.vault.accountSeed, deviceSeed: device.deviceSeed, recoveryKey: recovered.recoveryKey, instanceId });
354
403
  await this.store.transaction((tx) => {
355
404
  t1Header(tx, { account, device, certificate, instanceId, generation });
405
+ recordChainIn(tx, verified, this.now());
406
+ rootIdentityIn(tx, accountId, this.accountName, resolved.rootCertificate);
356
407
  t2PrepareActivation(tx, {
357
408
  certificate: toHex(certificate),
358
409
  vault: toHex(nextVault.wire),
@@ -387,7 +438,7 @@ class ClientImpl {
387
438
  tx.delete("activation", "pending");
388
439
  t9Vault(tx, { revision: big(input.vaultRevision), hash: toHex(input.vault.hash), wire: toHex(input.vault.wire), pending: false, dirty: false });
389
440
  for (const e of input.entries) {
390
- const owned = equalBytes(e.ownerIdentityId, pin.identityId);
441
+ const owned = equalBytes(e.ownerIdentityId, pin.accountId);
391
442
  const c = pinCircle(e.circleId, e.ownerIdentityId, e.sharingEnabled, owned ? "member" : "requested");
392
443
  tx.put("circles", toHex(e.circleId), circleToRow(c));
393
444
  }
@@ -398,7 +449,7 @@ class ClientImpl {
398
449
  this.device = device;
399
450
  this.selfPin = pin;
400
451
  try {
401
- await this.provider.save(this.accountName, pin.identityId, input.recoveryKey);
452
+ await this.provider?.save(this.accountName, pin.identityId, input.recoveryKey);
402
453
  }
403
454
  catch {
404
455
  /* reported by recoveryStatus() */
@@ -418,14 +469,15 @@ class ClientImpl {
418
469
  if (!row)
419
470
  throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its prepared bytes");
420
471
  const [accountSeed, deviceSeed, recoveryKey, instanceId] = await Promise.all([this.secrets.get("accountSeed"), this.secrets.get("deviceSeed"), this.secrets.get("recoveryKey"), this.secrets.get("instanceId")]);
421
- const self = await this.store.read((tx) => tx.get("self", "self"));
422
- if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId || !self)
472
+ const [self, meta] = await this.store.read((tx) => [tx.get("self", "self"), tx.get("meta", META)]);
473
+ if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId || !self || !meta)
423
474
  throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its roots");
424
- const account = openAccount(this.accountName, accountSeed);
475
+ const accountId = fromHex(meta.accountId ?? meta.identityId);
476
+ const account = openAccount(this.accountName, accountSeed, accountId);
425
477
  const device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
426
478
  const certificate = fromHex(row.certificate);
427
479
  const prepared = expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage);
428
- const pin = pinFromCertificate(prepared);
480
+ const pin = pinFromCertificate(prepared, undefined, accountId);
429
481
  const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
430
482
  if (!me)
431
483
  throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
@@ -457,7 +509,7 @@ class ClientImpl {
457
509
  * asks the provider and saves again when it has nothing for this identity.
458
510
  */
459
511
  async ensureBackup() {
460
- if (this.backupChecked || !this.selfPin)
512
+ if (this.backupChecked || !this.selfPin || !this.provider)
461
513
  return;
462
514
  this.backupChecked = true;
463
515
  try {
@@ -503,7 +555,7 @@ class ClientImpl {
503
555
  /** After commit: the device's own record is stored like any author's, so one subscription covers self and others. */
504
556
  enqueued(kind, r) {
505
557
  if (!r.duplicate && this.selfPin)
506
- this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.identityId, recordId: r.recordId, tookSlot: r.own });
558
+ this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.accountId, recordId: r.recordId, tookSlot: r.own });
507
559
  return { recordId: r.recordId, duplicate: r.duplicate };
508
560
  }
509
561
  // Epochs ----------------------------------------------------------------------------------------
@@ -511,7 +563,15 @@ class ClientImpl {
511
563
  const { pin } = this.requireAccount("audience");
512
564
  const retired = new Set(this.trust.state.retired);
513
565
  return this.store.read((tx) => {
566
+ // A reset retires the whole account, however many successions it went through.
567
+ for (const [, r] of tx.list("identities"))
568
+ if (r.resetAt !== undefined)
569
+ retired.add(r.accountId);
514
570
  const pins = new Map(tx.list("pins").map(([k, p]) => [k, rowToPin(p)]));
571
+ // An account whose chain this device has not finished verifying is out of every audience until it has.
572
+ for (const [, p] of tx.list("chainProgress"))
573
+ if (!p.complete)
574
+ pins.delete(p.accountId);
515
575
  const circles = tx.list("circles").map(([, c]) => rowToCircle(c));
516
576
  return resolveAudience(pin, circles, pins, retired);
517
577
  });
@@ -552,7 +612,7 @@ class ClientImpl {
552
612
  else {
553
613
  const revision = revisionRow ? unbig(revisionRow.desiredRevision) : 1n;
554
614
  const fresh = prepareEpoch({
555
- author: { identityId: pin.identityId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
615
+ author: { identityId: pin.accountId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
556
616
  epoch: stream.highestCommittedEpoch + 1,
557
617
  createdAt: this.now(),
558
618
  audience,
@@ -605,13 +665,32 @@ class ClientImpl {
605
665
  }
606
666
  async runSync(options) {
607
667
  const report = {
608
- metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0 },
668
+ metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
609
669
  epoch: "unchanged",
610
670
  outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
611
671
  inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
612
672
  purged: 0,
613
673
  errors: []
614
674
  };
675
+ this.syncBudget = {
676
+ remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
677
+ deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
678
+ };
679
+ if (!this.selfPin) {
680
+ // A prepared replacement whose commit this device never saw: the chain says whether it landed.
681
+ const row = await this.store.read((tx) => tx.get("replacement", "pending"));
682
+ if (row && !row.stale) {
683
+ try {
684
+ const link = await this.findOwnLink(row);
685
+ const roots = await this.secrets.get("pendingReplacement");
686
+ if (link && roots && roots.length === RESET_ROOTS_BYTES)
687
+ await this.activateReplacement(row, roots, link.wire);
688
+ }
689
+ catch (e) {
690
+ report.errors.push(syncError("resumeReplacement", e));
691
+ }
692
+ }
693
+ }
615
694
  if (!this.account || !this.selfPin)
616
695
  return report;
617
696
  const meta = await this.store.read((tx) => tx.get("meta", META));
@@ -643,12 +722,26 @@ class ClientImpl {
643
722
  else if (meta.state !== "ACTIVE")
644
723
  return report;
645
724
  await this.ensureBackup();
725
+ if (meta.bootstrap && !meta.bootstrap.complete) {
726
+ try {
727
+ report.bootstrap = await this.bootstrap(report, meta.bootstrap);
728
+ }
729
+ catch (e) {
730
+ report.errors.push(syncError("bootstrap", e));
731
+ }
732
+ }
646
733
  try {
647
734
  await this.syncMetadata(report);
648
735
  }
649
736
  catch (e) {
650
737
  report.errors.push(syncError("metadata", e));
651
738
  }
739
+ try {
740
+ await this.pushPreferences(report);
741
+ }
742
+ catch (e) {
743
+ report.errors.push(syncError("preference", e));
744
+ }
652
745
  const after = await this.store.read((tx) => tx.get("meta", META));
653
746
  if (after?.state === "RETIRED")
654
747
  return report;
@@ -674,10 +767,9 @@ class ClientImpl {
674
767
  // Each stream on its own: a failed download or a refused record names its stream in the report
675
768
  // and leaves the other streams to be read; a stream that failed is not counted as synced.
676
769
  // One budget spans the streams; a stream it stops early reports hasMore.
677
- const budget = {
678
- remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
679
- deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
680
- };
770
+ const budget = this.syncBudget;
771
+ if (budget.remainingRecords < 1)
772
+ budget.remainingRecords = 1; // every stream reads at least one page
681
773
  for (const s of subs) {
682
774
  try {
683
775
  const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
@@ -708,7 +800,16 @@ class ClientImpl {
708
800
  if (me) {
709
801
  const theirs = expectType(verifyDocument(me.certificate), DocumentType.AccountDevice, "sync");
710
802
  if (!equalBytes(theirs.accountKey, pin.accountKey)) {
711
- // Our own key changed at the host: only a receipt we produced explains that; otherwise ignore.
803
+ // Our own key changed at the host. A succession from our identity, attested by an authority
804
+ // we trust, retires this device (its key is no longer the account's); a bare key change or a
805
+ // reset we did not make is ignored here, as before.
806
+ if (await this.supersededBySuccession(pin, me, report)) {
807
+ await this.retire();
808
+ // The key itself is retired, not only the device: nothing of it stays here.
809
+ await this.secrets.delete("accountSeed");
810
+ this.account = undefined;
811
+ return;
812
+ }
712
813
  }
713
814
  else if (!equalBytes(theirs.bodyHash, pin.certificateHash) && theirs.generation > pin.generation) {
714
815
  await this.retire();
@@ -733,7 +834,7 @@ class ClientImpl {
733
834
  // Unconfirmed writes of our own first: a successor snapshot the host never took, a vault update
734
835
  // it never saw. Until they land, peers read stale membership and a replacement phone a stale
735
836
  // preference.
736
- const pendingOwned = await this.store.read((tx) => tx.list("circles").filter(([, r]) => r.pending && r.ownerIdentityId === toHex(pin.identityId)).map(([, r]) => fromHex(r.circleId)));
837
+ const pendingOwned = await this.store.read((tx) => tx.list("circles").filter(([, r]) => r.pending && r.ownerIdentityId === toHex(pin.accountId)).map(([, r]) => fromHex(r.circleId)));
737
838
  for (const circleId of pendingOwned) {
738
839
  try {
739
840
  if ((await this.publishPendingSnapshot(circleId)) === "COMMITTED")
@@ -755,18 +856,18 @@ class ClientImpl {
755
856
  for (const c of circles) {
756
857
  if (c.status === "closed" || c.status === "quarantined" || c.status === "left")
757
858
  continue;
758
- if (!equalBytes(c.ownerIdentityId, pin.identityId) && !(await this.pinFor(c.ownerIdentityId)))
859
+ if (!equalBytes(c.ownerIdentityId, pin.accountId) && !(await this.pinFor(c.ownerIdentityId)))
759
860
  unknownOwners.push(c.ownerIdentityId);
760
861
  }
761
862
  if (unknownOwners.length > 0) {
762
863
  for (const r of await this.transport.directory.resolveIdentities(unknownOwners))
763
- await this.acceptMemberCertificate(r.certificate, r.resetReceipt, report);
864
+ await this.acceptMemberCertificate(r, report);
764
865
  }
765
866
  const memberIds = new Map();
766
867
  for (const c of circles) {
767
868
  if (c.status === "closed" || c.status === "quarantined" || c.status === "left")
768
869
  continue;
769
- const ownerPin = equalBytes(c.ownerIdentityId, pin.identityId) ? pin : await this.pinFor(c.ownerIdentityId);
870
+ const ownerPin = equalBytes(c.ownerIdentityId, pin.accountId) ? pin : await this.pinFor(c.ownerIdentityId);
770
871
  if (!ownerPin) {
771
872
  memberIds.set(toHex(c.ownerIdentityId), c.ownerIdentityId);
772
873
  continue;
@@ -774,7 +875,7 @@ class ClientImpl {
774
875
  const wire = await this.transport.directory.getCircle(c.circleId);
775
876
  if (wire) {
776
877
  try {
777
- const r = acceptSnapshot(c, wire, ownerPin.accountKey, pin.identityId, now);
878
+ const r = await this.acceptSnapshotFrom(c, wire, ownerPin, now, report);
778
879
  if (r.outcome === "ACCEPTED") {
779
880
  // The row may have moved since it was read: a sharing preference, a leave or a close
780
881
  // made meanwhile wins over the fetched snapshot, and a newer snapshot already stored
@@ -821,7 +922,7 @@ class ClientImpl {
821
922
  else
822
923
  for (const m of c.members)
823
924
  memberIds.set(toHex(m), m);
824
- if (equalBytes(c.ownerIdentityId, pin.identityId) && c.status === "member") {
925
+ if (equalBytes(c.ownerIdentityId, pin.accountId) && c.status === "member") {
825
926
  const requests = await this.transport.directory.readRequests(c.circleId);
826
927
  const joins = requests.filter((r) => r.action === "join").map((r) => r.accountName);
827
928
  if (joins.length)
@@ -830,9 +931,8 @@ class ClientImpl {
830
931
  const [acct] = await this.transport.directory.resolveAccounts([r.accountName]);
831
932
  if (!acct)
832
933
  continue;
833
- const leaver = pinFromCertificate(expectType(verifyDocument(acct.certificate), DocumentType.AccountDevice));
834
934
  try {
835
- await this.changeMembership(c.circleId, { remove: leaver.identityId });
935
+ await this.changeMembership(c.circleId, { remove: resolvedAccountId(acct) });
836
936
  }
837
937
  catch (e) {
838
938
  report.errors.push(syncError("removeLeaver", e));
@@ -840,28 +940,308 @@ class ClientImpl {
840
940
  }
841
941
  }
842
942
  }
843
- memberIds.delete(toHex(pin.identityId));
943
+ memberIds.delete(toHex(pin.accountId));
844
944
  if (memberIds.size > 0) {
845
945
  const resolved = await this.transport.directory.resolveIdentities([...memberIds.values()]);
846
946
  const byIdentity = new Map();
847
947
  for (const r of resolved) {
848
- const doc = expectType(verifyDocument(r.certificate), DocumentType.AccountDevice, "sync");
849
- const iid = toHex(pinFromCertificate(doc).identityId);
850
- byIdentity.set(iid, { accountName: r.accountName, accountKey: r.accountKey });
851
- await this.acceptMemberCertificate(r.certificate, r.resetReceipt, report);
948
+ expectType(verifyDocument(r.certificate), DocumentType.AccountDevice, "sync");
949
+ byIdentity.set(toHex(resolvedAccountId(r)), { accountName: r.accountName, accountKey: r.accountKey });
950
+ await this.acceptMemberCertificate(r, report);
852
951
  }
853
952
  checkMemberResolution(byIdentity);
854
953
  }
855
954
  }
856
- async pinFor(identityId) {
857
- const row = await this.store.read((tx) => tx.get("pins", toHex(identityId)));
955
+ /**
956
+ * Has the host's chain for our own account moved past our key? A valid succession from our
957
+ * current identity means an authority replaced this account's key; the device holding the old
958
+ * key has nothing left to say. Deferred (no fresh list, untrusted clock) and invalid
959
+ * attestations leave the device as it is.
960
+ */
961
+ async supersededBySuccession(pin, me, report) {
962
+ if (!me.successions?.length)
963
+ return false;
964
+ try {
965
+ const r = await this.followChain({ accountId: pin.accountId, accountName: pin.accountName, current: pin, firstPage: me.successions, next: me.successionsNext, budget: this.syncBudget });
966
+ return r.pin !== undefined && !equalBytes(r.pin.identityId, pin.identityId);
967
+ }
968
+ catch (e) {
969
+ // A chain this device cannot follow for its own account is a diagnostic the product must
970
+ // see: the host has already fenced the device, and only this report says why it still runs.
971
+ report.errors.push(syncError("ownSuccession", e));
972
+ return false;
973
+ }
974
+ }
975
+ /**
976
+ * Verify a snapshot under the owner's current key, or, when the owner's key changed through a
977
+ * succession and the snapshot predates it, under the retired key it was signed with. The
978
+ * successor republishes at the next revision; until then the older snapshot stays valid.
979
+ */
980
+ async acceptSnapshotFrom(c, wire, ownerPin, now, report, resolved = false) {
981
+ const { pin } = this.requireAccount("sync");
982
+ // The very bytes this device already verified (a bootstrap snapshot under a key since retired)
983
+ // are a duplicate whatever key signed them; only new bytes are verified.
984
+ if (c.snapshot && equalBytes(c.snapshot, wire))
985
+ return { outcome: "DUPLICATE", state: c };
986
+ try {
987
+ return acceptSnapshot(c, wire, ownerPin.accountKey, pin.accountId, now);
988
+ }
989
+ catch (e) {
990
+ if (!isKavachError(e, "BAD_SIGNATURE"))
991
+ throw e;
992
+ // A retired key may only vouch for a circle this device holds at revision 0 (bootstrap, first
993
+ // contact): an established circle advances under the current owner key alone.
994
+ const ownerHex = toHex(ownerPin.accountId);
995
+ const retiredKeys = c.revision === 0n ? await this.store.read((tx) => tx.list("identities").flatMap(([, r]) => (r.accountId === ownerHex && r.retired && r.accountKey ? [fromHex(r.accountKey)] : []))) : [];
996
+ for (const key of retiredKeys) {
997
+ try {
998
+ return acceptSnapshot(c, wire, key, pin.accountId, now);
999
+ }
1000
+ catch (inner) {
1001
+ if (!isKavachError(inner, "BAD_SIGNATURE"))
1002
+ throw inner;
1003
+ }
1004
+ }
1005
+ // Signed by a key we do not know: the owner may have succeeded since we last resolved it.
1006
+ // The chain is verified first; only a key it produced may sign this circle.
1007
+ if (!resolved && report && !equalBytes(ownerPin.accountId, pin.accountId)) {
1008
+ const [r] = await this.transport.directory.resolveIdentities([ownerPin.accountId]);
1009
+ if (r && !equalBytes(r.accountKey, ownerPin.accountKey)) {
1010
+ await this.acceptMemberCertificate(r, report);
1011
+ const fresh = await this.pinFor(ownerPin.accountId);
1012
+ if (fresh && !equalBytes(fresh.accountKey, ownerPin.accountKey))
1013
+ return this.acceptSnapshotFrom(c, wire, fresh, now, report, true);
1014
+ }
1015
+ }
1016
+ throw e;
1017
+ }
1018
+ }
1019
+ async pinFor(accountId) {
1020
+ const row = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
858
1021
  return row ? rowToPin(row) : undefined;
859
1022
  }
860
- /** Accept a member's certificate; a changed account key is quarantined unless a valid receipt explains it. */
861
- async acceptMemberCertificate(wire, resetReceipt, report) {
1023
+ /**
1024
+ * The one path every succession takes (section 4, "Chains"): the policy gate first, then the
1025
+ * key list, then each link verified in order from the start identity (the pinned identity for
1026
+ * an established contact, the account id at first contact or for a restoring device), page by
1027
+ * page within the sync's budget, with the verified prefix persisted so that the next sync
1028
+ * resumes instead of starting over. Only when the chain is complete does anything move: for a
1029
+ * pinned contact every link is applied in one transaction, so no epoch is ever sealed to an
1030
+ * intermediate successor; for a first contact or a restore the verified links are returned and
1031
+ * the caller records them with the pin or the header. Any failure leaves the pin where it was.
1032
+ */
1033
+ async followChain(input) {
1034
+ const stage = "chain";
1035
+ if (!acceptsSuccession(this.policy))
1036
+ throw new KavachError("UNSUPPORTED_TYPE", stage, "this deployment does not accept successions");
1037
+ const accountHex = toHex(input.accountId);
1038
+ const startIdentity = input.current?.identityId ?? input.accountId;
1039
+ await this.ensureResetList();
1040
+ // The state the chain is validated against: the pin with what this store retired, or the root.
1041
+ const current = input.current;
1042
+ let state = current ? await this.store.read((tx) => pinnedState(tx, current, this.trust)) : rootState(input.accountId, input.accountName, this.trust);
1043
+ // Reconcile first: the host serves the chain from the root, and a pinned contact already holds
1044
+ // a prefix of it. Links whose transition is on record are dropped (the same transition at a
1045
+ // recorded state; a different one is the authority signing two successors), so verification
1046
+ // starts at the pinned identity with only the transitions this device has not seen.
1047
+ const recorded = current ? await this.store.read((tx) => tx.list("successions", accountHex + ":").map(([, r]) => r)) : [];
1048
+ // The bound, applied to every page alike: links past `untilGeneration` are not this call's to
1049
+ // follow (a replacing device stops at the state its own succession left from).
1050
+ const within = (l) => input.untilGeneration === undefined || expectType(parseDocument(l.wire), DocumentType.AccountSuccession, stage).newGeneration <= input.untilGeneration;
1051
+ const unseen = (links) => links.filter(within).filter((l) => {
1052
+ const doc = expectType(parseDocument(l.wire), DocumentType.AccountSuccession, stage);
1053
+ const t = transitionOf(doc);
1054
+ const row = recorded.find((r) => unbig(r.previousGeneration) === t.previousGeneration && r.previousIdentityId === t.previousIdentityId);
1055
+ if (!row)
1056
+ return true;
1057
+ if (!sameTransition({ accountName: row.accountName, accountId: row.accountId, successionId: row.successionId, previousIdentityId: row.previousIdentityId, newIdentityId: row.newIdentityId, previousGeneration: unbig(row.previousGeneration), newGeneration: unbig(row.newGeneration), newCertificateHash: row.newCertificateHash }, t)) {
1058
+ // A different transition at a recorded state is evidence against the authority only if
1059
+ // the authority signed it: unauthenticated bytes are a bad signature, nothing more.
1060
+ authenticateSuccession(l.wire, this.trust, this.now());
1061
+ throw new KavachError("EQUIVOCATION", stage, "two successors for one account state");
1062
+ }
1063
+ return false;
1064
+ });
1065
+ const progress = await this.store.read((tx) => tx.get("chainProgress", accountHex));
1066
+ const verified = [];
1067
+ let cursor;
1068
+ let pending;
1069
+ // Only links this device has not recorded are verified, and only they count against the budget.
1070
+ const verifyMore = (links) => {
1071
+ const fresh = unseen(links);
1072
+ const r = verifyChain(fresh, { state, trust: this.trust, now: this.now() });
1073
+ verified.push(...r.links);
1074
+ state = r.state;
1075
+ if (input.budget)
1076
+ input.budget.remainingRecords -= fresh.length;
1077
+ };
1078
+ if (progress && progress.startIdentityId === toHex(startIdentity)) {
1079
+ // Resume at the saved cursor, however long the verified prefix is. The prefix's transitions
1080
+ // were verified when their attestations were allowed, so they are replayed through the
1081
+ // transition rules alone; their bytes are authenticated again below, and refreshed from the
1082
+ // host where a key rotation since made them stale.
1083
+ for (const l of progress.links) {
1084
+ const doc = expectType(parseDocument(fromHex(l.wire)), DocumentType.AccountSuccession, stage);
1085
+ state = validateTransition(doc, fromHex(l.certificate), state).next;
1086
+ verified.push({ doc, wire: fromHex(l.wire), certificate: fromHex(l.certificate) });
1087
+ }
1088
+ cursor = unbig(progress.nextGeneration);
1089
+ pending = [];
1090
+ }
1091
+ else {
1092
+ pending = input.firstPage;
1093
+ cursor = input.next;
1094
+ }
1095
+ const persist = () => this.store.transaction((tx) => {
1096
+ if (cursor === undefined)
1097
+ tx.delete("chainProgress", accountHex);
1098
+ else
1099
+ tx.put("chainProgress", accountHex, { accountId: accountHex, startIdentityId: toHex(startIdentity), links: verified.map((v) => ({ wire: toHex(v.wire), certificate: toHex(v.certificate) })), nextGeneration: big(cursor), complete: false });
1100
+ });
1101
+ const reached = () => input.untilGeneration !== undefined && state.generation >= input.untilGeneration;
1102
+ verifyMore(pending);
1103
+ if (reached())
1104
+ cursor = undefined;
1105
+ while (cursor !== undefined) {
1106
+ if (input.budget && (input.budget.remainingRecords <= 0 || this.now() >= input.budget.deadline)) {
1107
+ await persist();
1108
+ throw new KavachError("METADATA_REQUIRED", stage, "the chain continues past this sync's budget");
1109
+ }
1110
+ let page;
1111
+ try {
1112
+ page = await this.transport.directory.getSuccessions(input.accountId, cursor, 32);
1113
+ }
1114
+ catch (e) {
1115
+ await persist();
1116
+ throw e;
1117
+ }
1118
+ if (page.successions.length === 0) {
1119
+ await persist();
1120
+ throw new KavachError("METADATA_REQUIRED", stage, "the host did not finish serving the chain");
1121
+ }
1122
+ verifyMore(page.successions);
1123
+ cursor = reached() ? undefined : page.nextGeneration;
1124
+ if (cursor !== undefined)
1125
+ await persist();
1126
+ }
1127
+ if (input.finalIdentityId && state.identityId !== toHex(input.finalIdentityId))
1128
+ throw new KavachError("SUCCESSION_BINDING", stage, "the chain does not end at the served key");
1129
+ // Every attestation under the current list before anything is recorded. A link whose signer
1130
+ // left the list since it was verified is fetched again: the host re-attests a legitimate
1131
+ // transition under a current key, and the transition must be the same one.
1132
+ for (let i = 0; i < verified.length; i++) {
1133
+ const link = verified[i];
1134
+ if (!link)
1135
+ continue;
1136
+ try {
1137
+ authenticateSuccession(link.wire, this.trust, this.now());
1138
+ }
1139
+ catch (e) {
1140
+ if (!isKavachError(e, "RESET_KEY_REVOKED"))
1141
+ throw e;
1142
+ const page = await this.transport.directory.getSuccessions(input.accountId, link.doc.newGeneration - 1n, 1);
1143
+ const again = page.successions[0];
1144
+ if (!again)
1145
+ throw e;
1146
+ const doc = authenticateSuccession(again.wire, this.trust, this.now());
1147
+ if (!sameTransition(transitionOf(doc), transitionOf(link.doc)) || !equalBytes(again.certificate, link.certificate))
1148
+ throw new KavachError("EQUIVOCATION", stage, "the re-attested link is a different transition");
1149
+ verified[i] = { doc, wire: again.wire, certificate: link.certificate };
1150
+ }
1151
+ }
1152
+ if (!current) {
1153
+ if (progress)
1154
+ await this.store.transaction((tx) => tx.delete("chainProgress", accountHex));
1155
+ return { pin: undefined, verified, accepted: [] };
1156
+ }
1157
+ const applied = await this.store.transaction((tx) => {
1158
+ const effects = [];
1159
+ let pin = current;
1160
+ for (const link of verified) {
1161
+ // A link behind the pinned key that is already on record: the pin moved past it earlier.
1162
+ if (link.doc.previousGeneration < pin.generation && tx.list("successions").some(([, s]) => s.accountId === accountHex && unbig(s.previousGeneration) === link.doc.previousGeneration))
1163
+ continue;
1164
+ const e = applySuccession(tx, { wire: link.wire, newCertificate: link.certificate, policy: this.policy, trust: this.trust, now: this.now() });
1165
+ effects.push(e);
1166
+ pin = e.pin;
1167
+ }
1168
+ this.persistTrustIn(tx);
1169
+ tx.delete("chainProgress", accountHex);
1170
+ return { effects, pin };
1171
+ });
1172
+ return { pin: applied.pin, verified, accepted: applied.effects };
1173
+ }
1174
+ /** The report side of an applied chain: counters and events for every accepted link. */
1175
+ reportAccepted(accepted, report) {
1176
+ for (const effect of accepted) {
1177
+ if (effect.outcome !== "ACCEPTED")
1178
+ continue;
1179
+ report.metadata.successions++;
1180
+ report.metadata.rotations++;
1181
+ this.emit({ kind: "accountKeyChanged", accountName: effect.accountName, quarantined: false });
1182
+ this.emit({ kind: "deviceChanged", identityId: effect.pin.accountId, generation: effect.pin.generation });
1183
+ }
1184
+ }
1185
+ securityCodeOf(e) {
1186
+ return e instanceof KavachError && (e.code === "EQUIVOCATION" || e.code === "ROLLBACK" || e.code === "SUCCESSION_BINDING" || e.code === "DEVICE_ID_REUSED" || e.code === "SUPERSEDED_IDENTITY") ? e.code : undefined;
1187
+ }
1188
+ /** Accept a member's certificate; a changed account key is quarantined unless a succession chain or a reset receipt explains it. */
1189
+ async acceptMemberCertificate(resolved, report) {
1190
+ const wire = resolved.certificate;
1191
+ const resetReceipt = resolved.resetReceipt;
862
1192
  const doc = expectType(verifyDocument(wire), DocumentType.AccountDevice, "sync");
863
1193
  const existing = await this.store.read((tx) => tx.list("pins").find(([, p]) => p.accountName === doc.accountName)?.[1]);
864
- const current = existing ? rowToPin(existing) : undefined;
1194
+ let current = existing ? rowToPin(existing) : undefined;
1195
+ const accountId = current?.accountId ?? resolvedAccountId(resolved);
1196
+ const chain = resolved.successions ?? [];
1197
+ // A served chain is followed whenever there is one, even when the served key equals the pinned
1198
+ // one: a certificate under a retired key at a higher generation must be refused as a return to
1199
+ // that identity, never accepted as a plain device change. Links already on record cost nothing.
1200
+ if (current && chain.length > 0) {
1201
+ try {
1202
+ const r = await this.followChain({ accountId, accountName: doc.accountName, current, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget });
1203
+ this.reportAccepted(r.accepted, report);
1204
+ current = r.pin;
1205
+ }
1206
+ catch (e) {
1207
+ report.errors.push(syncError("succession", e));
1208
+ // A deployment that refuses successions quarantines the key change below, as a bare
1209
+ // directory key change. Anything else leaves the pin where it was until the next sync.
1210
+ if (!isKavachError(e, "UNSUPPORTED_TYPE")) {
1211
+ const code = this.securityCodeOf(e);
1212
+ if (code)
1213
+ this.emit({ kind: "securityError", code, detail: doc.accountName });
1214
+ return;
1215
+ }
1216
+ }
1217
+ if (!current)
1218
+ return;
1219
+ }
1220
+ let verified = [];
1221
+ if (!current && chain.length > 0) {
1222
+ // A first contact with an account that went through successions: the chain is verified from
1223
+ // the root to the served key before any pin is written, under the same policy gate as for an
1224
+ // established contact. A deployment that refuses successions pins nothing and quarantines.
1225
+ try {
1226
+ verified = (await this.followChain({ accountId, accountName: doc.accountName, finalIdentityId: pinFromCertificate(doc).identityId, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget })).verified;
1227
+ }
1228
+ catch (e) {
1229
+ report.errors.push(syncError("succession", e));
1230
+ if (isKavachError(e, "UNSUPPORTED_TYPE")) {
1231
+ const already = await this.store.read((tx) => tx.get("quarantine", doc.accountName));
1232
+ await this.store.transaction((tx) => tx.put("quarantine", doc.accountName, { accountName: doc.accountName, certificate: toHex(wire), reason: "succession refused", at: big(this.now()) }));
1233
+ report.metadata.quarantined++;
1234
+ if (!already)
1235
+ this.emit({ kind: "accountKeyChanged", accountName: doc.accountName, quarantined: true });
1236
+ }
1237
+ else {
1238
+ const code = this.securityCodeOf(e);
1239
+ if (code)
1240
+ this.emit({ kind: "securityError", code, detail: doc.accountName });
1241
+ }
1242
+ return;
1243
+ }
1244
+ }
865
1245
  let outcome;
866
1246
  try {
867
1247
  outcome = acceptCertificate(current, wire);
@@ -881,24 +1261,33 @@ class ClientImpl {
881
1261
  }
882
1262
  if (outcome.outcome === "DUPLICATE")
883
1263
  return;
884
- const p = outcome.pin;
1264
+ const p = current ? outcome.pin : { ...outcome.pin, accountId };
885
1265
  await this.store.transaction((tx) => {
886
- tx.put("pins", toHex(p.identityId), pinToRow(p));
1266
+ tx.put("pins", toHex(p.accountId), pinToRow(p));
1267
+ if (verified.length) {
1268
+ recordChainIn(tx, verified, this.now());
1269
+ rootIdentityIn(tx, p.accountId, p.accountName, resolved.rootCertificate);
1270
+ }
1271
+ if (!tx.get("identities", toHex(p.identityId))) {
1272
+ const last = resolved.successions?.at(-1);
1273
+ const since = last ? expectType(parseDocument(last.wire), DocumentType.AccountSuccession, "sync").newGeneration : 1n;
1274
+ tx.put("identities", toHex(p.identityId), { accountId: toHex(p.accountId), accountName: p.accountName, accountKey: toHex(p.accountKey), sinceGeneration: big(since), retired: false });
1275
+ }
887
1276
  if (outcome.previous) {
888
1277
  const old = tx.get("certificates", toHex(outcome.previous.certificateHash));
889
1278
  if (old)
890
1279
  tx.put("certificates", toHex(outcome.previous.certificateHash), { ...old, current: false });
891
1280
  }
892
- tx.put("certificates", toHex(p.certificateHash), { identityId: toHex(p.identityId), certificateHash: toHex(p.certificateHash), signingKey: toHex(p.signingKey), boxKey: toHex(p.boxKey), streamId: toHex(p.streamId), generation: big(p.generation), wire: toHex(p.certificate), current: true });
1281
+ tx.put("certificates", toHex(p.certificateHash), { identityId: toHex(p.accountId), certificateHash: toHex(p.certificateHash), signingKey: toHex(p.signingKey), boxKey: toHex(p.boxKey), streamId: toHex(p.streamId), generation: big(p.generation), wire: toHex(p.certificate), current: true });
893
1282
  if (outcome.rotate) {
894
- const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.identityId, certificateHash: p.certificateHash });
1283
+ const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.accountId, certificateHash: p.certificateHash });
895
1284
  if (ch.changed)
896
1285
  report.metadata.rotations++;
897
1286
  }
898
1287
  });
899
1288
  report.metadata.certificates++;
900
1289
  if (outcome.rotate)
901
- this.emit({ kind: "deviceChanged", identityId: p.identityId, generation: p.generation });
1290
+ this.emit({ kind: "deviceChanged", identityId: p.accountId, generation: p.generation });
902
1291
  }
903
1292
  async onKeyChanged(current, wire, receipt, report) {
904
1293
  if (receipt) {
@@ -934,20 +1323,26 @@ class ClientImpl {
934
1323
  }
935
1324
  /** Peers on a validated reset: stop sharing to the old identity, close its circles, rotate. */
936
1325
  applyResetIn(tx, old, newIdentityId) {
937
- tx.delete("pins", toHex(old.identityId));
1326
+ const accountHex = toHex(old.accountId);
1327
+ tx.delete("pins", accountHex);
938
1328
  tx.delete("quarantine", old.accountName);
939
1329
  for (const [k, c] of tx.list("certificates"))
940
- if (c.identityId === toHex(old.identityId))
1330
+ if (c.identityId === accountHex)
941
1331
  tx.put("certificates", k, { ...c, current: false });
1332
+ // Every identity of the chain is retired; the root remembers the reset, so no successor of
1333
+ // the old account is ever a recipient again.
1334
+ for (const [k, r] of tx.list("identities"))
1335
+ if (r.accountId === accountHex)
1336
+ tx.put("identities", k, k === accountHex ? { ...r, retired: true, resetAt: big(this.now()) } : { ...r, retired: true });
942
1337
  for (const [k, c] of tx.list("circles")) {
943
- if (c.ownerIdentityId === toHex(old.identityId)) {
1338
+ if (c.ownerIdentityId === accountHex) {
944
1339
  t3Circle(tx, { ...c, status: "closed" }, { kind: "leave", circleId: fromHex(k) });
945
1340
  }
946
- else if (c.members.includes(toHex(old.identityId))) {
947
- t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.identityId, certificateHash: newIdentityId });
1341
+ else if (c.members.includes(accountHex)) {
1342
+ t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.accountId, certificateHash: newIdentityId });
948
1343
  }
949
1344
  }
950
- forgetAuthorIn(tx, old.identityId);
1345
+ forgetAuthorIn(tx, old.accountId);
951
1346
  }
952
1347
  persistTrustIn(tx) {
953
1348
  const rows = this.trust.toRows();
@@ -968,12 +1363,13 @@ class ClientImpl {
968
1363
  const { pin } = this.requireAccount("left");
969
1364
  const others = await this.store.read((tx) => tx.list("circles").filter(([, x]) => x.circleId !== toHex(c.circleId) && x.status === "member").flatMap(([, x]) => x.members));
970
1365
  for (const m of c.members) {
971
- if (equalBytes(m, pin.identityId) || others.includes(toHex(m)))
1366
+ if (equalBytes(m, pin.accountId) || others.includes(toHex(m)))
972
1367
  continue;
973
1368
  await forgetAuthor(this.store, m);
974
1369
  }
975
1370
  }
976
1371
  async retire() {
1372
+ this.retired = true;
977
1373
  await this.store.transaction((tx) => {
978
1374
  const meta = tx.get("meta", META);
979
1375
  if (meta)
@@ -1002,9 +1398,30 @@ class ClientImpl {
1002
1398
  newest(identityId, kind) {
1003
1399
  return newestSlot(this.store, identityId, kind, this.now());
1004
1400
  }
1401
+ keyChanges(options = {}) {
1402
+ const limit = Math.max(1, Math.min(500, Math.floor(options.limit ?? 50)));
1403
+ const who = options.accountId ? toHex(options.accountId) : undefined;
1404
+ return this.store.read((tx) => tx
1405
+ .list("keyChanges")
1406
+ .map(([, r]) => r)
1407
+ .filter((r) => who === undefined || r.accountId === who)
1408
+ // Newest first; two changes accepted in one transaction order by generation.
1409
+ .sort((x, y) => (unbig(y.acceptedAt) > unbig(x.acceptedAt) ? 1 : unbig(y.acceptedAt) < unbig(x.acceptedAt) ? -1 : unbig(y.generation) > unbig(x.generation) ? 1 : unbig(y.generation) < unbig(x.generation) ? -1 : 0))
1410
+ .slice(0, limit)
1411
+ .map((r) => ({
1412
+ accountId: fromHex(r.accountId),
1413
+ accountName: r.accountName,
1414
+ previousIdentityId: fromHex(r.previousIdentityId),
1415
+ newIdentityId: fromHex(r.newIdentityId),
1416
+ generation: unbig(r.generation),
1417
+ reason: r.reason,
1418
+ successionId: r.successionId === undefined ? undefined : fromHex(r.successionId),
1419
+ acceptedAt: unbig(r.acceptedAt)
1420
+ })));
1421
+ }
1005
1422
  // Circles ---------------------------------------------------------------------------------------
1006
1423
  async listCircles() {
1007
- const self = this.selfPin?.identityId;
1424
+ const self = this.selfPin?.accountId;
1008
1425
  const now = this.now();
1009
1426
  return this.store.read((tx) => tx.list("circles").map(([, r]) => {
1010
1427
  const c = rowToCircle(r);
@@ -1019,6 +1436,8 @@ class ClientImpl {
1019
1436
  * the state, so a change that landed in the rows reaches the vault however the process dies.
1020
1437
  */
1021
1438
  async syncVault() {
1439
+ if (!this.vaultEnabled)
1440
+ return;
1022
1441
  const { account, pin } = this.requireAccount("vault");
1023
1442
  const recoveryKey = await this.secrets.get("recoveryKey");
1024
1443
  const accountSeed = await this.secrets.get("accountSeed");
@@ -1101,11 +1520,14 @@ class ClientImpl {
1101
1520
  async createCircle() {
1102
1521
  const { account, pin } = this.requireAccount("createCircle");
1103
1522
  const circleId = newCircleId();
1104
- const genesis = createCircleSnapshot({ identityId: pin.identityId, accountSecretKey: account.account.secretKey }, circleId);
1105
- const state = acceptSnapshot(pinCircle(circleId, pin.identityId, true, "member"), genesis, pin.accountKey, pin.identityId, this.now());
1523
+ const genesis = createCircleSnapshot({ identityId: pin.accountId, accountSecretKey: account.account.secretKey }, circleId);
1524
+ const state = acceptSnapshot(pinCircle(circleId, pin.accountId, true, "member"), genesis, pin.accountKey, pin.accountId, this.now());
1106
1525
  if (state.outcome !== "ACCEPTED")
1107
1526
  throw new KavachError("STORAGE_FAILURE", "createCircle");
1108
- await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(state.state), pending: { snapshot: toHex(genesis), revision: "1" } }));
1527
+ await this.store.transaction((tx) => {
1528
+ t3Circle(tx, { ...circleToRow(state.state), pending: { snapshot: toHex(genesis), revision: "1" } });
1529
+ this.recordPreferenceIn(tx, circleId, true);
1530
+ });
1109
1531
  const published = await this.publishPendingSnapshot(circleId);
1110
1532
  if (published !== "COMMITTED")
1111
1533
  throw new KavachError("STORAGE_FAILURE", "createCircle", published);
@@ -1114,6 +1536,7 @@ class ClientImpl {
1114
1536
  t9MarkVault(tx);
1115
1537
  });
1116
1538
  await this.syncVault();
1539
+ await this.pushPreferencesQuietly();
1117
1540
  this.emit({ kind: "circleChanged", circleId, revision: 1n, status: "member" });
1118
1541
  return { circleId };
1119
1542
  }
@@ -1121,6 +1544,8 @@ class ClientImpl {
1121
1544
  this.requireAccount("requestJoin");
1122
1545
  await this.store.transaction((tx) => {
1123
1546
  const existing = tx.get("circles", toHex(circleId));
1547
+ if (!existing || existing.status === "left")
1548
+ this.recordPreferenceIn(tx, circleId, shareOnApproval);
1124
1549
  if (!existing)
1125
1550
  t3Circle(tx, circleToRow(pinCircle(circleId, ownerIdentityId, shareOnApproval, "requested")));
1126
1551
  else if (existing.status === "left") {
@@ -1136,18 +1561,30 @@ class ClientImpl {
1136
1561
  const outcome = await this.transport.directory.requestJoin(circleId);
1137
1562
  if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED")
1138
1563
  throw new KavachError("STORAGE_FAILURE", "requestJoin", outcome.status);
1564
+ await this.pushPreferencesQuietly();
1565
+ }
1566
+ /** The host's copy of a preference, best effort now and by sync later. */
1567
+ async pushPreferencesQuietly() {
1568
+ if (!acceptsSuccession(this.policy))
1569
+ return;
1570
+ try {
1571
+ await this.pushPreferences();
1572
+ }
1573
+ catch {
1574
+ /* retried by sync */
1575
+ }
1139
1576
  }
1140
1577
  async approve(circleId, accountName) {
1141
1578
  const [resolved] = await this.transport.directory.resolveAccounts([accountName]);
1142
1579
  if (!resolved)
1143
1580
  throw new KavachError("METADATA_REQUIRED", "approve", "unknown account");
1144
- const report = { metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0 }, epoch: "unchanged", outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 }, inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false }, purged: 0, errors: [] };
1145
- await this.acceptMemberCertificate(resolved.certificate, resolved.resetReceipt, report);
1146
- const requester = pinFromCertificate(expectType(verifyDocument(resolved.certificate), DocumentType.AccountDevice));
1147
- const pinned = await this.pinFor(requester.identityId);
1148
- if (!pinned)
1581
+ const report = { metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 }, epoch: "unchanged", outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 }, inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false }, purged: 0, errors: [] };
1582
+ await this.acceptMemberCertificate(resolved, report);
1583
+ const requester = resolvedAccountId(resolved);
1584
+ const pinned = await this.pinFor(requester);
1585
+ if (!pinned || !equalBytes(pinned.certificateHash, expectType(verifyDocument(resolved.certificate), DocumentType.AccountDevice).bodyHash))
1149
1586
  throw new KavachError("ACCOUNT_KEY_CHANGED", "approve", "requester's key is quarantined");
1150
- return this.changeMembership(circleId, { admit: requester.identityId });
1587
+ return this.changeMembership(circleId, { admit: requester });
1151
1588
  }
1152
1589
  async changeMembership(circleId, change) {
1153
1590
  const { account, pin } = this.requireAccount("changeMembership");
@@ -1163,7 +1600,7 @@ class ClientImpl {
1163
1600
  const state = rowToCircle(row);
1164
1601
  if (state.status === "closed")
1165
1602
  return { revision: state.revision };
1166
- const successor = successorSnapshot(state, change, { identityId: pin.identityId, accountSecretKey: account.account.secretKey });
1603
+ const successor = successorSnapshot(state, change, { identityId: pin.accountId, accountSecretKey: account.account.secretKey });
1167
1604
  if (!successor) {
1168
1605
  if (!("close" in change))
1169
1606
  return { revision: state.revision };
@@ -1205,7 +1642,7 @@ class ClientImpl {
1205
1642
  const expectedRevision = unbig(pending.revision) - 1n;
1206
1643
  const outcome = await this.transport.directory.publishOwnedCircle({ circleId, expectedRevision, expectedHash: expectedRevision === state.revision ? state.bodyHash : undefined, newSnapshot: successor });
1207
1644
  if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
1208
- const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.identityId, this.now());
1645
+ const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.accountId, this.now());
1209
1646
  if (accepted.outcome !== "ACCEPTED" && accepted.outcome !== "DUPLICATE")
1210
1647
  throw new KavachError("STORAGE_FAILURE", stage, "the successor was refused locally");
1211
1648
  let next = pending.close ? { ...accepted.state, status: "closed" } : accepted.state;
@@ -1219,7 +1656,7 @@ class ClientImpl {
1219
1656
  });
1220
1657
  const after = new Set(next.members.map(toHex));
1221
1658
  for (const m of state.members) {
1222
- if (after.has(toHex(m)) || equalBytes(m, pin.identityId))
1659
+ if (after.has(toHex(m)) || equalBytes(m, pin.accountId))
1223
1660
  continue;
1224
1661
  const stillElsewhere = await this.store.read((tx) => tx.list("circles").some(([, c]) => c.circleId !== toHex(circleId) && c.status === "member" && c.members.includes(toHex(m))));
1225
1662
  if (!stillElsewhere)
@@ -1231,7 +1668,7 @@ class ClientImpl {
1231
1668
  return "COMMITTED";
1232
1669
  }
1233
1670
  if (outcome.status === "CONFLICT" && outcome.current) {
1234
- const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.identityId, this.now());
1671
+ const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.accountId, this.now());
1235
1672
  await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(accepted.outcome === "ACCEPTED" ? accepted.state : state), pending: undefined }));
1236
1673
  return "CONFLICT";
1237
1674
  }
@@ -1242,7 +1679,7 @@ class ClientImpl {
1242
1679
  const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1243
1680
  if (!row)
1244
1681
  return;
1245
- if (row.ownerIdentityId === toHex(pin.identityId))
1682
+ if (row.ownerIdentityId === toHex(pin.accountId))
1246
1683
  throw new KavachError("NOT_AUTHORIZED", "leave", "the owner closes instead");
1247
1684
  const state = rowToCircle(row);
1248
1685
  await this.store.transaction((tx) => {
@@ -1263,13 +1700,393 @@ class ClientImpl {
1263
1700
  }
1264
1701
  await this.store.transaction((tx) => {
1265
1702
  t3Circle(tx, { ...row, enabled }, { kind: enabled ? "enable" : "disable", circleId });
1703
+ this.recordPreferenceIn(tx, circleId, enabled);
1266
1704
  });
1267
1705
  await this.syncVault();
1706
+ await this.pushPreferencesQuietly();
1707
+ }
1708
+ /** The user's latest action on a circle's preference, with the local sequence that a host response must name to clear it. */
1709
+ recordPreferenceIn(tx, circleId, enabled) {
1710
+ const key = toHex(circleId);
1711
+ const row = tx.get("preferences", key) ?? { circleId: key, enabled, revision: "0", pending: undefined, nextSequence: "1" };
1712
+ const sequence = row.nextSequence;
1713
+ tx.put("preferences", key, { ...row, enabled, pending: { enabled, sequence }, nextSequence: big(unbig(sequence) + 1n) });
1714
+ }
1715
+ /**
1716
+ * Send pending preference changes to the host (amendment A1, section 7). On CONFLICT the
1717
+ * device's latest persisted action wins, read from the store at retry time, and is written again
1718
+ * at the served revision; a response clears only the sequence it answered.
1719
+ */
1720
+ async pushPreferences(report) {
1721
+ if (!acceptsSuccession(this.policy) || !this.selfPin)
1722
+ return;
1723
+ const pending = await this.store.read((tx) => tx.list("preferences").filter(([, p]) => p.pending !== undefined).map(([, p]) => p.circleId));
1724
+ for (const key of pending) {
1725
+ for (let attempt = 0; attempt < 3; attempt++) {
1726
+ const row = await this.store.read((tx) => tx.get("preferences", key));
1727
+ if (!row?.pending)
1728
+ break;
1729
+ const sent = row.pending;
1730
+ const outcome = await this.transport.directory.setSharingPreference(fromHex(key), sent.enabled, unbig(row.revision));
1731
+ if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
1732
+ await this.store.transaction((tx) => {
1733
+ const live = tx.get("preferences", key);
1734
+ if (!live)
1735
+ return;
1736
+ const cleared = live.pending?.sequence === sent.sequence ? undefined : live.pending;
1737
+ // A late success for an older request never moves the revision back.
1738
+ const revision = outcome.revision > unbig(live.revision) ? big(outcome.revision) : live.revision;
1739
+ tx.put("preferences", key, { ...live, revision, pending: cleared });
1740
+ });
1741
+ break;
1742
+ }
1743
+ if (outcome.status === "CONFLICT") {
1744
+ // Adopt the served revision; the latest persisted action is re-sent against it.
1745
+ await this.store.transaction((tx) => {
1746
+ const live = tx.get("preferences", key);
1747
+ if (live)
1748
+ tx.put("preferences", key, { ...live, revision: big(outcome.revision) });
1749
+ });
1750
+ continue;
1751
+ }
1752
+ if (report)
1753
+ report.errors.push({ operation: "preference", code: outcome.status === "UNAUTHORIZED" ? "NOT_AUTHORIZED" : "NETWORK", stage: "preference", retryable: outcome.status !== "UNAUTHORIZED" });
1754
+ break;
1755
+ }
1756
+ }
1268
1757
  }
1269
1758
  /** Close: the successor names only the owner; its publication forgets the members and removes the vault entry. */
1270
1759
  async closeCircle(circleId) {
1271
1760
  await this.changeMembership(circleId, { close: true });
1272
1761
  }
1762
+ // Account recovery (amendment A1) ---------------------------------------------------------------
1763
+ async prepareReplacement() {
1764
+ const stage = "prepareReplacement";
1765
+ if (!acceptsSuccession(this.policy))
1766
+ throw new KavachError("NOT_AUTHORIZED", stage, "this deployment does not use account recovery");
1767
+ const opened = await openStore(this.store, this.secrets, this.accountName);
1768
+ if (opened.kind !== "fresh")
1769
+ throw new KavachError("MALFORMED", stage, "an account already exists on this device");
1770
+ const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
1771
+ const roots = await this.secrets.get("pendingReplacement");
1772
+ if (existing && !existing.stale && roots && roots.length === RESET_ROOTS_BYTES)
1773
+ return proposalOf(existing);
1774
+ const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
1775
+ if (!resolved)
1776
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
1777
+ const current = expectType(verifyDocument(resolved.certificate), DocumentType.AccountDevice, stage);
1778
+ const accountId = resolvedAccountId(resolved);
1779
+ // Fresh roots: nothing from the old device, nothing from the host. The certificate is at the
1780
+ // next generation under the new key; the vault is fresh at revision 1 so that identity
1781
+ // continuity works on the next replacement.
1782
+ const accountSeed = newAccountSeed();
1783
+ const device = newDevice();
1784
+ const recoveryKey = newRecoveryKey();
1785
+ const instanceId = randomBytes(16);
1786
+ const successionId = randomBytes(16);
1787
+ const account = openAccount(this.accountName, accountSeed, accountId);
1788
+ const generation = current.generation + 1n;
1789
+ const certificate = certify(account, device, generation);
1790
+ const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage), undefined, accountId);
1791
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: generation, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
1792
+ // T11: roots in the secret store, the exact bytes in the store, before anything is sent.
1793
+ await this.secrets.set("pendingReplacement", concat(accountSeed, device.deviceSeed, recoveryKey, instanceId, device.deviceId));
1794
+ const row = {
1795
+ successionId: toHex(successionId),
1796
+ accountName: this.accountName,
1797
+ accountId: toHex(accountId),
1798
+ expectedCertificateHash: toHex(current.bodyHash),
1799
+ expectedGeneration: big(current.generation),
1800
+ certificate: toHex(certificate),
1801
+ vault: vault ? toHex(vault.wire) : undefined,
1802
+ vaultHash: vault ? toHex(vault.hash) : undefined,
1803
+ deviceId: toHex(device.deviceId),
1804
+ stale: false,
1805
+ conflictGeneration: undefined
1806
+ };
1807
+ await this.store.transaction((tx) => tx.put("replacement", "pending", row));
1808
+ await this.emitState();
1809
+ return proposalOf(row);
1810
+ }
1811
+ async completeReplacement(input) {
1812
+ const stage = "completeReplacement";
1813
+ // Already activated (by an earlier call, or by a sync that found the commit on the chain): idempotent.
1814
+ if (this.selfPin && this.account && !this.retired)
1815
+ return { identityId: this.selfPin.identityId, generation: this.selfPin.generation };
1816
+ const row = await this.store.read((tx) => tx.get("replacement", "pending"));
1817
+ if (!row || row.successionId !== toHex(input.successionId))
1818
+ throw new KavachError("MALFORMED", stage, "no such prepared replacement on this device");
1819
+ if (row.stale)
1820
+ throw new KavachError("REPLACEMENT_CONFLICT", stage, row.conflictGeneration === undefined ? "the account moved" : `the account is at generation ${row.conflictGeneration}`);
1821
+ const roots = await this.secrets.get("pendingReplacement");
1822
+ if (!roots || roots.length !== RESET_ROOTS_BYTES)
1823
+ throw new KavachError("RECOVERY_REQUIRED", stage, "replacement prepared without its roots");
1824
+ let outcome;
1825
+ try {
1826
+ outcome = await this.transport.directory.succeedAccount({
1827
+ accountName: this.accountName,
1828
+ expectedCertificateHash: fromHex(row.expectedCertificateHash),
1829
+ newCertificate: fromHex(row.certificate),
1830
+ newVault: row.vault === undefined ? undefined : fromHex(row.vault),
1831
+ successionId: input.successionId,
1832
+ authorization: input.authorization
1833
+ });
1834
+ }
1835
+ catch (e) {
1836
+ if (!isKavachError(e, "NETWORK"))
1837
+ throw e;
1838
+ // Unknown: the chain says whether ours committed. Never a second identity.
1839
+ const link = await this.findOwnLink(row).catch(() => undefined);
1840
+ if (!link)
1841
+ throw new KavachError("SUCCESSION_PENDING", stage, "the commit's outcome is unknown; call again with the same proposal");
1842
+ outcome = { status: "COMMITTED", succession: link.wire };
1843
+ }
1844
+ if (outcome.status === "CONFLICT") {
1845
+ // Definitive: the current certificate moved under the proposal, and the succession id is
1846
+ // not on the chain (that answers first). The proposal can never commit: it is marked stale
1847
+ // and its keys, never activated anywhere, are discarded.
1848
+ let current;
1849
+ try {
1850
+ current = outcome.current ? expectType(verifyDocument(outcome.current), DocumentType.AccountDevice, stage).generation : undefined;
1851
+ }
1852
+ catch {
1853
+ current = undefined;
1854
+ }
1855
+ await this.store.transaction((tx) => tx.put("replacement", "pending", { ...row, stale: true, conflictGeneration: current === undefined ? undefined : big(current) }));
1856
+ await this.secrets.delete("pendingReplacement");
1857
+ await this.emitState();
1858
+ throw new KavachError("REPLACEMENT_CONFLICT", stage, current === undefined ? "the account moved" : `the account is at generation ${current}`);
1859
+ }
1860
+ if (outcome.status === "UNAUTHORIZED")
1861
+ throw new KavachError("AUTHORIZATION_REQUIRED", stage, "the host refused the authorization; obtain a fresh one for the same proposal");
1862
+ if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.succession) {
1863
+ throw new KavachError("SUCCESSION_PENDING", stage, `host answered ${outcome.status}`);
1864
+ }
1865
+ return this.activateReplacement(row, roots, outcome.succession);
1866
+ }
1867
+ /** Our own link on the host's chain, if the proposal committed: the one naming our succession id and certificate. */
1868
+ async findOwnLink(row) {
1869
+ const page = await this.transport.directory.getSuccessions(fromHex(row.accountId), unbig(row.expectedGeneration), 8);
1870
+ for (const l of page.successions) {
1871
+ try {
1872
+ const doc = expectType(parseDocument(l.wire), DocumentType.AccountSuccession, "resume");
1873
+ if (toHex(doc.successionId) === row.successionId && equalBytes(l.certificate, fromHex(row.certificate)))
1874
+ return l;
1875
+ }
1876
+ catch {
1877
+ /* not ours */
1878
+ }
1879
+ }
1880
+ return undefined;
1881
+ }
1882
+ /** The reset-key list, fetched when the one held is not fresh. */
1883
+ async ensureResetList() {
1884
+ const now = this.now();
1885
+ if (this.trust.fresh(now))
1886
+ return;
1887
+ const wire = await this.transport.directory.getResetKeyList();
1888
+ if (!wire)
1889
+ return;
1890
+ this.trust.install(wire, now);
1891
+ await this.persistTrust();
1892
+ }
1893
+ /**
1894
+ * Activate: verify the returned succession exactly as a peer would, against the previous state
1895
+ * pinned from the host, then the T1 header under the new identity with the account id carried
1896
+ * from the proposal. A succession that does not verify leaves the proposal intact.
1897
+ */
1898
+ async activateReplacement(row, roots, succession) {
1899
+ const stage = "completeReplacement";
1900
+ await this.ensureResetList();
1901
+ const previousWire = await this.transport.directory.getCertificate(fromHex(row.expectedCertificateHash));
1902
+ if (!previousWire)
1903
+ throw new KavachError("METADATA_REQUIRED", stage, "the previous certificate is not served");
1904
+ const previous = expectType(verifyDocument(previousWire), DocumentType.AccountDevice, stage);
1905
+ if (toHex(previous.bodyHash) !== row.expectedCertificateHash)
1906
+ throw new KavachError("SUCCESSION_BINDING", stage, "the served certificate is not the expected one");
1907
+ const accountId = fromHex(row.accountId);
1908
+ const previousPin = pinFromCertificate(previous, undefined, accountId);
1909
+ // The account's earlier successions, if any: the chain from the root to the previous identity,
1910
+ // verified through the one chain path and kept, so this device holds every key the account
1911
+ // has signed with (an owned circle's snapshot from before an earlier replacement) and can
1912
+ // follow its own chain later (to learn of its retirement).
1913
+ const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
1914
+ // The evidence must be there before anything is written: without the account's resolution the
1915
+ // history is unknown, not empty, and the proposal stays for the next call. An empty history is
1916
+ // right only when the previous identity is the account root.
1917
+ if (!me)
1918
+ throw new KavachError("METADATA_REQUIRED", stage, "the account's history is not served; the proposal stays");
1919
+ const served = me.successions ?? [];
1920
+ const ownSuccession = authenticateSuccession(succession, this.trust, this.now());
1921
+ if (served.length === 0 && me.successionsNext === undefined && !equalBytes(previousPin.identityId, accountId))
1922
+ throw new KavachError("METADATA_REQUIRED", stage, "the account's earlier successions are not served; the proposal stays");
1923
+ // Bounded by the generation this device's own succession left from, on every page alike.
1924
+ const history = served.length > 0 || me.successionsNext !== undefined ? (await this.followChain({ accountId, accountName: this.accountName, finalIdentityId: previousPin.identityId, untilGeneration: ownSuccession.previousGeneration, firstPage: served, next: me.successionsNext })).verified : [];
1925
+ const accountSeed = roots.subarray(0, 32);
1926
+ const deviceSeed = roots.subarray(32, 64);
1927
+ const recoveryKey = roots.subarray(64, 96);
1928
+ const instanceId = roots.subarray(96, 112);
1929
+ const deviceId = roots.subarray(112, 128);
1930
+ const account = openAccount(this.accountName, accountSeed, accountId);
1931
+ const device = deviceFromSeed(deviceSeed, deviceId);
1932
+ const certificate = fromHex(row.certificate);
1933
+ const doc = expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage);
1934
+ const pin = pinFromCertificate(doc, undefined, accountId);
1935
+ // Roots first, then the store: a crash in between leaves a device the next call finishes.
1936
+ await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
1937
+ await this.store.transaction((tx) => {
1938
+ // The previous state as a first contact pins it, then the succession as a peer verifies it:
1939
+ // nothing is written unless the document binds this account, this generation and this key.
1940
+ tx.put("pins", toHex(accountId), pinToRow(previousPin));
1941
+ tx.put("certificates", toHex(previousPin.certificateHash), { identityId: toHex(accountId), certificateHash: toHex(previousPin.certificateHash), signingKey: toHex(previousPin.signingKey), boxKey: toHex(previousPin.boxKey), streamId: toHex(previousPin.streamId), generation: big(previous.generation), wire: toHex(previousWire), current: true });
1942
+ recordChainIn(tx, history, this.now());
1943
+ rootIdentityIn(tx, accountId, this.accountName, me?.rootCertificate);
1944
+ const effect = applySuccession(tx, { wire: succession, newCertificate: certificate, policy: this.policy, trust: this.trust, now: this.now() });
1945
+ if (!equalBytes(effect.newIdentityId, account.identityId) || toHex(effect.accountId) !== row.accountId)
1946
+ throw new KavachError("SUCCESSION_BINDING", stage, "the succession does not name this device's key");
1947
+ this.persistTrustIn(tx);
1948
+ t1Header(tx, { account, device, certificate, instanceId, generation: doc.generation });
1949
+ if (row.vault !== undefined && row.vaultHash !== undefined)
1950
+ t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
1951
+ t3ApplyChange(tx, { kind: "ownActivation" });
1952
+ t1Activate(tx);
1953
+ const meta = tx.get("meta", META);
1954
+ if (meta)
1955
+ tx.put("meta", META, { ...meta, bootstrap: { complete: false } });
1956
+ tx.delete("replacement", "pending");
1957
+ });
1958
+ await this.secrets.delete("pendingReplacement");
1959
+ this.account = account;
1960
+ this.device = device;
1961
+ this.selfPin = pin;
1962
+ this.retired = false;
1963
+ try {
1964
+ await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
1965
+ }
1966
+ catch {
1967
+ /* reported by recoveryStatus() */
1968
+ }
1969
+ await this.emitState();
1970
+ return { identityId: pin.identityId, generation: pin.generation };
1971
+ }
1972
+ /**
1973
+ * Discovery after account recovery (amendment A1, section 5): the host lists the account's
1974
+ * circles page by page; each is pinned only when its snapshot verifies under the owner's
1975
+ * verified chain and names this account as the host says. Discovery is not permission to share:
1976
+ * the circle is enabled only if the served preference says so. Never writes membership; an
1977
+ * owner's successor republishes the served snapshot at the next revision.
1978
+ */
1979
+ async bootstrap(report, state) {
1980
+ const out = { circles: 0, pending: 0, failed: 0 };
1981
+ // A circle that could not be adopted is kept with what the host served and retried by every
1982
+ // sync; a failure never advances past it silently, and never undoes another circle.
1983
+ let retry = state.retry ?? [];
1984
+ let cursor = state.cursor;
1985
+ let pagesDone = state.pagesDone === true;
1986
+ const adopt = async (c) => {
1987
+ try {
1988
+ const r = await this.adoptListing(c, report);
1989
+ if (r === "member")
1990
+ out.circles++;
1991
+ else if (r === "pending")
1992
+ out.pending++;
1993
+ return true;
1994
+ }
1995
+ catch (e) {
1996
+ out.failed++;
1997
+ report.errors.push(syncError("bootstrap", e, { circleId: c.circleId }));
1998
+ return false;
1999
+ }
2000
+ };
2001
+ const save = (done) => this.store.transaction((tx) => {
2002
+ const meta = tx.get("meta", META);
2003
+ if (!meta)
2004
+ return;
2005
+ const bootstrap = done && retry.length === 0 ? { complete: true } : { ...(cursor === undefined ? {} : { cursor }), complete: false, retry, pagesDone: done };
2006
+ tx.put("meta", META, { ...meta, bootstrap });
2007
+ t9MarkVault(tx);
2008
+ });
2009
+ const stillFailing = [];
2010
+ for (const r of retry) {
2011
+ const listing = { circleId: fromHex(r.circleId), ownerAccountId: fromHex(r.ownerAccountId), role: r.role, snapshot: r.snapshot === undefined ? undefined : fromHex(r.snapshot), enabled: r.enabled, preferenceRevision: unbig(r.preferenceRevision) };
2012
+ if (!(await adopt(listing)))
2013
+ stillFailing.push(r);
2014
+ }
2015
+ retry = stillFailing;
2016
+ for (let pages = 0; !pagesDone && pages < 64; pages++) {
2017
+ let page;
2018
+ try {
2019
+ page = await this.transport.directory.listCircles(cursor);
2020
+ }
2021
+ catch (e) {
2022
+ // What landed stays; the cursor says where to resume on the next sync.
2023
+ report.errors.push(syncError("bootstrap", e));
2024
+ await save(false);
2025
+ return out;
2026
+ }
2027
+ for (const c of page.circles) {
2028
+ if (!(await adopt(c)))
2029
+ retry.push({ circleId: toHex(c.circleId), ownerAccountId: toHex(c.ownerAccountId), role: c.role, snapshot: c.snapshot === undefined ? undefined : toHex(c.snapshot), enabled: c.enabled, preferenceRevision: big(c.preferenceRevision) });
2030
+ }
2031
+ cursor = page.cursor;
2032
+ if (cursor === undefined)
2033
+ pagesDone = true;
2034
+ await save(pagesDone);
2035
+ }
2036
+ await save(pagesDone);
2037
+ return out;
2038
+ }
2039
+ async adoptListing(c, report) {
2040
+ const stage = "bootstrap";
2041
+ const { pin, account } = this.requireAccount(stage);
2042
+ const key = toHex(c.circleId);
2043
+ const existing = await this.store.read((tx) => tx.get("circles", key));
2044
+ if (existing && (existing.revision !== "0" || existing.status === "requested"))
2045
+ return "skipped"; // a restart between pages
2046
+ if (!c.snapshot)
2047
+ return "skipped"; // closed: nothing to pin, nothing to reopen
2048
+ const ownerIsMe = equalBytes(c.ownerAccountId, pin.accountId);
2049
+ let ownerPin = ownerIsMe ? pin : await this.pinFor(c.ownerAccountId);
2050
+ if (!ownerPin) {
2051
+ const [r] = await this.transport.directory.resolveIdentities([c.ownerAccountId]);
2052
+ if (r)
2053
+ await this.acceptMemberCertificate(r, report);
2054
+ ownerPin = await this.pinFor(c.ownerAccountId);
2055
+ }
2056
+ if (!ownerPin)
2057
+ throw new KavachError("METADATA_REQUIRED", stage, "the circle's owner cannot be resolved");
2058
+ const enabled = c.enabled === true;
2059
+ const base = pinCircle(c.circleId, c.ownerAccountId, enabled, c.role === "pending" ? "requested" : "member");
2060
+ const accepted = await this.acceptSnapshotFrom(base, c.snapshot, ownerPin, this.now());
2061
+ if (accepted.outcome !== "ACCEPTED")
2062
+ throw new KavachError("MALFORMED", stage, "an empty snapshot");
2063
+ let state = accepted.state;
2064
+ const named = c.role === "owner" ? equalBytes(state.ownerIdentityId, pin.accountId) : c.role === "member" ? state.members.some((m) => equalBytes(m, pin.accountId)) : true;
2065
+ if (!named)
2066
+ throw new KavachError("NOT_AUTHORIZED", stage, "the snapshot does not name this account as the host says");
2067
+ if (c.role === "pending")
2068
+ state = { ...state, status: "requested" };
2069
+ // The successor owner republishes what it was served at the next revision; the host's
2070
+ // compare-and-swap decides.
2071
+ const republish = ownerIsMe ? buildCircleSnapshot({ circleId: c.circleId, ownerIdentityId: pin.accountId, revision: state.revision + 1n, members: state.members }, account.account.secretKey) : undefined;
2072
+ await this.store.transaction((tx) => {
2073
+ const row = circleToRow(state);
2074
+ t3Circle(tx, republish ? { ...row, pending: { snapshot: toHex(republish), revision: big(state.revision + 1n) } } : row, { kind: "snapshot", circleId: c.circleId, revision: state.revision });
2075
+ if (state.status === "member")
2076
+ t3ApplyChange(tx, { kind: "join", circleId: c.circleId });
2077
+ tx.put("preferences", key, { circleId: key, enabled, revision: big(c.preferenceRevision), pending: undefined, nextSequence: "1" });
2078
+ });
2079
+ this.emit({ kind: "circleChanged", circleId: c.circleId, revision: state.revision, status: state.status });
2080
+ if (republish) {
2081
+ try {
2082
+ await this.publishPendingSnapshot(c.circleId);
2083
+ }
2084
+ catch (e) {
2085
+ report.errors.push(syncError("publishSnapshot", e));
2086
+ }
2087
+ }
2088
+ return state.status === "requested" ? "pending" : "member";
2089
+ }
1273
2090
  // Reset -----------------------------------------------------------------------------------------
1274
2091
  async resetAccount(options) {
1275
2092
  const stage = "resetAccount";
@@ -1284,13 +2101,13 @@ class ClientImpl {
1284
2101
  const certificate = certify(account, device, 1n);
1285
2102
  const newPin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
1286
2103
  const recoveryKey = newRecoveryKey();
1287
- const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey);
2104
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey) : undefined;
1288
2105
  const resetId = randomBytes(16);
1289
2106
  const instanceId = randomBytes(16);
1290
2107
  // Persisted before it is sent: the host may commit the new identity and the response be lost,
1291
2108
  // and the only keys that can continue are these. Roots in the secret store, wires in the store.
1292
2109
  await this.secrets.set("pendingReset", concat(accountSeed, device.deviceSeed, recoveryKey, instanceId, device.deviceId));
1293
- await this.store.transaction((tx) => tx.put("pendingReset", "pending", { resetId: toHex(resetId), oldIdentityId: toHex(pin.identityId), certificate: toHex(certificate), vault: toHex(vault.wire), vaultHash: toHex(vault.hash) }));
2110
+ await this.store.transaction((tx) => tx.put("pendingReset", "pending", { resetId: toHex(resetId), oldIdentityId: toHex(pin.identityId), certificate: toHex(certificate), vault: vault ? toHex(vault.wire) : undefined, vaultHash: vault ? toHex(vault.hash) : undefined }));
1294
2111
  return this.resumeReset(stage, options.reauthProof);
1295
2112
  }
1296
2113
  /** Send (or re-send) the persisted reset with its own reset id, and finish locally once the host holds it. */
@@ -1301,7 +2118,7 @@ class ClientImpl {
1301
2118
  throw new KavachError("RECOVERY_REQUIRED", stage, "reset pending without its roots");
1302
2119
  const certificate = fromHex(row.certificate);
1303
2120
  expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage);
1304
- const outcome = await this.transport.directory.resetAccount({ accountName: this.accountName, expectedOldIdentityId: fromHex(row.oldIdentityId), newCertificate: certificate, newVault: fromHex(row.vault), resetId: fromHex(row.resetId), reauthProof: reauthProof ?? new Uint8Array() });
2121
+ const outcome = await this.transport.directory.resetAccount({ accountName: this.accountName, expectedOldIdentityId: fromHex(row.oldIdentityId), newCertificate: certificate, newVault: row.vault === undefined ? undefined : fromHex(row.vault), resetId: fromHex(row.resetId), reauthProof: reauthProof ?? new Uint8Array() });
1305
2122
  if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.receipt)
1306
2123
  throw new KavachError("NOT_AUTHORIZED", stage, `host refused reset: ${outcome.status}`);
1307
2124
  return this.finishReset(stage, row, roots);
@@ -1323,12 +2140,13 @@ class ClientImpl {
1323
2140
  const newPin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage));
1324
2141
  await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
1325
2142
  await this.store.transaction((tx) => {
1326
- for (const table of ["meta", "self", "pins", "certificates", "quarantine", "circles", "revision", "vault", "epochs", "stream", "outbox", "history", "historyIndex", "enqueueKeys", "newest", "readerEpochs", "subscriptions", "unresolved", "activation", "pendingReset"]) {
2143
+ for (const table of ["meta", "self", "pins", "certificates", "quarantine", "circles", "revision", "vault", "epochs", "stream", "outbox", "history", "historyIndex", "enqueueKeys", "newest", "successions", "identities", "keyChanges", "replacement", "preferences", "chainProgress", "readerEpochs", "subscriptions", "unresolved", "activation", "pendingReset"]) {
1327
2144
  for (const [k] of tx.list(table))
1328
2145
  tx.delete(table, k);
1329
2146
  }
1330
2147
  t1Header(tx, { account, device, certificate, instanceId, generation: 1n });
1331
- t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
2148
+ if (row.vault !== undefined && row.vaultHash !== undefined)
2149
+ t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
1332
2150
  t1Activate(tx);
1333
2151
  });
1334
2152
  await this.secrets.delete("pendingReset");
@@ -1336,7 +2154,7 @@ class ClientImpl {
1336
2154
  this.device = device;
1337
2155
  this.selfPin = newPin;
1338
2156
  try {
1339
- await this.provider.save(this.accountName, newPin.identityId, recoveryKey);
2157
+ await this.provider?.save(this.accountName, newPin.identityId, recoveryKey);
1340
2158
  }
1341
2159
  catch {
1342
2160
  /* reported by recoveryStatus() */