@plane31/blind-relay 0.2.0 → 0.4.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 +28 -0
  2. package/dist/adapters/index.d.ts +1 -0
  3. package/dist/adapters/index.d.ts.map +1 -1
  4. package/dist/adapters/index.js +1 -0
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/client.d.ts +113 -0
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +284 -29
  9. package/dist/client.js.map +1 -1
  10. package/dist/crypto/noble.d.ts.map +1 -1
  11. package/dist/crypto/noble.js +19 -1
  12. package/dist/crypto/noble.js.map +1 -1
  13. package/dist/errors.d.ts +6 -2
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +5 -1
  16. package/dist/errors.js.map +1 -1
  17. package/dist/inbox.d.ts +4 -0
  18. package/dist/inbox.d.ts.map +1 -1
  19. package/dist/inbox.js +22 -14
  20. package/dist/inbox.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/outbox.d.ts +5 -0
  25. package/dist/outbox.d.ts.map +1 -1
  26. package/dist/outbox.js +6 -3
  27. package/dist/outbox.js.map +1 -1
  28. package/dist/store.d.ts +2 -0
  29. package/dist/store.d.ts.map +1 -1
  30. package/dist/store.js +2 -0
  31. package/dist/store.js.map +1 -1
  32. package/dist/testing/faults.d.ts +8 -1
  33. package/dist/testing/faults.d.ts.map +1 -1
  34. package/dist/testing/faults.js +16 -0
  35. package/dist/testing/faults.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/adapters/index.ts +1 -0
  38. package/src/client.ts +390 -30
  39. package/src/crypto/noble.ts +18 -1
  40. package/src/errors.ts +7 -1
  41. package/src/inbox.ts +24 -13
  42. package/src/index.ts +7 -1
  43. package/src/outbox.ts +9 -3
  44. package/src/store.ts +3 -0
  45. package/src/testing/faults.ts +22 -1
@@ -4,7 +4,24 @@ import { hsalsa, xsalsa20poly1305 } from "@noble/ciphers/salsa";
4
4
  import { ed25519, x25519 } from "@noble/curves/ed25519";
5
5
  import { blake2b } from "@noble/hashes/blake2b";
6
6
  import { sha512 } from "@noble/hashes/sha512";
7
- import { randomBytes } from "@noble/hashes/utils";
7
+ import { KavachError } from "../errors.js";
8
+
9
+ /**
10
+ * Randomness from the Web Crypto global, read at every call. `@noble/hashes` captures
11
+ * `globalThis.crypto` once when it is imported, which on Hermes is before the app has installed
12
+ * a source (the Expo helper installs expo-crypto's inside `openKavach`), so noble's own
13
+ * `randomBytes` would throw for the life of the process. Reading the global here, when the bytes
14
+ * are needed, sees whatever is installed by then.
15
+ */
16
+ function randomBytes(length: number): Uint8Array {
17
+ const g = globalThis as { crypto?: { getRandomValues?: (array: Uint8Array) => Uint8Array } };
18
+ const get = g.crypto?.getRandomValues;
19
+ if (typeof get !== "function") throw new KavachError("INVALID_KEY", "randomBytes", "no crypto.getRandomValues: install a secure random source before opening Kavach");
20
+ const out = new Uint8Array(length);
21
+ // Web Crypto caps one call at 65,536 bytes; nothing here is near it, but the rule is kept.
22
+ for (let offset = 0; offset < length; offset += 65_536) get.call(g.crypto, out.subarray(offset, Math.min(length, offset + 65_536)));
23
+ return out;
24
+ }
8
25
  import type { BoxKeypair, CryptoBackend, SignKeypair } from "./backend.js";
9
26
 
10
27
  // "expand 32-byte k" as little-endian u32 words: the Salsa constant.
package/src/errors.ts CHANGED
@@ -49,8 +49,10 @@ export type ErrorCode =
49
49
  | "STALE_APPEND"
50
50
  // Storage and transport
51
51
  | "LOCKED"
52
+ | "CLOSED"
52
53
  | "RECOVERY_REQUIRED"
53
54
  | "STORAGE_FAILURE"
55
+ | "RATE_LIMITED"
54
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. */
55
57
  | "NETWORK";
56
58
 
@@ -62,6 +64,7 @@ const RETRYABLE: ReadonlySet<ErrorCode> = new Set<ErrorCode>([
62
64
  "LOCKED",
63
65
  "STORAGE_FAILURE",
64
66
  "NETWORK",
67
+ "RATE_LIMITED",
65
68
  "SUCCESSION_PENDING"
66
69
  ]);
67
70
 
@@ -74,13 +77,16 @@ export class KavachError extends Error {
74
77
  readonly code: ErrorCode;
75
78
  readonly stage: string;
76
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;
77
82
 
78
- constructor(code: ErrorCode, stage = "unknown", message?: string) {
83
+ constructor(code: ErrorCode, stage = "unknown", message?: string, options: { retryAfterMs?: number } = {}) {
79
84
  super(message ?? `${code} at ${stage}`);
80
85
  this.name = "KavachError";
81
86
  this.code = code;
82
87
  this.stage = stage;
83
88
  this.retryable = RETRYABLE.has(code);
89
+ this.retryAfterMs = options.retryAfterMs;
84
90
  }
85
91
  }
86
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
@@ -14,6 +14,9 @@ export {
14
14
  open,
15
15
  type CircleSummary,
16
16
  type Client,
17
+ type DrainOptions,
18
+ type DrainReport,
19
+ type DrainStop,
17
20
  type ClientEvent,
18
21
  type ClientState,
19
22
  type ClientStatus,
@@ -28,10 +31,13 @@ export {
28
31
  type OpenOptions,
29
32
  type PauseReason,
30
33
  type ReceiveResult,
34
+ type ReplaceDeviceOptions,
35
+ type ReplaceDeviceResult,
31
36
  type ReplacementProposal,
32
37
  type SyncError,
33
38
  type SyncOptions,
34
- type SyncReport
39
+ type SyncReport,
40
+ type SyncTimings
35
41
  } from "./client.js";
36
42
 
37
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++;
package/src/store.ts CHANGED
@@ -19,6 +19,9 @@ export interface SecretStore {
19
19
 
20
20
  export type SecretName = "accountSeed" | "deviceSeed" | "recoveryKey" | "storageKey" | "instanceId" | "pendingReset" | "pendingReplacement";
21
21
 
22
+ /** Every secret the SDK keeps for an account, in the order an erasure deletes them: the account's roots first, the storage key last, so an interrupted erasure leaves a store that refuses to open rather than one that re-enrolls under old roots. */
23
+ export const SECRET_NAMES: readonly SecretName[] = ["accountSeed", "deviceSeed", "recoveryKey", "instanceId", "pendingReset", "pendingReplacement", "storageKey"];
24
+
22
25
  // Logical tables. Rows are JSON-safe: bytes as hex strings, u64 as decimal strings.
23
26
 
24
27
  export type StoreState = "INITIALIZING" | "ACTIVE" | "ACTIVATION_PENDING" | "RETIRED";
@@ -111,8 +111,14 @@ export interface MaliciousKnobs {
111
111
  readonly refuse: Set<MutationName>;
112
112
  /** Mutations whose next response waits for the given promise after the host committed (consumed once). */
113
113
  readonly hold: Map<MutationName, Promise<void>>;
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
+ 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;
114
120
  /** Reads that answer "nothing" while present. */
115
- readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates" | "successions" | "circleList">;
121
+ readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates" | "successions" | "circleList" | "subscriptions">;
116
122
  /** Older documents served instead of the current ones. */
117
123
  readonly replay: {
118
124
  vault?: Uint8Array;
@@ -140,6 +146,7 @@ export function emptyKnobs(): MaliciousKnobs {
140
146
  loseResponseOf: new Set(),
141
147
  refuse: new Set(),
142
148
  hold: new Map(),
149
+ failWith: new Map(),
143
150
  withhold: new Set(),
144
151
  replay: { snapshots: new Map() },
145
152
  fork: { snapshots: new Map(), manifests: new Map(), records: new Map(), certificates: new Map() },
@@ -188,6 +195,7 @@ export class MaliciousTransport implements Transport {
188
195
  const gate = this.knobs.hold.get(name);
189
196
  if (gate) {
190
197
  this.knobs.hold.delete(name);
198
+ this.knobs.onHeld?.(name);
191
199
  await gate;
192
200
  }
193
201
  if (this.knobs.loseResponseOf.has(name)) {
@@ -295,6 +303,11 @@ export class MaliciousTransport implements Transport {
295
303
  appendRecord: (recordId, wire) => {
296
304
  // Must stay synchronous up to the inner call (T6h).
297
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
+ }
298
311
  const p = r().appendRecord(recordId, wire);
299
312
  if (!k.loseResponseOf.has("appendRecord")) return p;
300
313
  k.loseResponseOf.delete("appendRecord");
@@ -305,6 +318,13 @@ export class MaliciousTransport implements Transport {
305
318
  },
306
319
  readRecords: async (streamId, afterSeq, limit): Promise<ReadRecordsResult> => {
307
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
+ }
308
328
  if (k.withhold.has("records")) {
309
329
  const probe = await r().readRecords(streamId, afterSeq, limit);
310
330
  return { ...probe, records: [], nextSeq: afterSeq };
@@ -328,6 +348,7 @@ export class MaliciousTransport implements Transport {
328
348
  ackThrough: (streamId, cursor) => this.mutate("ackThrough", () => r().ackThrough(streamId, cursor)),
329
349
  listSubscriptions: async () => {
330
350
  this.calls.push("listSubscriptions");
351
+ if (k.withhold.has("subscriptions")) throw new KavachError("NETWORK", "transport", "network unreachable");
331
352
  return r().listSubscriptions();
332
353
  }
333
354
  };