@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.
- package/CHANGELOG.md +61 -0
- package/README.md +142 -30
- package/dist/anon-rpc-worker.js +2171 -235
- package/dist/anon-rpc-worker.js.keccak256 +1 -1
- package/dist/anon-rpc-worker.provenance.json +19 -8
- package/package.json +3 -3
- package/snapshot/capabilities.json +7 -4
- package/snapshot/nox-snapshot.json +6 -3
- package/snapshot/nox-snapshot.json.keccak256 +1 -1
- package/src/config.ts +73 -0
- package/src/core.ts +43 -4
- package/src/embed-modules.d.ts +16 -0
- package/src/errors.ts +7 -1
- package/src/fetch-map.ts +18 -1
- package/src/tls/channel.ts +183 -0
- package/src/tls/module.ts +98 -0
- package/src/tls/pool.ts +581 -0
- package/src/tls/transport.ts +279 -0
- package/src/tls/tunnel.ts +360 -0
- package/src/worker.ts +12 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
0xd9fa04b080a33062680a27cad828196bfb04615a0ebc76bcbffedb6dcbe838d4 anon-rpc-worker.js
|
|
@@ -3,29 +3,40 @@
|
|
|
3
3
|
"arch": "x64",
|
|
4
4
|
"os": "linux"
|
|
5
5
|
},
|
|
6
|
-
"gitCommit": "
|
|
6
|
+
"gitCommit": "66bce4af274b4be457ef11336ba9cb6d9200efd1",
|
|
7
7
|
"gitTreeClean": true,
|
|
8
8
|
"inputs": {
|
|
9
9
|
"bootstrapKeccak": "0x6567e3ad5e1b1ac81529391c890d2652695d3ec139d56625170384794085fb74",
|
|
10
10
|
"lockfileSha256": "0a205a04cd3b6d861b8fedef8b8e6cd6356642e5f2cd9752ce0e2f403fbae426",
|
|
11
11
|
"snapshotBlock": 316207920,
|
|
12
|
-
"snapshotKeccak": "
|
|
13
|
-
"
|
|
14
|
-
"
|
|
12
|
+
"snapshotKeccak": "0x1090f071896b6ef41ddfd40d4ebcd20fae85a66468f82497e19a635488014681",
|
|
13
|
+
"tlsWasmKeccak": "0x60b29cd4fca7ee2f31fbcff2dca25e64939e4b71a4629ffcfea1b89546f501ce",
|
|
14
|
+
"tlsWasmSha256": "f74e8cfbed7be33ef1a9158b7b66e454b4ca0849bcb8956487fd13da47e4f4d2",
|
|
15
|
+
"wasmKeccak": "0x91060e59264ba44c3f16181caff1adcf16193a1e57d6ba4dd3531b192ac42de4",
|
|
16
|
+
"wasmSha256": "6db306a34ef50e9198f4ad6f31c6ffd9feafe459c24b8a29895824c7138241c8"
|
|
15
17
|
},
|
|
16
18
|
"output": {
|
|
17
|
-
"bytes":
|
|
18
|
-
"keccak256": "
|
|
19
|
-
"sha256": "
|
|
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.
|
|
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
|
+
"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.
|
|
29
|
-
"@hisoka-io/nox-wasm": "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.
|
|
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
|
-
|
|
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);
|
package/src/embed-modules.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
+
}
|