@hisoka-io/anon-rpc-worker 0.3.0 → 0.4.1

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.
@@ -1 +1 @@
1
- 0x24604525d220bcc7e39f2dbc22966a814600a63ada51baafad5dd0bcc1d28549 anon-rpc-worker.js
1
+ 0xd9fa04b080a33062680a27cad828196bfb04615a0ebc76bcbffedb6dcbe838d4 anon-rpc-worker.js
@@ -3,29 +3,40 @@
3
3
  "arch": "x64",
4
4
  "os": "linux"
5
5
  },
6
- "gitCommit": "6bddd851c5b1f4e5508f4718e2ea9b4db626b17f",
6
+ "gitCommit": "66bce4af274b4be457ef11336ba9cb6d9200efd1",
7
7
  "gitTreeClean": true,
8
8
  "inputs": {
9
9
  "bootstrapKeccak": "0x6567e3ad5e1b1ac81529391c890d2652695d3ec139d56625170384794085fb74",
10
10
  "lockfileSha256": "0a205a04cd3b6d861b8fedef8b8e6cd6356642e5f2cd9752ce0e2f403fbae426",
11
11
  "snapshotBlock": 316207920,
12
- "snapshotKeccak": "0xa351960e2c268b9665d1aabec87feaa132d30ade773aef64ef495ec25c94fbb2",
13
- "wasmKeccak": "0x6ff418a78fc022d34dff00a867e173269413fb6887c9d6bf8e6505d4dcfffe2d",
14
- "wasmSha256": "5171416972ea4499fd4b4ea8bcadf002f4c10f10ed42cafb0d5a426121da9c4f"
12
+ "snapshotKeccak": "0x1090f071896b6ef41ddfd40d4ebcd20fae85a66468f82497e19a635488014681",
13
+ "tlsWasmKeccak": "0x60b29cd4fca7ee2f31fbcff2dca25e64939e4b71a4629ffcfea1b89546f501ce",
14
+ "tlsWasmSha256": "f74e8cfbed7be33ef1a9158b7b66e454b4ca0849bcb8956487fd13da47e4f4d2",
15
+ "wasmKeccak": "0x91060e59264ba44c3f16181caff1adcf16193a1e57d6ba4dd3531b192ac42de4",
16
+ "wasmSha256": "6db306a34ef50e9198f4ad6f31c6ffd9feafe459c24b8a29895824c7138241c8"
15
17
  },
16
18
  "output": {
17
- "bytes": 961799,
18
- "keccak256": "0x24604525d220bcc7e39f2dbc22966a814600a63ada51baafad5dd0bcc1d28549",
19
- "sha256": "0458b19258dffcfb9ce23d584096f3df06532080390d34ee71b201b6b78dbbac"
19
+ "bytes": 1831246,
20
+ "keccak256": "0xd9fa04b080a33062680a27cad828196bfb04615a0ebc76bcbffedb6dcbe838d4",
21
+ "sha256": "9544fb10390eb135d4f6b9a32de674ea859e4977514afed4772cb4642d62ed19"
20
22
  },
21
23
  "package": "@hisoka-io/anon-rpc-worker",
24
+ "tls": {
25
+ "extraRoots": 0,
26
+ "webpkiRoots": "1.0.9",
27
+ "webpkiRootsReleased": "2026-07-18"
28
+ },
22
29
  "toolchain": {
30
+ "clang": "19.1.7",
23
31
  "esbuild": "0.27.3",
24
32
  "node": "v22.23.3",
25
33
  "pins": {
26
34
  "BINARYEN_SHA256_AARCH64": "ad560204426015a815faa45693c83bef7d58677d38a39422c272a30ba4b6da2a",
27
35
  "BINARYEN_SHA256_X86_64": "3dc677006555b355ea2da5e82602065a161d5e83eaefd3f759afa00b96e83212",
28
36
  "BINARYEN_VERSION": "117",
37
+ "CLANG_PACKAGE_VERSION": "1:19.1.7-3~deb12u1",
38
+ "CLANG_VERSION": "19.1.7",
39
+ "DEBIAN_SNAPSHOT": "20261001T000000Z",
29
40
  "NODE_IMAGE": "docker.io/library/node:22.23.3-bookworm-slim@sha256:43ac6c60b8f89723f746e8a92ce91abd5017e627ce1ddfe4238355d3a30b772c",
30
41
  "NODE_VERSION": "22.23.3",
31
42
  "RUST_IMAGE": "docker.io/library/rust:1.95.0-slim-bookworm@sha256:d7482085ff5b415f84dba5647ae71606650bdef00db7aeb69f4b3d170c3e4082",
@@ -44,5 +55,5 @@
44
55
  "wasmPack": "0.13.1",
45
56
  "wasmPinsChecked": true
46
57
  },
47
- "version": "0.3.0"
58
+ "version": "0.4.1"
48
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hisoka-io/anon-rpc-worker",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Nox mixnet worker for the anon-rpc standard, built as one reproducible classic-script bundle with a pinned NoxRegistry snapshot",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "ethers": "6.16.0",
26
26
  "typescript": "5.9.3",
27
27
  "vitest": "1.6.1",
28
- "@hisoka-io/nox-client": "0.7.0",
29
- "@hisoka-io/nox-wasm": "0.2.0"
28
+ "@hisoka-io/nox-client": "0.9.0",
29
+ "@hisoka-io/nox-wasm": "0.3.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"
@@ -3,21 +3,24 @@
3
3
  "members": {
4
4
  "0x03a42846c18b99c453a49d7fce69f336f865c48b": [
5
5
  "paid_v2",
6
- "surb_v2"
6
+ "surb_v2",
7
+ "tunnel_v1"
7
8
  ],
8
9
  "0x074a13b271b73eb5c0c44e2037d035e7eef4a462": [
9
10
  "surb_v2"
10
11
  ],
11
12
  "0x1efa385556a6e8643df0033048ba1dd67ec43f93": [
12
13
  "paid_v2",
13
- "surb_v2"
14
+ "surb_v2",
15
+ "tunnel_v1"
14
16
  ],
15
17
  "0x3e98ee4572741fd932e031ac60e79f7c012758be": [
16
18
  "surb_v2"
17
19
  ],
18
20
  "0x6774ca4baf6fff84f02898a3dee4299ed1f5ab4e": [
19
21
  "paid_v2",
20
- "surb_v2"
22
+ "surb_v2",
23
+ "tunnel_v1"
21
24
  ],
22
25
  "0x74486dc1ac551e5cd3f4eef80727cc9d50d3abe9": [
23
26
  "surb_v2"
@@ -35,5 +38,5 @@
35
38
  "surb_v2"
36
39
  ]
37
40
  },
38
- "source": "Capability hints reviewed for the release snapshot. Taken from the liveness section of the public seed topology (https://api.hisoka.io/seed/topology, fleet build 0.4.0-rc.4+b30e46d3ab9d2d707de37011a10639a4c15232ce), re-read at block 315566145 on 2026-10-04 with identical hints for all 10 members. Hints are untrusted by design and only choose between reply-block formats."
41
+ "source": "Capability hints reviewed for the release snapshot. Taken from the liveness section of the public seed topology (https://api.hisoka.io/seed/topology, fleet build 0.4.0-rc.9+3ef92f06f8265700b7b07e9e21e8f6dff0f47a09), read at block 317672566 on 2026-10-10 with all 10 members online; the three exits advertise tunnel_v1. Hints are untrusted by design: they choose between reply-block formats and pick tunnel exits (tunnel_v1), where a false hint costs one open timeout and plaintext stays out of reach."
39
42
  }
@@ -9,7 +9,8 @@
9
9
  "address": "0x03a42846c18b99c453a49d7fce69f336f865c48b",
10
10
  "capabilities": [
11
11
  "paid_v2",
12
- "surb_v2"
12
+ "surb_v2",
13
+ "tunnel_v1"
13
14
  ],
14
15
  "frozen": false,
15
16
  "ingressUrl": "https://nox-7.hisoka.io",
@@ -40,7 +41,8 @@
40
41
  "address": "0x1efa385556a6e8643df0033048ba1dd67ec43f93",
41
42
  "capabilities": [
42
43
  "paid_v2",
43
- "surb_v2"
44
+ "surb_v2",
45
+ "tunnel_v1"
44
46
  ],
45
47
  "frozen": false,
46
48
  "ingressUrl": "https://nox-10.hisoka.io",
@@ -71,7 +73,8 @@
71
73
  "address": "0x6774ca4baf6fff84f02898a3dee4299ed1f5ab4e",
72
74
  "capabilities": [
73
75
  "paid_v2",
74
- "surb_v2"
76
+ "surb_v2",
77
+ "tunnel_v1"
75
78
  ],
76
79
  "frozen": false,
77
80
  "ingressUrl": "https://nox-6.hisoka.io",
@@ -1 +1 @@
1
- 0xa351960e2c268b9665d1aabec87feaa132d30ade773aef64ef495ec25c94fbb2 nox-snapshot.json
1
+ 0x1090f071896b6ef41ddfd40d4ebcd20fae85a66468f82497e19a635488014681 nox-snapshot.json
package/src/config.ts CHANGED
@@ -15,6 +15,25 @@ export type SurbFormatSetting = "auto" | "v1" | "v2";
15
15
  /** `"chain"`: registry reads through the mixnet (S1). `"snapshot"`: the pinned snapshot is the only membership source. */
16
16
  export type DiscoverySetting = "chain" | "snapshot";
17
17
 
18
+ /**
19
+ * End-to-end TLS (README "End-to-end TLS"):
20
+ * - `"required"`: https calls to a host name on port 443 go through TLS
21
+ * tunnels, so exits relay ciphertext only; every other call is refused and
22
+ * nothing ever falls back to the exit's `HttpRequest` path.
23
+ * - `"preferred"`: tunnels where a tunnel exit is known and the URL allows
24
+ * one, `HttpRequest` otherwise. An operator who withholds `tunnel_v1` gets
25
+ * plaintext, so only `"required"` holds against the operator.
26
+ * - `"off"`: every call is an exit `HttpRequest` (worker 0.3 behaviour).
27
+ */
28
+ export type TlsSetting = "required" | "preferred" | "off";
29
+
30
+ /**
31
+ * `"per-call"`: one TLS session per call (with `tlsSpares` handshaken spares
32
+ * per recent host). `"keep-alive"`: calls to one host share a session on one
33
+ * exit, which links them at the exit and at the provider.
34
+ */
35
+ export type TlsSessionSetting = "per-call" | "keep-alive";
36
+
18
37
  export interface NoxWorkerConfig {
19
38
  readonly v: 1;
20
39
  /** KPS addresses (`<ip>:<port>:<certhash>`) tried first, in place of the bundle's default anchors. */
@@ -49,6 +68,24 @@ export interface NoxWorkerConfig {
49
68
  readonly topologySources: number;
50
69
  /** One echo through a full route before `signalReady`. */
51
70
  readonly warmup: boolean;
71
+ readonly tls: TlsSetting;
72
+ readonly tlsSession: TlsSessionSetting;
73
+ /** Per-call mode: handshaken, unused sessions kept per recently used host. */
74
+ readonly tlsSpares: number;
75
+ /** An unused spare is torn down after this long. */
76
+ readonly tlsSpareTtlMs: number;
77
+ /** Keep-alive mode: a session serves calls for this long after it opened. */
78
+ readonly tlsKeepAliveMs: number;
79
+ /** Keep-alive mode: calls per session. */
80
+ readonly tlsMaxCallsPerSession: number;
81
+ /** An exit that does not answer a tunnel open in this long is skipped for a while. */
82
+ readonly tlsOpenTimeoutMs: number;
83
+ /** Another copy of the current exchange when nothing arrived for this long (raised to the observed p95). */
84
+ readonly tlsCopyAfterMs: number;
85
+ /** Another copy when a gap in the reply stream stays open this long. */
86
+ readonly tlsGapMs: number;
87
+ /** Copies per exchange sent for silence or a gap. */
88
+ readonly tlsMaxCopies: number;
52
89
  }
53
90
 
54
91
  export const CONFIG_DEFAULTS: NoxWorkerConfig = Object.freeze({
@@ -71,6 +108,21 @@ export const CONFIG_DEFAULTS: NoxWorkerConfig = Object.freeze({
71
108
  bootRetryMaxMs: 60_000,
72
109
  topologySources: 2,
73
110
  warmup: false,
111
+ // Founder decisions D1/D2 (E2E TLS design §8): the most private settings until decided.
112
+ tls: "required",
113
+ tlsSession: "per-call",
114
+ // Spare policy measured from India on 2026-10-15: the upstream RPC hosts close an idle TLS connection 15.0 s
115
+ // after the handshake, and spares used 14.2 s or more after their handshake had been closed (one wasted exchange,
116
+ // then a retry on a new session). A 12 s TTL kept every spare live (0 retries in 70 calls); a second spare per
117
+ // host raised spare use at a 1 s gap from 16/30 to 23/30 calls.
118
+ tlsSpares: 2,
119
+ tlsSpareTtlMs: 12_000,
120
+ tlsKeepAliveMs: 30_000,
121
+ tlsMaxCallsPerSession: 100,
122
+ tlsOpenTimeoutMs: 5_000,
123
+ tlsCopyAfterMs: 2_500,
124
+ tlsGapMs: 800,
125
+ tlsMaxCopies: 3,
74
126
  });
75
127
 
76
128
  /** Inclusive integer ranges of the numeric fields. */
@@ -84,6 +136,14 @@ export const CONFIG_RANGES = Object.freeze({
84
136
  claimIntervalMs: [50, 2_000],
85
137
  bootRetryMaxMs: [5_000, 300_000],
86
138
  topologySources: [1, 4],
139
+ tlsSpares: [0, 4],
140
+ tlsSpareTtlMs: [1_000, 50_000],
141
+ tlsKeepAliveMs: [1_000, 50_000],
142
+ tlsMaxCallsPerSession: [1, 1_000],
143
+ tlsOpenTimeoutMs: [1_000, 30_000],
144
+ tlsCopyAfterMs: [500, 30_000],
145
+ tlsGapMs: [100, 10_000],
146
+ tlsMaxCopies: [1, 5],
87
147
  } as const satisfies Record<string, readonly [number, number]>);
88
148
 
89
149
  /** `gateways` and `bridges` list length bounds. */
@@ -91,6 +151,8 @@ export const GATEWAYS_RANGE = Object.freeze([1, 16] as const);
91
151
 
92
152
  const SURB_FORMATS: readonly SurbFormatSetting[] = ["auto", "v1", "v2"];
93
153
  const DISCOVERY_SETTINGS: readonly DiscoverySetting[] = ["chain", "snapshot"];
154
+ const TLS_SETTINGS: readonly TlsSetting[] = ["required", "preferred", "off"];
155
+ const TLS_SESSION_SETTINGS: readonly TlsSessionSetting[] = ["per-call", "keep-alive"];
94
156
  /** Names reserved for later stages (PROPOSAL §2.5): refused so they are never reused differently. */
95
157
  const RESERVED_KEYS = new Set(["checkpoint"]);
96
158
  const KNOWN_KEYS = new Set<string>([...Object.keys(CONFIG_DEFAULTS), ...RESERVED_KEYS]);
@@ -166,6 +228,8 @@ export function parseConfig(raw: unknown): NoxWorkerConfig {
166
228
  }
167
229
  out.discovery = discovery as DiscoverySetting;
168
230
  }
231
+ out.tls = pickOne(raw, "tls", TLS_SETTINGS, out.tls);
232
+ out.tlsSession = pickOne(raw, "tlsSession", TLS_SESSION_SETTINGS, out.tlsSession);
169
233
  const fail = (detail: string): ConfigError => new ConfigError(`config.${detail}`);
170
234
  if (raw["gateways"] !== undefined && raw["bridges"] !== undefined) {
171
235
  throw new ConfigError("config.gateways and config.bridges exclude each other: with bridges the worker dials only bridges");
@@ -186,6 +250,15 @@ export function parseConfig(raw: unknown): NoxWorkerConfig {
186
250
  return Object.freeze(out);
187
251
  }
188
252
 
253
+ function pickOne<T extends string>(raw: Record<string, unknown>, field: string, allowed: readonly T[], fallback: T): T {
254
+ const value = raw[field];
255
+ if (value === undefined) return fallback;
256
+ if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) {
257
+ throw new ConfigError(`config.${field} must be one of ${allowed.join(", ")}`);
258
+ }
259
+ return value as T;
260
+ }
261
+
189
262
  function isPlainObject(value: unknown): value is Record<string, unknown> {
190
263
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
191
264
  const proto = Object.getPrototypeOf(value) as unknown;
package/src/core.ts CHANGED
@@ -12,7 +12,9 @@
12
12
  * call `signalFailed`; transient ones retry forever with back-off while
13
13
  * `ready` stays pending. After ready the SDK checks NoxRegistry through the
14
14
  * mixnet in the background (S1); each verified check refreshes the
15
- * learned-anchor cache.
15
+ * learned-anchor cache. With `tls` other than `"off"` the embedded TLS module
16
+ * is compiled at boot too, off the first call's path, and https calls go
17
+ * through TLS tunnels (src/tls/).
16
18
  */
17
19
  import {
18
20
  DISCOVERY_FIRST_CHECK_MAX_DEFER_MS,
@@ -51,6 +53,10 @@ import {
51
53
  type PreparedRequest,
52
54
  } from "./fetch-map.js";
53
55
  import { LocalAnswers } from "./local-answers.js";
56
+ import type { NoxTlsBindings } from "./tls/module.js";
57
+ import { TlsPool } from "./tls/pool.js";
58
+ import { TlsTransport } from "./tls/transport.js";
59
+ import type { TunnelPort } from "./tls/tunnel.js";
54
60
  import { createLogger, describeError, errorCode, type LogLevel, type WorkerLogger } from "./log.js";
55
61
  import {
56
62
  LearnedCacheWriter,
@@ -63,7 +69,7 @@ import {
63
69
  } from "./storage.js";
64
70
 
65
71
  /** The slice of `NoxClient` the worker uses. */
66
- export interface NoxClientPort extends NoxHttpPort {
72
+ export interface NoxClientPort extends NoxHttpPort, TunnelPort {
67
73
  readonly nodes: readonly { readonly id: string }[];
68
74
  sendEcho(data: Uint8Array): Promise<Uint8Array>;
69
75
  disconnect(): void;
@@ -77,6 +83,8 @@ export interface WorkerDeps {
77
83
  readonly bootstrap: unknown;
78
84
  /** Initialise the embedded WASM and return its bindings; throws when WebAssembly is blocked. */
79
85
  loadWasm(): Promise<NoxWasmBindings>;
86
+ /** Initialise the embedded TLS module (only when `tls` is not `"off"`). */
87
+ loadTls(): Promise<NoxTlsBindings>;
80
88
  /** `NoxClient.connect`. */
81
89
  connect(config: NoxClientConfig): Promise<NoxClientPort>;
82
90
  /** Milliseconds since the epoch. Default `Date.now`. */
@@ -158,6 +166,8 @@ class NoxWorker {
158
166
  private cacheTimer: ReturnType<typeof setInterval> | undefined;
159
167
  private learnedWriter: LearnedCacheWriter | undefined;
160
168
  private callSeq = 0;
169
+ private tlsPool: TlsPool | undefined;
170
+ private tunnels: TlsTransport | undefined;
161
171
  private readonly answers = new LocalAnswers();
162
172
  private readonly now: () => number;
163
173
  private readonly random: () => number;
@@ -231,6 +241,21 @@ class NoxWorker {
231
241
  }
232
242
  this.log.info("boot.wasm");
233
243
 
244
+ let tls: NoxTlsBindings | undefined;
245
+ if (cfg.tls !== "off") {
246
+ try {
247
+ tls = await this.deps.loadTls();
248
+ } catch (error) {
249
+ this.fail(
250
+ FAILED_CODES.wasmBlocked,
251
+ `The TLS module could not be compiled or instantiated (${describeError(error)}); ` +
252
+ "the embedder's Content-Security-Policy must allow 'wasm-unsafe-eval'",
253
+ );
254
+ return;
255
+ }
256
+ this.log.info("boot.tls", { mode: cfg.tls, session: cfg.tlsSession, roots: tls.info.webpkiRoots });
257
+ }
258
+
234
259
  void this.acceptLoop(cfg);
235
260
 
236
261
  const snapshotId = snapshotIdentity(pinned);
@@ -250,6 +275,15 @@ class NoxWorker {
250
275
  return;
251
276
  }
252
277
  this.client = client;
278
+ if (tls !== undefined) {
279
+ this.tlsPool = new TlsPool(client, tls, cfg, {
280
+ now: this.now,
281
+ random: this.random,
282
+ randomBytes: (length) => crypto.getRandomValues(new Uint8Array(length)),
283
+ log: this.log,
284
+ });
285
+ this.tunnels = new TlsTransport(this.tlsPool, tls, cfg, this.log, this.now);
286
+ }
253
287
  this.startRemovalCache(pinned, snapshotId, stored);
254
288
  this.api.signalReady();
255
289
  this.log.info("ready");
@@ -273,6 +307,9 @@ class NoxWorker {
273
307
  private shutdown(reason: NoxWorkerError): void {
274
308
  if (this.cacheTimer !== undefined) clearInterval(this.cacheTimer);
275
309
  this.cacheTimer = undefined;
310
+ this.tlsPool?.close();
311
+ this.tlsPool = undefined;
312
+ this.tunnels = undefined;
276
313
  this.client?.disconnect();
277
314
  this.client = undefined;
278
315
  for (const waiter of this.readyWaiters) waiter.reject(reason);
@@ -481,7 +518,7 @@ class NoxWorker {
481
518
  return local.response;
482
519
  }
483
520
  const client = await this.waitReady(budget.signal);
484
- const response = await sendPrepared(prepared, client, cfg, budget);
521
+ const response = await sendPrepared(prepared, client, cfg, budget, this.tunnels);
485
522
  if (this.answers.observe(prepared, response) === "verify") this.verifyAnswer(prepared, client, cfg);
486
523
  if (this.log.enabled("debug")) {
487
524
  this.log.debug("call.done", {
@@ -501,6 +538,8 @@ class NoxWorker {
501
538
  throw final;
502
539
  } finally {
503
540
  budget.dispose();
541
+ // Spares open only once a wallet call settled (background work never delays the first call).
542
+ this.tlsPool?.enableBackground();
504
543
  }
505
544
  }
506
545
 
@@ -511,7 +550,7 @@ class NoxWorker {
511
550
  */
512
551
  private verifyAnswer(prepared: PreparedRequest, client: NoxClientPort, cfg: NoxWorkerConfig): void {
513
552
  const budget = new CallControl(undefined, cfg.callDeadlineMs, this.now);
514
- sendPrepared(prepared, client, cfg, budget)
553
+ sendPrepared(prepared, client, cfg, budget, this.tunnels)
515
554
  .then(
516
555
  (response) => {
517
556
  this.answers.observe(prepared, response);
@@ -13,3 +13,19 @@ declare module "nox-embed:bootstrap" {
13
13
  const bootstrap: unknown;
14
14
  export default bootstrap;
15
15
  }
16
+
17
+ declare module "nox-embed:tls" {
18
+ /** Initialise the embedded nox-tls module (no fetch; the bytes are in the bundle). */
19
+ export default function init(): Promise<void>;
20
+ export const TlsClientConfig: import("./tls/module.js").NoxTlsExports["TlsClientConfig"];
21
+ export const TlsClientSession: import("./tls/module.js").NoxTlsExports["TlsClientSession"];
22
+ export const HttpResponseParser: import("./tls/module.js").NoxTlsExports["HttpResponseParser"];
23
+ export const encodeHttpRequest: import("./tls/module.js").NoxTlsExports["encodeHttpRequest"];
24
+ export const buildInfo: import("./tls/module.js").NoxTlsExports["buildInfo"];
25
+ }
26
+
27
+ declare module "nox-embed:tls-root" {
28
+ /** A test bed's CA certificate (DER), trusted next to the compiled-in roots; `null` in every release build. */
29
+ const extraRootDer: Uint8Array | null;
30
+ export default extraRootDer;
31
+ }
package/src/errors.ts CHANGED
@@ -54,8 +54,14 @@ export const CALL_CODES = Object.freeze({
54
54
  tooLarge: "too-large",
55
55
  /** Not an absolute http(s) URL, or an invalid method, header or redirect mode. */
56
56
  unsupported: "unsupported",
57
- /** The exit's reply could not be decoded or is not a valid HTTP response. */
57
+ /**
58
+ * The exit's reply could not be decoded or is not a valid HTTP response; or
59
+ * TLS through a tunnel failed its own checks (a refused record, an alert, a
60
+ * failed record authentication, a body cut short).
61
+ */
58
62
  protocolError: "protocol-error",
63
+ /** The tunnel exit's policy refuses the destination (port, host name or a blocked address). */
64
+ permissionDenied: "permission-denied",
59
65
  /** An unexpected exception (a bug). */
60
66
  internalError: "internal-error",
61
67
  } as const);
package/src/fetch-map.ts CHANGED
@@ -27,6 +27,13 @@ export interface NoxHttpPort {
27
27
  ): Promise<Uint8Array>;
28
28
  }
29
29
 
30
+ /** TLS tunnels for fetch hops (src/tls/transport.ts). */
31
+ export interface TunnelHop {
32
+ /** `"tunnel"` or `"http"` for this hop; throws the call's rejection when the hop may not leave. */
33
+ route(request: PreparedRequest, hop: number, startedOnTunnel: boolean): "tunnel" | "http";
34
+ exchange(request: PreparedRequest, budget: CallBudget): Promise<DecodedHttpResponse>;
35
+ }
36
+
30
37
  /** Limits from the worker config. */
31
38
  export interface FetchSettings {
32
39
  readonly attemptTimeoutMs: number;
@@ -185,17 +192,27 @@ export interface CallBudget {
185
192
  * need time and writes are never resent (ARCHITECTURE §4.7). The SDK still
186
193
  * resends any request whose packet was certainly never sent (a KPS dial or
187
194
  * stream-open failure) through another entry.
195
+ *
196
+ * With `tunnels`, each hop first asks it for its transport: a hop that goes
197
+ * through a TLS tunnel is exchanged there, and once one hop did, every later
198
+ * hop must too.
188
199
  */
189
200
  export async function sendPrepared(
190
201
  request: PreparedRequest,
191
202
  port: NoxHttpPort,
192
203
  settings: FetchSettings,
193
204
  budget: CallBudget,
205
+ tunnels?: TunnelHop,
194
206
  ): Promise<AnonFetchResponse> {
195
207
  if (budget.remainingMs() <= 0) throw callError(CALL_CODES.timeout, "The call deadline passed before sending");
196
208
  let current = request;
209
+ let onTunnel = false;
197
210
  for (let hop = 0; ; hop++) {
198
- const reply = await exchange(current, port, settings, budget);
211
+ const via = tunnels?.route(current, hop, onTunnel) ?? "http";
212
+ const reply = tunnels !== undefined && via === "tunnel"
213
+ ? await tunnels.exchange(current, budget)
214
+ : await exchange(current, port, settings, budget);
215
+ if (via === "tunnel") onTunnel = true;
199
216
  checkStatus(reply);
200
217
  if (!REDIRECT_STATUSES.has(reply.status) || current.redirect === "manual") {
201
218
  return finishResponse(reply, current, settings);
@@ -0,0 +1,183 @@
1
+ /**
2
+ * A TLS session to one host over one tunnel: the handshake exchange, then
3
+ * HTTP/1.1 requests. Completeness of a response comes from HTTP framing or,
4
+ * for a close-delimited body, from TLS close_notify; an exit that ends the
5
+ * stream early produces a truncation error, never a short response.
6
+ */
7
+ import type { TopologyNode } from "@hisoka-io/nox-client";
8
+ import type { HttpParserLike, HttpReplyHead, NoxTlsBindings, TlsSessionLike } from "./module.js";
9
+ import { Tunnel, TunnelProtocolError, type CopyPolicy, type TunnelDeps, type TunnelPort } from "./tunnel.js";
10
+
11
+ /** Reply blocks for the handshake exchange: one carries the server flight, the other is the exit's reserve. */
12
+ export const HANDSHAKE_SURBS = 2;
13
+ /** The TLS port every tunnel uses. */
14
+ export const TLS_PORT = 443;
15
+
16
+ export interface ExchangeTiming {
17
+ readonly deadlineAt: number;
18
+ readonly signal?: AbortSignal;
19
+ readonly background: boolean;
20
+ }
21
+
22
+ export interface RequestSpec extends ExchangeTiming {
23
+ /** The encoded HTTP/1.1 request. */
24
+ readonly http: Uint8Array;
25
+ readonly headRequest: boolean;
26
+ readonly maxHeadBytes: number;
27
+ readonly maxBodyBytes: number;
28
+ /** Reply blocks for the exchange that carries the request. */
29
+ readonly surbs: number;
30
+ }
31
+
32
+ export class TlsChannel {
33
+ readonly tunnel: Tunnel;
34
+ readonly openedAt: number;
35
+ /** Requests sent on this session. */
36
+ calls = 0;
37
+ /** Milliseconds spent in the TLS module processing the server's handshake flight. */
38
+ handshakeCpuMs = 0;
39
+ /** False once a response asked to close or the stream ended. */
40
+ private keepAlive = true;
41
+ private parser: HttpParserLike | undefined;
42
+ private parserDone = false;
43
+ private freed = false;
44
+ private readonly tls: TlsSessionLike;
45
+
46
+ constructor(
47
+ port: TunnelPort,
48
+ exit: TopologyNode,
49
+ readonly host: string,
50
+ policy: CopyPolicy,
51
+ deps: TunnelDeps,
52
+ private readonly bindings: NoxTlsBindings,
53
+ ) {
54
+ this.tls = bindings.newSession(host);
55
+ this.openedAt = deps.now();
56
+ this.tunnel = new Tunnel(port, exit, host, TLS_PORT, policy, deps, (bytes) => this.onBytes(bytes));
57
+ }
58
+
59
+ get exit(): TopologyNode {
60
+ return this.tunnel.exit;
61
+ }
62
+
63
+ /** TLS version negotiated (`TLSv1_3`, `TLSv1_2`). */
64
+ get protocol(): string {
65
+ return this.tls.protocol();
66
+ }
67
+
68
+ /** The session can carry another request. */
69
+ get reusable(): boolean {
70
+ return !this.freed && this.keepAlive && !this.tunnel.eof && !this.tls.isClosed();
71
+ }
72
+
73
+ /**
74
+ * Seq 0: open the tunnel with the ClientHello and process the server's
75
+ * flight. Afterwards the client's next flight (the TLS 1.3 Finished) waits
76
+ * in the session until the first request, which travels with it.
77
+ */
78
+ async handshake(timing: ExchangeTiming & { readonly openTimeoutMs: number }): Promise<void> {
79
+ const hello = this.tls.takeOutgoing();
80
+ const outcome = await this.tunnel.exchange({
81
+ data: hello,
82
+ surbs: HANDSHAKE_SURBS,
83
+ deadlineAt: timing.deadlineAt,
84
+ background: timing.background,
85
+ openTimeoutMs: timing.openTimeoutMs,
86
+ ...(timing.signal === undefined ? {} : { signal: timing.signal }),
87
+ satisfied: () => this.tls.wantsWrite() || !this.tls.isHandshaking(),
88
+ });
89
+ if (outcome === "eof") throw new TunnelProtocolError("The upstream closed the connection during the TLS handshake");
90
+ }
91
+
92
+ /** One HTTP request and its complete response. */
93
+ async request(spec: RequestSpec): Promise<HttpReplyHead> {
94
+ this.calls += 1;
95
+ const parser = this.bindings.newParser(spec.headRequest, spec.maxHeadBytes, spec.maxBodyBytes);
96
+ this.parser = parser;
97
+ this.parserDone = false;
98
+ try {
99
+ this.tls.writePlaintext(spec.http);
100
+ for (;;) {
101
+ const data = this.tls.takeOutgoing();
102
+ const outcome = await this.tunnel.exchange({
103
+ data,
104
+ surbs: spec.surbs,
105
+ deadlineAt: spec.deadlineAt,
106
+ background: spec.background,
107
+ ...(spec.signal === undefined ? {} : { signal: spec.signal }),
108
+ satisfied: () => this.parserDone || this.tls.wantsWrite(),
109
+ });
110
+ if (outcome === "eof") {
111
+ this.keepAlive = false;
112
+ this.tls.pushTransportEof();
113
+ this.feedParser(this.tls.takePlaintext());
114
+ if (!this.parserDone) {
115
+ parser.finishOnClose(this.tls.closeNotifyReceived());
116
+ this.parserDone = true;
117
+ }
118
+ }
119
+ if (this.parserDone) break;
120
+ }
121
+ const response = parser.response();
122
+ if (response === null) throw new TunnelProtocolError("The HTTP parser finished without a response");
123
+ if (!response.keepAlive) this.keepAlive = false;
124
+ return response;
125
+ } finally {
126
+ this.parser = undefined;
127
+ parser.free();
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Close the tunnel: close_notify when the TLS session can still send one,
133
+ * and the acknowledgement of every byte received, so the exit drops the
134
+ * session at once. Nothing waits for it.
135
+ */
136
+ teardown(deadlineAt: number): Promise<void> {
137
+ if (this.freed) return Promise.resolve();
138
+ this.keepAlive = false;
139
+ const data = this.tunnel.eof ? new Uint8Array(0) : this.closeNotify();
140
+ this.free();
141
+ return this.tunnel.teardown(data, deadlineAt);
142
+ }
143
+
144
+ /** The close_notify record, or nothing when the session can no longer send one. */
145
+ private closeNotify(): Uint8Array {
146
+ try {
147
+ this.tls.close();
148
+ return this.tls.takeOutgoing();
149
+ } catch {
150
+ return new Uint8Array(0);
151
+ }
152
+ }
153
+
154
+ /** Release the TLS session's WebAssembly memory. */
155
+ free(): void {
156
+ if (this.freed) return;
157
+ this.freed = true;
158
+ this.tls.free();
159
+ }
160
+
161
+ private onBytes(bytes: Uint8Array): void {
162
+ if (this.tls.isHandshaking()) {
163
+ const started = performance.now();
164
+ this.tls.pushIncoming(bytes);
165
+ this.handshakeCpuMs += performance.now() - started;
166
+ } else {
167
+ this.tls.pushIncoming(bytes);
168
+ }
169
+ this.feedParser(this.tls.takePlaintext());
170
+ if (this.tls.closeNotifyReceived() && this.parser !== undefined && !this.parserDone) {
171
+ this.parser.finishOnClose(true);
172
+ this.parserDone = true;
173
+ }
174
+ }
175
+
176
+ private feedParser(plaintext: Uint8Array): void {
177
+ if (plaintext.length === 0) return;
178
+ if (this.parser === undefined || this.parserDone) {
179
+ throw new TunnelProtocolError(`The upstream sent ${plaintext.length} bytes with no request outstanding`);
180
+ }
181
+ this.parserDone = this.parser.push(plaintext);
182
+ }
183
+ }