@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.
- package/LICENSE +202 -0
- package/README.md +26 -2
- package/dist/adapters/index.d.ts +18 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +22 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/audience.d.ts +77 -0
- package/dist/audience.d.ts.map +1 -0
- package/dist/audience.js +76 -0
- package/dist/audience.js.map +1 -0
- package/dist/bytes.d.ts +54 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +200 -0
- package/dist/bytes.js.map +1 -0
- package/dist/circle.d.ts +59 -0
- package/dist/circle.d.ts.map +1 -0
- package/dist/circle.js +105 -0
- package/dist/circle.js.map +1 -0
- package/dist/client.d.ts +234 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1365 -0
- package/dist/client.js.map +1 -0
- package/dist/codec.d.ts +189 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +512 -0
- package/dist/codec.js.map +1 -0
- package/dist/crypto/backend.d.ts +39 -0
- package/dist/crypto/backend.d.ts.map +1 -0
- package/dist/crypto/backend.js +8 -0
- package/dist/crypto/backend.js.map +1 -0
- package/dist/crypto/noble.d.ts +3 -0
- package/dist/crypto/noble.d.ts.map +1 -0
- package/dist/crypto/noble.js +107 -0
- package/dist/crypto/noble.js.map +1 -0
- package/dist/crypto/sodium.d.ts +9 -0
- package/dist/crypto/sodium.d.ts.map +1 -0
- package/dist/crypto/sodium.js +51 -0
- package/dist/crypto/sodium.js.map +1 -0
- package/dist/crypto.d.ts +66 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +225 -0
- package/dist/crypto.js.map +1 -0
- package/dist/epoch.d.ts +93 -0
- package/dist/epoch.d.ts.map +1 -0
- package/dist/epoch.js +121 -0
- package/dist/epoch.js.map +1 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +30 -0
- package/dist/errors.js.map +1 -0
- package/dist/historyIndex.d.ts +15 -0
- package/dist/historyIndex.d.ts.map +1 -0
- package/dist/historyIndex.js +57 -0
- package/dist/historyIndex.js.map +1 -0
- package/dist/identity.d.ts +62 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +99 -0
- package/dist/identity.js.map +1 -0
- package/dist/inbox.d.ts +153 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +472 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/outbox.d.ts +96 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +339 -0
- package/dist/outbox.js.map +1 -0
- package/dist/policy.d.ts +33 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +71 -0
- package/dist/policy.js.map +1 -0
- package/dist/record.d.ts +49 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +46 -0
- package/dist/record.js.map +1 -0
- package/dist/reset.d.ts +74 -0
- package/dist/reset.d.ts.map +1 -0
- package/dist/reset.js +127 -0
- package/dist/reset.js.map +1 -0
- package/dist/rows.d.ts +16 -0
- package/dist/rows.d.ts.map +1 -0
- package/dist/rows.js +103 -0
- package/dist/rows.js.map +1 -0
- package/dist/slots.d.ts +22 -0
- package/dist/slots.d.ts.map +1 -0
- package/dist/slots.js +38 -0
- package/dist/slots.js.map +1 -0
- package/dist/store.d.ts +357 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +192 -0
- package/dist/store.js.map +1 -0
- package/dist/testing/clock.d.ts +14 -0
- package/dist/testing/clock.d.ts.map +1 -0
- package/dist/testing/clock.js +27 -0
- package/dist/testing/clock.js.map +1 -0
- package/dist/testing/fakeHost.d.ts +118 -0
- package/dist/testing/fakeHost.d.ts.map +1 -0
- package/dist/testing/fakeHost.js +462 -0
- package/dist/testing/fakeHost.js.map +1 -0
- package/dist/testing/faults.d.ts +97 -0
- package/dist/testing/faults.d.ts.map +1 -0
- package/dist/testing/faults.js +264 -0
- package/dist/testing/faults.js.map +1 -0
- package/dist/testing/fixtures.d.ts +15 -0
- package/dist/testing/fixtures.d.ts.map +1 -0
- package/dist/testing/fixtures.js +22 -0
- package/dist/testing/fixtures.js.map +1 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +7 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/providers.d.ts +16 -0
- package/dist/testing/providers.d.ts.map +1 -0
- package/dist/testing/providers.js +36 -0
- package/dist/testing/providers.js.map +1 -0
- package/dist/testing/sweep.d.ts +46 -0
- package/dist/testing/sweep.d.ts.map +1 -0
- package/dist/testing/sweep.js +366 -0
- package/dist/testing/sweep.js.map +1 -0
- package/dist/transactions.d.ts +75 -0
- package/dist/transactions.d.ts.map +1 -0
- package/dist/transactions.js +212 -0
- package/dist/transactions.js.map +1 -0
- package/dist/transport.d.ts +125 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +6 -0
- package/dist/transport.js.map +1 -0
- package/dist/vault.d.ts +59 -0
- package/dist/vault.d.ts.map +1 -0
- package/dist/vault.js +69 -0
- package/dist/vault.js.map +1 -0
- package/package.json +67 -3
- 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,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fault harness of build step 16: a store wrapper that crashes the process at a chosen
|
|
3
|
+
* transaction boundary, a transport wrapper that loses responses after the host committed,
|
|
4
|
+
* duplicates and reorders deliveries, withholds, replays, forks and tampers with documents,
|
|
5
|
+
* and a sweep that runs one scenario once per boundary and checks the durable-state outcomes
|
|
6
|
+
* of protocol section 11 after a restart. Exported under `@plane31/blind-relay/testing` only.
|
|
7
|
+
*/
|
|
8
|
+
import { equalBytes, toHex } from "../bytes.js";
|
|
9
|
+
import { KavachError } from "../errors.js";
|
|
10
|
+
import { invokeSync, type Store, type Tx } from "../store.js";
|
|
11
|
+
import type { AppendOutcome, Directory, EpochBundle, MutationOutcome, ReadRecordsResult, Relay, Transport } from "../transport.js";
|
|
12
|
+
|
|
13
|
+
// Crashes ------------------------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export type CrashPhase = "beforeCommit" | "afterCommit";
|
|
16
|
+
|
|
17
|
+
export interface CrashPlan {
|
|
18
|
+
/** The 1-based index of the transaction (counted across every handle of the process) to crash at. */
|
|
19
|
+
readonly at: number;
|
|
20
|
+
/** Before commit: the transaction rolls back. After commit: it is durable but no caller sees the result. */
|
|
21
|
+
readonly phase: CrashPhase;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The process died here. Every later store call from the same process fails. */
|
|
25
|
+
export class SimulatedCrash extends Error {
|
|
26
|
+
constructor(
|
|
27
|
+
readonly at: number,
|
|
28
|
+
readonly phase: CrashPhase
|
|
29
|
+
) {
|
|
30
|
+
super(`simulated crash ${phase} transaction ${at}`);
|
|
31
|
+
this.name = "SimulatedCrash";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ProcessState {
|
|
36
|
+
transactions: number;
|
|
37
|
+
crashed: SimulatedCrash | undefined;
|
|
38
|
+
readonly plan: CrashPlan | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A store handle that belongs to one simulated process. All handles made with `fork()` share
|
|
43
|
+
* the transaction counter and die together. A read-only probe that must survive the crash reads
|
|
44
|
+
* the inner store directly.
|
|
45
|
+
*/
|
|
46
|
+
export class FaultyStore implements Store {
|
|
47
|
+
private readonly process: ProcessState;
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
readonly inner: Store,
|
|
51
|
+
plan?: CrashPlan,
|
|
52
|
+
private readonly forkInner?: () => Store,
|
|
53
|
+
process?: ProcessState
|
|
54
|
+
) {
|
|
55
|
+
this.process = process ?? { transactions: 0, crashed: undefined, plan };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get crashed(): SimulatedCrash | undefined {
|
|
59
|
+
return this.process.crashed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get transactions(): number {
|
|
63
|
+
return this.process.transactions;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async transaction<R>(fn: (tx: Tx) => R): Promise<R> {
|
|
67
|
+
this.alive();
|
|
68
|
+
const n = ++this.process.transactions;
|
|
69
|
+
const plan = this.process.plan;
|
|
70
|
+
const result = await this.inner.transaction((tx) => {
|
|
71
|
+
const r = invokeSync(fn, tx);
|
|
72
|
+
if (plan && plan.phase === "beforeCommit" && plan.at === n) throw this.die(n, "beforeCommit");
|
|
73
|
+
return r;
|
|
74
|
+
});
|
|
75
|
+
if (plan && plan.phase === "afterCommit" && plan.at === n) throw this.die(n, "afterCommit");
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async read<R>(fn: (tx: Omit<Tx, "put" | "delete" | "handoff">) => R): Promise<R> {
|
|
80
|
+
this.alive();
|
|
81
|
+
return this.inner.read(fn);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Another execution context of the same process. */
|
|
85
|
+
fork(): FaultyStore {
|
|
86
|
+
if (!this.forkInner) throw new Error("this store cannot fork");
|
|
87
|
+
return new FaultyStore(this.forkInner(), undefined, this.forkInner, this.process);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private die(at: number, phase: CrashPhase): SimulatedCrash {
|
|
91
|
+
this.process.crashed = new SimulatedCrash(at, phase);
|
|
92
|
+
return this.process.crashed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private alive(): void {
|
|
96
|
+
if (this.process.crashed) throw new KavachError("STORAGE_FAILURE", "faultyStore", "the process has crashed");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The malicious transport ----------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
export type MutationName = "registerAccount" | "activateDevice" | "publishVault" | "publishOwnedCircle" | "requestJoin" | "requestLeave" | "resetAccount" | "publishEpoch" | "appendRecord" | "ackThrough";
|
|
103
|
+
|
|
104
|
+
export type ServedKind = "certificate" | "snapshot" | "vault" | "manifest" | "wrap" | "record" | "keyList" | "receipt";
|
|
105
|
+
|
|
106
|
+
export interface MaliciousKnobs {
|
|
107
|
+
/** Mutations whose next response is lost after the host committed it (each name is consumed once). */
|
|
108
|
+
readonly loseResponseOf: Set<MutationName>;
|
|
109
|
+
/** Mutations whose next call fails before reaching the host (each name is consumed once). */
|
|
110
|
+
readonly refuse: Set<MutationName>;
|
|
111
|
+
/** Mutations whose next response waits for the given promise after the host committed (consumed once). */
|
|
112
|
+
readonly hold: Map<MutationName, Promise<void>>;
|
|
113
|
+
/** Reads that answer "nothing" while present. */
|
|
114
|
+
readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates">;
|
|
115
|
+
/** Older documents served instead of the current ones. */
|
|
116
|
+
readonly replay: {
|
|
117
|
+
vault?: Uint8Array;
|
|
118
|
+
snapshots: Map<string, Uint8Array>;
|
|
119
|
+
keyList?: Uint8Array;
|
|
120
|
+
currentEpoch?: { epoch: number; manifestHash: Uint8Array };
|
|
121
|
+
};
|
|
122
|
+
/** Served documents to substitute: forks of the host's log. Keys are hex (circle id, stream id, record id). */
|
|
123
|
+
readonly fork: {
|
|
124
|
+
snapshots: Map<string, Uint8Array>;
|
|
125
|
+
manifests: Map<string, Uint8Array>;
|
|
126
|
+
records: Map<string, Uint8Array>;
|
|
127
|
+
certificates: Map<string, Uint8Array>;
|
|
128
|
+
};
|
|
129
|
+
/** Every record page is served twice over, and each record appears twice inside a page. */
|
|
130
|
+
duplicate: boolean;
|
|
131
|
+
/** Records inside a page are served newest first. */
|
|
132
|
+
reorder: boolean;
|
|
133
|
+
/** Flip a byte in every served document of these kinds. */
|
|
134
|
+
readonly tamper: Set<ServedKind>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function emptyKnobs(): MaliciousKnobs {
|
|
138
|
+
return {
|
|
139
|
+
loseResponseOf: new Set(),
|
|
140
|
+
refuse: new Set(),
|
|
141
|
+
hold: new Map(),
|
|
142
|
+
withhold: new Set(),
|
|
143
|
+
replay: { snapshots: new Map() },
|
|
144
|
+
fork: { snapshots: new Map(), manifests: new Map(), records: new Map(), certificates: new Map() },
|
|
145
|
+
duplicate: false,
|
|
146
|
+
reorder: false,
|
|
147
|
+
tamper: new Set()
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function flipByte(bytes: Uint8Array): Uint8Array {
|
|
152
|
+
const out = Uint8Array.from(bytes);
|
|
153
|
+
const i = out.length - 1; // inside the signature for every signed type; inside the tag for a wrap
|
|
154
|
+
out[i] = (out[i] ?? 0) ^ 0x01;
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Wraps a transport. Everything not switched on passes through unchanged, so a scenario can be
|
|
160
|
+
* driven to a point and then attacked. `calls` records every method name in order.
|
|
161
|
+
*/
|
|
162
|
+
export class MaliciousTransport implements Transport {
|
|
163
|
+
readonly knobs: MaliciousKnobs = emptyKnobs();
|
|
164
|
+
readonly calls: string[] = [];
|
|
165
|
+
/** The mutations whose response this transport has lost so far. */
|
|
166
|
+
readonly losses: MutationName[] = [];
|
|
167
|
+
readonly directory: Directory;
|
|
168
|
+
readonly relay: Relay;
|
|
169
|
+
|
|
170
|
+
constructor(private readonly inner: Transport) {
|
|
171
|
+
this.directory = this.wrapDirectory();
|
|
172
|
+
this.relay = this.wrapRelay();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private serve(kind: ServedKind, bytes: Uint8Array | undefined): Uint8Array | undefined {
|
|
176
|
+
if (!bytes) return bytes;
|
|
177
|
+
return this.knobs.tamper.has(kind) ? flipByte(bytes) : bytes;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async mutate<O extends MutationOutcome | AppendOutcome | { status: string }>(name: MutationName, call: () => Promise<O>): Promise<O> {
|
|
181
|
+
this.calls.push(name);
|
|
182
|
+
if (this.knobs.refuse.has(name)) {
|
|
183
|
+
this.knobs.refuse.delete(name);
|
|
184
|
+
throw new KavachError("NETWORK", "transport", "network unreachable");
|
|
185
|
+
}
|
|
186
|
+
const result = await call();
|
|
187
|
+
const gate = this.knobs.hold.get(name);
|
|
188
|
+
if (gate) {
|
|
189
|
+
this.knobs.hold.delete(name);
|
|
190
|
+
await gate;
|
|
191
|
+
}
|
|
192
|
+
if (this.knobs.loseResponseOf.has(name)) {
|
|
193
|
+
this.knobs.loseResponseOf.delete(name);
|
|
194
|
+
this.losses.push(name);
|
|
195
|
+
throw new KavachError("NETWORK", "transport", "response lost");
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private wrapDirectory(): Directory {
|
|
201
|
+
// The inner transport may rebind itself per call (a device's certificate changes at
|
|
202
|
+
// activation), so it is read on every call, never captured.
|
|
203
|
+
const d = (): Directory => this.inner.directory;
|
|
204
|
+
const k = this.knobs;
|
|
205
|
+
return {
|
|
206
|
+
resolveAccounts: async (names) => {
|
|
207
|
+
this.calls.push("resolveAccounts");
|
|
208
|
+
if (k.withhold.has("certificates")) return [];
|
|
209
|
+
const rs = await d().resolveAccounts(names);
|
|
210
|
+
return rs.map((r) => ({ ...r, certificate: k.fork.certificates.get(r.accountName) ?? this.serve("certificate", r.certificate) ?? r.certificate }));
|
|
211
|
+
},
|
|
212
|
+
resolveIdentities: async (ids) => {
|
|
213
|
+
this.calls.push("resolveIdentities");
|
|
214
|
+
if (k.withhold.has("certificates")) return [];
|
|
215
|
+
const rs = await d().resolveIdentities(ids);
|
|
216
|
+
return rs.map((r) => ({ ...r, certificate: k.fork.certificates.get(r.accountName) ?? this.serve("certificate", r.certificate) ?? r.certificate }));
|
|
217
|
+
},
|
|
218
|
+
getCertificate: async (hash) => {
|
|
219
|
+
this.calls.push("getCertificate");
|
|
220
|
+
return this.serve("certificate", await d().getCertificate(hash));
|
|
221
|
+
},
|
|
222
|
+
activateDevice: (input) => this.mutate("activateDevice", () => d().activateDevice(input)),
|
|
223
|
+
registerAccount: (input) => this.mutate("registerAccount", () => d().registerAccount(input)),
|
|
224
|
+
getVault: async (name) => {
|
|
225
|
+
this.calls.push("getVault");
|
|
226
|
+
return this.serve("vault", k.replay.vault ?? (await d().getVault(name)));
|
|
227
|
+
},
|
|
228
|
+
publishVault: (input) => this.mutate("publishVault", () => d().publishVault(input)),
|
|
229
|
+
getCircle: async (circleId) => {
|
|
230
|
+
this.calls.push("getCircle");
|
|
231
|
+
if (k.withhold.has("snapshots")) return undefined;
|
|
232
|
+
const key = toHex(circleId);
|
|
233
|
+
return this.serve("snapshot", k.fork.snapshots.get(key) ?? k.replay.snapshots.get(key) ?? (await d().getCircle(circleId)));
|
|
234
|
+
},
|
|
235
|
+
publishOwnedCircle: (input) => this.mutate("publishOwnedCircle", () => d().publishOwnedCircle(input)),
|
|
236
|
+
requestJoin: (circleId) => this.mutate("requestJoin", () => d().requestJoin(circleId)),
|
|
237
|
+
requestLeave: (circleId) => this.mutate("requestLeave", () => d().requestLeave(circleId)),
|
|
238
|
+
readRequests: async (circleId) => {
|
|
239
|
+
this.calls.push("readRequests");
|
|
240
|
+
return k.withhold.has("requests") ? [] : d().readRequests(circleId);
|
|
241
|
+
},
|
|
242
|
+
getResetKeyList: async () => {
|
|
243
|
+
this.calls.push("getResetKeyList");
|
|
244
|
+
if (k.withhold.has("keyList")) return undefined;
|
|
245
|
+
return this.serve("keyList", k.replay.keyList ?? (await d().getResetKeyList()));
|
|
246
|
+
},
|
|
247
|
+
getResetReceipt: async (id) => {
|
|
248
|
+
this.calls.push("getResetReceipt");
|
|
249
|
+
return this.serve("receipt", await d().getResetReceipt(id));
|
|
250
|
+
},
|
|
251
|
+
resetAccount: (input) => this.mutate("resetAccount", () => d().resetAccount(input))
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private wrapRelay(): Relay {
|
|
256
|
+
const r = (): Relay => this.inner.relay;
|
|
257
|
+
const k = this.knobs;
|
|
258
|
+
const pagesServed = new Map<string, number>();
|
|
259
|
+
return {
|
|
260
|
+
getCurrentEpoch: async (streamId) => {
|
|
261
|
+
this.calls.push("getCurrentEpoch");
|
|
262
|
+
if (k.withhold.has("currentEpoch")) return undefined;
|
|
263
|
+
return k.replay.currentEpoch ?? r().getCurrentEpoch(streamId);
|
|
264
|
+
},
|
|
265
|
+
getEpoch: async (streamId, epoch): Promise<EpochBundle | undefined> => {
|
|
266
|
+
this.calls.push("getEpoch");
|
|
267
|
+
if (k.withhold.has("epochs")) return undefined;
|
|
268
|
+
const b = await r().getEpoch(streamId, epoch);
|
|
269
|
+
if (!b) return b;
|
|
270
|
+
const manifest = k.fork.manifests.get(`${toHex(streamId)}:${epoch}`) ?? this.serve("manifest", b.manifest) ?? b.manifest;
|
|
271
|
+
return { manifest, wrap: this.serve("wrap", b.wrap) };
|
|
272
|
+
},
|
|
273
|
+
publishEpoch: (input) => this.mutate("publishEpoch", () => r().publishEpoch(input)),
|
|
274
|
+
appendRecord: (recordId, wire) => {
|
|
275
|
+
// Must stay synchronous up to the inner call (T6h).
|
|
276
|
+
this.calls.push("appendRecord");
|
|
277
|
+
const p = r().appendRecord(recordId, wire);
|
|
278
|
+
if (!k.loseResponseOf.has("appendRecord")) return p;
|
|
279
|
+
k.loseResponseOf.delete("appendRecord");
|
|
280
|
+
this.losses.push("appendRecord");
|
|
281
|
+
return p.then(() => {
|
|
282
|
+
throw new KavachError("NETWORK", "transport", "response lost");
|
|
283
|
+
});
|
|
284
|
+
},
|
|
285
|
+
readRecords: async (streamId, afterSeq, limit): Promise<ReadRecordsResult> => {
|
|
286
|
+
this.calls.push("readRecords");
|
|
287
|
+
if (k.withhold.has("records")) {
|
|
288
|
+
const probe = await r().readRecords(streamId, afterSeq, limit);
|
|
289
|
+
return { ...probe, records: [], nextSeq: afterSeq };
|
|
290
|
+
}
|
|
291
|
+
const page = await r().readRecords(streamId, afterSeq, limit);
|
|
292
|
+
let records = page.records.map((x) => {
|
|
293
|
+
const forked = k.fork.records.get(`${toHex(streamId)}:${x.deliverySeq}`);
|
|
294
|
+
return { deliverySeq: x.deliverySeq, wire: forked ?? this.serve("record", x.wire) ?? x.wire };
|
|
295
|
+
});
|
|
296
|
+
if (k.reorder) records = [...records].reverse();
|
|
297
|
+
if (k.duplicate) {
|
|
298
|
+
records = records.flatMap((x) => [x, x]);
|
|
299
|
+
const key = `${toHex(streamId)}:${afterSeq}`;
|
|
300
|
+
const n = (pagesServed.get(key) ?? 0) + 1;
|
|
301
|
+
pagesServed.set(key, n);
|
|
302
|
+
// The first time a page is asked for, claim no progress so the reader asks again.
|
|
303
|
+
if (n === 1 && records.length > 0) return { ...page, records, nextSeq: afterSeq };
|
|
304
|
+
}
|
|
305
|
+
return { ...page, records };
|
|
306
|
+
},
|
|
307
|
+
ackThrough: (streamId, cursor) => this.mutate("ackThrough", () => r().ackThrough(streamId, cursor)),
|
|
308
|
+
listSubscriptions: async () => {
|
|
309
|
+
this.calls.push("listSubscriptions");
|
|
310
|
+
return r().listSubscriptions();
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Helpers shared by the sweeps ----------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
/** Whether two byte strings are equal, for assertions on exact-bytes outcomes. */
|
|
319
|
+
export function sameBytes(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
|
|
320
|
+
return a !== undefined && b !== undefined && equalBytes(a, b);
|
|
321
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol state a test builds by hand where an application would let the client build it:
|
|
3
|
+
* accounts, devices and certificates for store tests, and a signed reset key list for a fake
|
|
4
|
+
* or real host. Call `ready()` first.
|
|
5
|
+
*/
|
|
6
|
+
import { buildResetKeyList } from "../codec.js";
|
|
7
|
+
import { C, H, signingKeypairFromSeed, type SigningKeypair } from "../crypto.js";
|
|
8
|
+
|
|
9
|
+
export { accountKeypair, identityId } from "../crypto.js";
|
|
10
|
+
export { certify, deviceFromSeed, newDevice, openAccount, type DeviceKeys, type OwnAccount } from "../identity.js";
|
|
11
|
+
|
|
12
|
+
export interface TestResetKeys {
|
|
13
|
+
/** The deployment's management keypair, derived from a fixed seed. */
|
|
14
|
+
readonly management: SigningKeypair;
|
|
15
|
+
/** The operational keypair that signs reset receipts, derived from a fixed seed. */
|
|
16
|
+
readonly operational: SigningKeypair;
|
|
17
|
+
readonly operationalKeyId: Uint8Array;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Deterministic management and operational keys: management seed H(C("identity")), operational seed H(C("stream")), key id 07x16. */
|
|
21
|
+
export function testResetKeys(): TestResetKeys {
|
|
22
|
+
return {
|
|
23
|
+
management: signingKeypairFromSeed(H(C("identity"))),
|
|
24
|
+
operational: signingKeypairFromSeed(H(C("stream"))),
|
|
25
|
+
operationalKeyId: new Uint8Array(16).fill(7)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A key list of version 1, issued at `now` and valid for thirty days, signed by the management key. */
|
|
30
|
+
export function testResetKeyList(keys: TestResetKeys, now: bigint): Uint8Array {
|
|
31
|
+
return buildResetKeyList(
|
|
32
|
+
{ version: 1n, issuedAt: now, expiresAt: now + 2_592_000_000n, keys: [{ keyId: keys.operationalKeyId, publicKey: keys.operational.publicKey }] },
|
|
33
|
+
keys.management.secretKey
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { FakeHost, type Notification } from "./fakeHost.js";
|
|
2
|
+
export { testPolicy, TestClock } from "./clock.js";
|
|
3
|
+
export { FaultyStore, MaliciousTransport, SimulatedCrash, emptyKnobs, sameBytes, type CrashPhase, type CrashPlan, type MaliciousKnobs, type MutationName, type ServedKind } from "./faults.js";
|
|
4
|
+
export { MemoryPlatformProvider } from "./providers.js";
|
|
5
|
+
export { countBoundaries, crashSweep, hostTransport, lostResponseSweep, type DeviceName, type LostResponseRun, type SweepOptions, type SweepResult, type SweepRun, type SweepStores } from "./sweep.js";
|
|
6
|
+
export { accountKeypair, certify, deviceFromSeed, identityId, newDevice, openAccount, testResetKeyList, testResetKeys, type DeviceKeys, type OwnAccount, type TestResetKeys } from "./fixtures.js";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { toHex } from "../bytes.js";
|
|
2
|
+
import type { ProviderLoadResult, ProviderSaveResult, ProviderStatus, ProviderStatusResult, RecoveryProvider } from "../vault.js";
|
|
3
|
+
|
|
4
|
+
/** A platform recovery provider shared by every device signed into one platform account. */
|
|
5
|
+
export class MemoryPlatformProvider implements RecoveryProvider {
|
|
6
|
+
status_: ProviderStatus = "available";
|
|
7
|
+
items: Map<string, Uint8Array> = new Map();
|
|
8
|
+
|
|
9
|
+
constructor(private readonly platformAccount: string) {}
|
|
10
|
+
|
|
11
|
+
private key(accountName: string, identityId: Uint8Array): string {
|
|
12
|
+
return `${this.platformAccount}:${accountName}:${toHex(identityId)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async load(accountName: string, identityId: Uint8Array): Promise<ProviderLoadResult> {
|
|
16
|
+
if (this.status_ !== "available") return { status: this.status_ };
|
|
17
|
+
const k = this.items.get(this.key(accountName, identityId));
|
|
18
|
+
return k ? { status: "available", recoveryKey: k } : { status: "unavailable", reason: "no item" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async save(accountName: string, identityId: Uint8Array, recoveryKey: Uint8Array): Promise<ProviderSaveResult> {
|
|
22
|
+
if (this.status_ !== "available") return { status: this.status_, backupState: "unavailable" };
|
|
23
|
+
this.items.set(this.key(accountName, identityId), recoveryKey);
|
|
24
|
+
return { status: "available", backupState: "pending" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async status(): Promise<ProviderStatusResult> {
|
|
28
|
+
return { status: this.status_, backupState: this.status_ === "available" ? "pending" : "unavailable" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async eraseLocalAccess(): Promise<void> {}
|
|
32
|
+
|
|
33
|
+
/** Another phone on the same platform account sees the same items. */
|
|
34
|
+
twin(): MemoryPlatformProvider {
|
|
35
|
+
const p = new MemoryPlatformProvider(this.platformAccount);
|
|
36
|
+
p.items = this.items;
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
}
|