@blamejs/core 0.7.1 → 0.7.18
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 +423 -389
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -282
- package/lib/api-key.js +660 -672
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -266
- package/lib/cache.js +1206 -1211
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +1136 -0
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -202
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -573
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -269
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -614
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -451
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -479
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -855
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -177
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -752
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -489
- package/lib/request-helpers.js +340 -336
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -630
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +879 -114
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -270
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -598
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/pqc-gate.js
CHANGED
|
@@ -1,279 +1,279 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* pqc-gate — TCP-level enforcement of post-quantum key exchange.
|
|
4
|
-
*
|
|
5
|
-
* Inspects the TLS ClientHello before letting the connection reach the
|
|
6
|
-
* HTTPS server. Connections that don't offer a PQC hybrid group in
|
|
7
|
-
* supported_groups are rejected with a TLS handshake_failure alert.
|
|
8
|
-
*
|
|
9
|
-
* Architecture:
|
|
10
|
-
*
|
|
11
|
-
* net.Server (public 443) → parse ClientHello → pipe to internal HTTPS server
|
|
12
|
-
*
|
|
13
|
-
* The HTTPS server binds to a private loopback port and never directly
|
|
14
|
-
* sees the internet. Operators set Node's TLS group preference at the
|
|
15
|
-
* HTTPS server level (so accepted connections still negotiate PQC) and
|
|
16
|
-
* use this gate as the outermost rejection point. Both layers must be
|
|
17
|
-
* configured PQC-only — gate without HTTPS-side enforcement leaves a
|
|
18
|
-
* connection that downgrades to a non-PQ group at handshake time.
|
|
19
|
-
*
|
|
20
|
-
* var server = b.pqcGate.create({
|
|
21
|
-
* internalPort: 8443,
|
|
22
|
-
* internalHost: "127.0.0.1",
|
|
23
|
-
* bypass: ["127.0.0.1", "::1", "::ffff:127.0.0.1"],
|
|
24
|
-
* clientHelloTimeoutMs: 5000,
|
|
25
|
-
* maxClientHelloBytes: 16384,
|
|
26
|
-
* log: logInstance,
|
|
27
|
-
* });
|
|
28
|
-
* server.listen(443, "0.0.0.0");
|
|
29
|
-
*
|
|
30
|
-
* Bypass: localhost connections skip the gate so health probes and
|
|
31
|
-
* sidecars (which often do plain TCP or use older TLS configurations)
|
|
32
|
-
* still work without weakening the public-facing posture.
|
|
33
|
-
*
|
|
34
|
-
* Test seam: opts._connect / _server / _setTimeout / _clearTimeout
|
|
35
|
-
* default to net.createConnection / net.createServer / setTimeout /
|
|
36
|
-
* clearTimeout. The unit tests use fakes to drive the parser and
|
|
37
|
-
* connection lifecycle deterministically.
|
|
38
|
-
*/
|
|
39
|
-
var net = require("node:net");
|
|
40
|
-
var C = require("./constants");
|
|
41
|
-
var { PQC_GROUPS } = require("./constants");
|
|
42
|
-
var nb = require("./numeric-bounds");
|
|
43
|
-
var validateOpts = require("./validate-opts");
|
|
44
|
-
var { boot } = require("./log");
|
|
45
|
-
|
|
46
|
-
var DEFAULT_LOG = boot("pqc-gate");
|
|
47
|
-
var DEFAULT_BYPASS = Object.freeze(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
48
|
-
var DEFAULT_CLIENTHELLO_TIMEOUT_MS = C.TIME.seconds(5);
|
|
49
|
-
var DEFAULT_MAX_CLIENTHELLO_BYTES = C.BYTES.kib(16);
|
|
50
|
-
|
|
51
|
-
// TLS handshake_failure alert (fatal). Sent before destroying a socket
|
|
52
|
-
// so a strict TLS client logs a precise reason rather than a vague
|
|
53
|
-
// connection-reset.
|
|
54
|
-
var TLS_ALERT_HANDSHAKE_FAILURE = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x28]);
|
|
55
|
-
|
|
56
|
-
// Set built once from the framework's PQC_GROUPS constant. New PQC
|
|
57
|
-
// groups added there flow into the gate without code changes.
|
|
58
|
-
var PQC_GROUP_IDS = new Set(Object.values(PQC_GROUPS));
|
|
59
|
-
|
|
60
|
-
// clientHelloHasPQC — return true iff the parsed ClientHello buffer
|
|
61
|
-
// includes any group listed in PQC_GROUP_IDS in its supported_groups
|
|
62
|
-
// extension. Returns false on malformed input or when no PQC group is
|
|
63
|
-
// present.
|
|
64
|
-
function clientHelloHasPQC(buf) {
|
|
65
|
-
if (!buf || buf.length < 44) return false;
|
|
66
|
-
|
|
67
|
-
// TLS record header: type(1) version(2) length(2)
|
|
68
|
-
if (buf[0] !== 0x16) return false; // not a handshake record
|
|
69
|
-
|
|
70
|
-
var recordLen = buf.readUInt16BE(3);
|
|
71
|
-
var recordEnd = Math.min(5 + recordLen, buf.length);
|
|
72
|
-
|
|
73
|
-
// Handshake header: type(1) length(3)
|
|
74
|
-
if (buf.length < 10) return false;
|
|
75
|
-
if (buf[5] !== 0x01) return false; // not ClientHello
|
|
76
|
-
|
|
77
|
-
// Body: version(2) + random(32) = 34 bytes after handshake header
|
|
78
|
-
var offset = 9 + 2 + C.BYTES.bytes(32);
|
|
79
|
-
if (offset + 1 > recordEnd) return false;
|
|
80
|
-
|
|
81
|
-
// Session ID: length(1) + data
|
|
82
|
-
var sessionIdLen = buf[offset];
|
|
83
|
-
offset += 1 + sessionIdLen;
|
|
84
|
-
if (offset + 2 > recordEnd) return false;
|
|
85
|
-
|
|
86
|
-
// Cipher suites: length(2) + data
|
|
87
|
-
var cipherSuitesLen = buf.readUInt16BE(offset);
|
|
88
|
-
offset += 2 + cipherSuitesLen;
|
|
89
|
-
if (offset + 1 > recordEnd) return false;
|
|
90
|
-
|
|
91
|
-
// Compression methods: length(1) + data
|
|
92
|
-
var compLen = buf[offset];
|
|
93
|
-
offset += 1 + compLen;
|
|
94
|
-
if (offset + 2 > recordEnd) return false;
|
|
95
|
-
|
|
96
|
-
// Extensions: total_length(2)
|
|
97
|
-
var extensionsLen = buf.readUInt16BE(offset);
|
|
98
|
-
offset += 2;
|
|
99
|
-
var extensionsEnd = Math.min(offset + extensionsLen, recordEnd);
|
|
100
|
-
|
|
101
|
-
while (offset + 4 <= extensionsEnd) {
|
|
102
|
-
var extType = buf.readUInt16BE(offset);
|
|
103
|
-
var extLen = buf.readUInt16BE(offset + 2);
|
|
104
|
-
offset += 4;
|
|
105
|
-
|
|
106
|
-
if (extType === 0x000A && extLen >= 2 && offset + extLen <= extensionsEnd) {
|
|
107
|
-
// supported_groups: list_length(2) + group_ids(2 each)
|
|
108
|
-
var listLen = buf.readUInt16BE(offset);
|
|
109
|
-
var groupsOffset = offset + 2;
|
|
110
|
-
var groupsEnd = Math.min(groupsOffset + listLen, offset + extLen);
|
|
111
|
-
while (groupsOffset + 2 <= groupsEnd) {
|
|
112
|
-
var groupId = buf.readUInt16BE(groupsOffset);
|
|
113
|
-
if (PQC_GROUP_IDS.has(groupId)) return true;
|
|
114
|
-
groupsOffset += 2;
|
|
115
|
-
}
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
offset += extLen;
|
|
119
|
-
}
|
|
120
|
-
return false;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function _isBypassed(remoteAddr, bypass) {
|
|
124
|
-
if (!remoteAddr) return false;
|
|
125
|
-
for (var i = 0; i < bypass.length; i++) {
|
|
126
|
-
if (bypass[i] === remoteAddr) return true;
|
|
127
|
-
}
|
|
128
|
-
return false;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function _logVia(log, level, msg, fields) {
|
|
132
|
-
if (log && typeof log[level] === "function") {
|
|
133
|
-
try { log[level](msg, fields); } catch (_e) { /* logger best-effort */ }
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
// Fallback to the framework's per-module console channel
|
|
137
|
-
var line = msg + (fields ? " " + JSON.stringify(fields) : "");
|
|
138
|
-
if (level === "error" || level === "fatal" || level === "warn") {
|
|
139
|
-
DEFAULT_LOG.warn(line);
|
|
140
|
-
} else {
|
|
141
|
-
DEFAULT_LOG(line);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function create(opts) {
|
|
146
|
-
opts = opts || {};
|
|
147
|
-
validateOpts(opts, [
|
|
148
|
-
"internalPort", "internalHost", "bypass",
|
|
149
|
-
"clientHelloTimeoutMs", "maxClientHelloBytes", "log",
|
|
150
|
-
"_connect", "_server", "_setTimeout", "_clearTimeout",
|
|
151
|
-
], "b.pqcGate");
|
|
152
|
-
var internalPort = opts.internalPort;
|
|
153
|
-
if (typeof internalPort !== "number" || internalPort < 1 || internalPort > 65535) {
|
|
154
|
-
throw new Error("pqc-gate: opts.internalPort must be a port number (1-65535)");
|
|
155
|
-
}
|
|
156
|
-
var internalHost = typeof opts.internalHost === "string" ? opts.internalHost : "127.0.0.1";
|
|
157
|
-
var bypass = Array.isArray(opts.bypass) ? opts.bypass.slice() : DEFAULT_BYPASS.slice();
|
|
158
|
-
if (opts.clientHelloTimeoutMs !== undefined && !nb.isPositiveFiniteInt(opts.clientHelloTimeoutMs)) {
|
|
159
|
-
throw new Error("pqc-gate: clientHelloTimeoutMs must be a positive finite integer; got " +
|
|
160
|
-
nb.shape(opts.clientHelloTimeoutMs));
|
|
161
|
-
}
|
|
162
|
-
var clientHelloTimeoutMs = opts.clientHelloTimeoutMs || DEFAULT_CLIENTHELLO_TIMEOUT_MS;
|
|
163
|
-
if (opts.maxClientHelloBytes !== undefined && !nb.isPositiveFiniteInt(opts.maxClientHelloBytes)) {
|
|
164
|
-
throw new Error("pqc-gate: maxClientHelloBytes must be a positive finite integer; got " +
|
|
165
|
-
nb.shape(opts.maxClientHelloBytes));
|
|
166
|
-
}
|
|
167
|
-
var maxClientHelloBytes = opts.maxClientHelloBytes || DEFAULT_MAX_CLIENTHELLO_BYTES;
|
|
168
|
-
var log = opts.log || null;
|
|
169
|
-
|
|
170
|
-
// Test seams
|
|
171
|
-
var connectFn = opts._connect || function (cOpts, cb) { return net.createConnection(cOpts, cb); };
|
|
172
|
-
var serverFn = opts._server || function (sOpts, cb) { return net.createServer(sOpts, cb); };
|
|
173
|
-
var setTimeoutFn = opts._setTimeout || setTimeout;
|
|
174
|
-
var clearTimeoutFn = opts._clearTimeout || clearTimeout;
|
|
175
|
-
|
|
176
|
-
function pipeToInternal(socket, prependData) {
|
|
177
|
-
var internal = connectFn({ port: internalPort, host: internalHost }, function () {
|
|
178
|
-
if (prependData) internal.write(prependData);
|
|
179
|
-
socket.pipe(internal);
|
|
180
|
-
internal.pipe(socket);
|
|
181
|
-
socket.resume();
|
|
182
|
-
});
|
|
183
|
-
internal.on("error", function () { socket.destroy(); });
|
|
184
|
-
socket.on("error", function () { internal.destroy(); });
|
|
185
|
-
internal.on("close", function () { socket.destroy(); });
|
|
186
|
-
socket.on("close", function () { internal.destroy(); });
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function _onConnection(socket) {
|
|
190
|
-
var clientIp = socket.remoteAddress || "";
|
|
191
|
-
|
|
192
|
-
if (_isBypassed(clientIp, bypass)) {
|
|
193
|
-
pipeToInternal(socket);
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
var chunks = [];
|
|
198
|
-
var totalLen = 0;
|
|
199
|
-
var resolved = false;
|
|
200
|
-
|
|
201
|
-
var timeout = setTimeoutFn(function () {
|
|
202
|
-
if (resolved) return;
|
|
203
|
-
resolved = true;
|
|
204
|
-
_logVia(log, "warn", "ClientHello timeout", { ip: clientIp });
|
|
205
|
-
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
206
|
-
}, clientHelloTimeoutMs);
|
|
207
|
-
|
|
208
|
-
socket.on("data", function onData(chunk) {
|
|
209
|
-
if (resolved) return;
|
|
210
|
-
chunks.push(chunk);
|
|
211
|
-
totalLen += chunk.length;
|
|
212
|
-
|
|
213
|
-
if (totalLen > maxClientHelloBytes) {
|
|
214
|
-
resolved = true;
|
|
215
|
-
try { clearTimeoutFn(timeout); } catch (_e) { /* timer may already have fired */ }
|
|
216
|
-
_logVia(log, "warn", "ClientHello too large", { ip: clientIp, size: totalLen });
|
|
217
|
-
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Reject non-handshake first byte immediately
|
|
222
|
-
if (totalLen >= 1 && chunks[0][0] !== 0x16) {
|
|
223
|
-
resolved = true;
|
|
224
|
-
try { clearTimeoutFn(timeout); } catch (_e) { /* timer may already have fired */ }
|
|
225
|
-
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (totalLen < 5) return; // need at least the record header
|
|
230
|
-
|
|
231
|
-
// TLS ClientHello parser needs streaming peek (re-concat on
|
|
232
|
-
// each chunk to read the record-length prefix) before deciding
|
|
233
|
-
// whether more data is needed. boundedChunkCollector is append-
|
|
234
|
-
// only with no peek, doesn't fit.
|
|
235
|
-
// allow:handrolled-buffer-collect — see comment above
|
|
236
|
-
var buf = Buffer.concat(chunks);
|
|
237
|
-
var recordLen = buf.readUInt16BE(3);
|
|
238
|
-
var neededLen = 5 + recordLen;
|
|
239
|
-
|
|
240
|
-
if (buf.length < Math.min(neededLen, maxClientHelloBytes)) return;
|
|
241
|
-
|
|
242
|
-
resolved = true;
|
|
243
|
-
try { clearTimeoutFn(timeout); } catch (_e) { /* timer may already have fired */ }
|
|
244
|
-
socket.removeListener("data", onData);
|
|
245
|
-
socket.pause();
|
|
246
|
-
|
|
247
|
-
if (clientHelloHasPQC(buf)) {
|
|
248
|
-
pipeToInternal(socket, buf);
|
|
249
|
-
} else {
|
|
250
|
-
_logVia(log, "warn",
|
|
251
|
-
"connection rejected — no PQC group in ClientHello", { ip: clientIp });
|
|
252
|
-
try {
|
|
253
|
-
socket.write(TLS_ALERT_HANDSHAKE_FAILURE, function () {
|
|
254
|
-
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
255
|
-
});
|
|
256
|
-
} catch (_e) {
|
|
257
|
-
try { socket.destroy(); } catch (_e2) { /* socket may already be torn down */ }
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
socket.on("error", function () {
|
|
263
|
-
resolved = true;
|
|
264
|
-
try { clearTimeoutFn(timeout); } catch (_e) { /* timer may already have fired */ }
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
socket.resume();
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return serverFn({ pauseOnConnect: true }, _onConnection);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
module.exports = {
|
|
274
|
-
create: create,
|
|
275
|
-
clientHelloHasPQC: clientHelloHasPQC,
|
|
276
|
-
PQC_GROUP_IDS: PQC_GROUP_IDS,
|
|
277
|
-
TLS_ALERT_HANDSHAKE_FAILURE: TLS_ALERT_HANDSHAKE_FAILURE,
|
|
278
|
-
DEFAULT_BYPASS: DEFAULT_BYPASS,
|
|
279
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* pqc-gate — TCP-level enforcement of post-quantum key exchange.
|
|
4
|
+
*
|
|
5
|
+
* Inspects the TLS ClientHello before letting the connection reach the
|
|
6
|
+
* HTTPS server. Connections that don't offer a PQC hybrid group in
|
|
7
|
+
* supported_groups are rejected with a TLS handshake_failure alert.
|
|
8
|
+
*
|
|
9
|
+
* Architecture:
|
|
10
|
+
*
|
|
11
|
+
* net.Server (public 443) → parse ClientHello → pipe to internal HTTPS server
|
|
12
|
+
*
|
|
13
|
+
* The HTTPS server binds to a private loopback port and never directly
|
|
14
|
+
* sees the internet. Operators set Node's TLS group preference at the
|
|
15
|
+
* HTTPS server level (so accepted connections still negotiate PQC) and
|
|
16
|
+
* use this gate as the outermost rejection point. Both layers must be
|
|
17
|
+
* configured PQC-only — gate without HTTPS-side enforcement leaves a
|
|
18
|
+
* connection that downgrades to a non-PQ group at handshake time.
|
|
19
|
+
*
|
|
20
|
+
* var server = b.pqcGate.create({
|
|
21
|
+
* internalPort: 8443,
|
|
22
|
+
* internalHost: "127.0.0.1",
|
|
23
|
+
* bypass: ["127.0.0.1", "::1", "::ffff:127.0.0.1"],
|
|
24
|
+
* clientHelloTimeoutMs: 5000,
|
|
25
|
+
* maxClientHelloBytes: 16384,
|
|
26
|
+
* log: logInstance,
|
|
27
|
+
* });
|
|
28
|
+
* server.listen(443, "0.0.0.0");
|
|
29
|
+
*
|
|
30
|
+
* Bypass: localhost connections skip the gate so health probes and
|
|
31
|
+
* sidecars (which often do plain TCP or use older TLS configurations)
|
|
32
|
+
* still work without weakening the public-facing posture.
|
|
33
|
+
*
|
|
34
|
+
* Test seam: opts._connect / _server / _setTimeout / _clearTimeout
|
|
35
|
+
* default to net.createConnection / net.createServer / setTimeout /
|
|
36
|
+
* clearTimeout. The unit tests use fakes to drive the parser and
|
|
37
|
+
* connection lifecycle deterministically.
|
|
38
|
+
*/
|
|
39
|
+
var net = require("node:net");
|
|
40
|
+
var C = require("./constants");
|
|
41
|
+
var { PQC_GROUPS } = require("./constants");
|
|
42
|
+
var nb = require("./numeric-bounds");
|
|
43
|
+
var validateOpts = require("./validate-opts");
|
|
44
|
+
var { boot } = require("./log");
|
|
45
|
+
|
|
46
|
+
var DEFAULT_LOG = boot("pqc-gate");
|
|
47
|
+
var DEFAULT_BYPASS = Object.freeze(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
48
|
+
var DEFAULT_CLIENTHELLO_TIMEOUT_MS = C.TIME.seconds(5);
|
|
49
|
+
var DEFAULT_MAX_CLIENTHELLO_BYTES = C.BYTES.kib(16);
|
|
50
|
+
|
|
51
|
+
// TLS handshake_failure alert (fatal). Sent before destroying a socket
|
|
52
|
+
// so a strict TLS client logs a precise reason rather than a vague
|
|
53
|
+
// connection-reset.
|
|
54
|
+
var TLS_ALERT_HANDSHAKE_FAILURE = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x28]);
|
|
55
|
+
|
|
56
|
+
// Set built once from the framework's PQC_GROUPS constant. New PQC
|
|
57
|
+
// groups added there flow into the gate without code changes.
|
|
58
|
+
var PQC_GROUP_IDS = new Set(Object.values(PQC_GROUPS));
|
|
59
|
+
|
|
60
|
+
// clientHelloHasPQC — return true iff the parsed ClientHello buffer
|
|
61
|
+
// includes any group listed in PQC_GROUP_IDS in its supported_groups
|
|
62
|
+
// extension. Returns false on malformed input or when no PQC group is
|
|
63
|
+
// present.
|
|
64
|
+
function clientHelloHasPQC(buf) {
|
|
65
|
+
if (!buf || buf.length < 44) return false;
|
|
66
|
+
|
|
67
|
+
// TLS record header: type(1) version(2) length(2)
|
|
68
|
+
if (buf[0] !== 0x16) return false; // not a handshake record
|
|
69
|
+
|
|
70
|
+
var recordLen = buf.readUInt16BE(3);
|
|
71
|
+
var recordEnd = Math.min(5 + recordLen, buf.length);
|
|
72
|
+
|
|
73
|
+
// Handshake header: type(1) length(3)
|
|
74
|
+
if (buf.length < 10) return false;
|
|
75
|
+
if (buf[5] !== 0x01) return false; // not ClientHello
|
|
76
|
+
|
|
77
|
+
// Body: version(2) + random(32) = 34 bytes after handshake header
|
|
78
|
+
var offset = 9 + 2 + C.BYTES.bytes(32);
|
|
79
|
+
if (offset + 1 > recordEnd) return false;
|
|
80
|
+
|
|
81
|
+
// Session ID: length(1) + data
|
|
82
|
+
var sessionIdLen = buf[offset];
|
|
83
|
+
offset += 1 + sessionIdLen;
|
|
84
|
+
if (offset + 2 > recordEnd) return false;
|
|
85
|
+
|
|
86
|
+
// Cipher suites: length(2) + data
|
|
87
|
+
var cipherSuitesLen = buf.readUInt16BE(offset);
|
|
88
|
+
offset += 2 + cipherSuitesLen;
|
|
89
|
+
if (offset + 1 > recordEnd) return false;
|
|
90
|
+
|
|
91
|
+
// Compression methods: length(1) + data
|
|
92
|
+
var compLen = buf[offset];
|
|
93
|
+
offset += 1 + compLen;
|
|
94
|
+
if (offset + 2 > recordEnd) return false;
|
|
95
|
+
|
|
96
|
+
// Extensions: total_length(2)
|
|
97
|
+
var extensionsLen = buf.readUInt16BE(offset);
|
|
98
|
+
offset += 2;
|
|
99
|
+
var extensionsEnd = Math.min(offset + extensionsLen, recordEnd);
|
|
100
|
+
|
|
101
|
+
while (offset + 4 <= extensionsEnd) {
|
|
102
|
+
var extType = buf.readUInt16BE(offset);
|
|
103
|
+
var extLen = buf.readUInt16BE(offset + 2);
|
|
104
|
+
offset += 4;
|
|
105
|
+
|
|
106
|
+
if (extType === 0x000A && extLen >= 2 && offset + extLen <= extensionsEnd) {
|
|
107
|
+
// supported_groups: list_length(2) + group_ids(2 each)
|
|
108
|
+
var listLen = buf.readUInt16BE(offset);
|
|
109
|
+
var groupsOffset = offset + 2;
|
|
110
|
+
var groupsEnd = Math.min(groupsOffset + listLen, offset + extLen);
|
|
111
|
+
while (groupsOffset + 2 <= groupsEnd) {
|
|
112
|
+
var groupId = buf.readUInt16BE(groupsOffset);
|
|
113
|
+
if (PQC_GROUP_IDS.has(groupId)) return true;
|
|
114
|
+
groupsOffset += 2;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
offset += extLen;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _isBypassed(remoteAddr, bypass) {
|
|
124
|
+
if (!remoteAddr) return false;
|
|
125
|
+
for (var i = 0; i < bypass.length; i++) {
|
|
126
|
+
if (bypass[i] === remoteAddr) return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _logVia(log, level, msg, fields) {
|
|
132
|
+
if (log && typeof log[level] === "function") {
|
|
133
|
+
try { log[level](msg, fields); } catch (_e) { /* logger best-effort */ }
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Fallback to the framework's per-module console channel
|
|
137
|
+
var line = msg + (fields ? " " + JSON.stringify(fields) : "");
|
|
138
|
+
if (level === "error" || level === "fatal" || level === "warn") {
|
|
139
|
+
DEFAULT_LOG.warn(line);
|
|
140
|
+
} else {
|
|
141
|
+
DEFAULT_LOG(line);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function create(opts) {
|
|
146
|
+
opts = opts || {};
|
|
147
|
+
validateOpts(opts, [
|
|
148
|
+
"internalPort", "internalHost", "bypass",
|
|
149
|
+
"clientHelloTimeoutMs", "maxClientHelloBytes", "log",
|
|
150
|
+
"_connect", "_server", "_setTimeout", "_clearTimeout",
|
|
151
|
+
], "b.pqcGate");
|
|
152
|
+
var internalPort = opts.internalPort;
|
|
153
|
+
if (typeof internalPort !== "number" || internalPort < 1 || internalPort > 65535) {
|
|
154
|
+
throw new Error("pqc-gate: opts.internalPort must be a port number (1-65535)");
|
|
155
|
+
}
|
|
156
|
+
var internalHost = typeof opts.internalHost === "string" ? opts.internalHost : "127.0.0.1";
|
|
157
|
+
var bypass = Array.isArray(opts.bypass) ? opts.bypass.slice() : DEFAULT_BYPASS.slice();
|
|
158
|
+
if (opts.clientHelloTimeoutMs !== undefined && !nb.isPositiveFiniteInt(opts.clientHelloTimeoutMs)) {
|
|
159
|
+
throw new Error("pqc-gate: clientHelloTimeoutMs must be a positive finite integer; got " +
|
|
160
|
+
nb.shape(opts.clientHelloTimeoutMs));
|
|
161
|
+
}
|
|
162
|
+
var clientHelloTimeoutMs = opts.clientHelloTimeoutMs || DEFAULT_CLIENTHELLO_TIMEOUT_MS;
|
|
163
|
+
if (opts.maxClientHelloBytes !== undefined && !nb.isPositiveFiniteInt(opts.maxClientHelloBytes)) {
|
|
164
|
+
throw new Error("pqc-gate: maxClientHelloBytes must be a positive finite integer; got " +
|
|
165
|
+
nb.shape(opts.maxClientHelloBytes));
|
|
166
|
+
}
|
|
167
|
+
var maxClientHelloBytes = opts.maxClientHelloBytes || DEFAULT_MAX_CLIENTHELLO_BYTES;
|
|
168
|
+
var log = opts.log || null;
|
|
169
|
+
|
|
170
|
+
// Test seams
|
|
171
|
+
var connectFn = opts._connect || function (cOpts, cb) { return net.createConnection(cOpts, cb); };
|
|
172
|
+
var serverFn = opts._server || function (sOpts, cb) { return net.createServer(sOpts, cb); };
|
|
173
|
+
var setTimeoutFn = opts._setTimeout || setTimeout;
|
|
174
|
+
var clearTimeoutFn = opts._clearTimeout || clearTimeout;
|
|
175
|
+
|
|
176
|
+
function pipeToInternal(socket, prependData) {
|
|
177
|
+
var internal = connectFn({ port: internalPort, host: internalHost }, function () {
|
|
178
|
+
if (prependData) internal.write(prependData);
|
|
179
|
+
socket.pipe(internal);
|
|
180
|
+
internal.pipe(socket);
|
|
181
|
+
socket.resume();
|
|
182
|
+
});
|
|
183
|
+
internal.on("error", function () { socket.destroy(); });
|
|
184
|
+
socket.on("error", function () { internal.destroy(); });
|
|
185
|
+
internal.on("close", function () { socket.destroy(); });
|
|
186
|
+
socket.on("close", function () { internal.destroy(); });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _onConnection(socket) {
|
|
190
|
+
var clientIp = socket.remoteAddress || "";
|
|
191
|
+
|
|
192
|
+
if (_isBypassed(clientIp, bypass)) {
|
|
193
|
+
pipeToInternal(socket);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
var chunks = [];
|
|
198
|
+
var totalLen = 0;
|
|
199
|
+
var resolved = false;
|
|
200
|
+
|
|
201
|
+
var timeout = setTimeoutFn(function () {
|
|
202
|
+
if (resolved) return;
|
|
203
|
+
resolved = true;
|
|
204
|
+
_logVia(log, "warn", "ClientHello timeout", { ip: clientIp });
|
|
205
|
+
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
206
|
+
}, clientHelloTimeoutMs);
|
|
207
|
+
|
|
208
|
+
socket.on("data", function onData(chunk) {
|
|
209
|
+
if (resolved) return;
|
|
210
|
+
chunks.push(chunk);
|
|
211
|
+
totalLen += chunk.length;
|
|
212
|
+
|
|
213
|
+
if (totalLen > maxClientHelloBytes) {
|
|
214
|
+
resolved = true;
|
|
215
|
+
try { clearTimeoutFn(timeout); } catch (_e) { /* timer may already have fired */ }
|
|
216
|
+
_logVia(log, "warn", "ClientHello too large", { ip: clientIp, size: totalLen });
|
|
217
|
+
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Reject non-handshake first byte immediately
|
|
222
|
+
if (totalLen >= 1 && chunks[0][0] !== 0x16) {
|
|
223
|
+
resolved = true;
|
|
224
|
+
try { clearTimeoutFn(timeout); } catch (_e) { /* timer may already have fired */ }
|
|
225
|
+
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (totalLen < 5) return; // need at least the record header
|
|
230
|
+
|
|
231
|
+
// TLS ClientHello parser needs streaming peek (re-concat on
|
|
232
|
+
// each chunk to read the record-length prefix) before deciding
|
|
233
|
+
// whether more data is needed. boundedChunkCollector is append-
|
|
234
|
+
// only with no peek, doesn't fit.
|
|
235
|
+
// allow:handrolled-buffer-collect — see comment above
|
|
236
|
+
var buf = Buffer.concat(chunks);
|
|
237
|
+
var recordLen = buf.readUInt16BE(3);
|
|
238
|
+
var neededLen = 5 + recordLen;
|
|
239
|
+
|
|
240
|
+
if (buf.length < Math.min(neededLen, maxClientHelloBytes)) return;
|
|
241
|
+
|
|
242
|
+
resolved = true;
|
|
243
|
+
try { clearTimeoutFn(timeout); } catch (_e) { /* timer may already have fired */ }
|
|
244
|
+
socket.removeListener("data", onData);
|
|
245
|
+
socket.pause();
|
|
246
|
+
|
|
247
|
+
if (clientHelloHasPQC(buf)) {
|
|
248
|
+
pipeToInternal(socket, buf);
|
|
249
|
+
} else {
|
|
250
|
+
_logVia(log, "warn",
|
|
251
|
+
"connection rejected — no PQC group in ClientHello", { ip: clientIp });
|
|
252
|
+
try {
|
|
253
|
+
socket.write(TLS_ALERT_HANDSHAKE_FAILURE, function () {
|
|
254
|
+
try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
255
|
+
});
|
|
256
|
+
} catch (_e) {
|
|
257
|
+
try { socket.destroy(); } catch (_e2) { /* socket may already be torn down */ }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
socket.on("error", function () {
|
|
263
|
+
resolved = true;
|
|
264
|
+
try { clearTimeoutFn(timeout); } catch (_e) { /* timer may already have fired */ }
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
socket.resume();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return serverFn({ pauseOnConnect: true }, _onConnection);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
create: create,
|
|
275
|
+
clientHelloHasPQC: clientHelloHasPQC,
|
|
276
|
+
PQC_GROUP_IDS: PQC_GROUP_IDS,
|
|
277
|
+
TLS_ALERT_HANDSHAKE_FAILURE: TLS_ALERT_HANDSHAKE_FAILURE,
|
|
278
|
+
DEFAULT_BYPASS: DEFAULT_BYPASS,
|
|
279
|
+
};
|