@plane31/blind-relay 0.1.1 → 0.1.3
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.
- package/dist/crypto/sodium.d.ts +5 -3
- package/dist/crypto/sodium.d.ts.map +1 -1
- package/dist/crypto/sodium.js +13 -1
- package/dist/crypto/sodium.js.map +1 -1
- package/package.json +3 -2
- package/src/adapters/index.ts +102 -0
- package/src/audience.ts +123 -0
- package/src/bytes.ts +209 -0
- package/src/circle.ts +127 -0
- package/src/client.ts +1442 -0
- package/src/codec.ts +642 -0
- package/src/crypto/backend.ts +48 -0
- package/src/crypto/noble.ts +104 -0
- package/src/crypto/node-module.d.ts +11 -0
- package/src/crypto/sodium.ts +53 -0
- package/src/crypto.ts +266 -0
- package/src/epoch.ts +201 -0
- package/src/errors.ts +78 -0
- package/src/historyIndex.ts +61 -0
- package/src/identity.ts +158 -0
- package/src/inbox.ts +560 -0
- package/src/index.ts +59 -0
- package/src/outbox.ts +385 -0
- package/src/policy.ts +88 -0
- package/src/record.ts +106 -0
- package/src/reset.ts +176 -0
- package/src/rows.ts +116 -0
- package/src/slots.ts +55 -0
- package/src/store.ts +557 -0
- package/src/testing/clock.ts +29 -0
- package/src/testing/fakeHost.ts +482 -0
- package/src/testing/faults.ts +321 -0
- package/src/testing/fixtures.ts +35 -0
- package/src/testing/index.ts +6 -0
- package/src/testing/providers.ts +39 -0
- package/src/testing/sweep.ts +407 -0
- package/src/transactions.ts +231 -0
- package/src/transport.ts +106 -0
- package/src/vault.ts +126 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The crash and lost-response sweeps of build step 16. One scenario (two accounts, a circle,
|
|
3
|
+
* records of a slot kind both ways, an event kind, concurrent contexts, a device replacement) runs once with no
|
|
4
|
+
* fault to count the victim's transaction boundaries, then once per boundary and phase with the
|
|
5
|
+
* victim's process crashing there, restarting, and finishing the scenario. After every run the
|
|
6
|
+
* durable-state outcomes of protocol section 11 are checked against the host's view.
|
|
7
|
+
*/
|
|
8
|
+
import { fromHex, toHex } from "../bytes.js";
|
|
9
|
+
import { open, type Client, type ClientEvent } from "../client.js";
|
|
10
|
+
import { KavachError } from "../errors.js";
|
|
11
|
+
import { MemorySecretStore, type Store } from "../store.js";
|
|
12
|
+
import type { Transport } from "../transport.js";
|
|
13
|
+
import { TestClock, testPolicy } from "./clock.js";
|
|
14
|
+
import { FakeHost } from "./fakeHost.js";
|
|
15
|
+
import { FaultyStore, MaliciousTransport, type CrashPhase, type CrashPlan, type MutationName } from "./faults.js";
|
|
16
|
+
import { testResetKeyList, testResetKeys } from "./fixtures.js";
|
|
17
|
+
import { MemoryPlatformProvider } from "./providers.js";
|
|
18
|
+
|
|
19
|
+
export type DeviceName = "alice" | "bob" | "bob2";
|
|
20
|
+
|
|
21
|
+
export interface SweepStores {
|
|
22
|
+
/** A durable store for the named device of this run, empty. */
|
|
23
|
+
open(run: string, name: DeviceName): Promise<Store> | Store;
|
|
24
|
+
/** The same durable state as a restarted process opens it: a new handle on the same file. */
|
|
25
|
+
reopen(run: string, name: DeviceName): Promise<Store> | Store;
|
|
26
|
+
/** Another in-process handle on the same durable state, when the engine can give one. */
|
|
27
|
+
fork?(run: string, name: DeviceName): Store;
|
|
28
|
+
dispose?(run: string): Promise<void> | void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SweepOptions {
|
|
32
|
+
readonly stores: SweepStores;
|
|
33
|
+
readonly victim: DeviceName;
|
|
34
|
+
readonly phases?: readonly CrashPhase[];
|
|
35
|
+
/** Run only these boundaries (for narrowing a failure). */
|
|
36
|
+
readonly only?: readonly number[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SweepRun {
|
|
40
|
+
readonly at: number;
|
|
41
|
+
readonly phase: CrashPhase;
|
|
42
|
+
/** The scenario step during which the process died. */
|
|
43
|
+
readonly crashedDuring: string;
|
|
44
|
+
/** What the re-invoked step answered after the restart. */
|
|
45
|
+
readonly retry: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SweepResult {
|
|
49
|
+
readonly boundaries: number;
|
|
50
|
+
readonly runs: readonly SweepRun[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface LostResponseRun {
|
|
54
|
+
readonly mutation: MutationName;
|
|
55
|
+
/** Whether the victim actually issued that mutation in this scenario. */
|
|
56
|
+
readonly exercised: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const resetKeys = testResetKeys();
|
|
60
|
+
const resetTrust = { managementKey: resetKeys.management.publicKey, minimumVersion: 1n };
|
|
61
|
+
|
|
62
|
+
interface Device {
|
|
63
|
+
name: DeviceName;
|
|
64
|
+
client: Client;
|
|
65
|
+
store: FaultyStore;
|
|
66
|
+
secrets: MemorySecretStore;
|
|
67
|
+
events: ClientEvent[];
|
|
68
|
+
provider: MemoryPlatformProvider;
|
|
69
|
+
transport: MaliciousTransport;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Runtime {
|
|
73
|
+
host: FakeHost;
|
|
74
|
+
clock: TestClock;
|
|
75
|
+
devices: Map<DeviceName, Device>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function newHost(): { host: FakeHost; clock: TestClock } {
|
|
79
|
+
const host = new FakeHost();
|
|
80
|
+
const clock = new TestClock();
|
|
81
|
+
host.now = clock.now;
|
|
82
|
+
host.resetSigner = { key: resetKeys.operational, keyId: resetKeys.operationalKeyId };
|
|
83
|
+
host.resetKeyList = testResetKeyList(resetKeys, clock.now());
|
|
84
|
+
return { host, clock };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function accountOf(name: DeviceName): string {
|
|
88
|
+
return name === "bob2" ? "bob" : name;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The transport of a device: bound to the account's current certificate at the host on every call. */
|
|
92
|
+
export function hostTransport(host: FakeHost, account: string): Transport {
|
|
93
|
+
const bind = (): Transport => {
|
|
94
|
+
const acct = host.accounts.get(account);
|
|
95
|
+
return host.asDevice(account, acct ? fromHex(acct.certificateHash) : new Uint8Array(32));
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
get directory() {
|
|
99
|
+
return bind().directory;
|
|
100
|
+
},
|
|
101
|
+
get relay() {
|
|
102
|
+
return bind().relay;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function openDevice(rt: Runtime, name: DeviceName, store: FaultyStore, secrets: MemorySecretStore, provider: MemoryPlatformProvider, transport: MaliciousTransport): Promise<Device> {
|
|
108
|
+
const events: ClientEvent[] = [];
|
|
109
|
+
const client = await open({ accountName: accountOf(name), retentionPolicy: testPolicy, recoveryProvider: provider, resetTrust, secretStore: secrets, store, transport, now: rt.clock.now });
|
|
110
|
+
client.subscribe((e) => events.push(e));
|
|
111
|
+
const d: Device = { name, client, store, secrets, events, provider, transport };
|
|
112
|
+
rt.devices.set(name, d);
|
|
113
|
+
return d;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type Step = { name: string; run: () => Promise<unknown> };
|
|
117
|
+
|
|
118
|
+
interface Scenario {
|
|
119
|
+
steps: Step[];
|
|
120
|
+
rt: Runtime;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function scenario(rt: Runtime, stores: SweepStores, run: string): Scenario {
|
|
124
|
+
const d = (n: DeviceName): Device => {
|
|
125
|
+
const x = rt.devices.get(n);
|
|
126
|
+
if (!x) throw new Error("no device " + n);
|
|
127
|
+
return x;
|
|
128
|
+
};
|
|
129
|
+
let circleId: Uint8Array = new Uint8Array(16);
|
|
130
|
+
let aliceId: Uint8Array = new Uint8Array(32);
|
|
131
|
+
// Distinct opaque payloads with the clock as the event time: the sweep needs bytes, not a codec.
|
|
132
|
+
const loc = (n: number): [Uint8Array, { eventTime: bigint }] => [Uint8Array.of(1, n), { eventTime: rt.clock.now() }];
|
|
133
|
+
const sync = (n: DeviceName) => async (): Promise<unknown> => {
|
|
134
|
+
rt.clock.advance(1_000n);
|
|
135
|
+
return d(n).client.sync();
|
|
136
|
+
};
|
|
137
|
+
const steps: Step[] = [
|
|
138
|
+
{ name: "alice.init", run: () => d("alice").client.initializeAccount() },
|
|
139
|
+
{ name: "bob.init", run: () => d("bob").client.initializeAccount() },
|
|
140
|
+
{
|
|
141
|
+
name: "alice.create",
|
|
142
|
+
run: async () => {
|
|
143
|
+
circleId = (await d("alice").client.circles.create()).circleId;
|
|
144
|
+
aliceId = (await d("alice").client.inspect()).identityId ?? aliceId;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
{ name: "bob.requestJoin", run: () => d("bob").client.circles.requestJoin(circleId, aliceId) },
|
|
148
|
+
{ name: "alice.sync#1", run: sync("alice") },
|
|
149
|
+
{ name: "alice.approve", run: () => d("alice").client.circles.approve(circleId, "bob") },
|
|
150
|
+
{ name: "alice.sync#2", run: sync("alice") },
|
|
151
|
+
{ name: "bob.sync#1", run: sync("bob") },
|
|
152
|
+
{ name: "alice.sync#3", run: sync("alice") },
|
|
153
|
+
{ name: "alice.enqueue#1", run: () => d("alice").client.enqueue(0, ...loc(1)) },
|
|
154
|
+
{ name: "alice.enqueue#2", run: () => d("alice").client.enqueue(0, ...loc(2)) },
|
|
155
|
+
{ name: "alice.sync#4", run: sync("alice") },
|
|
156
|
+
{ name: "bob.sync#2", run: sync("bob") },
|
|
157
|
+
{ name: "bob.event", run: () => d("bob").client.enqueue(1, ...loc(3)) },
|
|
158
|
+
{ name: "bob.sync#3", run: sync("bob") },
|
|
159
|
+
{ name: "alice.sync#5", run: sync("alice") },
|
|
160
|
+
{
|
|
161
|
+
name: "alice.concurrent",
|
|
162
|
+
run: async () => {
|
|
163
|
+
if (!stores.fork) return;
|
|
164
|
+
// A background context: a second client over another handle on the same store.
|
|
165
|
+
const a = d("alice");
|
|
166
|
+
const bg = await open({ accountName: "alice", retentionPolicy: testPolicy, recoveryProvider: a.provider, resetTrust, secretStore: a.secrets, store: a.store.fork(), transport: a.transport, now: rt.clock.now });
|
|
167
|
+
await Promise.all([a.client.sync(), bg.enqueue(0, ...loc(4)), bg.enqueue(0, ...loc(5))]);
|
|
168
|
+
await a.client.sync();
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
{ name: "bob2.restore", run: () => d("bob2").client.restoreAccount() },
|
|
172
|
+
{ name: "bob2.sync#1", run: sync("bob2") },
|
|
173
|
+
{ name: "alice.sync#6", run: sync("alice") },
|
|
174
|
+
{ name: "alice.enqueue#3", run: () => d("alice").client.enqueue(0, ...loc(6)) },
|
|
175
|
+
{ name: "alice.sync#7", run: sync("alice") },
|
|
176
|
+
{ name: "bob2.sync#2", run: sync("bob2") },
|
|
177
|
+
{ name: "bob.sync#4", run: sync("bob") },
|
|
178
|
+
{ name: "bob2.event", run: () => d("bob2").client.enqueue(1, ...loc(7)) },
|
|
179
|
+
{ name: "bob2.sync#3", run: sync("bob2") },
|
|
180
|
+
{ name: "alice.sync#8", run: sync("alice") }
|
|
181
|
+
];
|
|
182
|
+
void run;
|
|
183
|
+
return { steps, rt };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function setup(stores: SweepStores, run: string, plan: { victim: DeviceName; crash?: CrashPlan; lose?: MutationName }): Promise<Runtime> {
|
|
187
|
+
const { host, clock } = newHost();
|
|
188
|
+
const rt: Runtime = { host, clock, devices: new Map() };
|
|
189
|
+
const bobPlatform = new MemoryPlatformProvider("bob@platform");
|
|
190
|
+
for (const name of ["alice", "bob", "bob2"] as const) {
|
|
191
|
+
const inner = await stores.open(run, name);
|
|
192
|
+
const isVictim = name === plan.victim;
|
|
193
|
+
const forkInner = stores.fork ? (): Store => stores.fork?.(run, name) as Store : undefined;
|
|
194
|
+
const store = new FaultyStore(inner, isVictim ? plan.crash : undefined, forkInner);
|
|
195
|
+
const transport = new MaliciousTransport(hostTransport(host, accountOf(name)));
|
|
196
|
+
if (isVictim && plan.lose) transport.knobs.loseResponseOf.add(plan.lose);
|
|
197
|
+
const provider = name === "alice" ? new MemoryPlatformProvider("alice@platform") : bobPlatform.twin();
|
|
198
|
+
await openDevice(rt, name, store, new MemorySecretStore(), provider, transport);
|
|
199
|
+
}
|
|
200
|
+
return rt;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The victim's process restarts: a new handle on the same durable state, the same secrets. */
|
|
204
|
+
async function restart(rt: Runtime, stores: SweepStores, run: string, victim: DeviceName): Promise<void> {
|
|
205
|
+
const old = rt.devices.get(victim);
|
|
206
|
+
if (!old) throw new Error("no victim");
|
|
207
|
+
const forkInner = stores.fork ? (): Store => stores.fork?.(run, victim) as Store : undefined;
|
|
208
|
+
const store = new FaultyStore(await stores.reopen(run, victim), undefined, forkInner);
|
|
209
|
+
const transport = new MaliciousTransport(hostTransport(rt.host, accountOf(victim)));
|
|
210
|
+
await openDevice(rt, victim, store, old.secrets, old.provider, transport);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function describeError(e: unknown): string {
|
|
214
|
+
return e instanceof KavachError ? `${e.code}@${e.stage}` : e instanceof Error ? e.name : String(e);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Runs the steps; on the victim's crash restarts it and re-invokes the step, then continues. */
|
|
218
|
+
async function drive(sc: Scenario, stores: SweepStores, run: string, victim: DeviceName): Promise<{ crashedDuring: string; retry: string }> {
|
|
219
|
+
let crashedDuring = "";
|
|
220
|
+
let retry = "";
|
|
221
|
+
for (const step of sc.steps) {
|
|
222
|
+
const v = sc.rt.devices.get(victim);
|
|
223
|
+
if (!v) throw new Error("no victim");
|
|
224
|
+
const lossesBefore = v.transport.losses.length;
|
|
225
|
+
let failed: unknown;
|
|
226
|
+
try {
|
|
227
|
+
await step.run();
|
|
228
|
+
} catch (e) {
|
|
229
|
+
failed = e;
|
|
230
|
+
}
|
|
231
|
+
if (!v.store.crashed && v.transport.losses.length > lossesBefore && failed !== undefined) {
|
|
232
|
+
// The app sees a failed operation and retries it; the host already committed the write.
|
|
233
|
+
try {
|
|
234
|
+
await step.run();
|
|
235
|
+
retry = "OK";
|
|
236
|
+
} catch (e) {
|
|
237
|
+
if (!(e instanceof KavachError)) throw e;
|
|
238
|
+
retry = describeError(e);
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (v.store.crashed) {
|
|
243
|
+
if (crashedDuring) throw new Error(`crashed twice (${crashedDuring}, ${step.name})`);
|
|
244
|
+
crashedDuring = step.name;
|
|
245
|
+
await restart(sc.rt, stores, run, victim);
|
|
246
|
+
try {
|
|
247
|
+
await step.run();
|
|
248
|
+
retry = "OK";
|
|
249
|
+
} catch (e) {
|
|
250
|
+
if (!(e instanceof KavachError)) throw e;
|
|
251
|
+
retry = describeError(e);
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (failed !== undefined) throw new Error(`step ${step.name} failed without a crash: ${describeError(failed)}`);
|
|
256
|
+
}
|
|
257
|
+
return { crashedDuring, retry };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The durable-state outcomes of section 11, checked against the host after quiescence. */
|
|
261
|
+
async function checkInvariants(rt: Runtime, label: string): Promise<void> {
|
|
262
|
+
const fail = (what: string): never => {
|
|
263
|
+
throw new Error(`[${label}] ${what}`);
|
|
264
|
+
};
|
|
265
|
+
const d = (n: DeviceName): Device => rt.devices.get(n) ?? fail("no device " + n);
|
|
266
|
+
// Quiescence.
|
|
267
|
+
for (let i = 0; i < 2; i++) {
|
|
268
|
+
rt.clock.advance(1_000n);
|
|
269
|
+
await d("alice").client.sync();
|
|
270
|
+
await d("bob2").client.sync();
|
|
271
|
+
}
|
|
272
|
+
const host = rt.host;
|
|
273
|
+
const alice = host.accounts.get("alice") ?? fail("alice unknown at host");
|
|
274
|
+
const bob = host.accounts.get("bob") ?? fail("bob unknown at host");
|
|
275
|
+
// Activation: exactly one replacement, never a third device, never a reset.
|
|
276
|
+
if (alice.generation !== 1n || alice.deviceIds.size !== 1) fail(`alice generation ${alice.generation} devices ${alice.deviceIds.size}`);
|
|
277
|
+
if (bob.generation !== 2n || bob.deviceIds.size !== 2) fail(`bob generation ${bob.generation} devices ${bob.deviceIds.size}`);
|
|
278
|
+
if (alice.previousIdentities.size || bob.previousIdentities.size) fail("an identity was reset");
|
|
279
|
+
const aliceState = await d("alice").client.getState();
|
|
280
|
+
const bob2State = await d("bob2").client.getState();
|
|
281
|
+
const bobState = await d("bob").client.getState();
|
|
282
|
+
if (aliceState.status !== "ready") fail(`alice is ${aliceState.status}`);
|
|
283
|
+
if (bob2State.status !== "ready") fail(`bob2 is ${bob2State.status}`);
|
|
284
|
+
if (bobState.status !== "retired") fail(`old bob is ${bobState.status}`);
|
|
285
|
+
if (toHex(aliceState.identityId ?? new Uint8Array()) !== alice.identityId) fail("alice identity differs from the host's");
|
|
286
|
+
if (toHex(bob2State.identityId ?? new Uint8Array()) !== bob.identityId) fail("bob2 identity differs from the host's");
|
|
287
|
+
if (toHex(bob2State.certificateHash ?? new Uint8Array()) !== bob.certificateHash) fail("bob2 certificate differs from the host's");
|
|
288
|
+
// Streams: every epoch the host holds is the exact bytes the author saved, and the author's
|
|
289
|
+
// active epoch is the host's current one.
|
|
290
|
+
for (const name of ["alice", "bob2", "bob"] as const) {
|
|
291
|
+
const dev = d(name);
|
|
292
|
+
const inner = dev.store.inner;
|
|
293
|
+
const pin = await inner.read((tx) => tx.get("self", "self")) ?? fail(`${name} has no self row`);
|
|
294
|
+
const s = host.streams.get(pin.streamId) ?? fail(`${name} has no stream at the host`);
|
|
295
|
+
const rows = await inner.read((tx) => tx.list("epochs"));
|
|
296
|
+
const byEpoch = new Map(rows.map(([, r]) => [r.epoch, r]));
|
|
297
|
+
for (const [epoch, e] of s.epochs) {
|
|
298
|
+
const row = byEpoch.get(epoch) ?? fail(`${name}: host epoch ${epoch} has no local row`);
|
|
299
|
+
if (row.manifest !== toHex(e.manifest)) fail(`${name}: epoch ${epoch} bytes differ between host and store`);
|
|
300
|
+
}
|
|
301
|
+
for (const [, r] of rows) {
|
|
302
|
+
if (r.status === "prepared") continue;
|
|
303
|
+
if (r.epoch > s.currentEpoch) fail(`${name}: local epoch ${r.epoch} (${r.status}) above the host's ${s.currentEpoch}`);
|
|
304
|
+
}
|
|
305
|
+
const stream = await inner.read((tx) => tx.get("stream", "stream"));
|
|
306
|
+
if (name !== "bob") {
|
|
307
|
+
if (stream?.activeEpoch !== s.currentEpoch) fail(`${name}: active epoch ${stream?.activeEpoch} but host current ${s.currentEpoch}`);
|
|
308
|
+
const active = rows.filter(([, r]) => r.status === "active");
|
|
309
|
+
if (active.length !== 1) fail(`${name}: ${active.length} active epochs`);
|
|
310
|
+
}
|
|
311
|
+
// Records: every append the host saw is in the author's own history, the same recordId was
|
|
312
|
+
// never asked to accept two different wires, and no counter was reused.
|
|
313
|
+
const own = new Set((await inner.read((tx) => tx.list("history"))).filter(([, h]) => h.own).map(([, h]) => h.recordId));
|
|
314
|
+
const log = host.appendLog.filter((a) => a.streamId === pin.streamId);
|
|
315
|
+
const hashes = new Map<string, string>();
|
|
316
|
+
for (const a of log) {
|
|
317
|
+
if (!own.has(a.recordId)) fail(`${name}: record ${a.recordId.slice(0, 8)} reached the host without a history row`);
|
|
318
|
+
const h = hashes.get(a.recordId);
|
|
319
|
+
if (h && h !== a.hash) fail(`${name}: record ${a.recordId.slice(0, 8)} was sent with two different wires`);
|
|
320
|
+
hashes.set(a.recordId, a.hash);
|
|
321
|
+
if (a.status === "EQUIVOCATION" || a.status === "STALE_APPEND") fail(`${name}: the host answered ${a.status}`);
|
|
322
|
+
}
|
|
323
|
+
const outbox = await inner.read((tx) => tx.list("outbox").map(([, r]) => r.status));
|
|
324
|
+
if (name !== "bob" && outbox.some((st) => st !== "accepted" && st !== "cancelled")) fail(`${name}: outbox left ${outbox.join(",")}`);
|
|
325
|
+
// Cursors: the client never records a cursor the host does not hold.
|
|
326
|
+
const subs = await inner.read((tx) => tx.list("subscriptions"));
|
|
327
|
+
for (const [, sub] of subs) {
|
|
328
|
+
const hs = host.streams.get(sub.streamId);
|
|
329
|
+
const hostSub = hs?.subscriptions.get(toHex(dev.client ? (await dev.client.getState()).certificateHash ?? new Uint8Array() : new Uint8Array()));
|
|
330
|
+
if (hostSub && hostSub.cursor < BigInt(sub.committedCursor)) fail(`${name}: committed cursor ${sub.committedCursor} above the host's ${hostSub.cursor}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// Delivery: bob2 holds alice's newest record of kind 0; alice has bob's kind 1 records.
|
|
334
|
+
const aliceId = fromHex(alice.identityId);
|
|
335
|
+
const pin = await d("bob2").client.newest(aliceId, 0);
|
|
336
|
+
const aliceStream = host.streams.get((await d("alice").store.inner.read((tx) => tx.get("self", "self")))?.streamId ?? "");
|
|
337
|
+
const accepted = host.appendLog.filter((a) => a.streamId === (aliceStream ? [...host.streams.entries()].find(([, s]) => s === aliceStream)?.[0] : "") && (a.status === "ACCEPTED" || a.status === "ALREADY_ACCEPTED"));
|
|
338
|
+
const last = [...accepted].reverse().find((a) => a.epoch === aliceStream?.currentEpoch);
|
|
339
|
+
if (last && (!pin || toHex(pin.recordId) !== last.recordId)) fail(`bob2's slot is ${pin ? toHex(pin.recordId).slice(0, 8) : "missing"}, alice's last is ${last.recordId.slice(0, 8)}`);
|
|
340
|
+
const events = await d("alice").client.history({ authorIdentityId: fromHex(bob.identityId), kind: 1 });
|
|
341
|
+
if (events.length < 2) fail(`alice has ${events.length} kind 1 records from bob`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function withRun<R>(stores: SweepStores, run: string, fn: () => Promise<R>): Promise<R> {
|
|
345
|
+
try {
|
|
346
|
+
return await fn();
|
|
347
|
+
} finally {
|
|
348
|
+
await stores.dispose?.(run);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Runs the scenario with no fault and returns the victim's transaction count. */
|
|
353
|
+
export async function countBoundaries(stores: SweepStores, victim: DeviceName): Promise<number> {
|
|
354
|
+
const run = `reference-${victim}`;
|
|
355
|
+
return withRun(stores, run, async () => {
|
|
356
|
+
const rt = await setup(stores, run, { victim });
|
|
357
|
+
const sc = scenario(rt, stores, run);
|
|
358
|
+
const { crashedDuring } = await drive(sc, stores, run, victim);
|
|
359
|
+
if (crashedDuring) throw new Error("the reference run crashed");
|
|
360
|
+
// Counted before the invariant check, whose own syncs are not part of the scenario.
|
|
361
|
+
const boundaries = rt.devices.get(victim)?.store.transactions ?? 0;
|
|
362
|
+
await checkInvariants(rt, "reference");
|
|
363
|
+
return boundaries;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function crashSweep(options: SweepOptions): Promise<SweepResult> {
|
|
368
|
+
const { stores, victim } = options;
|
|
369
|
+
const phases = options.phases ?? ["beforeCommit", "afterCommit"];
|
|
370
|
+
const boundaries = await countBoundaries(stores, victim);
|
|
371
|
+
const runs: SweepRun[] = [];
|
|
372
|
+
for (let at = 1; at <= boundaries; at++) {
|
|
373
|
+
if (options.only && !options.only.includes(at)) continue;
|
|
374
|
+
for (const phase of phases) {
|
|
375
|
+
const run = `${victim}-${phase}-${at}`;
|
|
376
|
+
await withRun(stores, run, async () => {
|
|
377
|
+
const rt = await setup(stores, run, { victim, crash: { at, phase } });
|
|
378
|
+
const sc = scenario(rt, stores, run);
|
|
379
|
+
const { crashedDuring, retry } = await drive(sc, stores, run, victim);
|
|
380
|
+
if (!crashedDuring) throw new Error(`[${run}] boundary ${at} was never reached`);
|
|
381
|
+
await checkInvariants(rt, `${run} during ${crashedDuring}, retry ${retry}`);
|
|
382
|
+
runs.push({ at, phase, crashedDuring, retry });
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { boundaries, runs };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const MUTATIONS: readonly MutationName[] = ["registerAccount", "activateDevice", "publishVault", "publishOwnedCircle", "requestJoin", "publishEpoch", "appendRecord", "ackThrough"];
|
|
390
|
+
|
|
391
|
+
/** For each mutation the victim issues, the host commits it and the response is lost once. */
|
|
392
|
+
export async function lostResponseSweep(stores: SweepStores, victim: DeviceName): Promise<LostResponseRun[]> {
|
|
393
|
+
const out: LostResponseRun[] = [];
|
|
394
|
+
for (const mutation of MUTATIONS) {
|
|
395
|
+
const run = `${victim}-lost-${mutation}`;
|
|
396
|
+
await withRun(stores, run, async () => {
|
|
397
|
+
const rt = await setup(stores, run, { victim, lose: mutation });
|
|
398
|
+
const sc = scenario(rt, stores, run);
|
|
399
|
+
const { crashedDuring } = await drive(sc, stores, run, victim);
|
|
400
|
+
if (crashedDuring) throw new Error("no crash was planned");
|
|
401
|
+
const v = rt.devices.get(victim);
|
|
402
|
+
await checkInvariants(rt, run);
|
|
403
|
+
out.push({ mutation, exercised: v?.transport.losses.includes(mutation) ?? false });
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The named transaction boundaries of protocol section 11 (T1 to T5, T8, T9). T6, T6h and T7
|
|
3
|
+
* live with the outbox and the inbox. Each function takes an open `Tx` so callers can compose
|
|
4
|
+
* them; nothing here opens the network.
|
|
5
|
+
*/
|
|
6
|
+
import { equalBytes, toHex } from "./bytes.js";
|
|
7
|
+
import { applyAudienceEvent, initialRevisionState, type AudienceEvent } from "./audience.js";
|
|
8
|
+
import { DocumentType, expectType, parseDocument, type AccountDevice } from "./codec.js";
|
|
9
|
+
import { identityId } from "./crypto.js";
|
|
10
|
+
import type { PreparedEpoch } from "./epoch.js";
|
|
11
|
+
import { promote } from "./epoch.js";
|
|
12
|
+
import { KavachError } from "./errors.js";
|
|
13
|
+
import { openAccount, pinFromCertificate, type DeviceKeys, type OwnAccount } from "./identity.js";
|
|
14
|
+
import { big, pinToRow, preparedToRow, revisionToRow, rowToPrepared, rowToRevision, unbig } from "./rows.js";
|
|
15
|
+
import type { ActivationRow, CircleRow, MetaRow, SecretStore, Store, StoreState, Tx, VaultRow } from "./store.js";
|
|
16
|
+
|
|
17
|
+
export const META = "meta";
|
|
18
|
+
export const SELF = "self";
|
|
19
|
+
export const STREAM = "stream";
|
|
20
|
+
export const REVISION = "revision";
|
|
21
|
+
export const VAULT = "vault";
|
|
22
|
+
|
|
23
|
+
// Opening -----------------------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export type OpenedState =
|
|
26
|
+
| { readonly kind: "fresh" }
|
|
27
|
+
| { readonly kind: "initializing"; readonly account: OwnAccount }
|
|
28
|
+
| { readonly kind: StoreState; readonly meta: MetaRow; readonly account: OwnAccount };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Bind the secret store to the local store. A mismatch between the two is `RECOVERY_REQUIRED`
|
|
32
|
+
* and is resolved by device replacement, never by inventing counters.
|
|
33
|
+
*/
|
|
34
|
+
export async function openStore(store: Store, secrets: SecretStore, accountName: string): Promise<OpenedState> {
|
|
35
|
+
const stage = "openStore";
|
|
36
|
+
const [accountSeed, instanceId] = await Promise.all([secrets.get("accountSeed"), secrets.get("instanceId")]);
|
|
37
|
+
const meta = await store.read((tx) => tx.get("meta", META));
|
|
38
|
+
if (!accountSeed || !instanceId) {
|
|
39
|
+
if (meta) throw new KavachError("RECOVERY_REQUIRED", stage, "store present but secrets missing");
|
|
40
|
+
return { kind: "fresh" };
|
|
41
|
+
}
|
|
42
|
+
const account = openAccount(accountName, accountSeed);
|
|
43
|
+
if (!meta) return { kind: "initializing", account };
|
|
44
|
+
if (meta.instanceId !== toHex(instanceId) || meta.identityId !== toHex(account.identityId) || meta.accountName !== accountName || meta.schema !== 1) {
|
|
45
|
+
throw new KavachError("RECOVERY_REQUIRED", stage, "store header does not match the secret store");
|
|
46
|
+
}
|
|
47
|
+
if (meta.state === "INITIALIZING") return { kind: "initializing", account };
|
|
48
|
+
return { kind: meta.state, meta, account };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// T1 ----------------------------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/** T1a: roots into the secret store. Idempotent for the same values. */
|
|
54
|
+
export async function t1Secrets(secrets: SecretStore, roots: { accountSeed: Uint8Array; deviceSeed: Uint8Array; recoveryKey: Uint8Array; instanceId: Uint8Array }): Promise<void> {
|
|
55
|
+
await secrets.set("accountSeed", roots.accountSeed);
|
|
56
|
+
await secrets.set("deviceSeed", roots.deviceSeed);
|
|
57
|
+
await secrets.set("recoveryKey", roots.recoveryKey);
|
|
58
|
+
await secrets.set("instanceId", roots.instanceId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** T1b: the store header with the INITIALIZING marker, the own pin and the stream state. */
|
|
62
|
+
export function t1Header(tx: Tx, input: { account: OwnAccount; device: DeviceKeys; certificate: Uint8Array; instanceId: Uint8Array; generation: bigint }): void {
|
|
63
|
+
const doc = expectType<AccountDevice>(parseDocument(input.certificate), DocumentType.AccountDevice, "t1");
|
|
64
|
+
const pin = pinFromCertificate(doc);
|
|
65
|
+
if (!equalBytes(pin.identityId, input.account.identityId)) throw new KavachError("MALFORMED", "t1", "certificate is not for this account");
|
|
66
|
+
tx.put("meta", META, {
|
|
67
|
+
schema: 1,
|
|
68
|
+
historyIndex: 1,
|
|
69
|
+
newest: 1,
|
|
70
|
+
instanceId: toHex(input.instanceId),
|
|
71
|
+
accountName: input.account.accountName,
|
|
72
|
+
identityId: toHex(input.account.identityId),
|
|
73
|
+
certificateHash: toHex(pin.certificateHash),
|
|
74
|
+
state: "INITIALIZING"
|
|
75
|
+
});
|
|
76
|
+
tx.put("self", SELF, {
|
|
77
|
+
generation: big(input.generation),
|
|
78
|
+
deviceId: toHex(input.device.deviceId),
|
|
79
|
+
certificate: toHex(input.certificate),
|
|
80
|
+
certificateHash: toHex(pin.certificateHash),
|
|
81
|
+
streamId: toHex(pin.streamId)
|
|
82
|
+
});
|
|
83
|
+
tx.put("pins", toHex(pin.identityId), pinToRow(pin));
|
|
84
|
+
tx.put("certificates", toHex(pin.certificateHash), {
|
|
85
|
+
identityId: toHex(pin.identityId),
|
|
86
|
+
certificateHash: toHex(pin.certificateHash),
|
|
87
|
+
signingKey: toHex(pin.signingKey),
|
|
88
|
+
boxKey: toHex(pin.boxKey),
|
|
89
|
+
streamId: toHex(pin.streamId),
|
|
90
|
+
generation: big(input.generation),
|
|
91
|
+
wire: toHex(input.certificate),
|
|
92
|
+
current: true
|
|
93
|
+
});
|
|
94
|
+
tx.put("stream", STREAM, { highestCommittedEpoch: 0, activeEpoch: undefined, pendingEpoch: undefined });
|
|
95
|
+
tx.put("revision", REVISION, revisionToRow(initialRevisionState, undefined));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** T1c: ACTIVE once the certificate is durable at the host. */
|
|
99
|
+
export function t1Activate(tx: Tx): void {
|
|
100
|
+
const meta = tx.get("meta", META);
|
|
101
|
+
if (!meta) throw new KavachError("STORAGE_FAILURE", "t1", "no header");
|
|
102
|
+
tx.put("meta", META, { ...meta, state: "ACTIVE" });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// T2 ----------------------------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** T2: the exact replacement certificate and vault bytes prepared for activation. */
|
|
108
|
+
export function t2PrepareActivation(tx: Tx, row: ActivationRow): void {
|
|
109
|
+
tx.put("activation", "pending", row);
|
|
110
|
+
const meta = tx.get("meta", META);
|
|
111
|
+
if (meta) tx.put("meta", META, { ...meta, state: "ACTIVATION_PENDING" });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// T3 ----------------------------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export interface ChangeResult {
|
|
117
|
+
readonly changed: boolean;
|
|
118
|
+
readonly desiredRevision: bigint;
|
|
119
|
+
readonly cancelled: readonly string[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* T3: a verified circle or device change. Bumps the desired revision, invalidates the active
|
|
124
|
+
* epoch, burns its counters and cancels queued (not yet handed off) outbox rows sealed under the
|
|
125
|
+
* obsolete revision. Pauses with `rotation`.
|
|
126
|
+
*/
|
|
127
|
+
export function t3ApplyChange(tx: Tx, event: AudienceEvent): ChangeResult {
|
|
128
|
+
const row = tx.get("revision", REVISION);
|
|
129
|
+
const state = row ? rowToRevision(row) : initialRevisionState;
|
|
130
|
+
const { state: next, changed } = applyAudienceEvent(state, event);
|
|
131
|
+
if (!changed) return { changed: false, desiredRevision: state.desiredRevision, cancelled: [] };
|
|
132
|
+
tx.put("revision", REVISION, revisionToRow(next, "rotation"));
|
|
133
|
+
const stream = tx.get("stream", STREAM);
|
|
134
|
+
if (stream?.activeEpoch !== undefined) {
|
|
135
|
+
const active = tx.get("epochs", String(stream.activeEpoch));
|
|
136
|
+
if (active) tx.put("epochs", String(stream.activeEpoch), { ...active, status: "obsolete" });
|
|
137
|
+
tx.put("stream", STREAM, { ...stream, activeEpoch: undefined });
|
|
138
|
+
}
|
|
139
|
+
const cancelled: string[] = [];
|
|
140
|
+
for (const [key, out] of tx.list("outbox")) {
|
|
141
|
+
if (out.status === "queued" && unbig(out.desiredRevision) < next.desiredRevision) {
|
|
142
|
+
tx.put("outbox", key, { ...out, wire: "", status: "cancelled", reason: "rotation" });
|
|
143
|
+
cancelled.push(key);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { changed: true, desiredRevision: next.desiredRevision, cancelled };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* T3c: one circle row change with its bookkeeping. The row is stored, the audience event it
|
|
151
|
+
* implies (if any) rotates the sending epoch, and the vault is marked so the backup follows the
|
|
152
|
+
* rows. Every circle write except the restore from the vault itself goes through here.
|
|
153
|
+
*/
|
|
154
|
+
export function t3Circle(tx: Tx, row: CircleRow, event?: AudienceEvent): ChangeResult {
|
|
155
|
+
tx.put("circles", row.circleId, row);
|
|
156
|
+
t9MarkVault(tx);
|
|
157
|
+
return event ? t3ApplyChange(tx, event) : { changed: false, desiredRevision: currentRevision(tx), cancelled: [] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function currentRevision(tx: Pick<Tx, "get">): bigint {
|
|
161
|
+
const row = tx.get("revision", REVISION);
|
|
162
|
+
return row ? unbig(row.desiredRevision) : 1n;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// T4 and T5 ---------------------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/** T4: the exact manifest, wraps, key and audience checkpoint of a prepared epoch. One unresolved epoch per stream. */
|
|
168
|
+
export function t4PersistPrepared(tx: Tx, prepared: PreparedEpoch): void {
|
|
169
|
+
const stream = tx.get("stream", STREAM) ?? { highestCommittedEpoch: 0, activeEpoch: undefined, pendingEpoch: undefined };
|
|
170
|
+
// Never overwrite an unresolved epoch, even at the same number: a second preparation carries a
|
|
171
|
+
// different key, and publishing both is an equivocation against ourselves.
|
|
172
|
+
if (stream.pendingEpoch !== undefined) throw new KavachError("ROTATION_REQUIRED", "t4", "an epoch is already unresolved");
|
|
173
|
+
if (prepared.epoch !== stream.highestCommittedEpoch + 1) throw new KavachError("MALFORMED", "t4", "epoch must be highestCommitted + 1");
|
|
174
|
+
tx.put("epochs", String(prepared.epoch), preparedToRow(prepared));
|
|
175
|
+
tx.put("stream", STREAM, { ...stream, pendingEpoch: prepared.epoch });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export type PublishRecord = "PROMOTED" | "OBSOLETE";
|
|
179
|
+
|
|
180
|
+
/** T5: a committed publication. Promotion is conditional on the revision and the exact audience. */
|
|
181
|
+
export function t5Committed(tx: Tx, epoch: number, currentAudienceKey: string): PublishRecord {
|
|
182
|
+
const stream = tx.get("stream", STREAM);
|
|
183
|
+
const row = tx.get("epochs", String(epoch));
|
|
184
|
+
if (!stream || !row) throw new KavachError("STORAGE_FAILURE", "t5", "unknown epoch");
|
|
185
|
+
// Idempotent: two contexts can both learn of one publication. The second must not reset a
|
|
186
|
+
// counter the first already started allocating from.
|
|
187
|
+
if (row.status === "active") return "PROMOTED";
|
|
188
|
+
if (row.status === "obsolete" || row.status === "retired") return "OBSOLETE";
|
|
189
|
+
const outcome = promote(rowToPrepared(row), currentRevision(tx), currentAudienceKey);
|
|
190
|
+
const revision = tx.get("revision", REVISION);
|
|
191
|
+
if (outcome === "PROMOTED") {
|
|
192
|
+
tx.put("epochs", String(epoch), { ...row, status: "active", nextCounter: 0 });
|
|
193
|
+
tx.put("stream", STREAM, { highestCommittedEpoch: Math.max(stream.highestCommittedEpoch, epoch), activeEpoch: epoch, pendingEpoch: undefined });
|
|
194
|
+
// A promoted epoch resolved whatever paused sending: the rotation, the missing certificate,
|
|
195
|
+
// or the audience limit. The sync pause belongs to the host and stays.
|
|
196
|
+
if (revision?.paused && revision.paused !== "sync") tx.put("revision", REVISION, { ...revision, paused: undefined });
|
|
197
|
+
} else {
|
|
198
|
+
tx.put("epochs", String(epoch), { ...row, status: "obsolete" });
|
|
199
|
+
tx.put("stream", STREAM, { ...stream, highestCommittedEpoch: Math.max(stream.highestCommittedEpoch, epoch), pendingEpoch: undefined });
|
|
200
|
+
}
|
|
201
|
+
return outcome;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// T8 and T9 ---------------------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/** T8: the owner's successor snapshot, kept until the host confirms it. */
|
|
207
|
+
export function t8OwnerSnapshot(tx: Tx, circleId: Uint8Array, snapshot: Uint8Array, revision: bigint, close = false): void {
|
|
208
|
+
const key = toHex(circleId);
|
|
209
|
+
const row = tx.get("circles", key);
|
|
210
|
+
if (!row) throw new KavachError("STORAGE_FAILURE", "t8", "unknown circle");
|
|
211
|
+
tx.put("circles", key, { ...row, pending: { snapshot: toHex(snapshot), revision: big(revision), ...(close ? { close: true } : {}) } });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** T9: a vault update prepared for publication (or confirmed). */
|
|
215
|
+
export function t9Vault(tx: Tx, row: VaultRow): void {
|
|
216
|
+
tx.put("vault", VAULT, row);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** T9 marker: the circle rows changed in this transaction, so the vault has to be derived from them again. */
|
|
220
|
+
export function t9MarkVault(tx: Tx): void {
|
|
221
|
+
const row = tx.get("vault", VAULT);
|
|
222
|
+
if (row && !row.dirty) tx.put("vault", VAULT, { ...row, dirty: true });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Own identity id from the header, for callers that only hold a transaction. */
|
|
226
|
+
export function selfIdentity(tx: Pick<Tx, "get">, accountName: string, accountKey: Uint8Array): Uint8Array {
|
|
227
|
+
const meta = tx.get("meta", META);
|
|
228
|
+
const iid = identityId(accountName, accountKey);
|
|
229
|
+
if (meta && meta.identityId !== toHex(iid)) throw new KavachError("RECOVERY_REQUIRED", "selfIdentity");
|
|
230
|
+
return iid;
|
|
231
|
+
}
|