@plane31/blind-relay 0.1.3 → 0.3.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 (86) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/index.d.ts +4 -3
  3. package/dist/adapters/index.d.ts.map +1 -1
  4. package/dist/adapters/index.js +2 -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 +159 -3
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +1112 -102
  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/noble.d.ts.map +1 -1
  19. package/dist/crypto/noble.js +19 -1
  20. package/dist/crypto/noble.js.map +1 -1
  21. package/dist/crypto.d.ts +1 -1
  22. package/dist/crypto.d.ts.map +1 -1
  23. package/dist/crypto.js.map +1 -1
  24. package/dist/errors.d.ts +9 -1
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +2 -1
  27. package/dist/errors.js.map +1 -1
  28. package/dist/identity.d.ts +8 -2
  29. package/dist/identity.d.ts.map +1 -1
  30. package/dist/identity.js +7 -3
  31. package/dist/identity.js.map +1 -1
  32. package/dist/index.d.ts +4 -4
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/policy.d.ts +13 -0
  37. package/dist/policy.d.ts.map +1 -1
  38. package/dist/policy.js +12 -1
  39. package/dist/policy.js.map +1 -1
  40. package/dist/rows.d.ts.map +1 -1
  41. package/dist/rows.js +2 -0
  42. package/dist/rows.js.map +1 -1
  43. package/dist/store.d.ts +116 -3
  44. package/dist/store.d.ts.map +1 -1
  45. package/dist/store.js +2 -0
  46. package/dist/store.js.map +1 -1
  47. package/dist/succession.d.ts +108 -0
  48. package/dist/succession.d.ts.map +1 -0
  49. package/dist/succession.js +205 -0
  50. package/dist/succession.js.map +1 -0
  51. package/dist/testing/clock.d.ts.map +1 -1
  52. package/dist/testing/clock.js +1 -0
  53. package/dist/testing/clock.js.map +1 -1
  54. package/dist/testing/fakeHost.d.ts +60 -1
  55. package/dist/testing/fakeHost.d.ts.map +1 -1
  56. package/dist/testing/fakeHost.js +201 -31
  57. package/dist/testing/fakeHost.js.map +1 -1
  58. package/dist/testing/faults.d.ts +7 -3
  59. package/dist/testing/faults.d.ts.map +1 -1
  60. package/dist/testing/faults.js +27 -3
  61. package/dist/testing/faults.js.map +1 -1
  62. package/dist/transactions.d.ts +6 -0
  63. package/dist/transactions.d.ts.map +1 -1
  64. package/dist/transactions.js +11 -5
  65. package/dist/transactions.js.map +1 -1
  66. package/dist/transport.d.ts +66 -2
  67. package/dist/transport.d.ts.map +1 -1
  68. package/package.json +1 -1
  69. package/src/adapters/index.ts +14 -0
  70. package/src/audience.ts +7 -6
  71. package/src/client.ts +1165 -108
  72. package/src/codec.ts +64 -1
  73. package/src/crypto/noble.ts +18 -1
  74. package/src/crypto.ts +1 -0
  75. package/src/errors.ts +13 -1
  76. package/src/identity.ts +13 -3
  77. package/src/index.ts +10 -3
  78. package/src/policy.ts +23 -1
  79. package/src/rows.ts +2 -0
  80. package/src/store.ts +113 -3
  81. package/src/succession.ts +270 -0
  82. package/src/testing/clock.ts +1 -0
  83. package/src/testing/fakeHost.ts +204 -28
  84. package/src/testing/faults.ts +32 -7
  85. package/src/transactions.ts +13 -6
  86. 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,24 @@ 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
+ closed = false;
88
+ /** Every public operation in flight, so `close()` can wait for them. */
89
+ running = new Set();
90
+ /** The current sync's reading budget: records, and the links of a succession chain, count against it. */
91
+ syncBudget;
67
92
  backupChecked = false;
68
93
  circles;
69
94
  constructor(options, policy) {
@@ -77,30 +102,61 @@ class ClientImpl {
77
102
  this.now = options.now ?? (() => BigInt(Date.now()));
78
103
  this.trust = new ResetTrust(options.resetTrust);
79
104
  this.circles = {
80
- create: () => this.createCircle(),
81
- requestJoin: (c, o, opts) => this.requestJoin(c, o, opts?.shareOnApproval ?? true),
82
- approve: (c, n) => this.approve(c, n),
83
- remove: (c, m) => this.changeMembership(c, { remove: m }),
84
- leave: (c) => this.leave(c),
85
- setEnabled: (c, e) => this.setEnabled(c, e),
86
- closeCircle: (c) => this.closeCircle(c),
105
+ create: () => this.track("createCircle", () => this.createCircle()),
106
+ requestJoin: (c, o, opts) => this.track("requestJoin", () => this.requestJoin(c, o, opts?.shareOnApproval ?? true)),
107
+ approve: (c, n) => this.track("approve", () => this.approve(c, n)),
108
+ remove: (c, m) => this.track("changeMembership", () => this.changeMembership(c, { remove: m })),
109
+ leave: (c) => this.track("leave", () => this.leave(c)),
110
+ setEnabled: (c, e) => this.track("setEnabled", () => this.setEnabled(c, e)),
111
+ closeCircle: (c) => this.track("closeCircle", () => this.closeCircle(c)),
87
112
  list: () => this.listCircles()
88
113
  };
89
114
  }
115
+ // Every public operation that writes enters through `track`, explicitly and typed, so `close()`
116
+ // refuses new ones and waits for those in flight. Reads stay untracked.
117
+ initializeAccount() {
118
+ return this.track("initializeAccount", () => this.initializeAccountNow());
119
+ }
120
+ restoreAccount(recoveryCode) {
121
+ return this.track("restoreAccount", () => this.restoreAccountNow(recoveryCode));
122
+ }
123
+ enqueue(kind, payload, options = {}) {
124
+ return this.track("enqueue", () => this.enqueueNow(kind, payload, options));
125
+ }
126
+ receive(wire) {
127
+ return this.track("receive", () => receiveRecord(this.inboxDeps(), wire));
128
+ }
129
+ prepareReplacement() {
130
+ return this.track("prepareReplacement", () => this.prepareReplacementNow());
131
+ }
132
+ completeReplacement(input) {
133
+ return this.track("completeReplacement", () => this.completeReplacementNow(input));
134
+ }
135
+ resetAccount(options) {
136
+ return this.track("resetAccount", () => this.resetAccountNow(options));
137
+ }
138
+ replaceDevice(options) {
139
+ return this.track("replaceDevice", () => this.replaceDeviceNow(options));
140
+ }
141
+ syncUntilDrained(options = {}) {
142
+ return this.track("syncUntilDrained", () => this.drainNow(options));
143
+ }
90
144
  // Loading ---------------------------------------------------------------------------------------
91
145
  async load() {
92
146
  await this.finishInterruptedReset();
93
147
  const opened = await openStore(this.store, this.secrets, this.accountName);
94
148
  if (opened.kind === "fresh")
95
149
  return;
150
+ this.retired = opened.kind === "RETIRED";
96
151
  await ensureHistoryIndex(this.store);
97
152
  await ensureSlots(this.store, this.policy);
98
153
  this.account = opened.account;
99
154
  const deviceSeed = await this.secrets.get("deviceSeed");
100
155
  const self = await this.store.read((tx) => tx.get("self", "self"));
101
- if (deviceSeed && self) {
156
+ if (deviceSeed && self && opened.account) {
157
+ const accountId = opened.account.accountId;
102
158
  this.device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
103
- const pinRow = await this.store.read((tx) => tx.get("pins", toHex(opened.account.identityId)));
159
+ const pinRow = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
104
160
  if (pinRow)
105
161
  this.selfPin = rowToPin(pinRow);
106
162
  }
@@ -119,8 +175,19 @@ class ClientImpl {
119
175
  retired: rows.retired
120
176
  });
121
177
  }
122
- /** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. */
178
+ /** Refuse after `close()`, and keep the operation's promise until it settles. */
179
+ track(stage, fn) {
180
+ if (this.closed)
181
+ return Promise.reject(new KavachError("CLOSED", stage, "the client is closed"));
182
+ const p = fn();
183
+ this.running.add(p);
184
+ void p.finally(() => this.running.delete(p)).catch(() => undefined);
185
+ return p;
186
+ }
187
+ /** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. Nothing after `close()`. */
123
188
  emit(event) {
189
+ if (this.closed)
190
+ return;
124
191
  for (const l of this.listeners) {
125
192
  try {
126
193
  l(event);
@@ -140,13 +207,15 @@ class ClientImpl {
140
207
  return () => this.listeners.delete(listener);
141
208
  }
142
209
  requireAccount(stage) {
210
+ if (this.retired)
211
+ throw new KavachError("SUPERSEDED_DEVICE", stage, "this device is retired");
143
212
  if (!this.account || !this.device || !this.selfPin)
144
213
  throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no account on this device");
145
214
  return { account: this.account, device: this.device, pin: this.selfPin };
146
215
  }
147
216
  selfView() {
148
217
  const { device, pin } = this.requireAccount("self");
149
- return { identityId: pin.identityId, certificateHash: pin.certificateHash, box: device.box };
218
+ return { identityId: pin.accountId, certificateHash: pin.certificateHash, box: device.box };
150
219
  }
151
220
  outboxDeps() {
152
221
  const { device, pin } = this.requireAccount("outbox");
@@ -155,7 +224,7 @@ class ClientImpl {
155
224
  relay: this.transport.relay,
156
225
  policy: this.policy,
157
226
  now: this.now,
158
- author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.identityId }),
227
+ author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.accountId }),
159
228
  events: {
160
229
  queued: (recordId, recordKind) => this.emit({ kind: "queued", recordId, recordKind }),
161
230
  accepted: (recordId) => this.emit({ kind: "accepted", recordId }),
@@ -185,9 +254,11 @@ class ClientImpl {
185
254
  // State -----------------------------------------------------------------------------------------
186
255
  async getState() {
187
256
  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 };
257
+ const base = { identityId: this.selfPin?.accountId, keyIdentityId: this.selfPin?.identityId, certificateHash: this.selfPin?.certificateHash, generation: this.selfPin?.generation, outbox: 0, waitingFor: [], circles: [] };
258
+ if (!meta || (!this.account && meta.state !== "RETIRED")) {
259
+ const proposal = await this.store.read((tx) => tx.get("replacement", "pending"));
260
+ return { status: proposal && !proposal.stale ? "replacementPrepared" : "fresh", paused: undefined, ...base };
261
+ }
191
262
  const circles = await this.listCircles();
192
263
  const outbox = await outboxCount(this.store);
193
264
  const waitingFor = await this.waitingFor();
@@ -209,7 +280,7 @@ class ClientImpl {
209
280
  async waitingFor() {
210
281
  if (!this.selfPin)
211
282
  return [];
212
- const selfHex = toHex(this.selfPin.identityId);
283
+ const selfHex = toHex(this.selfPin.accountId);
213
284
  return this.store.read((tx) => {
214
285
  const authors = new Set();
215
286
  for (const [, c] of tx.list("circles"))
@@ -235,9 +306,15 @@ class ClientImpl {
235
306
  const stream = tx.get("stream", STREAM);
236
307
  const revision = tx.get("revision", REVISION);
237
308
  const vault = tx.get("vault", VAULT);
309
+ const meta = tx.get("meta", META);
310
+ const proposal = tx.get("replacement", "pending");
238
311
  const pendingEpoch = tx.list("epochs").find(([, e]) => e.status === "prepared" || e.status === "committed")?.[1].epoch;
239
312
  return {
240
- identityId: this.selfPin?.identityId,
313
+ identityId: this.selfPin?.accountId,
314
+ keyIdentityId: this.selfPin?.identityId,
315
+ replacementModel: this.policy.replacement,
316
+ replacement: proposal ? { successionId: fromHex(proposal.successionId), expectedGeneration: unbig(proposal.expectedGeneration), stale: proposal.stale, conflictGeneration: proposal.conflictGeneration === undefined ? undefined : unbig(proposal.conflictGeneration) } : undefined,
317
+ bootstrap: meta?.bootstrap ? { cursor: meta.bootstrap.cursor, complete: meta.bootstrap.complete } : undefined,
241
318
  certificateHash: this.selfPin?.certificateHash,
242
319
  generation: this.selfPin?.generation,
243
320
  desiredRevision: revision ? unbig(revision.desiredRevision) : 1n,
@@ -250,7 +327,7 @@ class ClientImpl {
250
327
  });
251
328
  }
252
329
  // Enrollment ------------------------------------------------------------------------------------
253
- async initializeAccount() {
330
+ async initializeAccountNow() {
254
331
  const stage = "initializeAccount";
255
332
  const opened = await openStore(this.store, this.secrets, this.accountName);
256
333
  if (opened.kind !== "fresh" && opened.kind !== "initializing")
@@ -272,8 +349,8 @@ class ClientImpl {
272
349
  const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
273
350
  const iid = instanceId;
274
351
  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 });
352
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
353
+ const outcome = await this.transport.directory.registerAccount({ certificate, vault: vault?.wire });
277
354
  if (outcome.status === "CONFLICT") {
278
355
  // A restart or a lost response after the host committed: the name is ours if the host holds
279
356
  // exactly the certificate this device saved; anything else is somebody else's account.
@@ -285,7 +362,8 @@ class ClientImpl {
285
362
  throw new KavachError("NOT_AUTHORIZED", stage, `host refused registration: ${outcome.status}`);
286
363
  }
287
364
  await this.store.transaction((tx) => {
288
- t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
365
+ if (vault)
366
+ t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
289
367
  t1Activate(tx);
290
368
  });
291
369
  this.account = account;
@@ -293,7 +371,7 @@ class ClientImpl {
293
371
  this.selfPin = pin;
294
372
  // The recovery key goes to the provider; enrollment never blocks on it or on the code.
295
373
  try {
296
- await this.provider.save(this.accountName, pin.identityId, recoveryKey);
374
+ await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
297
375
  }
298
376
  catch {
299
377
  /* status is reported by recoveryStatus() */
@@ -302,15 +380,21 @@ class ClientImpl {
302
380
  return { identityId: pin.identityId };
303
381
  }
304
382
  async recoveryStatus() {
383
+ if (!this.provider)
384
+ return { status: "unsupported", backupState: "unavailable", reason: "no recovery provider on a recovery-only profile" };
305
385
  return this.provider.status();
306
386
  }
307
387
  async recoveryCode() {
388
+ // A code opens a vault for identity continuity; where the profile disables that, or where no
389
+ // vault exists, the code would open nothing and is not issued.
390
+ if (!allowsContinuity(this.policy) || !this.vaultEnabled)
391
+ throw new KavachError("NOT_AUTHORIZED", "recoveryCode", "this deployment does not restore accounts by recovery code");
308
392
  const key = await this.secrets.get("recoveryKey");
309
393
  if (!key)
310
394
  throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", "recoveryCode");
311
395
  return encodeRecoveryCode(key);
312
396
  }
313
- async restoreAccount(recoveryCode) {
397
+ async restoreAccountNow(recoveryCode) {
314
398
  const stage = "restoreAccount";
315
399
  const opened = await openStore(this.store, this.secrets, this.accountName);
316
400
  if (opened.kind === "ACTIVE" || opened.kind === "RETIRED")
@@ -324,6 +408,8 @@ class ClientImpl {
324
408
  // moved under us): prepare again with the same device keys. Nothing was allocated remotely.
325
409
  pending = resumed;
326
410
  }
411
+ if (!allowsContinuity(this.policy))
412
+ throw new KavachError("NOT_AUTHORIZED", stage, "this deployment replaces devices through successions, not the recovery key");
327
413
  const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
328
414
  if (!resolved)
329
415
  throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
@@ -331,7 +417,13 @@ class ClientImpl {
331
417
  const vaultWire = await this.transport.directory.getVault(this.accountName);
332
418
  if (!vaultWire)
333
419
  throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no vault");
420
+ const accountId = resolvedAccountId(resolved);
334
421
  const identity = pinFromCertificate(current).identityId;
422
+ if (!this.provider)
423
+ throw new KavachError("NOT_AUTHORIZED", stage, "identity continuity needs a recovery provider");
424
+ // An account that succeeded: the served chain is verified link by link under the current list,
425
+ // from the root to the key this device is about to hold, before anything is written.
426
+ const verified = resolved.successions?.length ? (await this.followChain({ accountId, accountName: this.accountName, finalIdentityId: identity, firstPage: resolved.successions, next: resolved.successionsNext })).verified : [];
335
427
  const recovered = await recoverAccount({
336
428
  accountName: this.accountName,
337
429
  identityId: identity,
@@ -340,12 +432,12 @@ class ClientImpl {
340
432
  provider: this.provider,
341
433
  ...(recoveryCode === undefined ? {} : { recoveryCode })
342
434
  });
343
- const account = openAccount(this.accountName, recovered.vault.accountSeed);
435
+ const account = openAccount(this.accountName, recovered.vault.accountSeed, accountId);
344
436
  // Fresh device keys and a fresh local store. Nothing from the old device is copied.
345
437
  const device = pending?.device ?? newDevice();
346
438
  const generation = current.generation + 1n;
347
439
  const certificate = certify(account, device, generation);
348
- const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
440
+ const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice), undefined, accountId);
349
441
  const header = parseVaultHeader(vaultWire);
350
442
  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
443
  const instanceId = pending?.instanceId ?? randomBytes(16);
@@ -353,6 +445,8 @@ class ClientImpl {
353
445
  await t1Secrets(this.secrets, { accountSeed: recovered.vault.accountSeed, deviceSeed: device.deviceSeed, recoveryKey: recovered.recoveryKey, instanceId });
354
446
  await this.store.transaction((tx) => {
355
447
  t1Header(tx, { account, device, certificate, instanceId, generation });
448
+ recordChainIn(tx, verified, this.now());
449
+ rootIdentityIn(tx, accountId, this.accountName, resolved.rootCertificate);
356
450
  t2PrepareActivation(tx, {
357
451
  certificate: toHex(certificate),
358
452
  vault: toHex(nextVault.wire),
@@ -387,7 +481,7 @@ class ClientImpl {
387
481
  tx.delete("activation", "pending");
388
482
  t9Vault(tx, { revision: big(input.vaultRevision), hash: toHex(input.vault.hash), wire: toHex(input.vault.wire), pending: false, dirty: false });
389
483
  for (const e of input.entries) {
390
- const owned = equalBytes(e.ownerIdentityId, pin.identityId);
484
+ const owned = equalBytes(e.ownerIdentityId, pin.accountId);
391
485
  const c = pinCircle(e.circleId, e.ownerIdentityId, e.sharingEnabled, owned ? "member" : "requested");
392
486
  tx.put("circles", toHex(e.circleId), circleToRow(c));
393
487
  }
@@ -398,7 +492,7 @@ class ClientImpl {
398
492
  this.device = device;
399
493
  this.selfPin = pin;
400
494
  try {
401
- await this.provider.save(this.accountName, pin.identityId, input.recoveryKey);
495
+ await this.provider?.save(this.accountName, pin.identityId, input.recoveryKey);
402
496
  }
403
497
  catch {
404
498
  /* reported by recoveryStatus() */
@@ -418,14 +512,15 @@ class ClientImpl {
418
512
  if (!row)
419
513
  throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its prepared bytes");
420
514
  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)
515
+ const [self, meta] = await this.store.read((tx) => [tx.get("self", "self"), tx.get("meta", META)]);
516
+ if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId || !self || !meta)
423
517
  throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its roots");
424
- const account = openAccount(this.accountName, accountSeed);
518
+ const accountId = fromHex(meta.accountId ?? meta.identityId);
519
+ const account = openAccount(this.accountName, accountSeed, accountId);
425
520
  const device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
426
521
  const certificate = fromHex(row.certificate);
427
522
  const prepared = expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage);
428
- const pin = pinFromCertificate(prepared);
523
+ const pin = pinFromCertificate(prepared, undefined, accountId);
429
524
  const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
430
525
  if (!me)
431
526
  throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
@@ -457,7 +552,7 @@ class ClientImpl {
457
552
  * asks the provider and saves again when it has nothing for this identity.
458
553
  */
459
554
  async ensureBackup() {
460
- if (this.backupChecked || !this.selfPin)
555
+ if (this.backupChecked || !this.selfPin || !this.provider)
461
556
  return;
462
557
  this.backupChecked = true;
463
558
  try {
@@ -476,7 +571,7 @@ class ClientImpl {
476
571
  this.emit({ kind: "stateChanged", state: await this.getState() });
477
572
  }
478
573
  // Sending ---------------------------------------------------------------------------------------
479
- async enqueue(kind, payload, options = {}) {
574
+ async enqueueNow(kind, payload, options) {
480
575
  const deps = this.outboxDeps();
481
576
  // A key answered before anything that may need the network: the record it names is already
482
577
  // durable, so a retry offline, or with a rotation due, returns it. The outbox checks the key
@@ -503,7 +598,7 @@ class ClientImpl {
503
598
  /** After commit: the device's own record is stored like any author's, so one subscription covers self and others. */
504
599
  enqueued(kind, r) {
505
600
  if (!r.duplicate && this.selfPin)
506
- this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.identityId, recordId: r.recordId, tookSlot: r.own });
601
+ this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.accountId, recordId: r.recordId, tookSlot: r.own });
507
602
  return { recordId: r.recordId, duplicate: r.duplicate };
508
603
  }
509
604
  // Epochs ----------------------------------------------------------------------------------------
@@ -511,7 +606,15 @@ class ClientImpl {
511
606
  const { pin } = this.requireAccount("audience");
512
607
  const retired = new Set(this.trust.state.retired);
513
608
  return this.store.read((tx) => {
609
+ // A reset retires the whole account, however many successions it went through.
610
+ for (const [, r] of tx.list("identities"))
611
+ if (r.resetAt !== undefined)
612
+ retired.add(r.accountId);
514
613
  const pins = new Map(tx.list("pins").map(([k, p]) => [k, rowToPin(p)]));
614
+ // An account whose chain this device has not finished verifying is out of every audience until it has.
615
+ for (const [, p] of tx.list("chainProgress"))
616
+ if (!p.complete)
617
+ pins.delete(p.accountId);
515
618
  const circles = tx.list("circles").map(([, c]) => rowToCircle(c));
516
619
  return resolveAudience(pin, circles, pins, retired);
517
620
  });
@@ -552,7 +655,7 @@ class ClientImpl {
552
655
  else {
553
656
  const revision = revisionRow ? unbig(revisionRow.desiredRevision) : 1n;
554
657
  const fresh = prepareEpoch({
555
- author: { identityId: pin.identityId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
658
+ author: { identityId: pin.accountId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
556
659
  epoch: stream.highestCommittedEpoch + 1,
557
660
  createdAt: this.now(),
558
661
  audience,
@@ -598,20 +701,137 @@ class ClientImpl {
598
701
  async sync(options = {}) {
599
702
  if (this.syncing)
600
703
  return this.syncing;
601
- this.syncing = this.runSync(options).finally(() => {
704
+ if (this.closed)
705
+ throw new KavachError("CLOSED", "sync", "the client is closed");
706
+ this.syncing = this.track("sync", () => this.runSync(options)).finally(() => {
602
707
  this.syncing = undefined;
603
708
  });
604
709
  return this.syncing;
605
710
  }
711
+ async drainNow(options) {
712
+ const maxRounds = Math.max(1, Math.floor(options.maxRounds ?? 10));
713
+ const maxMs = Math.max(0, Math.floor(options.maxMs ?? 30_000));
714
+ const deadline = this.now() + BigInt(maxMs);
715
+ const merged = {
716
+ metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
717
+ epoch: "unchanged",
718
+ outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
719
+ inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
720
+ purged: 0,
721
+ errors: []
722
+ };
723
+ let rounds = 0;
724
+ let previousErrors;
725
+ let stop = "drained";
726
+ let last;
727
+ // An inbox this device could not fully read this round: discovery or a stream failed, or metadata it
728
+ // depends on did. Such a round never establishes that the inbox is empty.
729
+ const inboxUncertain = (r) => r.errors.some((e) => e.operation === "listSubscriptions" || e.operation === "inbox" || e.operation === "metadata");
730
+ // Chains verified only partly: remaining work, and their cursors moving is progress.
731
+ const chains = () => this.store.read((tx) => new Map(tx.list("chainProgress").filter(([, c]) => !c.complete).map(([k, c]) => [k, c.nextGeneration])));
732
+ let previousChains = await chains();
733
+ for (;;) {
734
+ if (this.closed) {
735
+ stop = "closed";
736
+ break;
737
+ }
738
+ // The time left is this round's reading budget too, so a round never starts work the deadline
739
+ // does not cover; a transport call already in flight ends on its own timeout.
740
+ const remainingMs = Number(deadline - this.now());
741
+ if (rounds > 0 && remainingMs <= 0) {
742
+ stop = "maxMs";
743
+ break;
744
+ }
745
+ const roundMs = Math.max(0, Math.min(remainingMs, options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs));
746
+ const r = await this.sync({ catchUp: { maxMs: roundMs, ...(options.catchUp?.maxRecords !== undefined ? { maxRecords: options.catchUp.maxRecords } : {}) } });
747
+ rounds++;
748
+ last = r;
749
+ // The round's work counts whether or not it was the last: what it accepted, stored and failed
750
+ // is merged first, and only then is a close that landed meanwhile reported.
751
+ for (const k of Object.keys(merged.metadata))
752
+ merged.metadata[k] += r.metadata[k];
753
+ for (const k of Object.keys(merged.outbox))
754
+ merged.outbox[k] += r.outbox[k];
755
+ merged.inbox.streams += r.inbox.streams;
756
+ merged.inbox.stored += r.inbox.stored;
757
+ merged.inbox.keyPending += r.inbox.keyPending;
758
+ merged.inbox.invalid += r.inbox.invalid;
759
+ merged.inbox.acked += r.inbox.acked;
760
+ merged.purged += r.purged;
761
+ merged.epoch = r.epoch;
762
+ merged.errors.push(...r.errors);
763
+ if (r.bootstrap)
764
+ merged.bootstrap = r.bootstrap;
765
+ if (this.closed) {
766
+ // The round may have stopped at a boundary before its inbox: nothing it reports is complete.
767
+ stop = "closed";
768
+ break;
769
+ }
770
+ const bootstrapIncomplete = (await this.inspect()).bootstrap?.complete === false;
771
+ const currentChains = await chains();
772
+ const more = r.inbox.hasMore || bootstrapIncomplete || currentChains.size > 0;
773
+ const status = (await this.getState()).status;
774
+ if (status === "replacementPrepared" || status === "accountRecoveryRequired") {
775
+ stop = "authentication";
776
+ break;
777
+ }
778
+ if (!more) {
779
+ stop = inboxUncertain(r) ? "incomplete" : "drained";
780
+ break;
781
+ }
782
+ const signature = r.errors.map((e) => `${e.operation}:${e.code}`).sort().join(",");
783
+ const chainsAdvanced = [...currentChains].some(([k, next]) => previousChains.get(k) !== next) || [...previousChains.keys()].some((k) => !currentChains.has(k));
784
+ previousChains = currentChains;
785
+ const changed = chainsAdvanced || r.inbox.stored + r.metadata.certificates + r.metadata.snapshots + r.metadata.successions + (r.bootstrap?.circles ?? 0) + (r.bootstrap?.pending ?? 0) > 0;
786
+ if (!changed && signature !== "" && signature === previousErrors) {
787
+ stop = r.errors.some((e) => e.code === "METADATA_REQUIRED" || e.code === "RESET_KEYS_REQUIRED" || e.code === "RESET_CLOCK_UNTRUSTED") ? "metadata" : "noProgress";
788
+ break;
789
+ }
790
+ previousErrors = signature;
791
+ if (rounds >= maxRounds) {
792
+ stop = "maxRounds";
793
+ break;
794
+ }
795
+ if (this.now() >= deadline) {
796
+ stop = "maxMs";
797
+ break;
798
+ }
799
+ }
800
+ merged.inbox.hasMore = last?.inbox.hasMore ?? false;
801
+ const bootstrapIncomplete = this.closed ? false : (await this.inspect()).bootstrap?.complete === false;
802
+ const chainsIncomplete = this.closed ? previousChains.size : (await chains()).size;
803
+ const waitingFor = this.closed ? 0 : (await this.getState()).waitingFor.length;
804
+ const inboxHasMore = !last || inboxUncertain(last) || stop === "closed" ? "unknown" : merged.inbox.hasMore;
805
+ return { ...merged, rounds, stoppedBecause: stop, remaining: { inboxHasMore, bootstrapIncomplete, chainsIncomplete, waitingFor } };
806
+ }
606
807
  async runSync(options) {
607
808
  const report = {
608
- metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0 },
809
+ metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
609
810
  epoch: "unchanged",
610
811
  outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
611
812
  inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
612
813
  purged: 0,
613
814
  errors: []
614
815
  };
816
+ this.syncBudget = {
817
+ remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
818
+ deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
819
+ };
820
+ if (!this.selfPin) {
821
+ // A prepared replacement whose commit this device never saw: the chain says whether it landed.
822
+ const row = await this.store.read((tx) => tx.get("replacement", "pending"));
823
+ if (row && !row.stale) {
824
+ try {
825
+ const link = await this.findOwnLink(row);
826
+ const roots = await this.secrets.get("pendingReplacement");
827
+ if (link && roots && roots.length === RESET_ROOTS_BYTES)
828
+ await this.activateReplacement(row, roots, link.wire);
829
+ }
830
+ catch (e) {
831
+ report.errors.push(syncError("resumeReplacement", e));
832
+ }
833
+ }
834
+ }
615
835
  if (!this.account || !this.selfPin)
616
836
  return report;
617
837
  const meta = await this.store.read((tx) => tx.get("meta", META));
@@ -643,14 +863,28 @@ class ClientImpl {
643
863
  else if (meta.state !== "ACTIVE")
644
864
  return report;
645
865
  await this.ensureBackup();
866
+ if (meta.bootstrap && !meta.bootstrap.complete) {
867
+ try {
868
+ report.bootstrap = await this.bootstrap(report, meta.bootstrap);
869
+ }
870
+ catch (e) {
871
+ report.errors.push(syncError("bootstrap", e));
872
+ }
873
+ }
646
874
  try {
647
875
  await this.syncMetadata(report);
648
876
  }
649
877
  catch (e) {
650
878
  report.errors.push(syncError("metadata", e));
651
879
  }
880
+ try {
881
+ await this.pushPreferences(report);
882
+ }
883
+ catch (e) {
884
+ report.errors.push(syncError("preference", e));
885
+ }
652
886
  const after = await this.store.read((tx) => tx.get("meta", META));
653
- if (after?.state === "RETIRED")
887
+ if (after?.state === "RETIRED" || this.closed)
654
888
  return report;
655
889
  try {
656
890
  report.epoch = await this.ensureEpoch();
@@ -658,12 +892,16 @@ class ClientImpl {
658
892
  catch (e) {
659
893
  report.errors.push(syncError("epoch", e));
660
894
  }
895
+ if (this.closed)
896
+ return report;
661
897
  try {
662
898
  report.outbox = await drain(this.outboxDeps());
663
899
  }
664
900
  catch (e) {
665
901
  report.errors.push(syncError("outbox", e));
666
902
  }
903
+ if (this.closed)
904
+ return report;
667
905
  let subs = [];
668
906
  try {
669
907
  subs = await this.transport.relay.listSubscriptions();
@@ -674,11 +912,12 @@ class ClientImpl {
674
912
  // Each stream on its own: a failed download or a refused record names its stream in the report
675
913
  // and leaves the other streams to be read; a stream that failed is not counted as synced.
676
914
  // 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
- };
915
+ const budget = this.syncBudget;
916
+ if (budget.remainingRecords < 1)
917
+ budget.remainingRecords = 1; // every stream reads at least one page
681
918
  for (const s of subs) {
919
+ if (this.closed)
920
+ return report;
682
921
  try {
683
922
  const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
684
923
  if (!known)
@@ -708,7 +947,16 @@ class ClientImpl {
708
947
  if (me) {
709
948
  const theirs = expectType(verifyDocument(me.certificate), DocumentType.AccountDevice, "sync");
710
949
  if (!equalBytes(theirs.accountKey, pin.accountKey)) {
711
- // Our own key changed at the host: only a receipt we produced explains that; otherwise ignore.
950
+ // Our own key changed at the host. A succession from our identity, attested by an authority
951
+ // we trust, retires this device (its key is no longer the account's); a bare key change or a
952
+ // reset we did not make is ignored here, as before.
953
+ if (await this.supersededBySuccession(pin, me, report)) {
954
+ await this.retire();
955
+ // The key itself is retired, not only the device: nothing of it stays here.
956
+ await this.secrets.delete("accountSeed");
957
+ this.account = undefined;
958
+ return;
959
+ }
712
960
  }
713
961
  else if (!equalBytes(theirs.bodyHash, pin.certificateHash) && theirs.generation > pin.generation) {
714
962
  await this.retire();
@@ -733,7 +981,7 @@ class ClientImpl {
733
981
  // Unconfirmed writes of our own first: a successor snapshot the host never took, a vault update
734
982
  // it never saw. Until they land, peers read stale membership and a replacement phone a stale
735
983
  // 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)));
984
+ 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
985
  for (const circleId of pendingOwned) {
738
986
  try {
739
987
  if ((await this.publishPendingSnapshot(circleId)) === "COMMITTED")
@@ -755,18 +1003,18 @@ class ClientImpl {
755
1003
  for (const c of circles) {
756
1004
  if (c.status === "closed" || c.status === "quarantined" || c.status === "left")
757
1005
  continue;
758
- if (!equalBytes(c.ownerIdentityId, pin.identityId) && !(await this.pinFor(c.ownerIdentityId)))
1006
+ if (!equalBytes(c.ownerIdentityId, pin.accountId) && !(await this.pinFor(c.ownerIdentityId)))
759
1007
  unknownOwners.push(c.ownerIdentityId);
760
1008
  }
761
1009
  if (unknownOwners.length > 0) {
762
1010
  for (const r of await this.transport.directory.resolveIdentities(unknownOwners))
763
- await this.acceptMemberCertificate(r.certificate, r.resetReceipt, report);
1011
+ await this.acceptMemberCertificate(r, report);
764
1012
  }
765
1013
  const memberIds = new Map();
766
1014
  for (const c of circles) {
767
1015
  if (c.status === "closed" || c.status === "quarantined" || c.status === "left")
768
1016
  continue;
769
- const ownerPin = equalBytes(c.ownerIdentityId, pin.identityId) ? pin : await this.pinFor(c.ownerIdentityId);
1017
+ const ownerPin = equalBytes(c.ownerIdentityId, pin.accountId) ? pin : await this.pinFor(c.ownerIdentityId);
770
1018
  if (!ownerPin) {
771
1019
  memberIds.set(toHex(c.ownerIdentityId), c.ownerIdentityId);
772
1020
  continue;
@@ -774,7 +1022,7 @@ class ClientImpl {
774
1022
  const wire = await this.transport.directory.getCircle(c.circleId);
775
1023
  if (wire) {
776
1024
  try {
777
- const r = acceptSnapshot(c, wire, ownerPin.accountKey, pin.identityId, now);
1025
+ const r = await this.acceptSnapshotFrom(c, wire, ownerPin, now, report);
778
1026
  if (r.outcome === "ACCEPTED") {
779
1027
  // The row may have moved since it was read: a sharing preference, a leave or a close
780
1028
  // made meanwhile wins over the fetched snapshot, and a newer snapshot already stored
@@ -821,7 +1069,7 @@ class ClientImpl {
821
1069
  else
822
1070
  for (const m of c.members)
823
1071
  memberIds.set(toHex(m), m);
824
- if (equalBytes(c.ownerIdentityId, pin.identityId) && c.status === "member") {
1072
+ if (equalBytes(c.ownerIdentityId, pin.accountId) && c.status === "member") {
825
1073
  const requests = await this.transport.directory.readRequests(c.circleId);
826
1074
  const joins = requests.filter((r) => r.action === "join").map((r) => r.accountName);
827
1075
  if (joins.length)
@@ -830,9 +1078,8 @@ class ClientImpl {
830
1078
  const [acct] = await this.transport.directory.resolveAccounts([r.accountName]);
831
1079
  if (!acct)
832
1080
  continue;
833
- const leaver = pinFromCertificate(expectType(verifyDocument(acct.certificate), DocumentType.AccountDevice));
834
1081
  try {
835
- await this.changeMembership(c.circleId, { remove: leaver.identityId });
1082
+ await this.changeMembership(c.circleId, { remove: resolvedAccountId(acct) });
836
1083
  }
837
1084
  catch (e) {
838
1085
  report.errors.push(syncError("removeLeaver", e));
@@ -840,28 +1087,308 @@ class ClientImpl {
840
1087
  }
841
1088
  }
842
1089
  }
843
- memberIds.delete(toHex(pin.identityId));
1090
+ memberIds.delete(toHex(pin.accountId));
844
1091
  if (memberIds.size > 0) {
845
1092
  const resolved = await this.transport.directory.resolveIdentities([...memberIds.values()]);
846
1093
  const byIdentity = new Map();
847
1094
  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);
1095
+ expectType(verifyDocument(r.certificate), DocumentType.AccountDevice, "sync");
1096
+ byIdentity.set(toHex(resolvedAccountId(r)), { accountName: r.accountName, accountKey: r.accountKey });
1097
+ await this.acceptMemberCertificate(r, report);
852
1098
  }
853
1099
  checkMemberResolution(byIdentity);
854
1100
  }
855
1101
  }
856
- async pinFor(identityId) {
857
- const row = await this.store.read((tx) => tx.get("pins", toHex(identityId)));
1102
+ /**
1103
+ * Has the host's chain for our own account moved past our key? A valid succession from our
1104
+ * current identity means an authority replaced this account's key; the device holding the old
1105
+ * key has nothing left to say. Deferred (no fresh list, untrusted clock) and invalid
1106
+ * attestations leave the device as it is.
1107
+ */
1108
+ async supersededBySuccession(pin, me, report) {
1109
+ if (!me.successions?.length)
1110
+ return false;
1111
+ try {
1112
+ const r = await this.followChain({ accountId: pin.accountId, accountName: pin.accountName, current: pin, firstPage: me.successions, next: me.successionsNext, budget: this.syncBudget });
1113
+ return r.pin !== undefined && !equalBytes(r.pin.identityId, pin.identityId);
1114
+ }
1115
+ catch (e) {
1116
+ // A chain this device cannot follow for its own account is a diagnostic the product must
1117
+ // see: the host has already fenced the device, and only this report says why it still runs.
1118
+ report.errors.push(syncError("ownSuccession", e));
1119
+ return false;
1120
+ }
1121
+ }
1122
+ /**
1123
+ * Verify a snapshot under the owner's current key, or, when the owner's key changed through a
1124
+ * succession and the snapshot predates it, under the retired key it was signed with. The
1125
+ * successor republishes at the next revision; until then the older snapshot stays valid.
1126
+ */
1127
+ async acceptSnapshotFrom(c, wire, ownerPin, now, report, resolved = false) {
1128
+ const { pin } = this.requireAccount("sync");
1129
+ // The very bytes this device already verified (a bootstrap snapshot under a key since retired)
1130
+ // are a duplicate whatever key signed them; only new bytes are verified.
1131
+ if (c.snapshot && equalBytes(c.snapshot, wire))
1132
+ return { outcome: "DUPLICATE", state: c };
1133
+ try {
1134
+ return acceptSnapshot(c, wire, ownerPin.accountKey, pin.accountId, now);
1135
+ }
1136
+ catch (e) {
1137
+ if (!isKavachError(e, "BAD_SIGNATURE"))
1138
+ throw e;
1139
+ // A retired key may only vouch for a circle this device holds at revision 0 (bootstrap, first
1140
+ // contact): an established circle advances under the current owner key alone.
1141
+ const ownerHex = toHex(ownerPin.accountId);
1142
+ 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)] : []))) : [];
1143
+ for (const key of retiredKeys) {
1144
+ try {
1145
+ return acceptSnapshot(c, wire, key, pin.accountId, now);
1146
+ }
1147
+ catch (inner) {
1148
+ if (!isKavachError(inner, "BAD_SIGNATURE"))
1149
+ throw inner;
1150
+ }
1151
+ }
1152
+ // Signed by a key we do not know: the owner may have succeeded since we last resolved it.
1153
+ // The chain is verified first; only a key it produced may sign this circle.
1154
+ if (!resolved && report && !equalBytes(ownerPin.accountId, pin.accountId)) {
1155
+ const [r] = await this.transport.directory.resolveIdentities([ownerPin.accountId]);
1156
+ if (r && !equalBytes(r.accountKey, ownerPin.accountKey)) {
1157
+ await this.acceptMemberCertificate(r, report);
1158
+ const fresh = await this.pinFor(ownerPin.accountId);
1159
+ if (fresh && !equalBytes(fresh.accountKey, ownerPin.accountKey))
1160
+ return this.acceptSnapshotFrom(c, wire, fresh, now, report, true);
1161
+ }
1162
+ }
1163
+ throw e;
1164
+ }
1165
+ }
1166
+ async pinFor(accountId) {
1167
+ const row = await this.store.read((tx) => tx.get("pins", toHex(accountId)));
858
1168
  return row ? rowToPin(row) : undefined;
859
1169
  }
860
- /** Accept a member's certificate; a changed account key is quarantined unless a valid receipt explains it. */
861
- async acceptMemberCertificate(wire, resetReceipt, report) {
1170
+ /**
1171
+ * The one path every succession takes (section 4, "Chains"): the policy gate first, then the
1172
+ * key list, then each link verified in order from the start identity (the pinned identity for
1173
+ * an established contact, the account id at first contact or for a restoring device), page by
1174
+ * page within the sync's budget, with the verified prefix persisted so that the next sync
1175
+ * resumes instead of starting over. Only when the chain is complete does anything move: for a
1176
+ * pinned contact every link is applied in one transaction, so no epoch is ever sealed to an
1177
+ * intermediate successor; for a first contact or a restore the verified links are returned and
1178
+ * the caller records them with the pin or the header. Any failure leaves the pin where it was.
1179
+ */
1180
+ async followChain(input) {
1181
+ const stage = "chain";
1182
+ if (!acceptsSuccession(this.policy))
1183
+ throw new KavachError("UNSUPPORTED_TYPE", stage, "this deployment does not accept successions");
1184
+ const accountHex = toHex(input.accountId);
1185
+ const startIdentity = input.current?.identityId ?? input.accountId;
1186
+ await this.ensureResetList();
1187
+ // The state the chain is validated against: the pin with what this store retired, or the root.
1188
+ const current = input.current;
1189
+ let state = current ? await this.store.read((tx) => pinnedState(tx, current, this.trust)) : rootState(input.accountId, input.accountName, this.trust);
1190
+ // Reconcile first: the host serves the chain from the root, and a pinned contact already holds
1191
+ // a prefix of it. Links whose transition is on record are dropped (the same transition at a
1192
+ // recorded state; a different one is the authority signing two successors), so verification
1193
+ // starts at the pinned identity with only the transitions this device has not seen.
1194
+ const recorded = current ? await this.store.read((tx) => tx.list("successions", accountHex + ":").map(([, r]) => r)) : [];
1195
+ // The bound, applied to every page alike: links past `untilGeneration` are not this call's to
1196
+ // follow (a replacing device stops at the state its own succession left from).
1197
+ const within = (l) => input.untilGeneration === undefined || expectType(parseDocument(l.wire), DocumentType.AccountSuccession, stage).newGeneration <= input.untilGeneration;
1198
+ const unseen = (links) => links.filter(within).filter((l) => {
1199
+ const doc = expectType(parseDocument(l.wire), DocumentType.AccountSuccession, stage);
1200
+ const t = transitionOf(doc);
1201
+ const row = recorded.find((r) => unbig(r.previousGeneration) === t.previousGeneration && r.previousIdentityId === t.previousIdentityId);
1202
+ if (!row)
1203
+ return true;
1204
+ 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)) {
1205
+ // A different transition at a recorded state is evidence against the authority only if
1206
+ // the authority signed it: unauthenticated bytes are a bad signature, nothing more.
1207
+ authenticateSuccession(l.wire, this.trust, this.now());
1208
+ throw new KavachError("EQUIVOCATION", stage, "two successors for one account state");
1209
+ }
1210
+ return false;
1211
+ });
1212
+ const progress = await this.store.read((tx) => tx.get("chainProgress", accountHex));
1213
+ const verified = [];
1214
+ let cursor;
1215
+ let pending;
1216
+ // Only links this device has not recorded are verified, and only they count against the budget.
1217
+ const verifyMore = (links) => {
1218
+ const fresh = unseen(links);
1219
+ const r = verifyChain(fresh, { state, trust: this.trust, now: this.now() });
1220
+ verified.push(...r.links);
1221
+ state = r.state;
1222
+ if (input.budget)
1223
+ input.budget.remainingRecords -= fresh.length;
1224
+ };
1225
+ if (progress && progress.startIdentityId === toHex(startIdentity)) {
1226
+ // Resume at the saved cursor, however long the verified prefix is. The prefix's transitions
1227
+ // were verified when their attestations were allowed, so they are replayed through the
1228
+ // transition rules alone; their bytes are authenticated again below, and refreshed from the
1229
+ // host where a key rotation since made them stale.
1230
+ for (const l of progress.links) {
1231
+ const doc = expectType(parseDocument(fromHex(l.wire)), DocumentType.AccountSuccession, stage);
1232
+ state = validateTransition(doc, fromHex(l.certificate), state).next;
1233
+ verified.push({ doc, wire: fromHex(l.wire), certificate: fromHex(l.certificate) });
1234
+ }
1235
+ cursor = unbig(progress.nextGeneration);
1236
+ pending = [];
1237
+ }
1238
+ else {
1239
+ pending = input.firstPage;
1240
+ cursor = input.next;
1241
+ }
1242
+ const persist = () => this.store.transaction((tx) => {
1243
+ if (cursor === undefined)
1244
+ tx.delete("chainProgress", accountHex);
1245
+ else
1246
+ 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 });
1247
+ });
1248
+ const reached = () => input.untilGeneration !== undefined && state.generation >= input.untilGeneration;
1249
+ verifyMore(pending);
1250
+ if (reached())
1251
+ cursor = undefined;
1252
+ while (cursor !== undefined) {
1253
+ if (input.budget && (input.budget.remainingRecords <= 0 || this.now() >= input.budget.deadline)) {
1254
+ await persist();
1255
+ throw new KavachError("METADATA_REQUIRED", stage, "the chain continues past this sync's budget");
1256
+ }
1257
+ let page;
1258
+ try {
1259
+ page = await this.transport.directory.getSuccessions(input.accountId, cursor, 32);
1260
+ }
1261
+ catch (e) {
1262
+ await persist();
1263
+ throw e;
1264
+ }
1265
+ if (page.successions.length === 0) {
1266
+ await persist();
1267
+ throw new KavachError("METADATA_REQUIRED", stage, "the host did not finish serving the chain");
1268
+ }
1269
+ verifyMore(page.successions);
1270
+ cursor = reached() ? undefined : page.nextGeneration;
1271
+ if (cursor !== undefined)
1272
+ await persist();
1273
+ }
1274
+ if (input.finalIdentityId && state.identityId !== toHex(input.finalIdentityId))
1275
+ throw new KavachError("SUCCESSION_BINDING", stage, "the chain does not end at the served key");
1276
+ // Every attestation under the current list before anything is recorded. A link whose signer
1277
+ // left the list since it was verified is fetched again: the host re-attests a legitimate
1278
+ // transition under a current key, and the transition must be the same one.
1279
+ for (let i = 0; i < verified.length; i++) {
1280
+ const link = verified[i];
1281
+ if (!link)
1282
+ continue;
1283
+ try {
1284
+ authenticateSuccession(link.wire, this.trust, this.now());
1285
+ }
1286
+ catch (e) {
1287
+ if (!isKavachError(e, "RESET_KEY_REVOKED"))
1288
+ throw e;
1289
+ const page = await this.transport.directory.getSuccessions(input.accountId, link.doc.newGeneration - 1n, 1);
1290
+ const again = page.successions[0];
1291
+ if (!again)
1292
+ throw e;
1293
+ const doc = authenticateSuccession(again.wire, this.trust, this.now());
1294
+ if (!sameTransition(transitionOf(doc), transitionOf(link.doc)) || !equalBytes(again.certificate, link.certificate))
1295
+ throw new KavachError("EQUIVOCATION", stage, "the re-attested link is a different transition");
1296
+ verified[i] = { doc, wire: again.wire, certificate: link.certificate };
1297
+ }
1298
+ }
1299
+ if (!current) {
1300
+ if (progress)
1301
+ await this.store.transaction((tx) => tx.delete("chainProgress", accountHex));
1302
+ return { pin: undefined, verified, accepted: [] };
1303
+ }
1304
+ const applied = await this.store.transaction((tx) => {
1305
+ const effects = [];
1306
+ let pin = current;
1307
+ for (const link of verified) {
1308
+ // A link behind the pinned key that is already on record: the pin moved past it earlier.
1309
+ if (link.doc.previousGeneration < pin.generation && tx.list("successions").some(([, s]) => s.accountId === accountHex && unbig(s.previousGeneration) === link.doc.previousGeneration))
1310
+ continue;
1311
+ const e = applySuccession(tx, { wire: link.wire, newCertificate: link.certificate, policy: this.policy, trust: this.trust, now: this.now() });
1312
+ effects.push(e);
1313
+ pin = e.pin;
1314
+ }
1315
+ this.persistTrustIn(tx);
1316
+ tx.delete("chainProgress", accountHex);
1317
+ return { effects, pin };
1318
+ });
1319
+ return { pin: applied.pin, verified, accepted: applied.effects };
1320
+ }
1321
+ /** The report side of an applied chain: counters and events for every accepted link. */
1322
+ reportAccepted(accepted, report) {
1323
+ for (const effect of accepted) {
1324
+ if (effect.outcome !== "ACCEPTED")
1325
+ continue;
1326
+ report.metadata.successions++;
1327
+ report.metadata.rotations++;
1328
+ this.emit({ kind: "accountKeyChanged", accountName: effect.accountName, quarantined: false });
1329
+ this.emit({ kind: "deviceChanged", identityId: effect.pin.accountId, generation: effect.pin.generation });
1330
+ }
1331
+ }
1332
+ securityCodeOf(e) {
1333
+ 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;
1334
+ }
1335
+ /** Accept a member's certificate; a changed account key is quarantined unless a succession chain or a reset receipt explains it. */
1336
+ async acceptMemberCertificate(resolved, report) {
1337
+ const wire = resolved.certificate;
1338
+ const resetReceipt = resolved.resetReceipt;
862
1339
  const doc = expectType(verifyDocument(wire), DocumentType.AccountDevice, "sync");
863
1340
  const existing = await this.store.read((tx) => tx.list("pins").find(([, p]) => p.accountName === doc.accountName)?.[1]);
864
- const current = existing ? rowToPin(existing) : undefined;
1341
+ let current = existing ? rowToPin(existing) : undefined;
1342
+ const accountId = current?.accountId ?? resolvedAccountId(resolved);
1343
+ const chain = resolved.successions ?? [];
1344
+ // A served chain is followed whenever there is one, even when the served key equals the pinned
1345
+ // one: a certificate under a retired key at a higher generation must be refused as a return to
1346
+ // that identity, never accepted as a plain device change. Links already on record cost nothing.
1347
+ if (current && chain.length > 0) {
1348
+ try {
1349
+ const r = await this.followChain({ accountId, accountName: doc.accountName, current, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget });
1350
+ this.reportAccepted(r.accepted, report);
1351
+ current = r.pin;
1352
+ }
1353
+ catch (e) {
1354
+ report.errors.push(syncError("succession", e));
1355
+ // A deployment that refuses successions quarantines the key change below, as a bare
1356
+ // directory key change. Anything else leaves the pin where it was until the next sync.
1357
+ if (!isKavachError(e, "UNSUPPORTED_TYPE")) {
1358
+ const code = this.securityCodeOf(e);
1359
+ if (code)
1360
+ this.emit({ kind: "securityError", code, detail: doc.accountName });
1361
+ return;
1362
+ }
1363
+ }
1364
+ if (!current)
1365
+ return;
1366
+ }
1367
+ let verified = [];
1368
+ if (!current && chain.length > 0) {
1369
+ // A first contact with an account that went through successions: the chain is verified from
1370
+ // the root to the served key before any pin is written, under the same policy gate as for an
1371
+ // established contact. A deployment that refuses successions pins nothing and quarantines.
1372
+ try {
1373
+ verified = (await this.followChain({ accountId, accountName: doc.accountName, finalIdentityId: pinFromCertificate(doc).identityId, firstPage: chain, next: resolved.successionsNext, budget: this.syncBudget })).verified;
1374
+ }
1375
+ catch (e) {
1376
+ report.errors.push(syncError("succession", e));
1377
+ if (isKavachError(e, "UNSUPPORTED_TYPE")) {
1378
+ const already = await this.store.read((tx) => tx.get("quarantine", doc.accountName));
1379
+ await this.store.transaction((tx) => tx.put("quarantine", doc.accountName, { accountName: doc.accountName, certificate: toHex(wire), reason: "succession refused", at: big(this.now()) }));
1380
+ report.metadata.quarantined++;
1381
+ if (!already)
1382
+ this.emit({ kind: "accountKeyChanged", accountName: doc.accountName, quarantined: true });
1383
+ }
1384
+ else {
1385
+ const code = this.securityCodeOf(e);
1386
+ if (code)
1387
+ this.emit({ kind: "securityError", code, detail: doc.accountName });
1388
+ }
1389
+ return;
1390
+ }
1391
+ }
865
1392
  let outcome;
866
1393
  try {
867
1394
  outcome = acceptCertificate(current, wire);
@@ -881,24 +1408,33 @@ class ClientImpl {
881
1408
  }
882
1409
  if (outcome.outcome === "DUPLICATE")
883
1410
  return;
884
- const p = outcome.pin;
1411
+ const p = current ? outcome.pin : { ...outcome.pin, accountId };
885
1412
  await this.store.transaction((tx) => {
886
- tx.put("pins", toHex(p.identityId), pinToRow(p));
1413
+ tx.put("pins", toHex(p.accountId), pinToRow(p));
1414
+ if (verified.length) {
1415
+ recordChainIn(tx, verified, this.now());
1416
+ rootIdentityIn(tx, p.accountId, p.accountName, resolved.rootCertificate);
1417
+ }
1418
+ if (!tx.get("identities", toHex(p.identityId))) {
1419
+ const last = resolved.successions?.at(-1);
1420
+ const since = last ? expectType(parseDocument(last.wire), DocumentType.AccountSuccession, "sync").newGeneration : 1n;
1421
+ tx.put("identities", toHex(p.identityId), { accountId: toHex(p.accountId), accountName: p.accountName, accountKey: toHex(p.accountKey), sinceGeneration: big(since), retired: false });
1422
+ }
887
1423
  if (outcome.previous) {
888
1424
  const old = tx.get("certificates", toHex(outcome.previous.certificateHash));
889
1425
  if (old)
890
1426
  tx.put("certificates", toHex(outcome.previous.certificateHash), { ...old, current: false });
891
1427
  }
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 });
1428
+ 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
1429
  if (outcome.rotate) {
894
- const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.identityId, certificateHash: p.certificateHash });
1430
+ const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.accountId, certificateHash: p.certificateHash });
895
1431
  if (ch.changed)
896
1432
  report.metadata.rotations++;
897
1433
  }
898
1434
  });
899
1435
  report.metadata.certificates++;
900
1436
  if (outcome.rotate)
901
- this.emit({ kind: "deviceChanged", identityId: p.identityId, generation: p.generation });
1437
+ this.emit({ kind: "deviceChanged", identityId: p.accountId, generation: p.generation });
902
1438
  }
903
1439
  async onKeyChanged(current, wire, receipt, report) {
904
1440
  if (receipt) {
@@ -934,20 +1470,26 @@ class ClientImpl {
934
1470
  }
935
1471
  /** Peers on a validated reset: stop sharing to the old identity, close its circles, rotate. */
936
1472
  applyResetIn(tx, old, newIdentityId) {
937
- tx.delete("pins", toHex(old.identityId));
1473
+ const accountHex = toHex(old.accountId);
1474
+ tx.delete("pins", accountHex);
938
1475
  tx.delete("quarantine", old.accountName);
939
1476
  for (const [k, c] of tx.list("certificates"))
940
- if (c.identityId === toHex(old.identityId))
1477
+ if (c.identityId === accountHex)
941
1478
  tx.put("certificates", k, { ...c, current: false });
1479
+ // Every identity of the chain is retired; the root remembers the reset, so no successor of
1480
+ // the old account is ever a recipient again.
1481
+ for (const [k, r] of tx.list("identities"))
1482
+ if (r.accountId === accountHex)
1483
+ tx.put("identities", k, k === accountHex ? { ...r, retired: true, resetAt: big(this.now()) } : { ...r, retired: true });
942
1484
  for (const [k, c] of tx.list("circles")) {
943
- if (c.ownerIdentityId === toHex(old.identityId)) {
1485
+ if (c.ownerIdentityId === accountHex) {
944
1486
  t3Circle(tx, { ...c, status: "closed" }, { kind: "leave", circleId: fromHex(k) });
945
1487
  }
946
- else if (c.members.includes(toHex(old.identityId))) {
947
- t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.identityId, certificateHash: newIdentityId });
1488
+ else if (c.members.includes(accountHex)) {
1489
+ t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.accountId, certificateHash: newIdentityId });
948
1490
  }
949
1491
  }
950
- forgetAuthorIn(tx, old.identityId);
1492
+ forgetAuthorIn(tx, old.accountId);
951
1493
  }
952
1494
  persistTrustIn(tx) {
953
1495
  const rows = this.trust.toRows();
@@ -968,12 +1510,13 @@ class ClientImpl {
968
1510
  const { pin } = this.requireAccount("left");
969
1511
  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
1512
  for (const m of c.members) {
971
- if (equalBytes(m, pin.identityId) || others.includes(toHex(m)))
1513
+ if (equalBytes(m, pin.accountId) || others.includes(toHex(m)))
972
1514
  continue;
973
1515
  await forgetAuthor(this.store, m);
974
1516
  }
975
1517
  }
976
1518
  async retire() {
1519
+ this.retired = true;
977
1520
  await this.store.transaction((tx) => {
978
1521
  const meta = tx.get("meta", META);
979
1522
  if (meta)
@@ -987,9 +1530,6 @@ class ClientImpl {
987
1530
  await this.emitState();
988
1531
  }
989
1532
  // Receiving and reads ---------------------------------------------------------------------------
990
- receive(wire) {
991
- return receiveRecord(this.inboxDeps(), wire);
992
- }
993
1533
  history(query) {
994
1534
  return history(this.store, this.policy, query);
995
1535
  }
@@ -1002,9 +1542,30 @@ class ClientImpl {
1002
1542
  newest(identityId, kind) {
1003
1543
  return newestSlot(this.store, identityId, kind, this.now());
1004
1544
  }
1545
+ keyChanges(options = {}) {
1546
+ const limit = Math.max(1, Math.min(500, Math.floor(options.limit ?? 50)));
1547
+ const who = options.accountId ? toHex(options.accountId) : undefined;
1548
+ return this.store.read((tx) => tx
1549
+ .list("keyChanges")
1550
+ .map(([, r]) => r)
1551
+ .filter((r) => who === undefined || r.accountId === who)
1552
+ // Newest first; two changes accepted in one transaction order by generation.
1553
+ .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))
1554
+ .slice(0, limit)
1555
+ .map((r) => ({
1556
+ accountId: fromHex(r.accountId),
1557
+ accountName: r.accountName,
1558
+ previousIdentityId: fromHex(r.previousIdentityId),
1559
+ newIdentityId: fromHex(r.newIdentityId),
1560
+ generation: unbig(r.generation),
1561
+ reason: r.reason,
1562
+ successionId: r.successionId === undefined ? undefined : fromHex(r.successionId),
1563
+ acceptedAt: unbig(r.acceptedAt)
1564
+ })));
1565
+ }
1005
1566
  // Circles ---------------------------------------------------------------------------------------
1006
1567
  async listCircles() {
1007
- const self = this.selfPin?.identityId;
1568
+ const self = this.selfPin?.accountId;
1008
1569
  const now = this.now();
1009
1570
  return this.store.read((tx) => tx.list("circles").map(([, r]) => {
1010
1571
  const c = rowToCircle(r);
@@ -1019,6 +1580,8 @@ class ClientImpl {
1019
1580
  * the state, so a change that landed in the rows reaches the vault however the process dies.
1020
1581
  */
1021
1582
  async syncVault() {
1583
+ if (!this.vaultEnabled)
1584
+ return;
1022
1585
  const { account, pin } = this.requireAccount("vault");
1023
1586
  const recoveryKey = await this.secrets.get("recoveryKey");
1024
1587
  const accountSeed = await this.secrets.get("accountSeed");
@@ -1101,11 +1664,14 @@ class ClientImpl {
1101
1664
  async createCircle() {
1102
1665
  const { account, pin } = this.requireAccount("createCircle");
1103
1666
  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());
1667
+ const genesis = createCircleSnapshot({ identityId: pin.accountId, accountSecretKey: account.account.secretKey }, circleId);
1668
+ const state = acceptSnapshot(pinCircle(circleId, pin.accountId, true, "member"), genesis, pin.accountKey, pin.accountId, this.now());
1106
1669
  if (state.outcome !== "ACCEPTED")
1107
1670
  throw new KavachError("STORAGE_FAILURE", "createCircle");
1108
- await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(state.state), pending: { snapshot: toHex(genesis), revision: "1" } }));
1671
+ await this.store.transaction((tx) => {
1672
+ t3Circle(tx, { ...circleToRow(state.state), pending: { snapshot: toHex(genesis), revision: "1" } });
1673
+ this.recordPreferenceIn(tx, circleId, true);
1674
+ });
1109
1675
  const published = await this.publishPendingSnapshot(circleId);
1110
1676
  if (published !== "COMMITTED")
1111
1677
  throw new KavachError("STORAGE_FAILURE", "createCircle", published);
@@ -1114,6 +1680,7 @@ class ClientImpl {
1114
1680
  t9MarkVault(tx);
1115
1681
  });
1116
1682
  await this.syncVault();
1683
+ await this.pushPreferencesQuietly();
1117
1684
  this.emit({ kind: "circleChanged", circleId, revision: 1n, status: "member" });
1118
1685
  return { circleId };
1119
1686
  }
@@ -1121,6 +1688,8 @@ class ClientImpl {
1121
1688
  this.requireAccount("requestJoin");
1122
1689
  await this.store.transaction((tx) => {
1123
1690
  const existing = tx.get("circles", toHex(circleId));
1691
+ if (!existing || existing.status === "left")
1692
+ this.recordPreferenceIn(tx, circleId, shareOnApproval);
1124
1693
  if (!existing)
1125
1694
  t3Circle(tx, circleToRow(pinCircle(circleId, ownerIdentityId, shareOnApproval, "requested")));
1126
1695
  else if (existing.status === "left") {
@@ -1136,18 +1705,30 @@ class ClientImpl {
1136
1705
  const outcome = await this.transport.directory.requestJoin(circleId);
1137
1706
  if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED")
1138
1707
  throw new KavachError("STORAGE_FAILURE", "requestJoin", outcome.status);
1708
+ await this.pushPreferencesQuietly();
1709
+ }
1710
+ /** The host's copy of a preference, best effort now and by sync later. */
1711
+ async pushPreferencesQuietly() {
1712
+ if (!acceptsSuccession(this.policy))
1713
+ return;
1714
+ try {
1715
+ await this.pushPreferences();
1716
+ }
1717
+ catch {
1718
+ /* retried by sync */
1719
+ }
1139
1720
  }
1140
1721
  async approve(circleId, accountName) {
1141
1722
  const [resolved] = await this.transport.directory.resolveAccounts([accountName]);
1142
1723
  if (!resolved)
1143
1724
  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)
1725
+ 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: [] };
1726
+ await this.acceptMemberCertificate(resolved, report);
1727
+ const requester = resolvedAccountId(resolved);
1728
+ const pinned = await this.pinFor(requester);
1729
+ if (!pinned || !equalBytes(pinned.certificateHash, expectType(verifyDocument(resolved.certificate), DocumentType.AccountDevice).bodyHash))
1149
1730
  throw new KavachError("ACCOUNT_KEY_CHANGED", "approve", "requester's key is quarantined");
1150
- return this.changeMembership(circleId, { admit: requester.identityId });
1731
+ return this.changeMembership(circleId, { admit: requester });
1151
1732
  }
1152
1733
  async changeMembership(circleId, change) {
1153
1734
  const { account, pin } = this.requireAccount("changeMembership");
@@ -1163,7 +1744,7 @@ class ClientImpl {
1163
1744
  const state = rowToCircle(row);
1164
1745
  if (state.status === "closed")
1165
1746
  return { revision: state.revision };
1166
- const successor = successorSnapshot(state, change, { identityId: pin.identityId, accountSecretKey: account.account.secretKey });
1747
+ const successor = successorSnapshot(state, change, { identityId: pin.accountId, accountSecretKey: account.account.secretKey });
1167
1748
  if (!successor) {
1168
1749
  if (!("close" in change))
1169
1750
  return { revision: state.revision };
@@ -1205,7 +1786,7 @@ class ClientImpl {
1205
1786
  const expectedRevision = unbig(pending.revision) - 1n;
1206
1787
  const outcome = await this.transport.directory.publishOwnedCircle({ circleId, expectedRevision, expectedHash: expectedRevision === state.revision ? state.bodyHash : undefined, newSnapshot: successor });
1207
1788
  if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
1208
- const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.identityId, this.now());
1789
+ const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.accountId, this.now());
1209
1790
  if (accepted.outcome !== "ACCEPTED" && accepted.outcome !== "DUPLICATE")
1210
1791
  throw new KavachError("STORAGE_FAILURE", stage, "the successor was refused locally");
1211
1792
  let next = pending.close ? { ...accepted.state, status: "closed" } : accepted.state;
@@ -1219,7 +1800,7 @@ class ClientImpl {
1219
1800
  });
1220
1801
  const after = new Set(next.members.map(toHex));
1221
1802
  for (const m of state.members) {
1222
- if (after.has(toHex(m)) || equalBytes(m, pin.identityId))
1803
+ if (after.has(toHex(m)) || equalBytes(m, pin.accountId))
1223
1804
  continue;
1224
1805
  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
1806
  if (!stillElsewhere)
@@ -1231,7 +1812,7 @@ class ClientImpl {
1231
1812
  return "COMMITTED";
1232
1813
  }
1233
1814
  if (outcome.status === "CONFLICT" && outcome.current) {
1234
- const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.identityId, this.now());
1815
+ const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.accountId, this.now());
1235
1816
  await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(accepted.outcome === "ACCEPTED" ? accepted.state : state), pending: undefined }));
1236
1817
  return "CONFLICT";
1237
1818
  }
@@ -1242,7 +1823,7 @@ class ClientImpl {
1242
1823
  const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1243
1824
  if (!row)
1244
1825
  return;
1245
- if (row.ownerIdentityId === toHex(pin.identityId))
1826
+ if (row.ownerIdentityId === toHex(pin.accountId))
1246
1827
  throw new KavachError("NOT_AUTHORIZED", "leave", "the owner closes instead");
1247
1828
  const state = rowToCircle(row);
1248
1829
  await this.store.transaction((tx) => {
@@ -1263,15 +1844,395 @@ class ClientImpl {
1263
1844
  }
1264
1845
  await this.store.transaction((tx) => {
1265
1846
  t3Circle(tx, { ...row, enabled }, { kind: enabled ? "enable" : "disable", circleId });
1847
+ this.recordPreferenceIn(tx, circleId, enabled);
1266
1848
  });
1267
1849
  await this.syncVault();
1850
+ await this.pushPreferencesQuietly();
1851
+ }
1852
+ /** The user's latest action on a circle's preference, with the local sequence that a host response must name to clear it. */
1853
+ recordPreferenceIn(tx, circleId, enabled) {
1854
+ const key = toHex(circleId);
1855
+ const row = tx.get("preferences", key) ?? { circleId: key, enabled, revision: "0", pending: undefined, nextSequence: "1" };
1856
+ const sequence = row.nextSequence;
1857
+ tx.put("preferences", key, { ...row, enabled, pending: { enabled, sequence }, nextSequence: big(unbig(sequence) + 1n) });
1858
+ }
1859
+ /**
1860
+ * Send pending preference changes to the host (amendment A1, section 7). On CONFLICT the
1861
+ * device's latest persisted action wins, read from the store at retry time, and is written again
1862
+ * at the served revision; a response clears only the sequence it answered.
1863
+ */
1864
+ async pushPreferences(report) {
1865
+ if (!acceptsSuccession(this.policy) || !this.selfPin)
1866
+ return;
1867
+ const pending = await this.store.read((tx) => tx.list("preferences").filter(([, p]) => p.pending !== undefined).map(([, p]) => p.circleId));
1868
+ for (const key of pending) {
1869
+ for (let attempt = 0; attempt < 3; attempt++) {
1870
+ const row = await this.store.read((tx) => tx.get("preferences", key));
1871
+ if (!row?.pending)
1872
+ break;
1873
+ const sent = row.pending;
1874
+ const outcome = await this.transport.directory.setSharingPreference(fromHex(key), sent.enabled, unbig(row.revision));
1875
+ if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
1876
+ await this.store.transaction((tx) => {
1877
+ const live = tx.get("preferences", key);
1878
+ if (!live)
1879
+ return;
1880
+ const cleared = live.pending?.sequence === sent.sequence ? undefined : live.pending;
1881
+ // A late success for an older request never moves the revision back.
1882
+ const revision = outcome.revision > unbig(live.revision) ? big(outcome.revision) : live.revision;
1883
+ tx.put("preferences", key, { ...live, revision, pending: cleared });
1884
+ });
1885
+ break;
1886
+ }
1887
+ if (outcome.status === "CONFLICT") {
1888
+ // Adopt the served revision; the latest persisted action is re-sent against it.
1889
+ await this.store.transaction((tx) => {
1890
+ const live = tx.get("preferences", key);
1891
+ if (live)
1892
+ tx.put("preferences", key, { ...live, revision: big(outcome.revision) });
1893
+ });
1894
+ continue;
1895
+ }
1896
+ if (report)
1897
+ report.errors.push({ operation: "preference", code: outcome.status === "UNAUTHORIZED" ? "NOT_AUTHORIZED" : "NETWORK", stage: "preference", retryable: outcome.status !== "UNAUTHORIZED" });
1898
+ break;
1899
+ }
1900
+ }
1268
1901
  }
1269
1902
  /** Close: the successor names only the owner; its publication forgets the members and removes the vault entry. */
1270
1903
  async closeCircle(circleId) {
1271
1904
  await this.changeMembership(circleId, { close: true });
1272
1905
  }
1906
+ // Account recovery (amendment A1) ---------------------------------------------------------------
1907
+ async prepareReplacementNow() {
1908
+ const stage = "prepareReplacement";
1909
+ if (!acceptsSuccession(this.policy))
1910
+ throw new KavachError("NOT_AUTHORIZED", stage, "this deployment does not use account recovery");
1911
+ const opened = await openStore(this.store, this.secrets, this.accountName);
1912
+ if (opened.kind !== "fresh")
1913
+ throw new KavachError("MALFORMED", stage, "an account already exists on this device");
1914
+ const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
1915
+ const roots = await this.secrets.get("pendingReplacement");
1916
+ if (existing && !existing.stale && roots && roots.length === RESET_ROOTS_BYTES)
1917
+ return proposalOf(existing);
1918
+ const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
1919
+ if (!resolved)
1920
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
1921
+ const current = expectType(verifyDocument(resolved.certificate), DocumentType.AccountDevice, stage);
1922
+ const accountId = resolvedAccountId(resolved);
1923
+ // Fresh roots: nothing from the old device, nothing from the host. The certificate is at the
1924
+ // next generation under the new key; the vault is fresh at revision 1 so that identity
1925
+ // continuity works on the next replacement.
1926
+ const accountSeed = newAccountSeed();
1927
+ const device = newDevice();
1928
+ const recoveryKey = newRecoveryKey();
1929
+ const instanceId = randomBytes(16);
1930
+ const successionId = randomBytes(16);
1931
+ const account = openAccount(this.accountName, accountSeed, accountId);
1932
+ const generation = current.generation + 1n;
1933
+ const certificate = certify(account, device, generation);
1934
+ const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage), undefined, accountId);
1935
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: generation, lastCertificateHash: pin.certificateHash, entries: [] }, recoveryKey) : undefined;
1936
+ // T11: roots in the secret store, the exact bytes in the store, before anything is sent.
1937
+ await this.secrets.set("pendingReplacement", concat(accountSeed, device.deviceSeed, recoveryKey, instanceId, device.deviceId));
1938
+ const row = {
1939
+ successionId: toHex(successionId),
1940
+ accountName: this.accountName,
1941
+ accountId: toHex(accountId),
1942
+ expectedCertificateHash: toHex(current.bodyHash),
1943
+ expectedGeneration: big(current.generation),
1944
+ certificate: toHex(certificate),
1945
+ vault: vault ? toHex(vault.wire) : undefined,
1946
+ vaultHash: vault ? toHex(vault.hash) : undefined,
1947
+ deviceId: toHex(device.deviceId),
1948
+ stale: false,
1949
+ conflictGeneration: undefined
1950
+ };
1951
+ await this.store.transaction((tx) => tx.put("replacement", "pending", row));
1952
+ await this.emitState();
1953
+ return proposalOf(row);
1954
+ }
1955
+ async completeReplacementNow(input) {
1956
+ const stage = "completeReplacement";
1957
+ // Already activated (by an earlier call, or by a sync that found the commit on the chain): idempotent.
1958
+ if (this.selfPin && this.account && !this.retired)
1959
+ return { identityId: this.selfPin.identityId, generation: this.selfPin.generation };
1960
+ const row = await this.store.read((tx) => tx.get("replacement", "pending"));
1961
+ if (!row || row.successionId !== toHex(input.successionId))
1962
+ throw new KavachError("MALFORMED", stage, "no such prepared replacement on this device");
1963
+ if (row.stale)
1964
+ throw new KavachError("REPLACEMENT_CONFLICT", stage, row.conflictGeneration === undefined ? "the account moved" : `the account is at generation ${row.conflictGeneration}`);
1965
+ const roots = await this.secrets.get("pendingReplacement");
1966
+ if (!roots || roots.length !== RESET_ROOTS_BYTES)
1967
+ throw new KavachError("RECOVERY_REQUIRED", stage, "replacement prepared without its roots");
1968
+ let outcome;
1969
+ try {
1970
+ outcome = await this.transport.directory.succeedAccount({
1971
+ accountName: this.accountName,
1972
+ expectedCertificateHash: fromHex(row.expectedCertificateHash),
1973
+ newCertificate: fromHex(row.certificate),
1974
+ newVault: row.vault === undefined ? undefined : fromHex(row.vault),
1975
+ successionId: input.successionId,
1976
+ authorization: input.authorization
1977
+ });
1978
+ }
1979
+ catch (e) {
1980
+ if (!isKavachError(e, "NETWORK"))
1981
+ throw e;
1982
+ // Unknown: the chain says whether ours committed. Never a second identity.
1983
+ const link = await this.findOwnLink(row).catch(() => undefined);
1984
+ if (!link)
1985
+ throw new KavachError("SUCCESSION_PENDING", stage, "the commit's outcome is unknown; call again with the same proposal");
1986
+ outcome = { status: "COMMITTED", succession: link.wire };
1987
+ }
1988
+ if (outcome.status === "CONFLICT") {
1989
+ // Definitive: the current certificate moved under the proposal, and the succession id is
1990
+ // not on the chain (that answers first). The proposal can never commit: it is marked stale
1991
+ // and its keys, never activated anywhere, are discarded.
1992
+ let current;
1993
+ try {
1994
+ current = outcome.current ? expectType(verifyDocument(outcome.current), DocumentType.AccountDevice, stage).generation : undefined;
1995
+ }
1996
+ catch {
1997
+ current = undefined;
1998
+ }
1999
+ await this.store.transaction((tx) => tx.put("replacement", "pending", { ...row, stale: true, conflictGeneration: current === undefined ? undefined : big(current) }));
2000
+ await this.secrets.delete("pendingReplacement");
2001
+ await this.emitState();
2002
+ throw new KavachError("REPLACEMENT_CONFLICT", stage, current === undefined ? "the account moved" : `the account is at generation ${current}`);
2003
+ }
2004
+ if (outcome.status === "UNAUTHORIZED")
2005
+ throw new KavachError("AUTHORIZATION_REQUIRED", stage, "the host refused the authorization; obtain a fresh one for the same proposal");
2006
+ if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.succession) {
2007
+ throw new KavachError("SUCCESSION_PENDING", stage, `host answered ${outcome.status}`);
2008
+ }
2009
+ return this.activateReplacement(row, roots, outcome.succession);
2010
+ }
2011
+ /** Our own link on the host's chain, if the proposal committed: the one naming our succession id and certificate. */
2012
+ async findOwnLink(row) {
2013
+ const page = await this.transport.directory.getSuccessions(fromHex(row.accountId), unbig(row.expectedGeneration), 8);
2014
+ for (const l of page.successions) {
2015
+ try {
2016
+ const doc = expectType(parseDocument(l.wire), DocumentType.AccountSuccession, "resume");
2017
+ if (toHex(doc.successionId) === row.successionId && equalBytes(l.certificate, fromHex(row.certificate)))
2018
+ return l;
2019
+ }
2020
+ catch {
2021
+ /* not ours */
2022
+ }
2023
+ }
2024
+ return undefined;
2025
+ }
2026
+ /** The reset-key list, fetched when the one held is not fresh. */
2027
+ async ensureResetList() {
2028
+ const now = this.now();
2029
+ if (this.trust.fresh(now))
2030
+ return;
2031
+ const wire = await this.transport.directory.getResetKeyList();
2032
+ if (!wire)
2033
+ return;
2034
+ this.trust.install(wire, now);
2035
+ await this.persistTrust();
2036
+ }
2037
+ /**
2038
+ * Activate: verify the returned succession exactly as a peer would, against the previous state
2039
+ * pinned from the host, then the T1 header under the new identity with the account id carried
2040
+ * from the proposal. A succession that does not verify leaves the proposal intact.
2041
+ */
2042
+ async activateReplacement(row, roots, succession) {
2043
+ const stage = "completeReplacement";
2044
+ await this.ensureResetList();
2045
+ const previousWire = await this.transport.directory.getCertificate(fromHex(row.expectedCertificateHash));
2046
+ if (!previousWire)
2047
+ throw new KavachError("METADATA_REQUIRED", stage, "the previous certificate is not served");
2048
+ const previous = expectType(verifyDocument(previousWire), DocumentType.AccountDevice, stage);
2049
+ if (toHex(previous.bodyHash) !== row.expectedCertificateHash)
2050
+ throw new KavachError("SUCCESSION_BINDING", stage, "the served certificate is not the expected one");
2051
+ const accountId = fromHex(row.accountId);
2052
+ const previousPin = pinFromCertificate(previous, undefined, accountId);
2053
+ // The account's earlier successions, if any: the chain from the root to the previous identity,
2054
+ // verified through the one chain path and kept, so this device holds every key the account
2055
+ // has signed with (an owned circle's snapshot from before an earlier replacement) and can
2056
+ // follow its own chain later (to learn of its retirement).
2057
+ const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
2058
+ // The evidence must be there before anything is written: without the account's resolution the
2059
+ // history is unknown, not empty, and the proposal stays for the next call. An empty history is
2060
+ // right only when the previous identity is the account root.
2061
+ if (!me)
2062
+ throw new KavachError("METADATA_REQUIRED", stage, "the account's history is not served; the proposal stays");
2063
+ const served = me.successions ?? [];
2064
+ const ownSuccession = authenticateSuccession(succession, this.trust, this.now());
2065
+ if (served.length === 0 && me.successionsNext === undefined && !equalBytes(previousPin.identityId, accountId))
2066
+ throw new KavachError("METADATA_REQUIRED", stage, "the account's earlier successions are not served; the proposal stays");
2067
+ // Bounded by the generation this device's own succession left from, on every page alike.
2068
+ 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 : [];
2069
+ const accountSeed = roots.subarray(0, 32);
2070
+ const deviceSeed = roots.subarray(32, 64);
2071
+ const recoveryKey = roots.subarray(64, 96);
2072
+ const instanceId = roots.subarray(96, 112);
2073
+ const deviceId = roots.subarray(112, 128);
2074
+ const account = openAccount(this.accountName, accountSeed, accountId);
2075
+ const device = deviceFromSeed(deviceSeed, deviceId);
2076
+ const certificate = fromHex(row.certificate);
2077
+ const doc = expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage);
2078
+ const pin = pinFromCertificate(doc, undefined, accountId);
2079
+ // Roots first, then the store: a crash in between leaves a device the next call finishes.
2080
+ await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
2081
+ await this.store.transaction((tx) => {
2082
+ // The previous state as a first contact pins it, then the succession as a peer verifies it:
2083
+ // nothing is written unless the document binds this account, this generation and this key.
2084
+ tx.put("pins", toHex(accountId), pinToRow(previousPin));
2085
+ 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 });
2086
+ recordChainIn(tx, history, this.now());
2087
+ rootIdentityIn(tx, accountId, this.accountName, me?.rootCertificate);
2088
+ const effect = applySuccession(tx, { wire: succession, newCertificate: certificate, policy: this.policy, trust: this.trust, now: this.now() });
2089
+ if (!equalBytes(effect.newIdentityId, account.identityId) || toHex(effect.accountId) !== row.accountId)
2090
+ throw new KavachError("SUCCESSION_BINDING", stage, "the succession does not name this device's key");
2091
+ this.persistTrustIn(tx);
2092
+ t1Header(tx, { account, device, certificate, instanceId, generation: doc.generation });
2093
+ if (row.vault !== undefined && row.vaultHash !== undefined)
2094
+ t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
2095
+ t3ApplyChange(tx, { kind: "ownActivation" });
2096
+ t1Activate(tx);
2097
+ const meta = tx.get("meta", META);
2098
+ if (meta)
2099
+ tx.put("meta", META, { ...meta, bootstrap: { complete: false } });
2100
+ tx.delete("replacement", "pending");
2101
+ });
2102
+ await this.secrets.delete("pendingReplacement");
2103
+ this.account = account;
2104
+ this.device = device;
2105
+ this.selfPin = pin;
2106
+ this.retired = false;
2107
+ try {
2108
+ await this.provider?.save(this.accountName, pin.identityId, recoveryKey);
2109
+ }
2110
+ catch {
2111
+ /* reported by recoveryStatus() */
2112
+ }
2113
+ await this.emitState();
2114
+ return { identityId: pin.identityId, generation: pin.generation };
2115
+ }
2116
+ /**
2117
+ * Discovery after account recovery (amendment A1, section 5): the host lists the account's
2118
+ * circles page by page; each is pinned only when its snapshot verifies under the owner's
2119
+ * verified chain and names this account as the host says. Discovery is not permission to share:
2120
+ * the circle is enabled only if the served preference says so. Never writes membership; an
2121
+ * owner's successor republishes the served snapshot at the next revision.
2122
+ */
2123
+ async bootstrap(report, state) {
2124
+ const out = { circles: 0, pending: 0, failed: 0 };
2125
+ // A circle that could not be adopted is kept with what the host served and retried by every
2126
+ // sync; a failure never advances past it silently, and never undoes another circle.
2127
+ let retry = state.retry ?? [];
2128
+ let cursor = state.cursor;
2129
+ let pagesDone = state.pagesDone === true;
2130
+ const adopt = async (c) => {
2131
+ try {
2132
+ const r = await this.adoptListing(c, report);
2133
+ if (r === "member")
2134
+ out.circles++;
2135
+ else if (r === "pending")
2136
+ out.pending++;
2137
+ return true;
2138
+ }
2139
+ catch (e) {
2140
+ out.failed++;
2141
+ report.errors.push(syncError("bootstrap", e, { circleId: c.circleId }));
2142
+ return false;
2143
+ }
2144
+ };
2145
+ const save = (done) => this.store.transaction((tx) => {
2146
+ const meta = tx.get("meta", META);
2147
+ if (!meta)
2148
+ return;
2149
+ const bootstrap = done && retry.length === 0 ? { complete: true } : { ...(cursor === undefined ? {} : { cursor }), complete: false, retry, pagesDone: done };
2150
+ tx.put("meta", META, { ...meta, bootstrap });
2151
+ t9MarkVault(tx);
2152
+ });
2153
+ const stillFailing = [];
2154
+ for (const r of retry) {
2155
+ 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) };
2156
+ if (!(await adopt(listing)))
2157
+ stillFailing.push(r);
2158
+ }
2159
+ retry = stillFailing;
2160
+ for (let pages = 0; !pagesDone && pages < 64; pages++) {
2161
+ let page;
2162
+ try {
2163
+ page = await this.transport.directory.listCircles(cursor);
2164
+ }
2165
+ catch (e) {
2166
+ // What landed stays; the cursor says where to resume on the next sync.
2167
+ report.errors.push(syncError("bootstrap", e));
2168
+ await save(false);
2169
+ return out;
2170
+ }
2171
+ for (const c of page.circles) {
2172
+ if (!(await adopt(c)))
2173
+ 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) });
2174
+ }
2175
+ cursor = page.cursor;
2176
+ if (cursor === undefined)
2177
+ pagesDone = true;
2178
+ await save(pagesDone);
2179
+ }
2180
+ await save(pagesDone);
2181
+ return out;
2182
+ }
2183
+ async adoptListing(c, report) {
2184
+ const stage = "bootstrap";
2185
+ const { pin, account } = this.requireAccount(stage);
2186
+ const key = toHex(c.circleId);
2187
+ const existing = await this.store.read((tx) => tx.get("circles", key));
2188
+ if (existing && (existing.revision !== "0" || existing.status === "requested"))
2189
+ return "skipped"; // a restart between pages
2190
+ if (!c.snapshot)
2191
+ return "skipped"; // closed: nothing to pin, nothing to reopen
2192
+ const ownerIsMe = equalBytes(c.ownerAccountId, pin.accountId);
2193
+ let ownerPin = ownerIsMe ? pin : await this.pinFor(c.ownerAccountId);
2194
+ if (!ownerPin) {
2195
+ const [r] = await this.transport.directory.resolveIdentities([c.ownerAccountId]);
2196
+ if (r)
2197
+ await this.acceptMemberCertificate(r, report);
2198
+ ownerPin = await this.pinFor(c.ownerAccountId);
2199
+ }
2200
+ if (!ownerPin)
2201
+ throw new KavachError("METADATA_REQUIRED", stage, "the circle's owner cannot be resolved");
2202
+ const enabled = c.enabled === true;
2203
+ const base = pinCircle(c.circleId, c.ownerAccountId, enabled, c.role === "pending" ? "requested" : "member");
2204
+ const accepted = await this.acceptSnapshotFrom(base, c.snapshot, ownerPin, this.now());
2205
+ if (accepted.outcome !== "ACCEPTED")
2206
+ throw new KavachError("MALFORMED", stage, "an empty snapshot");
2207
+ let state = accepted.state;
2208
+ const named = c.role === "owner" ? equalBytes(state.ownerIdentityId, pin.accountId) : c.role === "member" ? state.members.some((m) => equalBytes(m, pin.accountId)) : true;
2209
+ if (!named)
2210
+ throw new KavachError("NOT_AUTHORIZED", stage, "the snapshot does not name this account as the host says");
2211
+ if (c.role === "pending")
2212
+ state = { ...state, status: "requested" };
2213
+ // The successor owner republishes what it was served at the next revision; the host's
2214
+ // compare-and-swap decides.
2215
+ const republish = ownerIsMe ? buildCircleSnapshot({ circleId: c.circleId, ownerIdentityId: pin.accountId, revision: state.revision + 1n, members: state.members }, account.account.secretKey) : undefined;
2216
+ await this.store.transaction((tx) => {
2217
+ const row = circleToRow(state);
2218
+ t3Circle(tx, republish ? { ...row, pending: { snapshot: toHex(republish), revision: big(state.revision + 1n) } } : row, { kind: "snapshot", circleId: c.circleId, revision: state.revision });
2219
+ if (state.status === "member")
2220
+ t3ApplyChange(tx, { kind: "join", circleId: c.circleId });
2221
+ tx.put("preferences", key, { circleId: key, enabled, revision: big(c.preferenceRevision), pending: undefined, nextSequence: "1" });
2222
+ });
2223
+ this.emit({ kind: "circleChanged", circleId: c.circleId, revision: state.revision, status: state.status });
2224
+ if (republish) {
2225
+ try {
2226
+ await this.publishPendingSnapshot(c.circleId);
2227
+ }
2228
+ catch (e) {
2229
+ report.errors.push(syncError("publishSnapshot", e));
2230
+ }
2231
+ }
2232
+ return state.status === "requested" ? "pending" : "member";
2233
+ }
1273
2234
  // Reset -----------------------------------------------------------------------------------------
1274
- async resetAccount(options) {
2235
+ async resetAccountNow(options) {
1275
2236
  const stage = "resetAccount";
1276
2237
  if (!options.confirmed)
1277
2238
  throw new KavachError("NOT_AUTHORIZED", stage, "reset requires confirmation");
@@ -1284,13 +2245,13 @@ class ClientImpl {
1284
2245
  const certificate = certify(account, device, 1n);
1285
2246
  const newPin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
1286
2247
  const recoveryKey = newRecoveryKey();
1287
- const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey);
2248
+ const vault = this.vaultEnabled ? buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey) : undefined;
1288
2249
  const resetId = randomBytes(16);
1289
2250
  const instanceId = randomBytes(16);
1290
2251
  // Persisted before it is sent: the host may commit the new identity and the response be lost,
1291
2252
  // and the only keys that can continue are these. Roots in the secret store, wires in the store.
1292
2253
  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) }));
2254
+ 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
2255
  return this.resumeReset(stage, options.reauthProof);
1295
2256
  }
1296
2257
  /** Send (or re-send) the persisted reset with its own reset id, and finish locally once the host holds it. */
@@ -1301,7 +2262,7 @@ class ClientImpl {
1301
2262
  throw new KavachError("RECOVERY_REQUIRED", stage, "reset pending without its roots");
1302
2263
  const certificate = fromHex(row.certificate);
1303
2264
  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() });
2265
+ 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
2266
  if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.receipt)
1306
2267
  throw new KavachError("NOT_AUTHORIZED", stage, `host refused reset: ${outcome.status}`);
1307
2268
  return this.finishReset(stage, row, roots);
@@ -1323,12 +2284,13 @@ class ClientImpl {
1323
2284
  const newPin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage));
1324
2285
  await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
1325
2286
  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"]) {
2287
+ 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
2288
  for (const [k] of tx.list(table))
1328
2289
  tx.delete(table, k);
1329
2290
  }
1330
2291
  t1Header(tx, { account, device, certificate, instanceId, generation: 1n });
1331
- t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
2292
+ if (row.vault !== undefined && row.vaultHash !== undefined)
2293
+ t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
1332
2294
  t1Activate(tx);
1333
2295
  });
1334
2296
  await this.secrets.delete("pendingReset");
@@ -1336,7 +2298,7 @@ class ClientImpl {
1336
2298
  this.device = device;
1337
2299
  this.selfPin = newPin;
1338
2300
  try {
1339
- await this.provider.save(this.accountName, newPin.identityId, recoveryKey);
2301
+ await this.provider?.save(this.accountName, newPin.identityId, recoveryKey);
1340
2302
  }
1341
2303
  catch {
1342
2304
  /* reported by recoveryStatus() */
@@ -1359,7 +2321,55 @@ class ClientImpl {
1359
2321
  await this.finishReset("load", row, roots);
1360
2322
  }
1361
2323
  async close() {
2324
+ this.closed = true;
2325
+ // Everything in flight finishes its current transaction; a sync stops at its next boundary.
2326
+ while (this.running.size > 0)
2327
+ await Promise.allSettled([...this.running]);
1362
2328
  this.listeners.clear();
1363
2329
  }
2330
+ // The replacement helper ------------------------------------------------------------------------
2331
+ async replaceDeviceNow(options) {
2332
+ const maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? 3));
2333
+ const activated = () => (this.selfPin && this.account && !this.retired ? { status: "activated", identityId: this.selfPin.identityId, generation: this.selfPin.generation } : undefined);
2334
+ const done = activated();
2335
+ if (done)
2336
+ return done;
2337
+ // A proposal left by an earlier run may have committed: the chain settles it before anything else.
2338
+ const existing = await this.store.read((tx) => tx.get("replacement", "pending"));
2339
+ if (existing && !existing.stale) {
2340
+ await this.sync();
2341
+ const settled = activated();
2342
+ if (settled)
2343
+ return settled;
2344
+ }
2345
+ const proposal = await this.prepareReplacementNow();
2346
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
2347
+ const authorization = await options.authorize(proposal);
2348
+ if (authorization === "reauthenticate")
2349
+ return { status: "reauthenticate", proposal };
2350
+ for (;;) {
2351
+ try {
2352
+ const r = await this.completeReplacementNow({ successionId: proposal.successionId, authorization });
2353
+ return { status: "activated", identityId: r.identityId, generation: r.generation };
2354
+ }
2355
+ catch (e) {
2356
+ if (isKavachError(e, "SUCCESSION_PENDING")) {
2357
+ // The same call again with the same bytes; the host answers a committed id first.
2358
+ if (++attempt >= maxAttempts)
2359
+ return { status: "pending", proposal };
2360
+ continue;
2361
+ }
2362
+ if (isKavachError(e, "AUTHORIZATION_REQUIRED"))
2363
+ break; // a fresh token for the same proposal
2364
+ if (isKavachError(e, "REPLACEMENT_CONFLICT")) {
2365
+ const conflict = (await this.inspect()).replacement;
2366
+ return { status: "conflict", currentGeneration: conflict?.conflictGeneration };
2367
+ }
2368
+ throw e;
2369
+ }
2370
+ }
2371
+ }
2372
+ return { status: "pending", proposal };
2373
+ }
1364
2374
  }
1365
2375
  //# sourceMappingURL=client.js.map