@plane31/blind-relay 0.0.0-stage → 0.1.2

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 (169) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +26 -2
  3. package/dist/adapters/index.d.ts +18 -0
  4. package/dist/adapters/index.d.ts.map +1 -0
  5. package/dist/adapters/index.js +22 -0
  6. package/dist/adapters/index.js.map +1 -0
  7. package/dist/audience.d.ts +77 -0
  8. package/dist/audience.d.ts.map +1 -0
  9. package/dist/audience.js +76 -0
  10. package/dist/audience.js.map +1 -0
  11. package/dist/bytes.d.ts +54 -0
  12. package/dist/bytes.d.ts.map +1 -0
  13. package/dist/bytes.js +200 -0
  14. package/dist/bytes.js.map +1 -0
  15. package/dist/circle.d.ts +59 -0
  16. package/dist/circle.d.ts.map +1 -0
  17. package/dist/circle.js +105 -0
  18. package/dist/circle.js.map +1 -0
  19. package/dist/client.d.ts +234 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +1365 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/codec.d.ts +189 -0
  24. package/dist/codec.d.ts.map +1 -0
  25. package/dist/codec.js +512 -0
  26. package/dist/codec.js.map +1 -0
  27. package/dist/crypto/backend.d.ts +39 -0
  28. package/dist/crypto/backend.d.ts.map +1 -0
  29. package/dist/crypto/backend.js +8 -0
  30. package/dist/crypto/backend.js.map +1 -0
  31. package/dist/crypto/noble.d.ts +3 -0
  32. package/dist/crypto/noble.d.ts.map +1 -0
  33. package/dist/crypto/noble.js +107 -0
  34. package/dist/crypto/noble.js.map +1 -0
  35. package/dist/crypto/sodium.d.ts +9 -0
  36. package/dist/crypto/sodium.d.ts.map +1 -0
  37. package/dist/crypto/sodium.js +51 -0
  38. package/dist/crypto/sodium.js.map +1 -0
  39. package/dist/crypto.d.ts +66 -0
  40. package/dist/crypto.d.ts.map +1 -0
  41. package/dist/crypto.js +225 -0
  42. package/dist/crypto.js.map +1 -0
  43. package/dist/epoch.d.ts +93 -0
  44. package/dist/epoch.d.ts.map +1 -0
  45. package/dist/epoch.js +121 -0
  46. package/dist/epoch.js.map +1 -0
  47. package/dist/errors.d.ts +17 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +30 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/historyIndex.d.ts +15 -0
  52. package/dist/historyIndex.d.ts.map +1 -0
  53. package/dist/historyIndex.js +57 -0
  54. package/dist/historyIndex.js.map +1 -0
  55. package/dist/identity.d.ts +62 -0
  56. package/dist/identity.d.ts.map +1 -0
  57. package/dist/identity.js +99 -0
  58. package/dist/identity.js.map +1 -0
  59. package/dist/inbox.d.ts +153 -0
  60. package/dist/inbox.d.ts.map +1 -0
  61. package/dist/inbox.js +472 -0
  62. package/dist/inbox.js.map +1 -0
  63. package/dist/index.d.ts +19 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +22 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/outbox.d.ts +96 -0
  68. package/dist/outbox.d.ts.map +1 -0
  69. package/dist/outbox.js +339 -0
  70. package/dist/outbox.js.map +1 -0
  71. package/dist/policy.d.ts +33 -0
  72. package/dist/policy.d.ts.map +1 -0
  73. package/dist/policy.js +71 -0
  74. package/dist/policy.js.map +1 -0
  75. package/dist/record.d.ts +49 -0
  76. package/dist/record.d.ts.map +1 -0
  77. package/dist/record.js +46 -0
  78. package/dist/record.js.map +1 -0
  79. package/dist/reset.d.ts +74 -0
  80. package/dist/reset.d.ts.map +1 -0
  81. package/dist/reset.js +127 -0
  82. package/dist/reset.js.map +1 -0
  83. package/dist/rows.d.ts +16 -0
  84. package/dist/rows.d.ts.map +1 -0
  85. package/dist/rows.js +103 -0
  86. package/dist/rows.js.map +1 -0
  87. package/dist/slots.d.ts +22 -0
  88. package/dist/slots.d.ts.map +1 -0
  89. package/dist/slots.js +38 -0
  90. package/dist/slots.js.map +1 -0
  91. package/dist/store.d.ts +357 -0
  92. package/dist/store.d.ts.map +1 -0
  93. package/dist/store.js +192 -0
  94. package/dist/store.js.map +1 -0
  95. package/dist/testing/clock.d.ts +14 -0
  96. package/dist/testing/clock.d.ts.map +1 -0
  97. package/dist/testing/clock.js +27 -0
  98. package/dist/testing/clock.js.map +1 -0
  99. package/dist/testing/fakeHost.d.ts +118 -0
  100. package/dist/testing/fakeHost.d.ts.map +1 -0
  101. package/dist/testing/fakeHost.js +462 -0
  102. package/dist/testing/fakeHost.js.map +1 -0
  103. package/dist/testing/faults.d.ts +97 -0
  104. package/dist/testing/faults.d.ts.map +1 -0
  105. package/dist/testing/faults.js +264 -0
  106. package/dist/testing/faults.js.map +1 -0
  107. package/dist/testing/fixtures.d.ts +15 -0
  108. package/dist/testing/fixtures.d.ts.map +1 -0
  109. package/dist/testing/fixtures.js +22 -0
  110. package/dist/testing/fixtures.js.map +1 -0
  111. package/dist/testing/index.d.ts +7 -0
  112. package/dist/testing/index.d.ts.map +1 -0
  113. package/dist/testing/index.js +7 -0
  114. package/dist/testing/index.js.map +1 -0
  115. package/dist/testing/providers.d.ts +16 -0
  116. package/dist/testing/providers.d.ts.map +1 -0
  117. package/dist/testing/providers.js +36 -0
  118. package/dist/testing/providers.js.map +1 -0
  119. package/dist/testing/sweep.d.ts +46 -0
  120. package/dist/testing/sweep.d.ts.map +1 -0
  121. package/dist/testing/sweep.js +366 -0
  122. package/dist/testing/sweep.js.map +1 -0
  123. package/dist/transactions.d.ts +75 -0
  124. package/dist/transactions.d.ts.map +1 -0
  125. package/dist/transactions.js +212 -0
  126. package/dist/transactions.js.map +1 -0
  127. package/dist/transport.d.ts +125 -0
  128. package/dist/transport.d.ts.map +1 -0
  129. package/dist/transport.js +6 -0
  130. package/dist/transport.js.map +1 -0
  131. package/dist/vault.d.ts +59 -0
  132. package/dist/vault.d.ts.map +1 -0
  133. package/dist/vault.js +69 -0
  134. package/dist/vault.js.map +1 -0
  135. package/package.json +67 -3
  136. package/src/adapters/index.ts +102 -0
  137. package/src/audience.ts +123 -0
  138. package/src/bytes.ts +209 -0
  139. package/src/circle.ts +127 -0
  140. package/src/client.ts +1442 -0
  141. package/src/codec.ts +642 -0
  142. package/src/crypto/backend.ts +48 -0
  143. package/src/crypto/noble.ts +104 -0
  144. package/src/crypto/node-module.d.ts +11 -0
  145. package/src/crypto/sodium.ts +53 -0
  146. package/src/crypto.ts +266 -0
  147. package/src/epoch.ts +201 -0
  148. package/src/errors.ts +78 -0
  149. package/src/historyIndex.ts +61 -0
  150. package/src/identity.ts +158 -0
  151. package/src/inbox.ts +560 -0
  152. package/src/index.ts +59 -0
  153. package/src/outbox.ts +385 -0
  154. package/src/policy.ts +88 -0
  155. package/src/record.ts +106 -0
  156. package/src/reset.ts +176 -0
  157. package/src/rows.ts +116 -0
  158. package/src/slots.ts +55 -0
  159. package/src/store.ts +557 -0
  160. package/src/testing/clock.ts +29 -0
  161. package/src/testing/fakeHost.ts +482 -0
  162. package/src/testing/faults.ts +321 -0
  163. package/src/testing/fixtures.ts +35 -0
  164. package/src/testing/index.ts +6 -0
  165. package/src/testing/providers.ts +39 -0
  166. package/src/testing/sweep.ts +407 -0
  167. package/src/transactions.ts +231 -0
  168. package/src/transport.ts +106 -0
  169. package/src/vault.ts +126 -0
package/dist/client.js ADDED
@@ -0,0 +1,1365 @@
1
+ /**
2
+ * The client (protocol sections 13 and 14): one asynchronous object over the store, the
3
+ * transport, the recovery provider and the reset trust. States are emitted only after the
4
+ * corresponding durable transition.
5
+ */
6
+ import { concat, equalBytes, fromHex, toHex } from "./bytes.js";
7
+ import { resolveAudience } from "./audience.js";
8
+ import { acceptSnapshot, checkMemberResolution, createCircleSnapshot, newCircleId, pinCircle, successorSnapshot } from "./circle.js";
9
+ import { DocumentType, expectType, parseVaultHeader, verifyDocument } from "./codec.js";
10
+ import { randomBytes, ready } from "./crypto.js";
11
+ import { prepareEpoch, resolvePublish } from "./epoch.js";
12
+ import { KavachError, isKavachError } from "./errors.js";
13
+ import { acceptCertificate, certify, deviceFromSeed, newAccountSeed, newDevice, openAccount, pinFromCertificate } from "./identity.js";
14
+ import { ensureHistoryIndex } from "./historyIndex.js";
15
+ import { ensureSlots, forgetAuthor, forgetAuthorIn, history, historyPage, newest, newestSlot, purgeHistory, receive as receiveRecord, syncStream } from "./inbox.js";
16
+ import { drain, enqueue, outboxCount, outboxCounts, purgeOutbox } from "./outbox.js";
17
+ import { kindOf, validatePolicy } from "./policy.js";
18
+ import { ResetTrust } from "./reset.js";
19
+ import { big, circleToRow, pinToRow, rowToCircle, rowToPin, rowToPrepared, unbig } from "./rows.js";
20
+ import { META, REVISION, STREAM, VAULT, openStore, t1Activate, t1Header, t1Secrets, t2PrepareActivation, t3ApplyChange, t3Circle, t4PersistPrepared, t5Committed, t8OwnerSnapshot, t9MarkVault, t9Vault } from "./transactions.js";
21
+ import { buildVault, encodeRecoveryCode, newRecoveryKey, openVault, recoverAccount, removeEntry, upsertEntry } from "./vault.js";
22
+ /** accountSeed, deviceSeed, recoveryKey, instanceId and deviceId of a pending reset, in the secret store. */
23
+ const RESET_ROOTS_BYTES = 32 * 3 + 16 + 16;
24
+ /** The vault's entries as the circle rows say they should be. Entries of circles without a row are kept. */
25
+ function reconcileEntries(entries, rows) {
26
+ let out = [...entries];
27
+ for (const r of rows) {
28
+ const circleId = fromHex(r.circleId);
29
+ if (r.status === "left" || r.status === "closed")
30
+ out = removeEntry(out, circleId);
31
+ else if (r.status === "member" || r.status === "requested")
32
+ out = upsertEntry(out, { circleId, ownerIdentityId: fromHex(r.ownerIdentityId), sharingEnabled: r.enabled });
33
+ }
34
+ return out;
35
+ }
36
+ function sameEntries(a, b) {
37
+ const key = (es) => es.map((e) => `${toHex(e.circleId)}:${toHex(e.ownerIdentityId)}:${e.sharingEnabled ? 1 : 0}`).sort().join(",");
38
+ return key(a) === key(b);
39
+ }
40
+ 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 };
43
+ }
44
+ const RESET_LIST_RECHECK_MS = 86400000n;
45
+ export async function open(options) {
46
+ await ready();
47
+ const policy = validatePolicy(options.retentionPolicy);
48
+ const impl = new ClientImpl(options, policy);
49
+ await impl.load();
50
+ return impl;
51
+ }
52
+ class ClientImpl {
53
+ options;
54
+ policy;
55
+ accountName;
56
+ store;
57
+ secrets;
58
+ transport;
59
+ provider;
60
+ now;
61
+ listeners = new Set();
62
+ account;
63
+ device;
64
+ selfPin;
65
+ trust;
66
+ syncing;
67
+ backupChecked = false;
68
+ circles;
69
+ constructor(options, policy) {
70
+ this.options = options;
71
+ this.policy = policy;
72
+ this.accountName = options.accountName;
73
+ this.store = options.store;
74
+ this.secrets = options.secretStore;
75
+ this.transport = options.transport;
76
+ this.provider = options.recoveryProvider;
77
+ this.now = options.now ?? (() => BigInt(Date.now()));
78
+ this.trust = new ResetTrust(options.resetTrust);
79
+ 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),
87
+ list: () => this.listCircles()
88
+ };
89
+ }
90
+ // Loading ---------------------------------------------------------------------------------------
91
+ async load() {
92
+ await this.finishInterruptedReset();
93
+ const opened = await openStore(this.store, this.secrets, this.accountName);
94
+ if (opened.kind === "fresh")
95
+ return;
96
+ await ensureHistoryIndex(this.store);
97
+ await ensureSlots(this.store, this.policy);
98
+ this.account = opened.account;
99
+ const deviceSeed = await this.secrets.get("deviceSeed");
100
+ const self = await this.store.read((tx) => tx.get("self", "self"));
101
+ if (deviceSeed && self) {
102
+ this.device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
103
+ const pinRow = await this.store.read((tx) => tx.get("pins", toHex(opened.account.identityId)));
104
+ if (pinRow)
105
+ this.selfPin = rowToPin(pinRow);
106
+ }
107
+ const rows = await this.store.read((tx) => ({
108
+ list: tx.get("resetList", "list"),
109
+ clock: tx.get("clock", "clock"),
110
+ accepted: tx.list("resetReceipts").map(([k]) => k),
111
+ events: tx.list("resetReceipts").map(([k, r]) => ({ resetId: k, accountName: r.accountName, oldIdentityId: r.oldIdentityId, newIdentityId: r.newIdentityId })),
112
+ retired: tx.list("retiredIdentities").map(([k]) => k)
113
+ }));
114
+ this.trust = ResetTrust.fromRows(this.options.resetTrust, {
115
+ list: rows.list,
116
+ clock: rows.clock ?? { highWater: "0" },
117
+ accepted: rows.events.map((e) => e.resetId).length ? (await this.store.read((tx) => tx.list("resetReceipts").map(([, r]) => r.receiptHash))) : [],
118
+ events: rows.events,
119
+ retired: rows.retired
120
+ });
121
+ }
122
+ /** Each listener on its own: a throwing subscriber affects neither the other listeners nor the protocol. */
123
+ emit(event) {
124
+ for (const l of this.listeners) {
125
+ try {
126
+ l(event);
127
+ }
128
+ catch (e) {
129
+ try {
130
+ this.options.onListenerError?.(e, event);
131
+ }
132
+ catch {
133
+ /* the diagnostic hook is the application's too */
134
+ }
135
+ }
136
+ }
137
+ }
138
+ subscribe(listener) {
139
+ this.listeners.add(listener);
140
+ return () => this.listeners.delete(listener);
141
+ }
142
+ requireAccount(stage) {
143
+ if (!this.account || !this.device || !this.selfPin)
144
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no account on this device");
145
+ return { account: this.account, device: this.device, pin: this.selfPin };
146
+ }
147
+ selfView() {
148
+ const { device, pin } = this.requireAccount("self");
149
+ return { identityId: pin.identityId, certificateHash: pin.certificateHash, box: device.box };
150
+ }
151
+ outboxDeps() {
152
+ const { device, pin } = this.requireAccount("outbox");
153
+ return {
154
+ store: this.store,
155
+ relay: this.transport.relay,
156
+ policy: this.policy,
157
+ now: this.now,
158
+ author: () => ({ streamId: pin.streamId, signingSecretKey: device.signing.secretKey, generation: pin.generation, identityId: pin.identityId }),
159
+ events: {
160
+ queued: (recordId, recordKind) => this.emit({ kind: "queued", recordId, recordKind }),
161
+ accepted: (recordId) => this.emit({ kind: "accepted", recordId }),
162
+ cancelled: (recordId, reason) => this.emit({ kind: "cancelled", recordId, reason }),
163
+ refresh: (reason) => this.emit({ kind: "refresh", reason }),
164
+ securityError: (code, detail) => this.emit({ kind: "securityError", code, detail })
165
+ }
166
+ };
167
+ }
168
+ inboxDeps() {
169
+ return {
170
+ store: this.store,
171
+ relay: this.transport.relay,
172
+ policy: this.policy,
173
+ now: this.now,
174
+ self: () => this.selfView(),
175
+ events: {
176
+ received: (r) => {
177
+ if (r.result === "updated" || r.result === "history")
178
+ this.emit({ kind: "recordStored", recordKind: r.kind, authorIdentityId: r.authorIdentityId, recordId: r.recordId, tookSlot: r.result === "updated" && (kindOf(this.policy, r.kind)?.newest ?? false) });
179
+ },
180
+ waiting: (identityId) => this.emit({ kind: "waiting", identityId }),
181
+ securityError: (code, detail) => this.emit({ kind: "securityError", code, detail })
182
+ }
183
+ };
184
+ }
185
+ // State -----------------------------------------------------------------------------------------
186
+ async getState() {
187
+ 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 };
191
+ const circles = await this.listCircles();
192
+ const outbox = await outboxCount(this.store);
193
+ const waitingFor = await this.waitingFor();
194
+ const revision = await this.store.read((tx) => tx.get("revision", REVISION));
195
+ const quarantined = await this.store.read((tx) => tx.count("quarantine") > 0);
196
+ let status = "ready";
197
+ if (meta.state === "INITIALIZING")
198
+ status = "accountRecoveryRequired";
199
+ else if (meta.state === "ACTIVATION_PENDING")
200
+ status = "activationPending";
201
+ else if (meta.state === "RETIRED")
202
+ status = "retired";
203
+ else if (revision?.paused)
204
+ status = "paused";
205
+ else if (quarantined)
206
+ status = "quarantined";
207
+ return { status, paused: revision?.paused, ...base, outbox, waitingFor, circles };
208
+ }
209
+ async waitingFor() {
210
+ if (!this.selfPin)
211
+ return [];
212
+ const selfHex = toHex(this.selfPin.identityId);
213
+ return this.store.read((tx) => {
214
+ const authors = new Set();
215
+ for (const [, c] of tx.list("circles"))
216
+ if (c.status === "member")
217
+ for (const m of c.members)
218
+ if (m !== selfHex)
219
+ authors.add(m);
220
+ const out = [];
221
+ for (const a of authors) {
222
+ const pin = tx.get("pins", a);
223
+ if (!pin)
224
+ continue;
225
+ const hasKey = tx.list("readerEpochs", pin.streamId + ":").length > 0;
226
+ if (!hasKey)
227
+ out.push(fromHex(a));
228
+ }
229
+ return out;
230
+ });
231
+ }
232
+ async inspect() {
233
+ const now = this.now();
234
+ return this.store.read((tx) => {
235
+ const stream = tx.get("stream", STREAM);
236
+ const revision = tx.get("revision", REVISION);
237
+ const vault = tx.get("vault", VAULT);
238
+ const pendingEpoch = tx.list("epochs").find(([, e]) => e.status === "prepared" || e.status === "committed")?.[1].epoch;
239
+ return {
240
+ identityId: this.selfPin?.identityId,
241
+ certificateHash: this.selfPin?.certificateHash,
242
+ generation: this.selfPin?.generation,
243
+ desiredRevision: revision ? unbig(revision.desiredRevision) : 1n,
244
+ paused: revision?.paused,
245
+ activeEpoch: stream?.activeEpoch,
246
+ pendingEpoch,
247
+ outbox: outboxCounts(tx, now),
248
+ vault: vault ? { revision: unbig(vault.revision), pending: vault.pending, dirty: vault.dirty } : undefined
249
+ };
250
+ });
251
+ }
252
+ // Enrollment ------------------------------------------------------------------------------------
253
+ async initializeAccount() {
254
+ const stage = "initializeAccount";
255
+ const opened = await openStore(this.store, this.secrets, this.accountName);
256
+ if (opened.kind !== "fresh" && opened.kind !== "initializing")
257
+ throw new KavachError("MALFORMED", stage, "an account already exists on this device");
258
+ let accountSeed = await this.secrets.get("accountSeed");
259
+ let deviceSeed = await this.secrets.get("deviceSeed");
260
+ let recoveryKey = await this.secrets.get("recoveryKey");
261
+ let instanceId = await this.secrets.get("instanceId");
262
+ const device = deviceSeed ? deviceFromSeed(deviceSeed, (await this.store.read((tx) => tx.get("self", "self")))?.deviceId ? fromHex((await this.store.read((tx) => tx.get("self", "self")))?.deviceId ?? "") : newDevice().deviceId) : newDevice();
263
+ if (!accountSeed || !deviceSeed || !recoveryKey || !instanceId) {
264
+ accountSeed = newAccountSeed();
265
+ deviceSeed = device.deviceSeed;
266
+ recoveryKey = newRecoveryKey();
267
+ instanceId = randomBytes(16);
268
+ await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
269
+ }
270
+ const account = openAccount(this.accountName, accountSeed);
271
+ const certificate = certify(account, device, 1n);
272
+ const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
273
+ const iid = instanceId;
274
+ 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 });
277
+ if (outcome.status === "CONFLICT") {
278
+ // A restart or a lost response after the host committed: the name is ours if the host holds
279
+ // exactly the certificate this device saved; anything else is somebody else's account.
280
+ const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
281
+ if (!me || !equalBytes(me.certificate, certificate))
282
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "an account with this name exists under another key");
283
+ }
284
+ else if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") {
285
+ throw new KavachError("NOT_AUTHORIZED", stage, `host refused registration: ${outcome.status}`);
286
+ }
287
+ await this.store.transaction((tx) => {
288
+ t9Vault(tx, { revision: "1", hash: toHex(vault.hash), wire: toHex(vault.wire), pending: false, dirty: false });
289
+ t1Activate(tx);
290
+ });
291
+ this.account = account;
292
+ this.device = device;
293
+ this.selfPin = pin;
294
+ // The recovery key goes to the provider; enrollment never blocks on it or on the code.
295
+ try {
296
+ await this.provider.save(this.accountName, pin.identityId, recoveryKey);
297
+ }
298
+ catch {
299
+ /* status is reported by recoveryStatus() */
300
+ }
301
+ await this.emitState();
302
+ return { identityId: pin.identityId };
303
+ }
304
+ async recoveryStatus() {
305
+ return this.provider.status();
306
+ }
307
+ async recoveryCode() {
308
+ const key = await this.secrets.get("recoveryKey");
309
+ if (!key)
310
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", "recoveryCode");
311
+ return encodeRecoveryCode(key);
312
+ }
313
+ async restoreAccount(recoveryCode) {
314
+ const stage = "restoreAccount";
315
+ const opened = await openStore(this.store, this.secrets, this.accountName);
316
+ if (opened.kind === "ACTIVE" || opened.kind === "RETIRED")
317
+ throw new KavachError("MALFORMED", stage, "an account already exists on this device");
318
+ let pending;
319
+ if (opened.kind === "ACTIVATION_PENDING") {
320
+ const resumed = await this.resumeActivation(stage);
321
+ if (resumed.outcome === "done")
322
+ return resumed.result;
323
+ // The host still shows the certificate we expected but refused our prepared bytes (the vault
324
+ // moved under us): prepare again with the same device keys. Nothing was allocated remotely.
325
+ pending = resumed;
326
+ }
327
+ const [resolved] = await this.transport.directory.resolveAccounts([this.accountName]);
328
+ if (!resolved)
329
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
330
+ const current = expectType(verifyDocument(resolved.certificate), DocumentType.AccountDevice, stage);
331
+ const vaultWire = await this.transport.directory.getVault(this.accountName);
332
+ if (!vaultWire)
333
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "no vault");
334
+ const identity = pinFromCertificate(current).identityId;
335
+ const recovered = await recoverAccount({
336
+ accountName: this.accountName,
337
+ identityId: identity,
338
+ accountKey: resolved.accountKey,
339
+ vaultWire,
340
+ provider: this.provider,
341
+ ...(recoveryCode === undefined ? {} : { recoveryCode })
342
+ });
343
+ const account = openAccount(this.accountName, recovered.vault.accountSeed);
344
+ // Fresh device keys and a fresh local store. Nothing from the old device is copied.
345
+ const device = pending?.device ?? newDevice();
346
+ const generation = current.generation + 1n;
347
+ const certificate = certify(account, device, generation);
348
+ const pin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
349
+ const header = parseVaultHeader(vaultWire);
350
+ 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
+ const instanceId = pending?.instanceId ?? randomBytes(16);
352
+ if (!pending)
353
+ await t1Secrets(this.secrets, { accountSeed: recovered.vault.accountSeed, deviceSeed: device.deviceSeed, recoveryKey: recovered.recoveryKey, instanceId });
354
+ await this.store.transaction((tx) => {
355
+ t1Header(tx, { account, device, certificate, instanceId, generation });
356
+ t2PrepareActivation(tx, {
357
+ certificate: toHex(certificate),
358
+ vault: toHex(nextVault.wire),
359
+ vaultRevision: big(header.vaultRevision + 1n),
360
+ expectedCertificateHash: toHex(current.bodyHash),
361
+ expectedVaultRevision: big(header.vaultRevision),
362
+ expectedVaultHash: toHex(header.hash)
363
+ });
364
+ });
365
+ // Durable now: the device exists locally as activationPending even if the next call fails.
366
+ this.account = account;
367
+ this.device = device;
368
+ this.selfPin = pin;
369
+ const outcome = await this.transport.directory.activateDevice({
370
+ accountName: this.accountName,
371
+ expectedCertificateHash: current.bodyHash,
372
+ expectedVaultRevision: header.vaultRevision,
373
+ expectedVaultHash: header.hash,
374
+ newCertificate: certificate,
375
+ newVault: nextVault.wire
376
+ });
377
+ if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") {
378
+ // Another candidate won, or the metadata moved: report it; the user may try again.
379
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, `activation ${outcome.status}`);
380
+ }
381
+ return this.finishActivation({ account, device, pin, vaultRevision: header.vaultRevision + 1n, vault: nextVault, entries: recovered.vault.entries, recoveryKey: recovered.recoveryKey });
382
+ }
383
+ /** The local half of activation, after the host committed it: no new randomness, ever. */
384
+ async finishActivation(input) {
385
+ const { account, device, pin } = input;
386
+ await this.store.transaction((tx) => {
387
+ tx.delete("activation", "pending");
388
+ t9Vault(tx, { revision: big(input.vaultRevision), hash: toHex(input.vault.hash), wire: toHex(input.vault.wire), pending: false, dirty: false });
389
+ for (const e of input.entries) {
390
+ const owned = equalBytes(e.ownerIdentityId, pin.identityId);
391
+ const c = pinCircle(e.circleId, e.ownerIdentityId, e.sharingEnabled, owned ? "member" : "requested");
392
+ tx.put("circles", toHex(e.circleId), circleToRow(c));
393
+ }
394
+ t3ApplyChange(tx, { kind: "ownActivation" });
395
+ t1Activate(tx);
396
+ });
397
+ this.account = account;
398
+ this.device = device;
399
+ this.selfPin = pin;
400
+ try {
401
+ await this.provider.save(this.accountName, pin.identityId, input.recoveryKey);
402
+ }
403
+ catch {
404
+ /* reported by recoveryStatus() */
405
+ }
406
+ await this.emitState();
407
+ return { identityId: pin.identityId, generation: pin.generation };
408
+ }
409
+ /**
410
+ * A device that crashed, or lost the response, between T2 and the local acknowledgement: the
411
+ * host is asked what it holds. Our prepared certificate there means the activation happened
412
+ * and is finished locally; the expected old certificate means it did not, and the exact
413
+ * prepared bytes are sent again; anything else means another candidate won. No key, device
414
+ * id or vault is ever generated here.
415
+ */
416
+ async resumeActivation(stage) {
417
+ const row = await this.store.read((tx) => tx.get("activation", "pending"));
418
+ if (!row)
419
+ throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its prepared bytes");
420
+ 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)
423
+ throw new KavachError("RECOVERY_REQUIRED", stage, "activation pending without its roots");
424
+ const account = openAccount(this.accountName, accountSeed);
425
+ const device = deviceFromSeed(deviceSeed, fromHex(self.deviceId));
426
+ const certificate = fromHex(row.certificate);
427
+ const prepared = expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage);
428
+ const pin = pinFromCertificate(prepared);
429
+ const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
430
+ if (!me)
431
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "unknown account");
432
+ const theirs = expectType(verifyDocument(me.certificate), DocumentType.AccountDevice, stage);
433
+ const vaultWire = fromHex(row.vault);
434
+ if (!equalBytes(theirs.bodyHash, prepared.bodyHash)) {
435
+ if (toHex(theirs.bodyHash) !== row.expectedCertificateHash)
436
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, "another device activated first");
437
+ const outcome = await this.transport.directory.activateDevice({
438
+ accountName: this.accountName,
439
+ expectedCertificateHash: fromHex(row.expectedCertificateHash),
440
+ expectedVaultRevision: unbig(row.expectedVaultRevision),
441
+ expectedVaultHash: fromHex(row.expectedVaultHash),
442
+ newCertificate: certificate,
443
+ newVault: vaultWire
444
+ });
445
+ if (outcome.status === "CONFLICT")
446
+ return { outcome: "prepareAgain", device, instanceId };
447
+ if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED")
448
+ throw new KavachError("ACCOUNT_RECOVERY_REQUIRED", stage, `activation ${outcome.status}`);
449
+ }
450
+ const opened = openVault(vaultWire, recoveryKey, this.accountName, account.account.publicKey);
451
+ const result = await this.finishActivation({ account, device, pin, vaultRevision: unbig(row.vaultRevision), vault: { wire: vaultWire, hash: parseVaultHeader(vaultWire).hash }, entries: opened.entries, recoveryKey });
452
+ return { outcome: "done", result };
453
+ }
454
+ /**
455
+ * Enrollment and activation hand the recovery key to the provider after their last transaction,
456
+ * so a process that dies in between leaves the account unbacked. The first sync of each process
457
+ * asks the provider and saves again when it has nothing for this identity.
458
+ */
459
+ async ensureBackup() {
460
+ if (this.backupChecked || !this.selfPin)
461
+ return;
462
+ this.backupChecked = true;
463
+ try {
464
+ const key = await this.secrets.get("recoveryKey");
465
+ if (!key)
466
+ return;
467
+ const have = await this.provider.load(this.accountName, this.selfPin.identityId);
468
+ if (have.status === "unavailable")
469
+ await this.provider.save(this.accountName, this.selfPin.identityId, key);
470
+ }
471
+ catch {
472
+ /* reported by recoveryStatus() */
473
+ }
474
+ }
475
+ async emitState() {
476
+ this.emit({ kind: "stateChanged", state: await this.getState() });
477
+ }
478
+ // Sending ---------------------------------------------------------------------------------------
479
+ async enqueue(kind, payload, options = {}) {
480
+ const deps = this.outboxDeps();
481
+ // A key answered before anything that may need the network: the record it names is already
482
+ // durable, so a retry offline, or with a rotation due, returns it. The outbox checks the key
483
+ // again inside its transaction for calls that race.
484
+ if (options.idempotencyKey !== undefined) {
485
+ const seen = await this.store.read((tx) => tx.get("enqueueKeys", options.idempotencyKey ?? ""));
486
+ if (seen)
487
+ return { recordId: fromHex(seen.recordId), duplicate: true };
488
+ }
489
+ await this.ensureEpoch();
490
+ try {
491
+ return this.enqueued(kind, await enqueue(deps, kind, payload, options));
492
+ }
493
+ catch (e) {
494
+ if (isKavachError(e, "ROTATION_REQUIRED") || isKavachError(e, "COUNTER_EXHAUSTED")) {
495
+ await this.ensureEpoch();
496
+ const r = await enqueue(deps, kind, payload, options);
497
+ this.emit({ kind: "refresh", reason: "rotation" });
498
+ return this.enqueued(kind, r);
499
+ }
500
+ throw e;
501
+ }
502
+ }
503
+ /** After commit: the device's own record is stored like any author's, so one subscription covers self and others. */
504
+ enqueued(kind, r) {
505
+ if (!r.duplicate && this.selfPin)
506
+ this.emit({ kind: "recordStored", recordKind: kind, authorIdentityId: this.selfPin.identityId, recordId: r.recordId, tookSlot: r.own });
507
+ return { recordId: r.recordId, duplicate: r.duplicate };
508
+ }
509
+ // Epochs ----------------------------------------------------------------------------------------
510
+ async currentAudience() {
511
+ const { pin } = this.requireAccount("audience");
512
+ const retired = new Set(this.trust.state.retired);
513
+ return this.store.read((tx) => {
514
+ const pins = new Map(tx.list("pins").map(([k, p]) => [k, rowToPin(p)]));
515
+ const circles = tx.list("circles").map(([, c]) => rowToCircle(c));
516
+ return resolveAudience(pin, circles, pins, retired);
517
+ });
518
+ }
519
+ /** Prepare, publish and promote epochs until one is active under the current revision. */
520
+ async ensureEpoch() {
521
+ const { device, pin } = this.requireAccount("epoch");
522
+ for (let attempt = 0; attempt < 4; attempt++) {
523
+ const stream = await this.store.read((tx) => tx.get("stream", STREAM));
524
+ const revisionRow = await this.store.read((tx) => tx.get("revision", REVISION));
525
+ if (!stream)
526
+ throw new KavachError("RECOVERY_REQUIRED", "epoch");
527
+ if (stream.activeEpoch !== undefined && revisionRow?.paused !== "rotation")
528
+ return attempt === 0 ? "unchanged" : "prepared";
529
+ let audience;
530
+ try {
531
+ audience = await this.currentAudience();
532
+ }
533
+ catch (e) {
534
+ if (isKavachError(e, "METADATA_REQUIRED") || isKavachError(e, "RECIPIENT_LIMIT")) {
535
+ const reason = e.code === "METADATA_REQUIRED" ? "metadata" : "limit";
536
+ await this.store.transaction((tx) => {
537
+ const r = tx.get("revision", REVISION);
538
+ if (r)
539
+ tx.put("revision", REVISION, { ...r, paused: reason });
540
+ });
541
+ return "paused";
542
+ }
543
+ throw e;
544
+ }
545
+ let prepared;
546
+ if (stream.pendingEpoch !== undefined) {
547
+ const row = await this.store.read((tx) => tx.get("epochs", String(stream.pendingEpoch)));
548
+ if (!row)
549
+ throw new KavachError("STORAGE_FAILURE", "epoch", "pending epoch row missing");
550
+ prepared = rowToPrepared(row);
551
+ }
552
+ else {
553
+ const revision = revisionRow ? unbig(revisionRow.desiredRevision) : 1n;
554
+ const fresh = prepareEpoch({
555
+ author: { identityId: pin.identityId, certificateHash: pin.certificateHash, streamId: pin.streamId, signingSecretKey: device.signing.secretKey },
556
+ epoch: stream.highestCommittedEpoch + 1,
557
+ createdAt: this.now(),
558
+ audience,
559
+ desiredRevision: revision
560
+ });
561
+ // Under the lock, another context may have prepared or even promoted first. One unresolved
562
+ // epoch per stream: adopt theirs, or start over if the stream moved on.
563
+ const adopted = await this.store.transaction((tx) => {
564
+ const st = tx.get("stream", STREAM);
565
+ if (!st)
566
+ throw new KavachError("RECOVERY_REQUIRED", "epoch");
567
+ if (st.pendingEpoch !== undefined) {
568
+ const row = tx.get("epochs", String(st.pendingEpoch));
569
+ if (!row)
570
+ throw new KavachError("STORAGE_FAILURE", "epoch", "pending epoch row missing");
571
+ return rowToPrepared(row);
572
+ }
573
+ if (st.highestCommittedEpoch !== stream.highestCommittedEpoch || st.activeEpoch !== undefined)
574
+ return undefined;
575
+ t4PersistPrepared(tx, fresh);
576
+ return fresh;
577
+ });
578
+ if (!adopted)
579
+ continue;
580
+ prepared = adopted;
581
+ }
582
+ const outcome = await this.transport.relay.publishEpoch({ streamId: pin.streamId, expectedEpoch: prepared.epoch - 1, expectedHash: undefined, manifest: prepared.manifest, wraps: prepared.wraps });
583
+ const resolution = resolvePublish(prepared, outcome.status === "CONFLICT"
584
+ ? { status: "CONFLICT", currentEpoch: outcome.currentEpoch ?? 0, currentManifest: outcome.currentManifest }
585
+ : { status: outcome.status === "IGNORED_OLDER" ? "RETRYABLE" : outcome.status });
586
+ if (resolution === "RETRY")
587
+ return "pending";
588
+ const promoted = await this.store.transaction((tx) => t5Committed(tx, prepared.epoch, audience.key));
589
+ if (promoted === "PROMOTED") {
590
+ if (attempt > 0 || stream.activeEpoch === undefined)
591
+ this.emit({ kind: "refresh", reason: "rotation" });
592
+ return "prepared";
593
+ }
594
+ }
595
+ return "pending";
596
+ }
597
+ // Sync ------------------------------------------------------------------------------------------
598
+ async sync(options = {}) {
599
+ if (this.syncing)
600
+ return this.syncing;
601
+ this.syncing = this.runSync(options).finally(() => {
602
+ this.syncing = undefined;
603
+ });
604
+ return this.syncing;
605
+ }
606
+ async runSync(options) {
607
+ const report = {
608
+ metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0 },
609
+ epoch: "unchanged",
610
+ outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
611
+ inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
612
+ purged: 0,
613
+ errors: []
614
+ };
615
+ if (!this.account || !this.selfPin)
616
+ return report;
617
+ const meta = await this.store.read((tx) => tx.get("meta", META));
618
+ if (!meta || meta.state === "RETIRED")
619
+ return report;
620
+ if (await this.store.read((tx) => tx.get("pendingReset", "pending"))) {
621
+ // A reset the host may already hold: nothing else runs under the old identity.
622
+ try {
623
+ await this.resumeReset("sync");
624
+ }
625
+ catch (e) {
626
+ report.errors.push(syncError("resumeReset", e));
627
+ return report;
628
+ }
629
+ }
630
+ if (meta.state === "ACTIVATION_PENDING") {
631
+ try {
632
+ const resumed = await this.resumeActivation("sync");
633
+ if (resumed.outcome !== "done") {
634
+ report.errors.push({ operation: "resumeActivation", code: "ACTIVATION_PENDING", stage: "sync", retryable: true }); // restoreAccount prepares again
635
+ return report;
636
+ }
637
+ }
638
+ catch (e) {
639
+ report.errors.push(syncError("resumeActivation", e));
640
+ return report;
641
+ }
642
+ }
643
+ else if (meta.state !== "ACTIVE")
644
+ return report;
645
+ await this.ensureBackup();
646
+ try {
647
+ await this.syncMetadata(report);
648
+ }
649
+ catch (e) {
650
+ report.errors.push(syncError("metadata", e));
651
+ }
652
+ const after = await this.store.read((tx) => tx.get("meta", META));
653
+ if (after?.state === "RETIRED")
654
+ return report;
655
+ try {
656
+ report.epoch = await this.ensureEpoch();
657
+ }
658
+ catch (e) {
659
+ report.errors.push(syncError("epoch", e));
660
+ }
661
+ try {
662
+ report.outbox = await drain(this.outboxDeps());
663
+ }
664
+ catch (e) {
665
+ report.errors.push(syncError("outbox", e));
666
+ }
667
+ let subs = [];
668
+ try {
669
+ subs = await this.transport.relay.listSubscriptions();
670
+ }
671
+ catch (e) {
672
+ report.errors.push(syncError("listSubscriptions", e));
673
+ }
674
+ // Each stream on its own: a failed download or a refused record names its stream in the report
675
+ // and leaves the other streams to be read; a stream that failed is not counted as synced.
676
+ // 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
+ };
681
+ for (const s of subs) {
682
+ try {
683
+ const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
684
+ if (!known)
685
+ continue;
686
+ const r = await syncStream(this.inboxDeps(), s.streamId, s.authorIdentityId, budget);
687
+ report.inbox.streams++;
688
+ report.inbox.stored += r.stored;
689
+ report.inbox.keyPending += r.keyPending;
690
+ report.inbox.invalid += r.invalid;
691
+ if (r.acked !== undefined)
692
+ report.inbox.acked++;
693
+ if (r.hasMore)
694
+ report.inbox.hasMore = true;
695
+ }
696
+ catch (e) {
697
+ report.errors.push({ ...syncError("inbox", e), streamId: s.streamId, authorIdentityId: s.authorIdentityId });
698
+ }
699
+ }
700
+ report.purged = (await purgeHistory(this.store, this.policy, this.now())) + (await purgeOutbox(this.store, this.policy, this.now()));
701
+ await this.emitState();
702
+ return report;
703
+ }
704
+ async syncMetadata(report) {
705
+ const { pin } = this.requireAccount("sync");
706
+ // Own account: has this device been superseded?
707
+ const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
708
+ if (me) {
709
+ const theirs = expectType(verifyDocument(me.certificate), DocumentType.AccountDevice, "sync");
710
+ if (!equalBytes(theirs.accountKey, pin.accountKey)) {
711
+ // Our own key changed at the host: only a receipt we produced explains that; otherwise ignore.
712
+ }
713
+ else if (!equalBytes(theirs.bodyHash, pin.certificateHash) && theirs.generation > pin.generation) {
714
+ await this.retire();
715
+ return;
716
+ }
717
+ }
718
+ // Reset-key list when due.
719
+ const now = this.now();
720
+ const due = !this.trust.fresh(now) || now - this.trust.state.timeHighWater >= RESET_LIST_RECHECK_MS;
721
+ if (due) {
722
+ const wire = await this.transport.directory.getResetKeyList();
723
+ if (wire) {
724
+ try {
725
+ this.trust.install(wire, now);
726
+ await this.persistTrust();
727
+ }
728
+ catch (e) {
729
+ report.errors.push(syncError("resetKeyList", e));
730
+ }
731
+ }
732
+ }
733
+ // Unconfirmed writes of our own first: a successor snapshot the host never took, a vault update
734
+ // it never saw. Until they land, peers read stale membership and a replacement phone a stale
735
+ // 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)));
737
+ for (const circleId of pendingOwned) {
738
+ try {
739
+ if ((await this.publishPendingSnapshot(circleId)) === "COMMITTED")
740
+ report.metadata.snapshots++;
741
+ }
742
+ catch (e) {
743
+ report.errors.push(syncError("publishSnapshot", e));
744
+ }
745
+ }
746
+ try {
747
+ await this.syncVault();
748
+ }
749
+ catch (e) {
750
+ report.errors.push(syncError("vault", e));
751
+ }
752
+ // Circles: owners we have not pinned yet, then snapshots, then members' certificates.
753
+ const circles = await this.store.read((tx) => tx.list("circles").map(([, c]) => rowToCircle(c)));
754
+ const unknownOwners = [];
755
+ for (const c of circles) {
756
+ if (c.status === "closed" || c.status === "quarantined" || c.status === "left")
757
+ continue;
758
+ if (!equalBytes(c.ownerIdentityId, pin.identityId) && !(await this.pinFor(c.ownerIdentityId)))
759
+ unknownOwners.push(c.ownerIdentityId);
760
+ }
761
+ if (unknownOwners.length > 0) {
762
+ for (const r of await this.transport.directory.resolveIdentities(unknownOwners))
763
+ await this.acceptMemberCertificate(r.certificate, r.resetReceipt, report);
764
+ }
765
+ const memberIds = new Map();
766
+ for (const c of circles) {
767
+ if (c.status === "closed" || c.status === "quarantined" || c.status === "left")
768
+ continue;
769
+ const ownerPin = equalBytes(c.ownerIdentityId, pin.identityId) ? pin : await this.pinFor(c.ownerIdentityId);
770
+ if (!ownerPin) {
771
+ memberIds.set(toHex(c.ownerIdentityId), c.ownerIdentityId);
772
+ continue;
773
+ }
774
+ const wire = await this.transport.directory.getCircle(c.circleId);
775
+ if (wire) {
776
+ try {
777
+ const r = acceptSnapshot(c, wire, ownerPin.accountKey, pin.identityId, now);
778
+ if (r.outcome === "ACCEPTED") {
779
+ // The row may have moved since it was read: a sharing preference, a leave or a close
780
+ // made meanwhile wins over the fetched snapshot, and a newer snapshot already stored
781
+ // makes this one stale.
782
+ const committed = await this.store.transaction((tx) => {
783
+ const row = tx.get("circles", toHex(c.circleId));
784
+ const fresh = row ? rowToCircle(row) : c;
785
+ if (fresh.revision >= r.state.revision)
786
+ return undefined;
787
+ const localFinal = fresh.status === "left" || fresh.status === "closed" || fresh.status === "quarantined";
788
+ const state = { ...r.state, enabled: fresh.enabled, status: localFinal ? fresh.status : r.state.status };
789
+ const ch = t3Circle(tx, { ...circleToRow(state), pending: row?.pending && unbig(row.pending.revision) <= state.revision ? undefined : row?.pending }, { kind: "snapshot", circleId: c.circleId, revision: state.revision });
790
+ return { state, rotated: ch.changed, leftNow: state.status === "left" && fresh.status !== "left" };
791
+ });
792
+ if (committed) {
793
+ if (committed.rotated)
794
+ report.metadata.rotations++;
795
+ report.metadata.snapshots++;
796
+ this.emit({ kind: "circleChanged", circleId: c.circleId, revision: committed.state.revision, status: committed.state.status });
797
+ if (committed.leftNow)
798
+ await this.onLeft(c);
799
+ for (const m of committed.state.members)
800
+ memberIds.set(toHex(m), m);
801
+ }
802
+ else
803
+ for (const m of c.members)
804
+ memberIds.set(toHex(m), m);
805
+ }
806
+ else
807
+ for (const m of c.members)
808
+ memberIds.set(toHex(m), m);
809
+ }
810
+ catch (e) {
811
+ if (isKavachError(e, "EQUIVOCATION")) {
812
+ await this.store.transaction((tx) => t3Circle(tx, circleToRow({ ...c, status: "quarantined" })));
813
+ this.emit({ kind: "securityError", code: "EQUIVOCATION", detail: "two snapshots at one revision" });
814
+ }
815
+ else
816
+ report.errors.push(syncError("circleSnapshot", e));
817
+ for (const m of c.members)
818
+ memberIds.set(toHex(m), m);
819
+ }
820
+ }
821
+ else
822
+ for (const m of c.members)
823
+ memberIds.set(toHex(m), m);
824
+ if (equalBytes(c.ownerIdentityId, pin.identityId) && c.status === "member") {
825
+ const requests = await this.transport.directory.readRequests(c.circleId);
826
+ const joins = requests.filter((r) => r.action === "join").map((r) => r.accountName);
827
+ if (joins.length)
828
+ this.emit({ kind: "joinRequests", circleId: c.circleId, accountNames: joins });
829
+ for (const r of requests.filter((r) => r.action === "leave")) {
830
+ const [acct] = await this.transport.directory.resolveAccounts([r.accountName]);
831
+ if (!acct)
832
+ continue;
833
+ const leaver = pinFromCertificate(expectType(verifyDocument(acct.certificate), DocumentType.AccountDevice));
834
+ try {
835
+ await this.changeMembership(c.circleId, { remove: leaver.identityId });
836
+ }
837
+ catch (e) {
838
+ report.errors.push(syncError("removeLeaver", e));
839
+ }
840
+ }
841
+ }
842
+ }
843
+ memberIds.delete(toHex(pin.identityId));
844
+ if (memberIds.size > 0) {
845
+ const resolved = await this.transport.directory.resolveIdentities([...memberIds.values()]);
846
+ const byIdentity = new Map();
847
+ 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);
852
+ }
853
+ checkMemberResolution(byIdentity);
854
+ }
855
+ }
856
+ async pinFor(identityId) {
857
+ const row = await this.store.read((tx) => tx.get("pins", toHex(identityId)));
858
+ return row ? rowToPin(row) : undefined;
859
+ }
860
+ /** Accept a member's certificate; a changed account key is quarantined unless a valid receipt explains it. */
861
+ async acceptMemberCertificate(wire, resetReceipt, report) {
862
+ const doc = expectType(verifyDocument(wire), DocumentType.AccountDevice, "sync");
863
+ const existing = await this.store.read((tx) => tx.list("pins").find(([, p]) => p.accountName === doc.accountName)?.[1]);
864
+ const current = existing ? rowToPin(existing) : undefined;
865
+ let outcome;
866
+ try {
867
+ outcome = acceptCertificate(current, wire);
868
+ }
869
+ catch (e) {
870
+ if (isKavachError(e, "ACCOUNT_KEY_CHANGED") && current) {
871
+ await this.onKeyChanged(current, wire, resetReceipt, report);
872
+ return;
873
+ }
874
+ if (e instanceof KavachError) {
875
+ report.errors.push(syncError("certificate", e));
876
+ if (e.code === "EQUIVOCATION" || e.code === "DEVICE_ROLLBACK" || e.code === "DEVICE_ID_REUSED")
877
+ this.emit({ kind: "securityError", code: e.code, detail: doc.accountName });
878
+ return;
879
+ }
880
+ throw e;
881
+ }
882
+ if (outcome.outcome === "DUPLICATE")
883
+ return;
884
+ const p = outcome.pin;
885
+ await this.store.transaction((tx) => {
886
+ tx.put("pins", toHex(p.identityId), pinToRow(p));
887
+ if (outcome.previous) {
888
+ const old = tx.get("certificates", toHex(outcome.previous.certificateHash));
889
+ if (old)
890
+ tx.put("certificates", toHex(outcome.previous.certificateHash), { ...old, current: false });
891
+ }
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 });
893
+ if (outcome.rotate) {
894
+ const ch = t3ApplyChange(tx, { kind: "deviceChanged", identityId: p.identityId, certificateHash: p.certificateHash });
895
+ if (ch.changed)
896
+ report.metadata.rotations++;
897
+ }
898
+ });
899
+ report.metadata.certificates++;
900
+ if (outcome.rotate)
901
+ this.emit({ kind: "deviceChanged", identityId: p.identityId, generation: p.generation });
902
+ }
903
+ async onKeyChanged(current, wire, receipt, report) {
904
+ if (receipt) {
905
+ try {
906
+ const effect = this.trust.apply(receipt, wire, current.accountName, current.identityId, this.now());
907
+ // One transaction: a receipt is never on record without the closures and rotations it
908
+ // causes, so a restart cannot see it as a duplicate with nothing done.
909
+ await this.store.transaction((tx) => {
910
+ this.persistTrustIn(tx);
911
+ if (effect.outcome === "ACCOUNT_KEY_CHANGED")
912
+ this.applyResetIn(tx, current, effect.newIdentityId);
913
+ });
914
+ if (effect.outcome === "ACCOUNT_KEY_CHANGED") {
915
+ report.metadata.resets++;
916
+ this.emit({ kind: "accountKeyChanged", accountName: current.accountName, quarantined: false });
917
+ }
918
+ return;
919
+ }
920
+ catch (e) {
921
+ report.errors.push(syncError("resetReceipt", e));
922
+ if (isKavachError(e, "RESET_KEYS_REQUIRED") || isKavachError(e, "RESET_CLOCK_UNTRUSTED")) {
923
+ // Deferred: sharing continues under the pinned key; the receipt is tried again later.
924
+ return;
925
+ }
926
+ }
927
+ }
928
+ // A bare directory key change: quarantine the new key, keep the pin, close nothing.
929
+ const already = await this.store.read((tx) => tx.get("quarantine", current.accountName));
930
+ await this.store.transaction((tx) => tx.put("quarantine", current.accountName, { accountName: current.accountName, certificate: toHex(wire), reason: receipt ? "receipt rejected" : "no receipt", at: big(this.now()) }));
931
+ report.metadata.quarantined++;
932
+ if (!already)
933
+ this.emit({ kind: "accountKeyChanged", accountName: current.accountName, quarantined: true });
934
+ }
935
+ /** Peers on a validated reset: stop sharing to the old identity, close its circles, rotate. */
936
+ applyResetIn(tx, old, newIdentityId) {
937
+ tx.delete("pins", toHex(old.identityId));
938
+ tx.delete("quarantine", old.accountName);
939
+ for (const [k, c] of tx.list("certificates"))
940
+ if (c.identityId === toHex(old.identityId))
941
+ tx.put("certificates", k, { ...c, current: false });
942
+ for (const [k, c] of tx.list("circles")) {
943
+ if (c.ownerIdentityId === toHex(old.identityId)) {
944
+ t3Circle(tx, { ...c, status: "closed" }, { kind: "leave", circleId: fromHex(k) });
945
+ }
946
+ else if (c.members.includes(toHex(old.identityId))) {
947
+ t3ApplyChange(tx, { kind: "deviceChanged", identityId: old.identityId, certificateHash: newIdentityId });
948
+ }
949
+ }
950
+ forgetAuthorIn(tx, old.identityId);
951
+ }
952
+ persistTrustIn(tx) {
953
+ const rows = this.trust.toRows();
954
+ if (rows.list)
955
+ tx.put("resetList", "list", rows.list);
956
+ tx.put("clock", "clock", rows.clock);
957
+ const hashes = rows.accepted;
958
+ for (const e of rows.events)
959
+ tx.put("resetReceipts", e.resetId, { accountName: e.accountName, oldIdentityId: e.oldIdentityId, newIdentityId: e.newIdentityId, receiptHash: hashes.find((h) => h) ?? "" });
960
+ for (const r of rows.retired)
961
+ tx.put("retiredIdentities", r, { identityId: r });
962
+ }
963
+ async persistTrust() {
964
+ await this.store.transaction((tx) => this.persistTrustIn(tx));
965
+ }
966
+ async onLeft(c) {
967
+ // Removed from a circle: stop opening its authors unless another circle still has them.
968
+ const { pin } = this.requireAccount("left");
969
+ 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
+ for (const m of c.members) {
971
+ if (equalBytes(m, pin.identityId) || others.includes(toHex(m)))
972
+ continue;
973
+ await forgetAuthor(this.store, m);
974
+ }
975
+ }
976
+ async retire() {
977
+ await this.store.transaction((tx) => {
978
+ const meta = tx.get("meta", META);
979
+ if (meta)
980
+ tx.put("meta", META, { ...meta, state: "RETIRED" });
981
+ for (const [k, r] of tx.list("outbox"))
982
+ if (r.status === "queued" || r.status === "inflight")
983
+ tx.put("outbox", k, { ...r, wire: "", status: "cancelled", reason: "retired" });
984
+ });
985
+ await this.secrets.delete("deviceSeed");
986
+ this.device = undefined;
987
+ await this.emitState();
988
+ }
989
+ // Receiving and reads ---------------------------------------------------------------------------
990
+ receive(wire) {
991
+ return receiveRecord(this.inboxDeps(), wire);
992
+ }
993
+ history(query) {
994
+ return history(this.store, this.policy, query);
995
+ }
996
+ historyPage(query) {
997
+ return historyPage(this.store, this.policy, query);
998
+ }
999
+ newestEntry(query) {
1000
+ return newest(this.store, this.policy, query);
1001
+ }
1002
+ newest(identityId, kind) {
1003
+ return newestSlot(this.store, identityId, kind, this.now());
1004
+ }
1005
+ // Circles ---------------------------------------------------------------------------------------
1006
+ async listCircles() {
1007
+ const self = this.selfPin?.identityId;
1008
+ const now = this.now();
1009
+ return this.store.read((tx) => tx.list("circles").map(([, r]) => {
1010
+ const c = rowToCircle(r);
1011
+ return { circleId: c.circleId, ownerIdentityId: c.ownerIdentityId, revision: c.revision, members: c.members, enabled: c.enabled, status: c.status, snapshotAgeMs: c.verifiedAt ? now - c.verifiedAt : 0n, owned: !!self && equalBytes(c.ownerIdentityId, self) };
1012
+ }));
1013
+ }
1014
+ /**
1015
+ * Bring the vault in line with the circle rows: the one path for every cause. A write the host
1016
+ * never confirmed is settled first (its exact bytes, else the host's verified current vault).
1017
+ * Then, while the rows are marked as changed, the vault is derived from them inside one
1018
+ * transaction, stored as prepared bytes and published. Nothing comes from a caller's view of
1019
+ * the state, so a change that landed in the rows reaches the vault however the process dies.
1020
+ */
1021
+ async syncVault() {
1022
+ const { account, pin } = this.requireAccount("vault");
1023
+ const recoveryKey = await this.secrets.get("recoveryKey");
1024
+ const accountSeed = await this.secrets.get("accountSeed");
1025
+ if (!recoveryKey || !accountSeed)
1026
+ throw new KavachError("RECOVERY_REQUIRED", "vault");
1027
+ const accountKey = account.account.publicKey;
1028
+ let conflicts = 0;
1029
+ for (let round = 0; round < 8; round++) {
1030
+ const unconfirmed = await this.store.read((tx) => tx.get("vault", VAULT));
1031
+ if (unconfirmed?.pending) {
1032
+ await this.settlePendingVault(unconfirmed, recoveryKey, accountKey);
1033
+ continue;
1034
+ }
1035
+ const prepared = await this.store.transaction((tx) => {
1036
+ const row = tx.get("vault", VAULT);
1037
+ if (!row || row.pending || !row.dirty)
1038
+ return undefined;
1039
+ const opened = openVault(fromHex(row.wire), recoveryKey, this.accountName, accountKey);
1040
+ const entries = reconcileEntries(opened.entries, tx.list("circles").map(([, r]) => r));
1041
+ if (sameEntries(entries, opened.entries)) {
1042
+ t9Vault(tx, { ...row, dirty: false });
1043
+ return undefined;
1044
+ }
1045
+ const revision = unbig(row.revision);
1046
+ const next = buildVault({ accountName: this.accountName, accountKey, vaultRevision: revision + 1n }, { accountSeed, lastDeviceGeneration: pin.generation, lastCertificateHash: pin.certificateHash, entries }, recoveryKey);
1047
+ t9Vault(tx, { revision: big(revision + 1n), hash: toHex(next.hash), wire: toHex(next.wire), pending: true, dirty: false });
1048
+ return { wire: next.wire, hash: toHex(next.hash), expectedRevision: revision, expectedHash: fromHex(row.hash) };
1049
+ });
1050
+ if (!prepared)
1051
+ return;
1052
+ const outcome = await this.transport.directory.publishVault({ expectedRevision: prepared.expectedRevision, expectedHash: prepared.expectedHash, newVault: prepared.wire });
1053
+ if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
1054
+ await this.confirmVault(prepared.hash);
1055
+ continue; // a change made while publishing has marked the row again
1056
+ }
1057
+ if (outcome.status === "CONFLICT" && outcome.current) {
1058
+ if (++conflicts >= 3)
1059
+ break;
1060
+ await this.adoptCurrentVault(outcome.current, recoveryKey, accountKey, prepared.expectedRevision);
1061
+ continue;
1062
+ }
1063
+ throw new KavachError("STORAGE_FAILURE", "vault", `publishVault ${outcome.status}`);
1064
+ }
1065
+ throw new KavachError("STORAGE_FAILURE", "vault", "vault conflict persisted");
1066
+ }
1067
+ /** The host holds the prepared bytes with this hash: they are the committed vault now. A marker set meanwhile stays. */
1068
+ async confirmVault(hash) {
1069
+ await this.store.transaction((tx) => {
1070
+ const row = tx.get("vault", VAULT);
1071
+ if (row?.pending && row.hash === hash)
1072
+ t9Vault(tx, { ...row, pending: false });
1073
+ });
1074
+ }
1075
+ /**
1076
+ * Publish the exact bytes of a vault write the host never confirmed. Either the host has them
1077
+ * now or its current vault replaces the unconfirmed one locally.
1078
+ */
1079
+ async settlePendingVault(row, recoveryKey, accountKey) {
1080
+ const localRevision = unbig(row.revision) - 1n;
1081
+ const outcome = await this.transport.directory.publishVault({ expectedRevision: localRevision, expectedHash: undefined, newVault: fromHex(row.wire) });
1082
+ if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED")
1083
+ return this.confirmVault(row.hash);
1084
+ if (outcome.status === "CONFLICT" && outcome.current)
1085
+ return this.adoptCurrentVault(outcome.current, recoveryKey, accountKey, localRevision);
1086
+ throw new KavachError("STORAGE_FAILURE", "vault", `publishVault ${outcome.status}`);
1087
+ }
1088
+ /**
1089
+ * Take the host's current vault as the local committed one. It has to open under this account's
1090
+ * recovery key, be bound to this account, and not fall behind the revision already committed
1091
+ * locally; otherwise the local copy is kept and the caller fails. The adopted vault is marked:
1092
+ * whatever the rows say and it does not is derived again on top of it.
1093
+ */
1094
+ async adoptCurrentVault(current, recoveryKey, accountKey, localRevision) {
1095
+ const { header } = openVault(current, recoveryKey, this.accountName, accountKey);
1096
+ if (header.vaultRevision < localRevision)
1097
+ throw new KavachError("ROLLBACK", "vault", `host vault at ${header.vaultRevision}, local at ${localRevision}`);
1098
+ const { hash } = parseVaultHeader(current);
1099
+ await this.store.transaction((tx) => t9Vault(tx, { revision: big(header.vaultRevision), hash: toHex(hash), wire: toHex(current), pending: false, dirty: true }));
1100
+ }
1101
+ async createCircle() {
1102
+ const { account, pin } = this.requireAccount("createCircle");
1103
+ 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());
1106
+ if (state.outcome !== "ACCEPTED")
1107
+ throw new KavachError("STORAGE_FAILURE", "createCircle");
1108
+ await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(state.state), pending: { snapshot: toHex(genesis), revision: "1" } }));
1109
+ const published = await this.publishPendingSnapshot(circleId);
1110
+ if (published !== "COMMITTED")
1111
+ throw new KavachError("STORAGE_FAILURE", "createCircle", published);
1112
+ await this.store.transaction((tx) => {
1113
+ t3ApplyChange(tx, { kind: "join", circleId });
1114
+ t9MarkVault(tx);
1115
+ });
1116
+ await this.syncVault();
1117
+ this.emit({ kind: "circleChanged", circleId, revision: 1n, status: "member" });
1118
+ return { circleId };
1119
+ }
1120
+ async requestJoin(circleId, ownerIdentityId, shareOnApproval) {
1121
+ this.requireAccount("requestJoin");
1122
+ await this.store.transaction((tx) => {
1123
+ const existing = tx.get("circles", toHex(circleId));
1124
+ if (!existing)
1125
+ t3Circle(tx, circleToRow(pinCircle(circleId, ownerIdentityId, shareOnApproval, "requested")));
1126
+ else if (existing.status === "left") {
1127
+ // An explicit rejoin: the pinned owner and the revision reached stay, so no older snapshot
1128
+ // can be served, and the new preference replaces the one the leave cleared.
1129
+ if (existing.ownerIdentityId !== toHex(ownerIdentityId))
1130
+ throw new KavachError("NOT_AUTHORIZED", "requestJoin", "another owner is pinned for this circle");
1131
+ t3Circle(tx, { ...existing, status: "requested", enabled: shareOnApproval, pending: undefined });
1132
+ }
1133
+ });
1134
+ // The vault before the request: a recovering device finds the pin either way.
1135
+ await this.syncVault();
1136
+ const outcome = await this.transport.directory.requestJoin(circleId);
1137
+ if (outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED")
1138
+ throw new KavachError("STORAGE_FAILURE", "requestJoin", outcome.status);
1139
+ }
1140
+ async approve(circleId, accountName) {
1141
+ const [resolved] = await this.transport.directory.resolveAccounts([accountName]);
1142
+ if (!resolved)
1143
+ 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)
1149
+ throw new KavachError("ACCOUNT_KEY_CHANGED", "approve", "requester's key is quarantined");
1150
+ return this.changeMembership(circleId, { admit: requester.identityId });
1151
+ }
1152
+ async changeMembership(circleId, change) {
1153
+ const { account, pin } = this.requireAccount("changeMembership");
1154
+ for (let attempt = 0; attempt < 3; attempt++) {
1155
+ const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1156
+ if (!row)
1157
+ throw new KavachError("NOT_AUTHORIZED", "changeMembership", "unknown circle");
1158
+ if (row.pending) {
1159
+ // An earlier change the host never confirmed goes first; this one is built on its result.
1160
+ await this.publishPendingSnapshot(circleId);
1161
+ continue;
1162
+ }
1163
+ const state = rowToCircle(row);
1164
+ if (state.status === "closed")
1165
+ return { revision: state.revision };
1166
+ const successor = successorSnapshot(state, change, { identityId: pin.identityId, accountSecretKey: account.account.secretKey });
1167
+ if (!successor) {
1168
+ if (!("close" in change))
1169
+ return { revision: state.revision };
1170
+ // Nobody but the owner to tell: the close is local, and the vault entry goes with it.
1171
+ await this.store.transaction((tx) => {
1172
+ const r = tx.get("circles", toHex(circleId));
1173
+ if (r)
1174
+ t3Circle(tx, { ...r, status: "closed", enabled: false, pending: undefined }, { kind: "leave", circleId });
1175
+ else
1176
+ t3ApplyChange(tx, { kind: "leave", circleId });
1177
+ });
1178
+ this.emit({ kind: "circleChanged", circleId, revision: state.revision, status: "closed" });
1179
+ await this.syncVault();
1180
+ return { revision: state.revision };
1181
+ }
1182
+ await this.store.transaction((tx) => t8OwnerSnapshot(tx, circleId, successor, state.revision + 1n, "close" in change));
1183
+ const published = await this.publishPendingSnapshot(circleId);
1184
+ if (published === "COMMITTED")
1185
+ return { revision: await this.store.read((tx) => unbig(tx.get("circles", toHex(circleId))?.revision ?? "0")) };
1186
+ }
1187
+ throw new KavachError("STORAGE_FAILURE", "changeMembership", "conflict persisted");
1188
+ }
1189
+ /**
1190
+ * Publish a circle's pending successor (T8) and, once the host holds it, accept it locally with
1191
+ * the revision bump, the live-pin cleanup of anyone it removed and the change event. Called by
1192
+ * the change itself and again by every sync while a successor is pending, so a change whose
1193
+ * publication failed is never left admitted-but-sharing. On CONFLICT the host's snapshot is
1194
+ * accepted and the pending successor dropped; the caller rebuilds its change on top.
1195
+ */
1196
+ async publishPendingSnapshot(circleId) {
1197
+ const stage = "publishSnapshot";
1198
+ const { pin } = this.requireAccount(stage);
1199
+ const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1200
+ if (!row?.pending)
1201
+ return "NONE";
1202
+ const pending = row.pending;
1203
+ const state = rowToCircle(row);
1204
+ const successor = fromHex(pending.snapshot);
1205
+ const expectedRevision = unbig(pending.revision) - 1n;
1206
+ const outcome = await this.transport.directory.publishOwnedCircle({ circleId, expectedRevision, expectedHash: expectedRevision === state.revision ? state.bodyHash : undefined, newSnapshot: successor });
1207
+ if (outcome.status === "COMMITTED" || outcome.status === "ALREADY_COMMITTED") {
1208
+ const accepted = acceptSnapshot(state, successor, pin.accountKey, pin.identityId, this.now());
1209
+ if (accepted.outcome !== "ACCEPTED" && accepted.outcome !== "DUPLICATE")
1210
+ throw new KavachError("STORAGE_FAILURE", stage, "the successor was refused locally");
1211
+ let next = pending.close ? { ...accepted.state, status: "closed" } : accepted.state;
1212
+ await this.store.transaction((tx) => {
1213
+ // The row may have moved while the publication was in flight (sharing disabled, the
1214
+ // circle closed): the host's answer settles membership, never the user's newer preference.
1215
+ const live = tx.get("circles", toHex(circleId));
1216
+ if (live)
1217
+ next = { ...next, enabled: live.enabled, status: live.status === "closed" ? "closed" : next.status };
1218
+ t3Circle(tx, { ...circleToRow(next), pending: undefined }, { kind: "snapshot", circleId, revision: next.revision });
1219
+ });
1220
+ const after = new Set(next.members.map(toHex));
1221
+ for (const m of state.members) {
1222
+ if (after.has(toHex(m)) || equalBytes(m, pin.identityId))
1223
+ continue;
1224
+ 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
+ if (!stillElsewhere)
1226
+ await forgetAuthor(this.store, m);
1227
+ }
1228
+ this.emit({ kind: "circleChanged", circleId, revision: next.revision, status: next.status });
1229
+ if (pending.close)
1230
+ await this.syncVault();
1231
+ return "COMMITTED";
1232
+ }
1233
+ if (outcome.status === "CONFLICT" && outcome.current) {
1234
+ const accepted = acceptSnapshot(state, outcome.current, pin.accountKey, pin.identityId, this.now());
1235
+ await this.store.transaction((tx) => t3Circle(tx, { ...circleToRow(accepted.outcome === "ACCEPTED" ? accepted.state : state), pending: undefined }));
1236
+ return "CONFLICT";
1237
+ }
1238
+ throw new KavachError("STORAGE_FAILURE", stage, outcome.status);
1239
+ }
1240
+ async leave(circleId) {
1241
+ const { pin } = this.requireAccount("leave");
1242
+ const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1243
+ if (!row)
1244
+ return;
1245
+ if (row.ownerIdentityId === toHex(pin.identityId))
1246
+ throw new KavachError("NOT_AUTHORIZED", "leave", "the owner closes instead");
1247
+ const state = rowToCircle(row);
1248
+ await this.store.transaction((tx) => {
1249
+ t3Circle(tx, circleToRow({ ...state, enabled: false, status: "left" }), { kind: "leave", circleId });
1250
+ });
1251
+ await this.onLeft(state);
1252
+ await this.syncVault();
1253
+ await this.transport.directory.requestLeave(circleId);
1254
+ this.emit({ kind: "circleChanged", circleId, revision: state.revision, status: "left" });
1255
+ }
1256
+ async setEnabled(circleId, enabled) {
1257
+ const row = await this.store.read((tx) => tx.get("circles", toHex(circleId)));
1258
+ if (!row)
1259
+ throw new KavachError("NOT_AUTHORIZED", "setEnabled", "unknown circle");
1260
+ if (row.enabled === enabled) {
1261
+ await this.syncVault();
1262
+ return;
1263
+ }
1264
+ await this.store.transaction((tx) => {
1265
+ t3Circle(tx, { ...row, enabled }, { kind: enabled ? "enable" : "disable", circleId });
1266
+ });
1267
+ await this.syncVault();
1268
+ }
1269
+ /** Close: the successor names only the owner; its publication forgets the members and removes the vault entry. */
1270
+ async closeCircle(circleId) {
1271
+ await this.changeMembership(circleId, { close: true });
1272
+ }
1273
+ // Reset -----------------------------------------------------------------------------------------
1274
+ async resetAccount(options) {
1275
+ const stage = "resetAccount";
1276
+ if (!options.confirmed)
1277
+ throw new KavachError("NOT_AUTHORIZED", stage, "reset requires confirmation");
1278
+ const { pin } = this.requireAccount(stage);
1279
+ if (await this.store.read((tx) => tx.get("pendingReset", "pending")))
1280
+ return this.resumeReset(stage, options.reauthProof);
1281
+ const accountSeed = newAccountSeed();
1282
+ const account = openAccount(this.accountName, accountSeed);
1283
+ const device = newDevice();
1284
+ const certificate = certify(account, device, 1n);
1285
+ const newPin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice));
1286
+ const recoveryKey = newRecoveryKey();
1287
+ const vault = buildVault({ accountName: this.accountName, accountKey: account.account.publicKey, vaultRevision: 1n }, { accountSeed, lastDeviceGeneration: 1n, lastCertificateHash: newPin.certificateHash, entries: [] }, recoveryKey);
1288
+ const resetId = randomBytes(16);
1289
+ const instanceId = randomBytes(16);
1290
+ // Persisted before it is sent: the host may commit the new identity and the response be lost,
1291
+ // and the only keys that can continue are these. Roots in the secret store, wires in the store.
1292
+ 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) }));
1294
+ return this.resumeReset(stage, options.reauthProof);
1295
+ }
1296
+ /** Send (or re-send) the persisted reset with its own reset id, and finish locally once the host holds it. */
1297
+ async resumeReset(stage, reauthProof) {
1298
+ const row = await this.store.read((tx) => tx.get("pendingReset", "pending"));
1299
+ const roots = await this.secrets.get("pendingReset");
1300
+ if (!row || !roots || roots.length !== RESET_ROOTS_BYTES)
1301
+ throw new KavachError("RECOVERY_REQUIRED", stage, "reset pending without its roots");
1302
+ const certificate = fromHex(row.certificate);
1303
+ 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() });
1305
+ if ((outcome.status !== "COMMITTED" && outcome.status !== "ALREADY_COMMITTED") || !outcome.receipt)
1306
+ throw new KavachError("NOT_AUTHORIZED", stage, `host refused reset: ${outcome.status}`);
1307
+ return this.finishReset(stage, row, roots);
1308
+ }
1309
+ /**
1310
+ * The local half of a reset the host holds: the new roots, then a new store under the new
1311
+ * identity. Nothing of the old identity's keys survives. Safe to run again after a crash at
1312
+ * any point, until the pending row is gone.
1313
+ */
1314
+ async finishReset(stage, row, roots) {
1315
+ const accountSeed = roots.subarray(0, 32);
1316
+ const deviceSeed = roots.subarray(32, 64);
1317
+ const recoveryKey = roots.subarray(64, 96);
1318
+ const instanceId = roots.subarray(96, 112);
1319
+ const deviceId = roots.subarray(112, 128);
1320
+ const account = openAccount(this.accountName, accountSeed);
1321
+ const device = deviceFromSeed(deviceSeed, deviceId);
1322
+ const certificate = fromHex(row.certificate);
1323
+ const newPin = pinFromCertificate(expectType(verifyDocument(certificate), DocumentType.AccountDevice, stage));
1324
+ await t1Secrets(this.secrets, { accountSeed, deviceSeed, recoveryKey, instanceId });
1325
+ 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"]) {
1327
+ for (const [k] of tx.list(table))
1328
+ tx.delete(table, k);
1329
+ }
1330
+ t1Header(tx, { account, device, certificate, instanceId, generation: 1n });
1331
+ t9Vault(tx, { revision: "1", hash: row.vaultHash, wire: row.vault, pending: false, dirty: false });
1332
+ t1Activate(tx);
1333
+ });
1334
+ await this.secrets.delete("pendingReset");
1335
+ this.account = account;
1336
+ this.device = device;
1337
+ this.selfPin = newPin;
1338
+ try {
1339
+ await this.provider.save(this.accountName, newPin.identityId, recoveryKey);
1340
+ }
1341
+ catch {
1342
+ /* reported by recoveryStatus() */
1343
+ }
1344
+ await this.emitState();
1345
+ return { identityId: newPin.identityId };
1346
+ }
1347
+ /**
1348
+ * A reset the host committed whose local half was interrupted: the roots already name the new
1349
+ * identity while the store header still names the old one, which openStore would refuse. The
1350
+ * roots are switched only after the host confirmed, so the new identity is the one to finish.
1351
+ */
1352
+ async finishInterruptedReset() {
1353
+ const row = await this.store.read((tx) => tx.get("pendingReset", "pending"));
1354
+ if (!row)
1355
+ return;
1356
+ const [roots, accountSeed] = await Promise.all([this.secrets.get("pendingReset"), this.secrets.get("accountSeed")]);
1357
+ if (!roots || roots.length !== RESET_ROOTS_BYTES || !accountSeed || !equalBytes(accountSeed, roots.subarray(0, 32)))
1358
+ return;
1359
+ await this.finishReset("load", row, roots);
1360
+ }
1361
+ async close() {
1362
+ this.listeners.clear();
1363
+ }
1364
+ }
1365
+ //# sourceMappingURL=client.js.map