@hisoka-io/anon-rpc-worker 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.
@@ -0,0 +1,257 @@
1
+ /**
2
+ * The transport of each fetch hop (E2E TLS design §5): a TLS tunnel, the
3
+ * exit's `HttpRequest` path, or a refusal. The choice is made before any
4
+ * byte of the hop leaves the worker, from the URL and the capability data
5
+ * only: exits skipped after failed opens still count, and a call that started
6
+ * on a tunnel never continues in plaintext, so dropping tunnel traffic gives
7
+ * a `network-error`, never a downgrade. Under `"preferred"`, a topology in
8
+ * which no exit advertises `tunnel_v1` sends calls in plaintext.
9
+ */
10
+ import { TUNNEL_PART_MAX_DATA, type DecodedHttpResponse, type TunnelRejectCodeV1 } from "@hisoka-io/nox-client";
11
+ import type { TlsSetting } from "../config.js";
12
+ import { CALL_CODES, NoxWorkerError, callError } from "../errors.js";
13
+ import {
14
+ MAX_REQUEST_HEADER_BYTES,
15
+ mapClientError,
16
+ type CallBudget,
17
+ type PreparedRequest,
18
+ type TunnelHop,
19
+ } from "../fetch-map.js";
20
+ import { describeError, type WorkerLogger } from "../log.js";
21
+ import { TLS_PORT } from "./channel.js";
22
+ import { tlsErrorCode, type NoxTlsBindings } from "./module.js";
23
+ import { NoTunnelExitError, type TlsPool } from "./pool.js";
24
+ import {
25
+ TunnelOpenTimeoutError,
26
+ TunnelProtocolError,
27
+ TunnelRejectedError,
28
+ TunnelTimeoutError,
29
+ } from "./tunnel.js";
30
+
31
+ /** Reply blocks per exchange the exits accept by default (`max_surbs_per_exchange`). */
32
+ export const MAX_SURBS_PER_EXCHANGE = 32;
33
+ /** Parts in one 1 MiB exit window (`max_window_bytes`). */
34
+ export const WINDOW_PARTS = 32;
35
+ /** Reply blocks for an exchange of unknown reply size. */
36
+ export const MIN_REQUEST_SURBS = 2;
37
+
38
+ /** Why a URL cannot go through a tunnel (logged as `tls.fallback{reason}`). */
39
+ export type FallbackReason = "http-url" | "port" | "ip-literal" | "host-name" | "no-tunnel-exit";
40
+
41
+ /** What a tunnel needs from a URL. */
42
+ export interface TunnelTarget {
43
+ readonly host: string;
44
+ /** Path and query, the HTTP request target. */
45
+ readonly target: string;
46
+ /** `Authorization` from URL credentials, when the URL carries any. */
47
+ readonly authorization: string | undefined;
48
+ }
49
+
50
+ /** Longest DNS name the exit accepts. */
51
+ const MAX_HOST_BYTES = 253;
52
+ const DNS_NAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/u;
53
+ const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/u;
54
+
55
+ /** The tunnel target of `url`, or why it has none. */
56
+ export function tunnelTarget(url: URL): TunnelTarget | FallbackReason {
57
+ if (url.protocol !== "https:") return "http-url";
58
+ if (url.port !== "" && Number(url.port) !== TLS_PORT) return "port";
59
+ const host = url.hostname;
60
+ if (host.startsWith("[") || IPV4_RE.test(host)) return "ip-literal";
61
+ if (host.length > MAX_HOST_BYTES || !DNS_NAME_RE.test(host)) return "host-name";
62
+ const authorization = url.username === "" && url.password === ""
63
+ ? undefined
64
+ : `Basic ${base64(new TextEncoder().encode(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`))}`;
65
+ return { host, target: `${url.pathname}${url.search}`, authorization };
66
+ }
67
+
68
+ /**
69
+ * The transport of one hop under `setting` (the §5 table). `hop` counts
70
+ * redirects; `startedOnTunnel` is true once an earlier hop of the call used
71
+ * a tunnel. Throws the call's rejection when the hop may not leave.
72
+ */
73
+ export function chooseTransport(
74
+ setting: TlsSetting,
75
+ url: URL,
76
+ tunnelExitKnown: boolean,
77
+ hop: number,
78
+ startedOnTunnel: boolean,
79
+ ): { readonly via: "tunnel" } | { readonly via: "http"; readonly reason: FallbackReason | undefined } {
80
+ if (setting === "off" && !startedOnTunnel) return { via: "http", reason: undefined };
81
+ const target = tunnelTarget(url);
82
+ const reason = typeof target === "string" ? target : tunnelExitKnown ? undefined : "no-tunnel-exit";
83
+ if (reason === undefined) return { via: "tunnel" };
84
+ if (startedOnTunnel) {
85
+ throw callError(
86
+ CALL_CODES.networkError,
87
+ `Redirect refused: this call started on a TLS tunnel and the target cannot use one (${reason})`,
88
+ );
89
+ }
90
+ if (setting === "preferred") return { via: "http", reason };
91
+ if (reason === "no-tunnel-exit") throw callError(CALL_CODES.networkError, "No exit offers TLS tunnels");
92
+ const message = "TLS tunnels reach https hosts by name on port 443";
93
+ throw hop === 0
94
+ ? callError(CALL_CODES.unsupported, `${message} (tls is "required")`)
95
+ : callError(CALL_CODES.networkError, `Redirect refused: ${message} (tls is "required")`);
96
+ }
97
+
98
+ /** Reply blocks for the exchange that carries a request expecting `expectedBytes`. */
99
+ export function requestSurbs(expectedBytes: number | undefined): number {
100
+ if (expectedBytes === undefined || expectedBytes <= 0) return MIN_REQUEST_SURBS;
101
+ const wanted = Math.ceil(expectedBytes / TUNNEL_PART_MAX_DATA) + 1;
102
+ return Math.max(MIN_REQUEST_SURBS, Math.min(wanted, MAX_SURBS_PER_EXCHANGE, WINDOW_PARTS + 1));
103
+ }
104
+
105
+ const PERMISSION_CODES: ReadonlySet<TunnelRejectCodeV1> = new Set(["PortNotAllowed", "HostNotAllowed", "DestinationBlocked"]);
106
+ const PROTOCOL_CODES: ReadonlySet<TunnelRejectCodeV1> = new Set(["NotTls", "Malformed", "OutOfOrder"]);
107
+ const TLS_PROTOCOL_CODES: ReadonlySet<string> = new Set([
108
+ "TLS_ALERT_RECEIVED",
109
+ "TLS_DECRYPT_FAILED",
110
+ "TLS_PEER_MISBEHAVED",
111
+ "TLS_PEER_INCOMPATIBLE",
112
+ "TLS_PROTOCOL",
113
+ "TLS_IO",
114
+ "TLS_CLOSED",
115
+ "HTTP_RESPONSE_MALFORMED",
116
+ "HTTP_RESPONSE_TRUNCATED",
117
+ ]);
118
+
119
+ /** A tunnel or TLS failure as a call rejection (the §5 error table). */
120
+ export function mapTlsError(error: unknown): NoxWorkerError {
121
+ if (error instanceof NoxWorkerError) return error;
122
+ const detail = describeError(error);
123
+ if (error instanceof TunnelRejectedError) {
124
+ if (PERMISSION_CODES.has(error.code)) {
125
+ return callError(CALL_CODES.permissionDenied, `The tunnel exit refuses this destination (${error.code})`, error);
126
+ }
127
+ if (PROTOCOL_CODES.has(error.code)) return callError(CALL_CODES.protocolError, detail, error);
128
+ if (error.code === "ByteLimit") return callError(CALL_CODES.tooLarge, detail, error);
129
+ return callError(CALL_CODES.networkError, detail, error);
130
+ }
131
+ if (error instanceof TunnelTimeoutError) return callError(CALL_CODES.timeout, detail, error);
132
+ if (error instanceof TunnelOpenTimeoutError || error instanceof NoTunnelExitError) {
133
+ return callError(CALL_CODES.networkError, detail, error);
134
+ }
135
+ if (error instanceof TunnelProtocolError) return callError(CALL_CODES.protocolError, detail, error);
136
+ const code = tlsErrorCode(error);
137
+ if (code === "TLS_CERTIFICATE_REJECTED") return callError(CALL_CODES.networkError, detail, error);
138
+ if (code === "HTTP_RESPONSE_TOO_LARGE") return callError(CALL_CODES.tooLarge, detail, error);
139
+ if (code === "HTTP_REQUEST_INVALID" || code === "TLS_INVALID_SERVER_NAME") {
140
+ return callError(CALL_CODES.unsupported, detail, error);
141
+ }
142
+ if (code !== undefined && TLS_PROTOCOL_CODES.has(code)) return callError(CALL_CODES.protocolError, detail, error);
143
+ return mapClientError(error);
144
+ }
145
+
146
+ /** Response headers that describe the TLS connection itself. */
147
+ const CONNECTION_HEADERS = new Set(["connection", "keep-alive", "transfer-encoding", "te", "trailer", "upgrade", "proxy-connection"]);
148
+
149
+ /** Tunnel exchanges for fetch hops. */
150
+ export class TlsTransport implements TunnelHop {
151
+ constructor(
152
+ private readonly pool: TlsPool,
153
+ private readonly bindings: NoxTlsBindings,
154
+ private readonly settings: { readonly tls: TlsSetting; readonly tlsSession: string; readonly maxResponseBytes: number },
155
+ private readonly log: WorkerLogger,
156
+ private readonly now: () => number,
157
+ ) {}
158
+
159
+ /** `chooseTransport` with this worker's setting and exits; logs a fallback. */
160
+ route(request: PreparedRequest, hop: number, startedOnTunnel: boolean): "tunnel" | "http" {
161
+ const choice = chooseTransport(this.settings.tls, new URL(request.url), this.pool.offersTunnels(), hop, startedOnTunnel);
162
+ if (choice.via === "http" && choice.reason !== undefined) this.log.info("tls.fallback", { reason: choice.reason });
163
+ return choice.via;
164
+ }
165
+
166
+ /**
167
+ * One hop through a TLS tunnel. A tunnel lost before the response (the
168
+ * exit no longer knows the session, a silent exit, a spare the upstream
169
+ * closed) is retried once on a new tunnel for a resendable read, or for
170
+ * any request whose only copy the exit refused before writing it
171
+ * (`UnknownSession`, `Expired`, `UpstreamClosed`).
172
+ */
173
+ async exchange(request: PreparedRequest, budget: CallBudget): Promise<DecodedHttpResponse> {
174
+ const target = tunnelTarget(new URL(request.url));
175
+ if (typeof target === "string") throw callError(CALL_CODES.internalError, `A ${target} URL reached the tunnel path`);
176
+ const http = this.encode(request, target);
177
+ const resendable = request.profile.retryable;
178
+ for (let attempt = 0; ; attempt++) {
179
+ const deadlineAt = this.now() + budget.remainingMs();
180
+ let channel;
181
+ try {
182
+ channel = await this.pool.acquire(target.host, { deadlineAt, signal: budget.signal });
183
+ } catch (error) {
184
+ if (budget.signal.aborted) throw budget.signal.reason;
185
+ throw mapTlsError(error);
186
+ }
187
+ try {
188
+ const reply = await channel.request({
189
+ http,
190
+ headRequest: request.method === "HEAD",
191
+ maxHeadBytes: MAX_REQUEST_HEADER_BYTES,
192
+ maxBodyBytes: this.settings.maxResponseBytes,
193
+ surbs: requestSurbs(request.profile.expectedResponseBytes),
194
+ deadlineAt,
195
+ signal: budget.signal,
196
+ background: false,
197
+ });
198
+ this.pool.release(channel, true);
199
+ return {
200
+ status: reply.status,
201
+ headers: reply.headers.filter(([name]) => !CONNECTION_HEADERS.has(name.toLowerCase())),
202
+ body: reply.body,
203
+ truncated: false,
204
+ };
205
+ } catch (error) {
206
+ this.pool.release(channel, false);
207
+ if (budget.signal.aborted) throw budget.signal.reason;
208
+ if (attempt > 0 || budget.remainingMs() <= 0 || !retriable(error, resendable)) throw mapTlsError(error);
209
+ this.log.info("tls.retry", { reason: error instanceof Error ? error.name : "error" });
210
+ }
211
+ }
212
+ }
213
+
214
+ private encode(request: PreparedRequest, target: TunnelTarget): Uint8Array {
215
+ const headers: string[] = [];
216
+ let hasAuthorization = false;
217
+ for (const [name, value] of request.headers) {
218
+ if (name.toLowerCase() === "authorization") hasAuthorization = true;
219
+ headers.push(name, value);
220
+ }
221
+ if (target.authorization !== undefined && !hasAuthorization) headers.push("authorization", target.authorization);
222
+ const contentType = request.headers.find(([name]) => name.toLowerCase() === "content-type")?.[1];
223
+ const json = request.body.length > 0 && (request.profile.method !== undefined || contentType?.toLowerCase().includes("json") === true);
224
+ try {
225
+ return this.bindings.encodeRequest(
226
+ request.method,
227
+ target.host,
228
+ target.target,
229
+ headers,
230
+ request.body,
231
+ this.settings.tlsSession === "keep-alive",
232
+ json,
233
+ );
234
+ } catch (error) {
235
+ throw mapTlsError(error);
236
+ }
237
+ }
238
+ }
239
+
240
+ /** Whether a failed tunnel hop may run again on a new tunnel. */
241
+ function retriable(error: unknown, resendable: boolean): boolean {
242
+ if (error instanceof TunnelRejectedError) {
243
+ const unwritten = error.code === "UnknownSession" || error.code === "Expired" || error.code === "UpstreamClosed";
244
+ if (unwritten && error.soleCopy) return true;
245
+ return resendable && unwritten;
246
+ }
247
+ if (!resendable) return false;
248
+ if (error instanceof TunnelTimeoutError || error instanceof TunnelOpenTimeoutError) return true;
249
+ // A spare the upstream had already closed: the stream ended before any response byte.
250
+ return tlsErrorCode(error) === "HTTP_RESPONSE_TRUNCATED";
251
+ }
252
+
253
+ function base64(bytes: Uint8Array): string {
254
+ let binary = "";
255
+ for (const byte of bytes) binary += String.fromCharCode(byte);
256
+ return btoa(binary);
257
+ }
@@ -0,0 +1,360 @@
1
+ /**
2
+ * One TLS tunnel through one Nox exit (E2E TLS design §2.5): the exit holds a
3
+ * TCP connection to the upstream and relays TLS records both ways; this side
4
+ * numbers the writes, reorders the reply parts and decides when to send
5
+ * another copy.
6
+ *
7
+ * - Stop-and-wait: one unconfirmed `seq` at a time. Any part or rejection for
8
+ * a `seq` confirms it.
9
+ * - Every copy of a `seq` carries identical `data` (the exit writes each `seq`
10
+ * once), goes to the same exit, and avoids the previous copy's entry and mix.
11
+ * - Downstream bytes are numbered from 0; parts are reordered by offset and
12
+ * only contiguous bytes reach TLS. `ackOffset` is the contiguous point.
13
+ * - Another copy goes out on `NeedSurbs` or `Expired`, and, at most
14
+ * `maxCopies` times per exchange, after silence or a gap that stays open.
15
+ * - `fin` is a hint: only the TLS session and HTTP framing decide that a
16
+ * response is complete.
17
+ */
18
+ import {
19
+ decodeTunnelReplyV1,
20
+ TUNNEL_ID_LEN,
21
+ type TopologyNode,
22
+ type TunnelRejectCodeV1,
23
+ type TunnelReplyV1,
24
+ type TunnelRequestV1,
25
+ type TunnelSendHandle,
26
+ type TunnelSendOptions,
27
+ } from "@hisoka-io/nox-client";
28
+
29
+ /** The slice of `NoxClient` tunnels use. */
30
+ export interface TunnelPort {
31
+ tunnelExits(): TopologyNode[];
32
+ tunnelSend(exit: TopologyNode, request: TunnelRequestV1, options: TunnelSendOptions): TunnelSendHandle;
33
+ }
34
+
35
+ /** Copy timing; `copyAfterMs` is read at each use (it follows the observed p95). */
36
+ export interface CopyPolicy {
37
+ copyAfterMs(): number;
38
+ readonly gapMs: number;
39
+ readonly maxCopies: number;
40
+ }
41
+
42
+ /** Longest hold the exit honours (`max_hold_ms`). */
43
+ export const MAX_HOLD_MS = 30_000;
44
+ /** Shortest hold the exit honours (`min_hold_ms`). */
45
+ export const MIN_HOLD_MS = 1_000;
46
+ /** A copy keeps listening this long past its hold, for parts still in the mixnet. */
47
+ export const REPLY_GRACE_MS = 5_000;
48
+
49
+ /** The exit refused an exchange. */
50
+ export class TunnelRejectedError extends Error {
51
+ constructor(
52
+ readonly code: TunnelRejectCodeV1,
53
+ readonly retryable: boolean,
54
+ readonly seq: number,
55
+ /** True when exactly one copy of `seq` went out, so this rejection proves it was never written. */
56
+ readonly soleCopy: boolean,
57
+ detail: string,
58
+ ) {
59
+ super(`The tunnel exit refused seq ${seq}: ${code}${detail === "" ? "" : ` (${detail})`}`);
60
+ this.name = "TunnelRejectedError";
61
+ }
62
+ }
63
+
64
+ /** No part of an open arrived within the open timeout. */
65
+ export class TunnelOpenTimeoutError extends Error {
66
+ constructor(readonly afterMs: number) {
67
+ super(`The tunnel exit did not answer the open within ${afterMs} ms`);
68
+ this.name = "TunnelOpenTimeoutError";
69
+ }
70
+ }
71
+
72
+ /** The exchange ran out of time before its job was done. */
73
+ export class TunnelTimeoutError extends Error {
74
+ constructor(readonly seq: number, readonly confirmed: boolean) {
75
+ super(`Tunnel exchange seq ${seq} did not finish before the call deadline${confirmed ? "" : " (no reply at all)"}`);
76
+ this.name = "TunnelTimeoutError";
77
+ }
78
+ }
79
+
80
+ /** A reply part that does not decode: the exit or the path is broken. */
81
+ export class TunnelProtocolError extends Error {
82
+ constructor(message: string, options?: { cause?: unknown }) {
83
+ super(message, options);
84
+ this.name = "TunnelProtocolError";
85
+ }
86
+ }
87
+
88
+ export interface ExchangeSpec {
89
+ /** TLS records to write upstream. */
90
+ readonly data: Uint8Array;
91
+ /** Reply blocks per copy. */
92
+ readonly surbs: number;
93
+ /** Half-close the upstream after writing. */
94
+ readonly close?: boolean;
95
+ /** Absolute time (ms) the exchange must finish by. */
96
+ readonly deadlineAt: number;
97
+ /** Spares and teardowns: not a wallet call. */
98
+ readonly background: boolean;
99
+ /** Aborts the exchange; its reason is the rejection. */
100
+ readonly signal?: AbortSignal;
101
+ /** Fail with `TunnelOpenTimeoutError` when nothing at all arrived in this long. */
102
+ readonly openTimeoutMs?: number;
103
+ /** Checked after each delivery: true ends the exchange. */
104
+ satisfied(): boolean;
105
+ }
106
+
107
+ /** How an exchange ended: its job was done, or the upstream ended the stream. */
108
+ export type ExchangeOutcome = "satisfied" | "eof";
109
+
110
+ export interface TunnelDeps {
111
+ readonly now: () => number;
112
+ readonly randomBytes: (length: number) => Uint8Array;
113
+ }
114
+
115
+ /**
116
+ * A tunnel to `host:port` through `exit`. `deliver` receives contiguous
117
+ * downstream bytes in order (it feeds the TLS session and may throw).
118
+ */
119
+ export class Tunnel {
120
+ readonly id: Uint8Array;
121
+ private nextSeq = 0;
122
+ private contiguous = 0n;
123
+ private readonly held = new Map<bigint, Uint8Array>();
124
+ private eofAt: bigint | undefined;
125
+ private ended = false;
126
+
127
+ constructor(
128
+ private readonly port: TunnelPort,
129
+ readonly exit: TopologyNode,
130
+ readonly host: string,
131
+ readonly tcpPort: number,
132
+ private readonly policy: CopyPolicy,
133
+ private readonly deps: TunnelDeps,
134
+ private readonly deliver: (bytes: Uint8Array) => void,
135
+ ) {
136
+ this.id = deps.randomBytes(TUNNEL_ID_LEN);
137
+ }
138
+
139
+ /** True once the upstream stream ended (every byte up to the exit's EOF delivered). */
140
+ get eof(): boolean {
141
+ return this.ended;
142
+ }
143
+
144
+ /** Short id prefix for debug logs. */
145
+ get label(): string {
146
+ return Array.from(this.id.slice(0, 4), (byte) => byte.toString(16).padStart(2, "0")).join("");
147
+ }
148
+
149
+ /** Sequence number the next exchange will use. */
150
+ get seq(): number {
151
+ return this.nextSeq;
152
+ }
153
+
154
+ /**
155
+ * Run one exchange: a new `seq` carrying `spec.data`, copies as needed,
156
+ * until `spec.satisfied()`, the end of the stream, a rejection, the
157
+ * deadline or an abort.
158
+ */
159
+ exchange(spec: ExchangeSpec): Promise<ExchangeOutcome> {
160
+ const seq = this.nextSeq++;
161
+ const base = {
162
+ tunnelId: this.id,
163
+ seq,
164
+ open: seq === 0 ? { host: this.host, port: this.tcpPort } : null,
165
+ data: spec.data,
166
+ close: spec.close === true,
167
+ };
168
+ const startedAt = this.deps.now();
169
+ const listeners = new AbortController();
170
+
171
+ return new Promise<ExchangeOutcome>((resolve, reject) => {
172
+ let settled = false;
173
+ let confirmed = false;
174
+ let copiesSent = 0;
175
+ let timedCopies = 0;
176
+ let lastRoute: { entryId: string; mixId: string } | undefined;
177
+ let silenceTimer: ReturnType<typeof setTimeout> | undefined;
178
+ let gapTimer: ReturnType<typeof setTimeout> | undefined;
179
+ let openTimer: ReturnType<typeof setTimeout> | undefined;
180
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
181
+
182
+ const finish = (outcome: ExchangeOutcome | Error): void => {
183
+ if (settled) return;
184
+ settled = true;
185
+ clearTimeout(silenceTimer);
186
+ clearTimeout(gapTimer);
187
+ clearTimeout(openTimer);
188
+ clearTimeout(deadlineTimer);
189
+ spec.signal?.removeEventListener("abort", onAbort);
190
+ listeners.abort();
191
+ if (outcome instanceof Error) reject(outcome);
192
+ else resolve(outcome);
193
+ };
194
+
195
+ const armSilence = (): void => {
196
+ clearTimeout(silenceTimer);
197
+ silenceTimer = setTimeout(() => timedCopy(), this.policy.copyAfterMs());
198
+ };
199
+
200
+ const timedCopy = (): void => {
201
+ if (settled) return;
202
+ if (timedCopies >= this.policy.maxCopies) return;
203
+ timedCopies += 1;
204
+ send();
205
+ };
206
+
207
+ const send = (): void => {
208
+ if (settled) return;
209
+ const remaining = spec.deadlineAt - this.deps.now();
210
+ if (remaining <= 0) {
211
+ finish(new TunnelTimeoutError(seq, confirmed));
212
+ return;
213
+ }
214
+ const holdMs = Math.max(MIN_HOLD_MS, Math.min(MAX_HOLD_MS, remaining));
215
+ const request: TunnelRequestV1 = { ...base, ackOffset: this.contiguous, holdMs };
216
+ // A copy steers clear of the previous copy's entry and mix.
217
+ const avoid = lastRoute === undefined ? new Set<string>() : new Set([lastRoute.entryId, lastRoute.mixId]);
218
+ let handle: TunnelSendHandle;
219
+ try {
220
+ handle = this.port.tunnelSend(this.exit, request, {
221
+ surbs: spec.surbs,
222
+ timeoutMs: holdMs + REPLY_GRACE_MS,
223
+ signal: listeners.signal,
224
+ avoid,
225
+ background: spec.background,
226
+ onReply: (body) => onPart(body),
227
+ });
228
+ } catch (error) {
229
+ finish(error instanceof Error ? error : new Error(String(error)));
230
+ return;
231
+ }
232
+ copiesSent += 1;
233
+ lastRoute = { entryId: handle.entryId, mixId: handle.mixId };
234
+ // A copy that could not be uploaded counts as silence: try another entry.
235
+ handle.done.catch(() => {
236
+ if (!settled && !listeners.signal.aborted && !confirmed) timedCopy();
237
+ });
238
+ armSilence();
239
+ };
240
+
241
+ const onPart = (body: Uint8Array): "more" | "done" => {
242
+ if (settled) return "done";
243
+ let reply: TunnelReplyV1;
244
+ try {
245
+ reply = decodeTunnelReplyV1(body);
246
+ } catch (error) {
247
+ finish(new TunnelProtocolError("A tunnel reply part does not decode", { cause: error }));
248
+ return "done";
249
+ }
250
+ // A late part of an earlier exchange: already superseded.
251
+ if (reply.seq !== seq) return "more";
252
+ confirmed = true;
253
+ clearTimeout(openTimer);
254
+ if (reply.kind === "Rejected") {
255
+ finish(new TunnelRejectedError(reply.code, reply.retryable, seq, copiesSent === 1, reply.detail));
256
+ return "done";
257
+ }
258
+ try {
259
+ this.accept(reply.offset, reply.data);
260
+ } catch (error) {
261
+ finish(error instanceof Error ? error : new Error(String(error)));
262
+ return "done";
263
+ }
264
+ if (reply.fin === "Eof") this.eofAt ??= reply.offset + BigInt(reply.data.length);
265
+ if (this.eofAt !== undefined && this.contiguous >= this.eofAt) this.ended = true;
266
+ if (spec.satisfied()) {
267
+ finish("satisfied");
268
+ return "done";
269
+ }
270
+ if (this.ended) {
271
+ finish("eof");
272
+ return "done";
273
+ }
274
+ if (this.held.size === 0) {
275
+ clearTimeout(gapTimer);
276
+ gapTimer = undefined;
277
+ } else if (gapTimer === undefined) {
278
+ gapTimer = setTimeout(() => {
279
+ gapTimer = undefined;
280
+ if (this.held.size > 0) timedCopy();
281
+ }, this.policy.gapMs);
282
+ }
283
+ if (reply.fin === "NeedSurbs" || reply.fin === "Expired") send();
284
+ else armSilence();
285
+ return "more";
286
+ };
287
+
288
+ const onAbort = (): void => finish(spec.signal?.reason instanceof Error ? spec.signal.reason : new Error("Tunnel exchange aborted"));
289
+ deadlineTimer = setTimeout(
290
+ () => finish(new TunnelTimeoutError(seq, confirmed)),
291
+ Math.max(0, spec.deadlineAt - startedAt),
292
+ );
293
+ if (spec.signal?.aborted === true) {
294
+ onAbort();
295
+ return;
296
+ }
297
+ spec.signal?.addEventListener("abort", onAbort, { once: true });
298
+ if (spec.openTimeoutMs !== undefined) {
299
+ const openTimeoutMs = spec.openTimeoutMs;
300
+ openTimer = setTimeout(() => {
301
+ if (!confirmed) finish(new TunnelOpenTimeoutError(openTimeoutMs));
302
+ }, openTimeoutMs);
303
+ }
304
+ send();
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Close the upstream write side after `data` (a close_notify), without
310
+ * reply blocks and without waiting. Only after the last exchange was
311
+ * confirmed, so the `seq` stays in step with the exit.
312
+ */
313
+ teardown(data: Uint8Array, deadlineAt: number): Promise<void> {
314
+ const seq = this.nextSeq++;
315
+ const holdMs = Math.max(MIN_HOLD_MS, Math.min(MAX_HOLD_MS, deadlineAt - this.deps.now()));
316
+ const request: TunnelRequestV1 = {
317
+ tunnelId: this.id,
318
+ seq,
319
+ open: null,
320
+ ackOffset: this.contiguous,
321
+ data,
322
+ close: true,
323
+ holdMs,
324
+ };
325
+ try {
326
+ return this.port.tunnelSend(this.exit, request, { surbs: 0, timeoutMs: holdMs, background: true }).done;
327
+ } catch (error) {
328
+ return Promise.reject(error);
329
+ }
330
+ }
331
+
332
+ /** Place one part: deliver what is contiguous, hold what lies beyond a gap. */
333
+ private accept(offset: bigint, data: Uint8Array): void {
334
+ const end = offset + BigInt(data.length);
335
+ if (end <= this.contiguous) return;
336
+ if (offset > this.contiguous) {
337
+ const existing = this.held.get(offset);
338
+ if (existing === undefined || existing.length < data.length) this.held.set(offset, data);
339
+ return;
340
+ }
341
+ this.push(data.subarray(Number(this.contiguous - offset)));
342
+ for (;;) {
343
+ let advanced = false;
344
+ for (const [heldOffset, heldData] of this.held) {
345
+ if (heldOffset > this.contiguous) continue;
346
+ this.held.delete(heldOffset);
347
+ const heldEnd = heldOffset + BigInt(heldData.length);
348
+ if (heldEnd > this.contiguous) this.push(heldData.subarray(Number(this.contiguous - heldOffset)));
349
+ advanced = true;
350
+ }
351
+ if (!advanced) return;
352
+ }
353
+ }
354
+
355
+ private push(bytes: Uint8Array): void {
356
+ if (bytes.length === 0) return;
357
+ this.contiguous += BigInt(bytes.length);
358
+ this.deliver(bytes);
359
+ }
360
+ }
package/src/worker.ts CHANGED
@@ -2,15 +2,19 @@
2
2
  // the anon-rpc harness runs with `anonRpcWorker` installed (SPEC §7).
3
3
  //
4
4
  // The bundle build resolves "@hisoka-io/nox-wasm" to an init that compiles the
5
- // embedded nox-wasm bytes (no .wasm fetch), "nox-embed:snapshot" to the
6
- // pinned registry snapshot and "nox-embed:bootstrap" to the discovery
7
- // bootstrap (default anchors, RPC providers, policy). Everything else lives in
8
- // core.ts.
5
+ // embedded nox-wasm bytes (no .wasm fetch), "nox-embed:tls" to the same for
6
+ // the embedded nox-tls module, "nox-embed:tls-root" to a test bed's extra CA
7
+ // (null in release builds), "nox-embed:snapshot" to the pinned registry
8
+ // snapshot and "nox-embed:bootstrap" to the discovery bootstrap (default
9
+ // anchors, RPC providers, policy). Everything else lives in core.ts.
9
10
  import { NoxClient, type NoxWasmBindings } from "@hisoka-io/nox-client";
10
11
  import initNoxWasm, * as noxWasm from "@hisoka-io/nox-wasm";
11
12
  import bootstrap from "nox-embed:bootstrap";
12
13
  import snapshot from "nox-embed:snapshot";
14
+ import initNoxTls, * as noxTls from "nox-embed:tls";
15
+ import extraRoot from "nox-embed:tls-root";
13
16
  import { installUnhandledRejectionLog, runNoxWorker } from "./core.js";
17
+ import { tlsBindings } from "./tls/module.js";
14
18
  import type { AnonRpcWorkerApi } from "./spec-types.js";
15
19
 
16
20
  declare const anonRpcWorker: AnonRpcWorkerApi;
@@ -24,5 +28,9 @@ void runNoxWorker(anonRpcWorker, {
24
28
  await initNoxWasm();
25
29
  return noxWasm as unknown as NoxWasmBindings;
26
30
  },
31
+ loadTls: async () => {
32
+ await initNoxTls();
33
+ return tlsBindings(noxTls, extraRoot);
34
+ },
27
35
  connect: (config) => NoxClient.connect(config),
28
36
  });