@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.
- package/CHANGELOG.md +51 -0
- package/README.md +120 -15
- package/dist/anon-rpc-worker.js +5955 -3361
- package/dist/anon-rpc-worker.js.keccak256 +1 -1
- package/dist/anon-rpc-worker.provenance.json +19 -8
- package/package.json +2 -2
- 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 +77 -0
- package/src/core.ts +96 -4
- package/src/embed-modules.d.ts +16 -0
- package/src/errors.ts +7 -1
- package/src/fetch-map.ts +109 -13
- package/src/local-answers.ts +238 -0
- package/src/tls/channel.ts +183 -0
- package/src/tls/module.ts +98 -0
- package/src/tls/pool.ts +358 -0
- package/src/tls/transport.ts +257 -0
- package/src/tls/tunnel.ts +360 -0
- package/src/worker.ts +12 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
0xd8bef626ec8511d7a468bca5137880680ca2da20d2ea4d9c981a2a3d0c86ba1e anon-rpc-worker.js
|
|
@@ -3,29 +3,40 @@
|
|
|
3
3
|
"arch": "x64",
|
|
4
4
|
"os": "linux"
|
|
5
5
|
},
|
|
6
|
-
"gitCommit": "
|
|
6
|
+
"gitCommit": "fee8d69861a112c65f0cbb0b848a7726ec12f01a",
|
|
7
7
|
"gitTreeClean": true,
|
|
8
8
|
"inputs": {
|
|
9
9
|
"bootstrapKeccak": "0x6567e3ad5e1b1ac81529391c890d2652695d3ec139d56625170384794085fb74",
|
|
10
|
-
"lockfileSha256": "
|
|
10
|
+
"lockfileSha256": "0a205a04cd3b6d861b8fedef8b8e6cd6356642e5f2cd9752ce0e2f403fbae426",
|
|
11
11
|
"snapshotBlock": 316207920,
|
|
12
|
-
"snapshotKeccak": "
|
|
12
|
+
"snapshotKeccak": "0x1090f071896b6ef41ddfd40d4ebcd20fae85a66468f82497e19a635488014681",
|
|
13
|
+
"tlsWasmKeccak": "0x60b29cd4fca7ee2f31fbcff2dca25e64939e4b71a4629ffcfea1b89546f501ce",
|
|
14
|
+
"tlsWasmSha256": "f74e8cfbed7be33ef1a9158b7b66e454b4ca0849bcb8956487fd13da47e4f4d2",
|
|
13
15
|
"wasmKeccak": "0x6ff418a78fc022d34dff00a867e173269413fb6887c9d6bf8e6505d4dcfffe2d",
|
|
14
16
|
"wasmSha256": "5171416972ea4499fd4b4ea8bcadf002f4c10f10ed42cafb0d5a426121da9c4f"
|
|
15
17
|
},
|
|
16
18
|
"output": {
|
|
17
|
-
"bytes":
|
|
18
|
-
"keccak256": "
|
|
19
|
-
"sha256": "
|
|
19
|
+
"bytes": 1820668,
|
|
20
|
+
"keccak256": "0xd8bef626ec8511d7a468bca5137880680ca2da20d2ea4d9c981a2a3d0c86ba1e",
|
|
21
|
+
"sha256": "4b7b69cd6f45c364cd87ff75f5f8cacdebea2cd852d5384c47215bb88d56f35a"
|
|
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
|
-
"node": "
|
|
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.0"
|
|
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.0",
|
|
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,7 +25,7 @@
|
|
|
25
25
|
"ethers": "6.16.0",
|
|
26
26
|
"typescript": "5.9.3",
|
|
27
27
|
"vitest": "1.6.1",
|
|
28
|
-
"@hisoka-io/nox-client": "0.
|
|
28
|
+
"@hisoka-io/nox-client": "0.8.0",
|
|
29
29
|
"@hisoka-io/nox-wasm": "0.2.0"
|
|
30
30
|
},
|
|
31
31
|
"publishConfig": {
|
|
@@ -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. */
|
|
@@ -31,6 +50,12 @@ export interface NoxWorkerConfig {
|
|
|
31
50
|
readonly logLevel: LogLevel;
|
|
32
51
|
/** Per attempt for small and medium reads. */
|
|
33
52
|
readonly attemptTimeoutMs: number;
|
|
53
|
+
/**
|
|
54
|
+
* Resendable reads: a second copy goes out on another route when no reply
|
|
55
|
+
* arrived after this long (raised to the observed p95 reply time); the
|
|
56
|
+
* first reply wins. 0 turns hedging off.
|
|
57
|
+
*/
|
|
58
|
+
readonly hedgeAfterMs: number;
|
|
34
59
|
/** Whole call, from acceptance: waiting for ready, sending, attempts. */
|
|
35
60
|
readonly callDeadlineMs: number;
|
|
36
61
|
readonly maxConcurrentCalls: number;
|
|
@@ -43,6 +68,24 @@ export interface NoxWorkerConfig {
|
|
|
43
68
|
readonly topologySources: number;
|
|
44
69
|
/** One echo through a full route before `signalReady`. */
|
|
45
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;
|
|
46
89
|
}
|
|
47
90
|
|
|
48
91
|
export const CONFIG_DEFAULTS: NoxWorkerConfig = Object.freeze({
|
|
@@ -55,6 +98,7 @@ export const CONFIG_DEFAULTS: NoxWorkerConfig = Object.freeze({
|
|
|
55
98
|
trust: "auto",
|
|
56
99
|
logLevel: "info",
|
|
57
100
|
attemptTimeoutMs: 12_000,
|
|
101
|
+
hedgeAfterMs: 3_000,
|
|
58
102
|
callDeadlineMs: 25_000,
|
|
59
103
|
maxConcurrentCalls: 16,
|
|
60
104
|
maxRequestBytes: 1_048_576,
|
|
@@ -64,11 +108,23 @@ export const CONFIG_DEFAULTS: NoxWorkerConfig = Object.freeze({
|
|
|
64
108
|
bootRetryMaxMs: 60_000,
|
|
65
109
|
topologySources: 2,
|
|
66
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
|
+
tlsSpares: 1,
|
|
115
|
+
tlsSpareTtlMs: 20_000,
|
|
116
|
+
tlsKeepAliveMs: 30_000,
|
|
117
|
+
tlsMaxCallsPerSession: 100,
|
|
118
|
+
tlsOpenTimeoutMs: 5_000,
|
|
119
|
+
tlsCopyAfterMs: 2_500,
|
|
120
|
+
tlsGapMs: 800,
|
|
121
|
+
tlsMaxCopies: 3,
|
|
67
122
|
});
|
|
68
123
|
|
|
69
124
|
/** Inclusive integer ranges of the numeric fields. */
|
|
70
125
|
export const CONFIG_RANGES = Object.freeze({
|
|
71
126
|
attemptTimeoutMs: [3_000, 60_000],
|
|
127
|
+
hedgeAfterMs: [0, 60_000],
|
|
72
128
|
callDeadlineMs: [3_000, 120_000],
|
|
73
129
|
maxConcurrentCalls: [1, 64],
|
|
74
130
|
maxRequestBytes: [1_024, 4_194_304],
|
|
@@ -76,6 +132,14 @@ export const CONFIG_RANGES = Object.freeze({
|
|
|
76
132
|
claimIntervalMs: [50, 2_000],
|
|
77
133
|
bootRetryMaxMs: [5_000, 300_000],
|
|
78
134
|
topologySources: [1, 4],
|
|
135
|
+
tlsSpares: [0, 4],
|
|
136
|
+
tlsSpareTtlMs: [1_000, 50_000],
|
|
137
|
+
tlsKeepAliveMs: [1_000, 50_000],
|
|
138
|
+
tlsMaxCallsPerSession: [1, 1_000],
|
|
139
|
+
tlsOpenTimeoutMs: [1_000, 30_000],
|
|
140
|
+
tlsCopyAfterMs: [500, 30_000],
|
|
141
|
+
tlsGapMs: [100, 10_000],
|
|
142
|
+
tlsMaxCopies: [1, 5],
|
|
79
143
|
} as const satisfies Record<string, readonly [number, number]>);
|
|
80
144
|
|
|
81
145
|
/** `gateways` and `bridges` list length bounds. */
|
|
@@ -83,6 +147,8 @@ export const GATEWAYS_RANGE = Object.freeze([1, 16] as const);
|
|
|
83
147
|
|
|
84
148
|
const SURB_FORMATS: readonly SurbFormatSetting[] = ["auto", "v1", "v2"];
|
|
85
149
|
const DISCOVERY_SETTINGS: readonly DiscoverySetting[] = ["chain", "snapshot"];
|
|
150
|
+
const TLS_SETTINGS: readonly TlsSetting[] = ["required", "preferred", "off"];
|
|
151
|
+
const TLS_SESSION_SETTINGS: readonly TlsSessionSetting[] = ["per-call", "keep-alive"];
|
|
86
152
|
/** Names reserved for later stages (PROPOSAL §2.5): refused so they are never reused differently. */
|
|
87
153
|
const RESERVED_KEYS = new Set(["checkpoint"]);
|
|
88
154
|
const KNOWN_KEYS = new Set<string>([...Object.keys(CONFIG_DEFAULTS), ...RESERVED_KEYS]);
|
|
@@ -158,6 +224,8 @@ export function parseConfig(raw: unknown): NoxWorkerConfig {
|
|
|
158
224
|
}
|
|
159
225
|
out.discovery = discovery as DiscoverySetting;
|
|
160
226
|
}
|
|
227
|
+
out.tls = pickOne(raw, "tls", TLS_SETTINGS, out.tls);
|
|
228
|
+
out.tlsSession = pickOne(raw, "tlsSession", TLS_SESSION_SETTINGS, out.tlsSession);
|
|
161
229
|
const fail = (detail: string): ConfigError => new ConfigError(`config.${detail}`);
|
|
162
230
|
if (raw["gateways"] !== undefined && raw["bridges"] !== undefined) {
|
|
163
231
|
throw new ConfigError("config.gateways and config.bridges exclude each other: with bridges the worker dials only bridges");
|
|
@@ -178,6 +246,15 @@ export function parseConfig(raw: unknown): NoxWorkerConfig {
|
|
|
178
246
|
return Object.freeze(out);
|
|
179
247
|
}
|
|
180
248
|
|
|
249
|
+
function pickOne<T extends string>(raw: Record<string, unknown>, field: string, allowed: readonly T[], fallback: T): T {
|
|
250
|
+
const value = raw[field];
|
|
251
|
+
if (value === undefined) return fallback;
|
|
252
|
+
if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) {
|
|
253
|
+
throw new ConfigError(`config.${field} must be one of ${allowed.join(", ")}`);
|
|
254
|
+
}
|
|
255
|
+
return value as T;
|
|
256
|
+
}
|
|
257
|
+
|
|
181
258
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
182
259
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
183
260
|
const proto = Object.getPrototypeOf(value) as unknown;
|
package/src/core.ts
CHANGED
|
@@ -12,9 +12,13 @@
|
|
|
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 {
|
|
20
|
+
DISCOVERY_FIRST_CHECK_MAX_DEFER_MS,
|
|
21
|
+
RESEND_FAST,
|
|
18
22
|
eligiblePinnedMembers,
|
|
19
23
|
pinnedKpsAddresses,
|
|
20
24
|
verifyBootstrap,
|
|
@@ -40,7 +44,19 @@ import {
|
|
|
40
44
|
type BootRetryCode,
|
|
41
45
|
type FailedCode,
|
|
42
46
|
} from "./errors.js";
|
|
43
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
mapClientError,
|
|
49
|
+
prepareRequest,
|
|
50
|
+
sendPrepared,
|
|
51
|
+
type CallBudget,
|
|
52
|
+
type NoxHttpPort,
|
|
53
|
+
type PreparedRequest,
|
|
54
|
+
} from "./fetch-map.js";
|
|
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";
|
|
44
60
|
import { createLogger, describeError, errorCode, type LogLevel, type WorkerLogger } from "./log.js";
|
|
45
61
|
import {
|
|
46
62
|
LearnedCacheWriter,
|
|
@@ -53,7 +69,7 @@ import {
|
|
|
53
69
|
} from "./storage.js";
|
|
54
70
|
|
|
55
71
|
/** The slice of `NoxClient` the worker uses. */
|
|
56
|
-
export interface NoxClientPort extends NoxHttpPort {
|
|
72
|
+
export interface NoxClientPort extends NoxHttpPort, TunnelPort {
|
|
57
73
|
readonly nodes: readonly { readonly id: string }[];
|
|
58
74
|
sendEcho(data: Uint8Array): Promise<Uint8Array>;
|
|
59
75
|
disconnect(): void;
|
|
@@ -67,6 +83,8 @@ export interface WorkerDeps {
|
|
|
67
83
|
readonly bootstrap: unknown;
|
|
68
84
|
/** Initialise the embedded WASM and return its bindings; throws when WebAssembly is blocked. */
|
|
69
85
|
loadWasm(): Promise<NoxWasmBindings>;
|
|
86
|
+
/** Initialise the embedded TLS module (only when `tls` is not `"off"`). */
|
|
87
|
+
loadTls(): Promise<NoxTlsBindings>;
|
|
70
88
|
/** `NoxClient.connect`. */
|
|
71
89
|
connect(config: NoxClientConfig): Promise<NoxClientPort>;
|
|
72
90
|
/** Milliseconds since the epoch. Default `Date.now`. */
|
|
@@ -148,6 +166,9 @@ class NoxWorker {
|
|
|
148
166
|
private cacheTimer: ReturnType<typeof setInterval> | undefined;
|
|
149
167
|
private learnedWriter: LearnedCacheWriter | undefined;
|
|
150
168
|
private callSeq = 0;
|
|
169
|
+
private tlsPool: TlsPool | undefined;
|
|
170
|
+
private tunnels: TlsTransport | undefined;
|
|
171
|
+
private readonly answers = new LocalAnswers();
|
|
151
172
|
private readonly now: () => number;
|
|
152
173
|
private readonly random: () => number;
|
|
153
174
|
private readonly sleep: (ms: number) => Promise<void>;
|
|
@@ -220,6 +241,21 @@ class NoxWorker {
|
|
|
220
241
|
}
|
|
221
242
|
this.log.info("boot.wasm");
|
|
222
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
|
+
|
|
223
259
|
void this.acceptLoop(cfg);
|
|
224
260
|
|
|
225
261
|
const snapshotId = snapshotIdentity(pinned);
|
|
@@ -239,6 +275,15 @@ class NoxWorker {
|
|
|
239
275
|
return;
|
|
240
276
|
}
|
|
241
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
|
+
}
|
|
242
287
|
this.startRemovalCache(pinned, snapshotId, stored);
|
|
243
288
|
this.api.signalReady();
|
|
244
289
|
this.log.info("ready");
|
|
@@ -262,6 +307,9 @@ class NoxWorker {
|
|
|
262
307
|
private shutdown(reason: NoxWorkerError): void {
|
|
263
308
|
if (this.cacheTimer !== undefined) clearInterval(this.cacheTimer);
|
|
264
309
|
this.cacheTimer = undefined;
|
|
310
|
+
this.tlsPool?.close();
|
|
311
|
+
this.tlsPool = undefined;
|
|
312
|
+
this.tunnels = undefined;
|
|
265
313
|
this.client?.disconnect();
|
|
266
314
|
this.client = undefined;
|
|
267
315
|
for (const waiter of this.readyWaiters) waiter.reject(reason);
|
|
@@ -291,11 +339,20 @@ class NoxWorker {
|
|
|
291
339
|
log: this.sdkLogSink(),
|
|
292
340
|
timeoutMs: cfg.attemptTimeoutMs,
|
|
293
341
|
surbFormat: cfg.surbFormat,
|
|
342
|
+
// Interactive latency: hedge at about p95, resend at once on a lost
|
|
343
|
+
// reply, transport resends of their own, same-entry resends for a
|
|
344
|
+
// single bridge (ARCHITECTURE §4.7).
|
|
345
|
+
resend: { ...RESEND_FAST, hedgeAfterMs: cfg.hedgeAfterMs },
|
|
294
346
|
kps: {
|
|
295
347
|
dial: kpsDial,
|
|
296
348
|
pinned,
|
|
297
349
|
topologySources: cfg.topologySources,
|
|
298
350
|
claimIntervalMs: cfg.claimIntervalMs,
|
|
351
|
+
// A warm standby entry. Claims share the primary connection: a second
|
|
352
|
+
// claim connection per entry measured mixed live and doubles per-IP
|
|
353
|
+
// connections at the relay.
|
|
354
|
+
claimLane: false,
|
|
355
|
+
standby: true,
|
|
299
356
|
discovery: this.discoveryOptions(cfg, bootstrap, learned),
|
|
300
357
|
...(deprioritize.length === 0 ? {} : { deprioritize }),
|
|
301
358
|
},
|
|
@@ -340,6 +397,8 @@ class NoxWorker {
|
|
|
340
397
|
return {
|
|
341
398
|
bootstrap,
|
|
342
399
|
chain: cfg.discovery === "chain",
|
|
400
|
+
// Wallet calls first: the first registry check waits for the first call (bounded).
|
|
401
|
+
firstCheckDeferMs: DISCOVERY_FIRST_CHECK_MAX_DEFER_MS,
|
|
343
402
|
onVerified: (state) => this.onVerified(state),
|
|
344
403
|
...(cfg.gateways === undefined ? {} : { gateways: cfg.gateways }),
|
|
345
404
|
...(cfg.bridges === undefined ? {} : { bridges: cfg.bridges }),
|
|
@@ -447,8 +506,20 @@ class NoxWorker {
|
|
|
447
506
|
const budget = new CallControl(call.requestInit?.signal, cfg.callDeadlineMs, this.now);
|
|
448
507
|
try {
|
|
449
508
|
const prepared = await prepareRequest(call.url, call.requestInit, cfg, budget.signal);
|
|
509
|
+
const local = this.answers.lookup(prepared);
|
|
510
|
+
if (local !== undefined) {
|
|
511
|
+
this.log.debug("call.done", {
|
|
512
|
+
seq,
|
|
513
|
+
jsonrpcMethod: prepared.profile.method ?? null,
|
|
514
|
+
ms: this.now() - started,
|
|
515
|
+
outcome: "ok",
|
|
516
|
+
local: local.kind,
|
|
517
|
+
});
|
|
518
|
+
return local.response;
|
|
519
|
+
}
|
|
450
520
|
const client = await this.waitReady(budget.signal);
|
|
451
|
-
const response = await sendPrepared(prepared, client, cfg, budget);
|
|
521
|
+
const response = await sendPrepared(prepared, client, cfg, budget, this.tunnels);
|
|
522
|
+
if (this.answers.observe(prepared, response) === "verify") this.verifyAnswer(prepared, client, cfg);
|
|
452
523
|
if (this.log.enabled("debug")) {
|
|
453
524
|
this.log.debug("call.done", {
|
|
454
525
|
seq,
|
|
@@ -467,9 +538,30 @@ class NoxWorker {
|
|
|
467
538
|
throw final;
|
|
468
539
|
} finally {
|
|
469
540
|
budget.dispose();
|
|
541
|
+
// Spares open only once a wallet call settled (background work never delays the first call).
|
|
542
|
+
this.tlsPool?.enableBackground();
|
|
470
543
|
}
|
|
471
544
|
}
|
|
472
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Send a memo call (`eth_chainId`, `net_version`) once more through the
|
|
548
|
+
* mixnet on a fresh route, in the background; the answer is kept only when
|
|
549
|
+
* both agree (see `LocalAnswers`).
|
|
550
|
+
*/
|
|
551
|
+
private verifyAnswer(prepared: PreparedRequest, client: NoxClientPort, cfg: NoxWorkerConfig): void {
|
|
552
|
+
const budget = new CallControl(undefined, cfg.callDeadlineMs, this.now);
|
|
553
|
+
sendPrepared(prepared, client, cfg, budget, this.tunnels)
|
|
554
|
+
.then(
|
|
555
|
+
(response) => {
|
|
556
|
+
this.answers.observe(prepared, response);
|
|
557
|
+
},
|
|
558
|
+
(error: unknown) => {
|
|
559
|
+
this.log.debug("local.verify.failed", { code: errorCode(mapClientError(error)) ?? "error" });
|
|
560
|
+
},
|
|
561
|
+
)
|
|
562
|
+
.finally(() => budget.dispose());
|
|
563
|
+
}
|
|
564
|
+
|
|
473
565
|
private waitReady(signal: AbortSignal): Promise<NoxClientPort> {
|
|
474
566
|
if (this.client !== undefined) return Promise.resolve(this.client);
|
|
475
567
|
if (this.failed || this.stopped) {
|
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);
|