@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.
- package/README.md +28 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/client.d.ts +113 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +284 -29
- package/dist/client.js.map +1 -1
- package/dist/crypto/noble.d.ts.map +1 -1
- package/dist/crypto/noble.js +19 -1
- package/dist/crypto/noble.js.map +1 -1
- package/dist/errors.d.ts +6 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +5 -1
- package/dist/errors.js.map +1 -1
- package/dist/inbox.d.ts +4 -0
- package/dist/inbox.d.ts.map +1 -1
- package/dist/inbox.js +22 -14
- package/dist/inbox.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/outbox.d.ts +5 -0
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +6 -3
- package/dist/outbox.js.map +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2 -0
- package/dist/store.js.map +1 -1
- package/dist/testing/faults.d.ts +8 -1
- package/dist/testing/faults.d.ts.map +1 -1
- package/dist/testing/faults.js +16 -0
- package/dist/testing/faults.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/index.ts +1 -0
- package/src/client.ts +390 -30
- package/src/crypto/noble.ts +18 -1
- package/src/errors.ts +7 -1
- package/src/inbox.ts +24 -13
- package/src/index.ts +7 -1
- package/src/outbox.ts +9 -3
- package/src/store.ts +3 -0
- package/src/testing/faults.ts +22 -1
package/src/crypto/noble.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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; //
|
|
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";
|
package/src/testing/faults.ts
CHANGED
|
@@ -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
|
};
|