@plane31/blind-relay 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +29 -1
  2. package/dist/adapters/index.d.ts +4 -0
  3. package/dist/adapters/index.d.ts.map +1 -1
  4. package/dist/adapters/index.js +5 -0
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/client.d.ts +33 -1
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +76 -13
  9. package/dist/client.js.map +1 -1
  10. package/dist/crypto/sodium.d.ts.map +1 -1
  11. package/dist/crypto/sodium.js +3 -33
  12. package/dist/crypto/sodium.js.map +1 -1
  13. package/dist/crypto/sodiumLike.d.ts +39 -0
  14. package/dist/crypto/sodiumLike.d.ts.map +1 -0
  15. package/dist/crypto/sodiumLike.js +142 -0
  16. package/dist/crypto/sodiumLike.js.map +1 -0
  17. package/dist/errors.d.ts +6 -2
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js +5 -1
  20. package/dist/errors.js.map +1 -1
  21. package/dist/inbox.d.ts +4 -0
  22. package/dist/inbox.d.ts.map +1 -1
  23. package/dist/inbox.js +22 -14
  24. package/dist/inbox.js.map +1 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/outbox.d.ts +5 -0
  29. package/dist/outbox.d.ts.map +1 -1
  30. package/dist/outbox.js +6 -3
  31. package/dist/outbox.js.map +1 -1
  32. package/dist/testing/faults.d.ts +5 -0
  33. package/dist/testing/faults.d.ts.map +1 -1
  34. package/dist/testing/faults.js +13 -0
  35. package/dist/testing/faults.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/adapters/index.ts +6 -0
  38. package/src/client.ts +107 -13
  39. package/src/crypto/sodium.ts +4 -33
  40. package/src/crypto/sodiumLike.ts +169 -0
  41. package/src/errors.ts +6 -1
  42. package/src/inbox.ts +24 -13
  43. package/src/index.ts +2 -1
  44. package/src/outbox.ts +9 -3
  45. package/src/testing/faults.ts +17 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * A libsodium backend over any object with libsodium's JavaScript API: `libsodium-wrappers-sumo`
3
+ * on Node (wasm), or a native binding on a phone (`react-native-libsodium` over JSI), where it
4
+ * replaces the portable backend's pure-JavaScript Ed25519 and X25519 with the C implementation.
5
+ *
6
+ * Bindings differ from libsodium's reference API in small ways (react-native-libsodium 1.7 has
7
+ * no `crypto_scalarmult`, no `memzero`, no `crypto_core_ed25519_is_valid_point`, and its AEAD
8
+ * takes only string associated data, while the protocol's is binary). So the backend is built per
9
+ * function: each one the binding provides is checked once against the portable implementation
10
+ * on known inputs, and a function the binding lacks or gets wrong on a type it does not support
11
+ * falls back to the portable one, while a function that answers differently on the same input is
12
+ * refused outright, since silently wrong bytes would be worse than slow ones. The caller awaits
13
+ * `sodium.ready` before building. Both backends pass the same vectors.
14
+ */
15
+ import { KavachError } from "../errors.js";
16
+ import type { BoxKeypair, CryptoBackend, SignKeypair } from "./backend.js";
17
+ import { nobleBackend } from "./noble.js";
18
+
19
+ /** The subset of libsodium's API the backend can use; everything but signing, boxing and hashing may be absent. */
20
+ export interface SodiumLike {
21
+ crypto_generichash(outLen: number, data: Uint8Array, key?: Uint8Array | null): Uint8Array;
22
+ randombytes_buf(length: number): Uint8Array;
23
+ crypto_sign_seed_keypair(seed: Uint8Array): { publicKey: Uint8Array; privateKey: Uint8Array };
24
+ crypto_sign_detached(message: Uint8Array, secretKey: Uint8Array): Uint8Array;
25
+ crypto_sign_verify_detached(signature: Uint8Array, message: Uint8Array, publicKey: Uint8Array): boolean;
26
+ crypto_core_ed25519_is_valid_point?(point: Uint8Array): boolean;
27
+ crypto_box_seed_keypair(seed: Uint8Array): { publicKey: Uint8Array; privateKey: Uint8Array };
28
+ crypto_box_keypair(): { publicKey: Uint8Array; privateKey: Uint8Array };
29
+ crypto_scalarmult?(secretKey: Uint8Array, publicKey: Uint8Array): Uint8Array;
30
+ crypto_box_easy(message: Uint8Array, nonce: Uint8Array, publicKey: Uint8Array, secretKey: Uint8Array): Uint8Array;
31
+ crypto_box_open_easy(box: Uint8Array, nonce: Uint8Array, publicKey: Uint8Array, secretKey: Uint8Array): Uint8Array;
32
+ crypto_aead_xchacha20poly1305_ietf_encrypt?(message: Uint8Array, aad: Uint8Array | null, secretNonce: Uint8Array | null, nonce: Uint8Array, key: Uint8Array): Uint8Array;
33
+ crypto_aead_xchacha20poly1305_ietf_decrypt?(secretNonce: Uint8Array | null, ciphertext: Uint8Array, aad: Uint8Array | null, nonce: Uint8Array, key: Uint8Array): Uint8Array;
34
+ memzero?(bytes: Uint8Array): void;
35
+ }
36
+
37
+ /** Which of the backend's operations run on the binding and which on the portable implementation, after the build-time check. */
38
+ export interface SodiumBackendReport {
39
+ readonly native: readonly string[];
40
+ readonly portable: readonly string[];
41
+ }
42
+
43
+ const same = (a: Uint8Array, b: Uint8Array): boolean => a.length === b.length && a.every((x, i) => x === b[i]);
44
+ /** The X25519 base point, for deriving a public key from a secret scalar on the portable implementation. */
45
+ const X25519_BASE = Uint8Array.from([9, ...new Array<number>(31).fill(0)]);
46
+
47
+ /** Build the backend; see the module comment for how each function is checked. */
48
+ export function sodiumBackend(sodium: SodiumLike): CryptoBackend {
49
+ return buildSodiumBackend(sodium).backend;
50
+ }
51
+
52
+ /** One build per binding object: the checks run once in a JavaScript context, however often the same binding is installed. */
53
+ const built = new WeakMap<SodiumLike, { backend: CryptoBackend; report: SodiumBackendReport }>();
54
+
55
+ export function buildSodiumBackend(sodium: SodiumLike): { backend: CryptoBackend; report: SodiumBackendReport } {
56
+ const cached = built.get(sodium);
57
+ if (cached) return cached;
58
+ const result = checkAndBuild(sodium);
59
+ built.set(sodium, result);
60
+ return result;
61
+ }
62
+
63
+ function checkAndBuild(sodium: SodiumLike): { backend: CryptoBackend; report: SodiumBackendReport } {
64
+ const stage = "sodiumBackend";
65
+ const native: string[] = [];
66
+ const portable: string[] = [];
67
+ const n = nobleBackend;
68
+ // Known inputs: deterministic, so the check is the same on every device.
69
+ const seed = Uint8Array.from({ length: 32 }, (_, i) => (i * 37 + 11) & 0xff);
70
+ const message = Uint8Array.from({ length: 70 }, (_, i) => (i * 29 + 3) & 0xff);
71
+ const nonce24 = Uint8Array.from({ length: 24 }, (_, i) => (i * 13 + 5) & 0xff);
72
+ const key32 = Uint8Array.from({ length: 32 }, (_, i) => (255 - i * 7) & 0xff);
73
+ const aad = Uint8Array.from([0, 1, 2, 250, 251, 252]);
74
+
75
+ /** A required function: present and agreeing with the portable implementation, or the binding is refused. */
76
+ const required = (name: string, check: () => boolean): void => {
77
+ let ok = false;
78
+ try {
79
+ ok = check();
80
+ } catch {
81
+ ok = false;
82
+ }
83
+ if (!ok) throw new KavachError("INVALID_KEY", stage, `${name} is missing or disagrees with the portable implementation; the binding cannot be used`);
84
+ native.push(name);
85
+ };
86
+ /** An optional function: used when present and agreeing on the protocol's input types, portable otherwise. */
87
+ const optional = (name: string, present: boolean, check: () => boolean): boolean => {
88
+ let ok = false;
89
+ if (present) {
90
+ try {
91
+ ok = check();
92
+ } catch {
93
+ ok = false;
94
+ }
95
+ }
96
+ (ok ? native : portable).push(name);
97
+ return ok;
98
+ };
99
+
100
+ const nSign = n.signKeypairFromSeed(seed);
101
+ required("signKeypairFromSeed", () => {
102
+ const kp = sodium.crypto_sign_seed_keypair(seed);
103
+ return same(kp.publicKey, nSign.publicKey) && same(kp.privateKey, nSign.secretKey);
104
+ });
105
+ required("signDetached", () => same(sodium.crypto_sign_detached(message, nSign.secretKey), n.signDetached(message, nSign.secretKey)));
106
+ required("verifyDetached", () => sodium.crypto_sign_verify_detached(n.signDetached(message, nSign.secretKey), message, nSign.publicKey) === true && sodium.crypto_sign_verify_detached(n.signDetached(message, nSign.secretKey), nonce24, nSign.publicKey) === false);
107
+ required("blake2b", () => same(sodium.crypto_generichash(32, message, null), n.blake2b(message, 32)) && same(sodium.crypto_generichash(64, message, key32), n.blake2b(message, 64, key32)));
108
+ const nBox = n.boxKeypairFromSeed(seed);
109
+ required("boxKeypairFromSeed", () => {
110
+ const kp = sodium.crypto_box_seed_keypair(seed);
111
+ return same(kp.publicKey, nBox.publicKey) && same(kp.privateKey, nBox.secretKey);
112
+ });
113
+ required("boxKeypair", () => {
114
+ // A fresh keypair cannot be compared with a known one; its public key must be the one its secret derives.
115
+ const kp = sodium.crypto_box_keypair();
116
+ return kp.publicKey.length === 32 && kp.privateKey.length === 32 && same(kp.publicKey, n.scalarMult(kp.privateKey, X25519_BASE));
117
+ });
118
+ const other = n.boxKeypairFromSeed(key32);
119
+ required("boxEasy", () => same(sodium.crypto_box_easy(message, nonce24, other.publicKey, nBox.secretKey), n.boxEasy(message, nonce24, other.publicKey, nBox.secretKey)));
120
+ required("boxOpenEasy", () => same(sodium.crypto_box_open_easy(n.boxEasy(message, nonce24, other.publicKey, nBox.secretKey), nonce24, nBox.publicKey, other.secretKey), message));
121
+ required("randomBytes", () => sodium.randombytes_buf(16).length === 16);
122
+ const scalarMultNative = optional("scalarMult", typeof sodium.crypto_scalarmult === "function", () => same(sodium.crypto_scalarmult?.(nBox.secretKey, other.publicKey) ?? new Uint8Array(), n.scalarMult(nBox.secretKey, other.publicKey)));
123
+ const aeadNative = optional("aead", typeof sodium.crypto_aead_xchacha20poly1305_ietf_encrypt === "function" && typeof sodium.crypto_aead_xchacha20poly1305_ietf_decrypt === "function", () => {
124
+ // The protocol's associated data is binary; a binding that takes only strings throws here and falls back.
125
+ const sealed = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt?.(message, aad, null, nonce24, key32) ?? new Uint8Array();
126
+ const opened = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt?.(null, n.aeadEncrypt(message, aad, nonce24, key32), aad, nonce24, key32) ?? new Uint8Array();
127
+ return same(sealed, n.aeadEncrypt(message, aad, nonce24, key32)) && same(opened, message);
128
+ });
129
+ const validPointNative = optional("isValidSignPoint", typeof sodium.crypto_core_ed25519_is_valid_point === "function", () => sodium.crypto_core_ed25519_is_valid_point?.(nSign.publicKey) === true && sodium.crypto_core_ed25519_is_valid_point?.(new Uint8Array(32)) === false);
130
+ const memzeroNative = optional("memzero", typeof sodium.memzero === "function", () => {
131
+ const probe = Uint8Array.from([1, 2, 3]);
132
+ sodium.memzero?.(probe);
133
+ return probe.every((b) => b === 0);
134
+ });
135
+
136
+ const backend: CryptoBackend = {
137
+ name: "sodium",
138
+ blake2b: (data, outLen, key) => sodium.crypto_generichash(outLen, data, key ?? null),
139
+ randomBytes: (length) => sodium.randombytes_buf(length),
140
+ signKeypairFromSeed(s): SignKeypair {
141
+ const kp = sodium.crypto_sign_seed_keypair(s);
142
+ return { publicKey: kp.publicKey, secretKey: kp.privateKey };
143
+ },
144
+ signDetached: (m, secretKey) => sodium.crypto_sign_detached(m, secretKey),
145
+ verifyDetached(signature, m, publicKey) {
146
+ try {
147
+ return sodium.crypto_sign_verify_detached(signature, m, publicKey);
148
+ } catch {
149
+ return false;
150
+ }
151
+ },
152
+ isValidSignPoint: validPointNative ? (publicKey) => publicKey.length === 32 && sodium.crypto_core_ed25519_is_valid_point?.(publicKey) === true : (publicKey) => n.isValidSignPoint(publicKey),
153
+ boxKeypairFromSeed(s): BoxKeypair {
154
+ const kp = sodium.crypto_box_seed_keypair(s);
155
+ return { publicKey: kp.publicKey, secretKey: kp.privateKey };
156
+ },
157
+ boxKeypair(): BoxKeypair {
158
+ const kp = sodium.crypto_box_keypair();
159
+ return { publicKey: kp.publicKey, secretKey: kp.privateKey };
160
+ },
161
+ scalarMult: scalarMultNative ? (secretKey, publicKey) => sodium.crypto_scalarmult?.(secretKey, publicKey) ?? n.scalarMult(secretKey, publicKey) : (secretKey, publicKey) => n.scalarMult(secretKey, publicKey),
162
+ boxEasy: (m, nonce, publicKey, secretKey) => sodium.crypto_box_easy(m, nonce, publicKey, secretKey),
163
+ boxOpenEasy: (box, nonce, publicKey, secretKey) => sodium.crypto_box_open_easy(box, nonce, publicKey, secretKey),
164
+ aeadEncrypt: aeadNative ? (plaintext, a, nonce, key) => sodium.crypto_aead_xchacha20poly1305_ietf_encrypt?.(plaintext, a, null, nonce, key) ?? n.aeadEncrypt(plaintext, a, nonce, key) : (plaintext, a, nonce, key) => n.aeadEncrypt(plaintext, a, nonce, key),
165
+ aeadDecrypt: aeadNative ? (c, a, nonce, key) => sodium.crypto_aead_xchacha20poly1305_ietf_decrypt?.(null, c, a, nonce, key) ?? n.aeadDecrypt(c, a, nonce, key) : (c, a, nonce, key) => n.aeadDecrypt(c, a, nonce, key),
166
+ memzero: memzeroNative ? (bytes) => sodium.memzero?.(bytes) : (bytes) => bytes.fill(0)
167
+ };
168
+ return { backend, report: { native, portable } };
169
+ }
package/src/errors.ts CHANGED
@@ -52,6 +52,7 @@ export type ErrorCode =
52
52
  | "CLOSED"
53
53
  | "RECOVERY_REQUIRED"
54
54
  | "STORAGE_FAILURE"
55
+ | "RATE_LIMITED"
55
56
  /** The host could not be reached or did not answer in time; the same call may succeed later. A transport raises it, never a plain error. */
56
57
  | "NETWORK";
57
58
 
@@ -63,6 +64,7 @@ const RETRYABLE: ReadonlySet<ErrorCode> = new Set<ErrorCode>([
63
64
  "LOCKED",
64
65
  "STORAGE_FAILURE",
65
66
  "NETWORK",
67
+ "RATE_LIMITED",
66
68
  "SUCCESSION_PENDING"
67
69
  ]);
68
70
 
@@ -75,13 +77,16 @@ export class KavachError extends Error {
75
77
  readonly code: ErrorCode;
76
78
  readonly stage: string;
77
79
  readonly retryable: boolean;
80
+ /** On `RATE_LIMITED`: how long the host asked the client to wait, when it said. */
81
+ readonly retryAfterMs: number | undefined;
78
82
 
79
- constructor(code: ErrorCode, stage = "unknown", message?: string) {
83
+ constructor(code: ErrorCode, stage = "unknown", message?: string, options: { retryAfterMs?: number } = {}) {
80
84
  super(message ?? `${code} at ${stage}`);
81
85
  this.name = "KavachError";
82
86
  this.code = code;
83
87
  this.stage = stage;
84
88
  this.retryable = RETRYABLE.has(code);
89
+ this.retryAfterMs = options.retryAfterMs;
85
90
  }
86
91
  }
87
92
 
package/src/inbox.ts CHANGED
@@ -191,6 +191,10 @@ export interface StreamSyncSummary {
191
191
  acked: bigint | undefined;
192
192
  /** The relay holds more records past the last page read; the budget stopped the catch-up. */
193
193
  hasMore: boolean;
194
+ /** Record bytes read from the relay. */
195
+ bytes: number;
196
+ /** Set when a read failed after the pages counted above: the caller reports it, with what landed. */
197
+ failure?: unknown;
194
198
  }
195
199
 
196
200
  function subscription(tx: Pick<Tx, "get">, streamId: Uint8Array, authorIdentityId: Uint8Array): SubscriptionRow {
@@ -236,23 +240,29 @@ export interface ReadBudget {
236
240
  * records past the last page when the budget stopped the loop.
237
241
  */
238
242
  export async function syncStream(deps: InboxDeps, streamId: Uint8Array, authorIdentityId: Uint8Array, budget: ReadBudget = { remainingRecords: READ_BATCH, deadline: deps.now() }): Promise<StreamSyncSummary> {
239
- const summary: StreamSyncSummary = { read: 0, stored: 0, duplicate: 0, keyPending: 0, invalid: 0, expired: 0, acked: undefined, hasMore: false };
243
+ const summary: StreamSyncSummary = { read: 0, stored: 0, duplicate: 0, keyPending: 0, invalid: 0, expired: 0, acked: undefined, hasMore: false, bytes: 0 };
240
244
  const sub = await deps.store.read((tx) => subscription(tx, streamId, authorIdentityId));
241
245
  const unresolved = await deps.store.read((tx) => tx.list("unresolved", toHex(streamId) + ":"));
242
246
  let from = unresolved.length > 0 ? unbig(sub.ackPrefix) : unbig(sub.scanPos);
243
- for (;;) {
244
- // A failed read is the caller's to report: the sync names the stream in its report and goes
245
- // on to the next one, rather than counting a stream it never read as synced.
246
- const page = await deps.relay.readRecords(streamId, from, READ_BATCH);
247
- await processPage(deps, streamId, authorIdentityId, page, summary);
248
- budget.remainingRecords -= page.records.length;
249
- const caughtUp = page.nextSeq >= page.tail || page.nextSeq <= from;
250
- if (caughtUp) break;
251
- if (budget.remainingRecords <= 0 || deps.now() >= budget.deadline) {
252
- summary.hasMore = true;
253
- break;
247
+ try {
248
+ for (;;) {
249
+ // A failed read is the caller's to report: the sync names the stream in its report and goes
250
+ // on to the next one, rather than counting a stream it never read as synced.
251
+ const page = await deps.relay.readRecords(streamId, from, READ_BATCH);
252
+ await processPage(deps, streamId, authorIdentityId, page, summary);
253
+ budget.remainingRecords -= page.records.length;
254
+ const caughtUp = page.nextSeq >= page.tail || page.nextSeq <= from;
255
+ if (caughtUp) break;
256
+ if (budget.remainingRecords <= 0 || deps.now() >= budget.deadline) {
257
+ summary.hasMore = true;
258
+ break;
259
+ }
260
+ from = page.nextSeq;
254
261
  }
255
- from = page.nextSeq;
262
+ } catch (e) {
263
+ // The pages that landed before the failure are real work: the summary keeps them and carries
264
+ // the failure beside them, untouched, for the caller to report.
265
+ summary.failure = e;
256
266
  }
257
267
  return summary;
258
268
  }
@@ -269,6 +279,7 @@ async function processPage(deps: InboxDeps, streamId: Uint8Array, authorIdentity
269
279
  }
270
280
  for (const item of page.records) {
271
281
  summary.read++;
282
+ summary.bytes += item.wire.length;
272
283
  if (item.deliverySeq <= page.expiredThrough) {
273
284
  summary.expired++;
274
285
  continue;
package/src/index.ts CHANGED
@@ -36,7 +36,8 @@ export {
36
36
  type ReplacementProposal,
37
37
  type SyncError,
38
38
  type SyncOptions,
39
- type SyncReport
39
+ type SyncReport,
40
+ type SyncTimings
40
41
  } from "./client.js";
41
42
 
42
43
  // Errors -------------------------------------------------------------------------------------------
package/src/outbox.ts CHANGED
@@ -199,6 +199,10 @@ export interface DrainSummary {
199
199
  cancelled: number;
200
200
  unknown: number;
201
201
  retry: number;
202
+ /** Record bytes handed to the relay by this drain. */
203
+ bytes: number;
204
+ /** The error that ended the drain early, when a send failed before or without an answer (NETWORK, RATE_LIMITED); its record stays queued. */
205
+ blocked?: KavachError;
202
206
  }
203
207
 
204
208
  /** "none": nothing to send, the drain ends. "skipped": a row was cancelled, the next one is tried. */
@@ -211,17 +215,19 @@ type HandoffStep = { kind: "none" } | { kind: "skipped" } | { kind: "sent"; key:
211
215
  * revalidates first.
212
216
  */
213
217
  export async function drain(deps: OutboxDeps, maxRecords = 1000): Promise<DrainSummary> {
214
- const summary: DrainSummary = { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 };
218
+ const summary: DrainSummary = { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0, bytes: 0 };
215
219
  for (let i = 0; i < maxRecords; i++) {
216
220
  const step = await handoffNext(deps, summary);
217
221
  if (step.kind === "none") break;
218
222
  if (step.kind === "skipped") continue; // a cancelled row counts against the limit, never ends the drain
219
223
  summary.sent++;
224
+ summary.bytes += step.row.wire.length / 2;
220
225
  let outcome: AppendOutcome | undefined;
221
226
  try {
222
227
  outcome = await step.outcome;
223
- } catch {
224
- outcome = undefined; // lost response
228
+ } catch (e) {
229
+ outcome = undefined; // no answer: the identical bytes are retried, the relay answers from its checkpoint
230
+ if (e instanceof KavachError) summary.blocked ??= e; // and the reason reaches the report
225
231
  }
226
232
  const final = await recordOutcome(deps, step.key, outcome);
227
233
  if (final === "accepted") summary.accepted++;
@@ -113,6 +113,10 @@ export interface MaliciousKnobs {
113
113
  readonly hold: Map<MutationName, Promise<void>>;
114
114
  /** Called when a held mutation has reached its gate (the host has answered; the response is being held), so a test can act at exactly that point. */
115
115
  onHeld?: (name: MutationName) => void;
116
+ /** Mutations whose next call fails with exactly this error before reaching the host (consumed once). */
117
+ readonly failWith: Map<MutationName, KavachError>;
118
+ /** Record reads fail once with NETWORK after this many pages have been served. */
119
+ failReadsAfter?: number | undefined;
116
120
  /** Reads that answer "nothing" while present. */
117
121
  readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates" | "successions" | "circleList" | "subscriptions">;
118
122
  /** Older documents served instead of the current ones. */
@@ -142,6 +146,7 @@ export function emptyKnobs(): MaliciousKnobs {
142
146
  loseResponseOf: new Set(),
143
147
  refuse: new Set(),
144
148
  hold: new Map(),
149
+ failWith: new Map(),
145
150
  withhold: new Set(),
146
151
  replay: { snapshots: new Map() },
147
152
  fork: { snapshots: new Map(), manifests: new Map(), records: new Map(), certificates: new Map() },
@@ -298,6 +303,11 @@ export class MaliciousTransport implements Transport {
298
303
  appendRecord: (recordId, wire) => {
299
304
  // Must stay synchronous up to the inner call (T6h).
300
305
  this.calls.push("appendRecord");
306
+ const fail = k.failWith.get("appendRecord");
307
+ if (fail) {
308
+ k.failWith.delete("appendRecord");
309
+ return Promise.reject(fail);
310
+ }
301
311
  const p = r().appendRecord(recordId, wire);
302
312
  if (!k.loseResponseOf.has("appendRecord")) return p;
303
313
  k.loseResponseOf.delete("appendRecord");
@@ -308,6 +318,13 @@ export class MaliciousTransport implements Transport {
308
318
  },
309
319
  readRecords: async (streamId, afterSeq, limit): Promise<ReadRecordsResult> => {
310
320
  this.calls.push("readRecords");
321
+ if (k.failReadsAfter !== undefined) {
322
+ if (k.failReadsAfter <= 0) {
323
+ k.failReadsAfter = undefined;
324
+ throw Object.freeze(new KavachError("NETWORK", "transport", "network unreachable")); // frozen, as a transport may hand it over
325
+ }
326
+ k.failReadsAfter--;
327
+ }
311
328
  if (k.withhold.has("records")) {
312
329
  const probe = await r().readRecords(streamId, afterSeq, limit);
313
330
  return { ...probe, records: [], nextSeq: afterSeq };