@blamejs/core 0.8.51 → 0.8.57
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 +6 -0
- package/index.js +8 -0
- package/lib/audit.js +4 -0
- package/lib/auth/fido-mds3.js +624 -0
- package/lib/auth/passkey.js +214 -2
- package/lib/auth-bot-challenge.js +1 -1
- package/lib/credential-hash.js +2 -2
- package/lib/framework-error.js +55 -0
- package/lib/guard-cidr.js +2 -1
- package/lib/guard-jwt.js +2 -2
- package/lib/guard-oauth.js +2 -2
- package/lib/http-client-cache.js +916 -0
- package/lib/http-client.js +242 -0
- package/lib/local-db-thin.js +8 -7
- package/lib/mail-arf.js +343 -0
- package/lib/mail-auth.js +265 -40
- package/lib/mail-bimi.js +948 -33
- package/lib/mail-bounce.js +386 -4
- package/lib/mail-mdn.js +424 -0
- package/lib/mail-unsubscribe.js +265 -25
- package/lib/mail.js +403 -21
- package/lib/middleware/bearer-auth.js +1 -1
- package/lib/middleware/clear-site-data.js +122 -0
- package/lib/middleware/dpop.js +1 -1
- package/lib/middleware/index.js +9 -0
- package/lib/middleware/nel.js +214 -0
- package/lib/middleware/security-headers.js +56 -4
- package/lib/middleware/speculation-rules.js +323 -0
- package/lib/mime-parse.js +198 -0
- package/lib/network-dns.js +890 -27
- package/lib/network-tls.js +745 -0
- package/lib/object-store/sigv4.js +54 -0
- package/lib/public-suffix.js +414 -0
- package/lib/safe-buffer.js +7 -0
- package/lib/safe-json.js +1 -1
- package/lib/static.js +120 -0
- package/lib/storage.js +11 -0
- package/lib/vendor/MANIFEST.json +33 -0
- package/lib/vendor/bimi-trust-anchors.pem +33 -0
- package/lib/vendor/public-suffix-list.dat +16376 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/network-tls.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
var tls = require("node:tls");
|
|
4
4
|
var fs = require("node:fs");
|
|
5
5
|
var path = require("node:path");
|
|
6
|
+
var net = require("node:net");
|
|
6
7
|
var nodeCrypto = require("node:crypto");
|
|
7
8
|
|
|
8
9
|
var blamejsCrypto = require("./crypto");
|
|
@@ -18,6 +19,7 @@ var NetworkTlsError = defineClass("NetworkTlsError", { alwaysPermanent: true });
|
|
|
18
19
|
|
|
19
20
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
20
21
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
22
|
+
var networkDns = lazyRequire(function () { return require("./network-dns"); });
|
|
21
23
|
var asn1 = require("./asn1-der");
|
|
22
24
|
|
|
23
25
|
// STATE.tlsKeyShares is initialized to the default PQC group list at
|
|
@@ -2277,6 +2279,745 @@ var ct = Object.freeze({
|
|
|
2277
2279
|
},
|
|
2278
2280
|
});
|
|
2279
2281
|
|
|
2282
|
+
// ---- ECH (Encrypted Client Hello) — RFC 9460 SVCB ech= SvcParam +
|
|
2283
|
+
// draft-ietf-tls-esni-22 §4 ECHConfigList -----------------------
|
|
2284
|
+
//
|
|
2285
|
+
// ECH is a TLS 1.3 extension that encrypts the Client Hello Inner
|
|
2286
|
+
// (SNI, ALPN, etc.) under a public key the server publishes via DNS
|
|
2287
|
+
// SVCB/HTTPS records. A passive observer sees only the public_name
|
|
2288
|
+
// SNI in the outer hello — the real virtual host stays confidential.
|
|
2289
|
+
//
|
|
2290
|
+
// Wire format reminder (uint16 lengths are big-endian throughout):
|
|
2291
|
+
//
|
|
2292
|
+
// ECHConfigList = uint16 total_length || ECHConfig[]
|
|
2293
|
+
// ECHConfig = uint16 version || uint16 length || contents
|
|
2294
|
+
// contents (v=0xfe0d) =
|
|
2295
|
+
// HpkeKeyConfig key_config
|
|
2296
|
+
// uint8 maximum_name_length
|
|
2297
|
+
// opaque<1..255> public_name (with uint8 length prefix)
|
|
2298
|
+
// Extension extensions<0..2^16-1> (uint16 length prefix +
|
|
2299
|
+
// list of (uint16 ext_type,
|
|
2300
|
+
// opaque<0..2^16-1> ext_data))
|
|
2301
|
+
// HpkeKeyConfig =
|
|
2302
|
+
// uint8 config_id
|
|
2303
|
+
// uint16 kem_id
|
|
2304
|
+
// opaque public_key<1..2^16-1> (uint16 length prefix)
|
|
2305
|
+
// HpkeSymmetricCipherSuite cipher_suites<4..2^16-1>
|
|
2306
|
+
// (uint16 length prefix; entries
|
|
2307
|
+
// each (uint16 kdf_id, uint16
|
|
2308
|
+
// aead_id) — 4 bytes apiece)
|
|
2309
|
+
|
|
2310
|
+
var ECH_CONFIG_VERSION_DRAFT_22 = 0xfe0d; // allow:raw-byte-literal — draft-ietf-tls-esni-22 ECH version codepoint
|
|
2311
|
+
|
|
2312
|
+
function _echReadU8(buf, off) {
|
|
2313
|
+
if (off + 1 > buf.length) {
|
|
2314
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2315
|
+
"ECHConfigList: truncated reading uint8 at offset " + off);
|
|
2316
|
+
}
|
|
2317
|
+
return buf[off];
|
|
2318
|
+
}
|
|
2319
|
+
function _echReadU16(buf, off) {
|
|
2320
|
+
if (off + 2 > buf.length) { // allow:raw-byte-literal — uint16 width
|
|
2321
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2322
|
+
"ECHConfigList: truncated reading uint16 at offset " + off);
|
|
2323
|
+
}
|
|
2324
|
+
return buf.readUInt16BE(off);
|
|
2325
|
+
}
|
|
2326
|
+
function _echReadVarOpaqueU16(buf, off) {
|
|
2327
|
+
var len = _echReadU16(buf, off);
|
|
2328
|
+
off += 2; // allow:raw-byte-literal — uint16 length-prefix width
|
|
2329
|
+
if (off + len > buf.length) {
|
|
2330
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2331
|
+
"ECHConfigList: opaque vector overflows buffer (declared " + len +
|
|
2332
|
+
" bytes at offset " + (off - 2) + ", " + (buf.length - off) + " available)");
|
|
2333
|
+
}
|
|
2334
|
+
return { value: buf.slice(off, off + len), nextOff: off + len };
|
|
2335
|
+
}
|
|
2336
|
+
function _echReadVarOpaqueU8(buf, off) {
|
|
2337
|
+
var len = _echReadU8(buf, off);
|
|
2338
|
+
off += 1;
|
|
2339
|
+
if (off + len > buf.length) {
|
|
2340
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2341
|
+
"ECHConfigList: u8-prefixed opaque overflows buffer");
|
|
2342
|
+
}
|
|
2343
|
+
return { value: buf.slice(off, off + len), nextOff: off + len };
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
/**
|
|
2347
|
+
* @primitive b.network.tls.parseEchConfigList
|
|
2348
|
+
* @signature b.network.tls.parseEchConfigList(raw)
|
|
2349
|
+
* @since 0.8.53
|
|
2350
|
+
* @status stable
|
|
2351
|
+
* @related b.network.tls.connectWithEch, b.network.dns.queryHttps
|
|
2352
|
+
*
|
|
2353
|
+
* Parse a draft-ietf-tls-esni-22 ECHConfigList byte string (the value
|
|
2354
|
+
* of the `ech=` SvcParam in an SVCB or HTTPS DNS record per RFC 9460
|
|
2355
|
+
* paragraph 7.4.2). Accepts a `Buffer` or a strict-base64 string. Returns
|
|
2356
|
+
* `{ rawLength, configs: [{ version, length, keyConfig, ... }] }`.
|
|
2357
|
+
*
|
|
2358
|
+
* For each ECHConfig at the published draft-22 version (`0xfe0d`) the
|
|
2359
|
+
* decoded `keyConfig` carries `configId`, `kemId`, `publicKey`
|
|
2360
|
+
* (Buffer), and `cipherSuites` (each `{ kdfId, aeadId }`); the entry
|
|
2361
|
+
* also exposes `maximumNameLength`, `publicName`, and `extensions`.
|
|
2362
|
+
* Unknown future ECH versions surface their raw `body` Buffer so the
|
|
2363
|
+
* caller can forward them to a Node build that supports them.
|
|
2364
|
+
*
|
|
2365
|
+
* Throws `NetworkTlsError("tls/ech-config-malformed")` on any framing
|
|
2366
|
+
* violation (truncated length prefix, vector overflow, bad
|
|
2367
|
+
* cipher_suites stride, etc.).
|
|
2368
|
+
*
|
|
2369
|
+
* @example
|
|
2370
|
+
* var b = require("@blamejs/core");
|
|
2371
|
+
* var rrs = await b.network.dns.queryHttps("example.com");
|
|
2372
|
+
* var rec = rrs.find(function (r) { return r.params && r.params.ech; });
|
|
2373
|
+
* var parsed = b.network.tls.parseEchConfigList(rec.params.ech);
|
|
2374
|
+
* // parsed.configs[0].keyConfig.kemId === 0x0020 (X25519)
|
|
2375
|
+
*/
|
|
2376
|
+
function parseEchConfigList(raw) {
|
|
2377
|
+
if (typeof raw === "string") {
|
|
2378
|
+
// Operators sometimes hold the SvcParam as a base64 string; accept
|
|
2379
|
+
// both. Reject anything that doesn't round-trip cleanly through
|
|
2380
|
+
// strict-base64 — Node's Buffer.from(b64, "base64") is lenient
|
|
2381
|
+
// (silently ignores stray bytes), so we re-encode and compare.
|
|
2382
|
+
var stripped = raw.replace(/\s+/g, "");
|
|
2383
|
+
var decoded = Buffer.from(stripped, "base64");
|
|
2384
|
+
if (decoded.length === 0 || decoded.toString("base64") !== stripped) {
|
|
2385
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2386
|
+
"parseEchConfigList: input string is not strict base64");
|
|
2387
|
+
}
|
|
2388
|
+
raw = decoded;
|
|
2389
|
+
}
|
|
2390
|
+
if (!Buffer.isBuffer(raw) || raw.length === 0) {
|
|
2391
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2392
|
+
"parseEchConfigList: input must be a non-empty Buffer or base64 string");
|
|
2393
|
+
}
|
|
2394
|
+
if (raw.length < 2) { // allow:raw-byte-literal — uint16 outer length prefix
|
|
2395
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2396
|
+
"ECHConfigList: too short for outer length prefix");
|
|
2397
|
+
}
|
|
2398
|
+
var totalLen = raw.readUInt16BE(0);
|
|
2399
|
+
if (2 + totalLen !== raw.length) { // allow:raw-byte-literal — uint16 prefix width
|
|
2400
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2401
|
+
"ECHConfigList: outer length " + totalLen + " does not match buffer " +
|
|
2402
|
+
"tail length " + (raw.length - 2));
|
|
2403
|
+
}
|
|
2404
|
+
var off = 2; // allow:raw-byte-literal — uint16 prefix width
|
|
2405
|
+
var configs = [];
|
|
2406
|
+
while (off < raw.length) {
|
|
2407
|
+
if (off + 4 > raw.length) { // allow:raw-byte-literal — uint16 ver + uint16 len
|
|
2408
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2409
|
+
"ECHConfig: truncated header at offset " + off);
|
|
2410
|
+
}
|
|
2411
|
+
var version = raw.readUInt16BE(off);
|
|
2412
|
+
var length = raw.readUInt16BE(off + 2);
|
|
2413
|
+
var bodyOff = off + 4;
|
|
2414
|
+
var bodyEnd = bodyOff + length;
|
|
2415
|
+
if (bodyEnd > raw.length) {
|
|
2416
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2417
|
+
"ECHConfig: declared length " + length + " overflows ECHConfigList");
|
|
2418
|
+
}
|
|
2419
|
+
var entry = { version: version, length: length };
|
|
2420
|
+
if (version === ECH_CONFIG_VERSION_DRAFT_22) {
|
|
2421
|
+
var p = bodyOff;
|
|
2422
|
+
// HpkeKeyConfig
|
|
2423
|
+
var configId = _echReadU8(raw, p); p += 1;
|
|
2424
|
+
var kemId = _echReadU16(raw, p); p += 2; // allow:raw-byte-literal — uint16 KEM id width
|
|
2425
|
+
var pkOpaque = _echReadVarOpaqueU16(raw, p); p = pkOpaque.nextOff;
|
|
2426
|
+
var suitesLen = _echReadU16(raw, p); p += 2; // allow:raw-byte-literal — uint16 length prefix width
|
|
2427
|
+
if (p + suitesLen > bodyEnd) {
|
|
2428
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2429
|
+
"ECHConfig: cipher_suites vector overflows config body");
|
|
2430
|
+
}
|
|
2431
|
+
if (suitesLen % 4 !== 0 || suitesLen < 4) { // allow:raw-byte-literal — kdf+aead = 4 bytes per suite
|
|
2432
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2433
|
+
"ECHConfig: cipher_suites length must be a positive multiple of 4");
|
|
2434
|
+
}
|
|
2435
|
+
var suites = [];
|
|
2436
|
+
for (var sp = p; sp < p + suitesLen; sp += 4) { // allow:raw-byte-literal — 4-byte cipher suite stride
|
|
2437
|
+
suites.push({
|
|
2438
|
+
kdfId: raw.readUInt16BE(sp),
|
|
2439
|
+
aeadId: raw.readUInt16BE(sp + 2),
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
p += suitesLen;
|
|
2443
|
+
// remainder of contents
|
|
2444
|
+
var maxNameLen = _echReadU8(raw, p); p += 1;
|
|
2445
|
+
var publicName = _echReadVarOpaqueU8(raw, p); p = publicName.nextOff;
|
|
2446
|
+
var extLen = _echReadU16(raw, p); p += 2; // allow:raw-byte-literal — uint16 length prefix width
|
|
2447
|
+
if (p + extLen !== bodyEnd) {
|
|
2448
|
+
throw new NetworkTlsError("tls/ech-config-malformed",
|
|
2449
|
+
"ECHConfig: extensions vector does not consume remaining body " +
|
|
2450
|
+
"(extLen=" + extLen + ", remaining=" + (bodyEnd - p) + ")");
|
|
2451
|
+
}
|
|
2452
|
+
var extensions = [];
|
|
2453
|
+
var extEnd = p + extLen;
|
|
2454
|
+
while (p < extEnd) {
|
|
2455
|
+
var extType = _echReadU16(raw, p); p += 2; // allow:raw-byte-literal — uint16 ext type
|
|
2456
|
+
var extData = _echReadVarOpaqueU16(raw, p); p = extData.nextOff;
|
|
2457
|
+
extensions.push({ type: extType, data: extData.value });
|
|
2458
|
+
}
|
|
2459
|
+
entry.keyConfig = {
|
|
2460
|
+
configId: configId,
|
|
2461
|
+
kemId: kemId,
|
|
2462
|
+
publicKey: pkOpaque.value,
|
|
2463
|
+
cipherSuites: suites,
|
|
2464
|
+
};
|
|
2465
|
+
entry.maximumNameLength = maxNameLen;
|
|
2466
|
+
entry.publicName = publicName.value.toString("ascii");
|
|
2467
|
+
entry.extensions = extensions;
|
|
2468
|
+
} else {
|
|
2469
|
+
// Unknown future version — surface raw bytes so the caller can
|
|
2470
|
+
// forward them to a Node build that does support that version.
|
|
2471
|
+
entry.body = Buffer.from(raw.slice(bodyOff, bodyEnd));
|
|
2472
|
+
}
|
|
2473
|
+
configs.push(entry);
|
|
2474
|
+
off = bodyEnd;
|
|
2475
|
+
}
|
|
2476
|
+
return { rawLength: raw.length, configs: configs };
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
// Feature-detect: probe whether tls.connect accepts the `ech` option.
|
|
2480
|
+
// Cached so repeated connect calls don't re-test on every connection.
|
|
2481
|
+
// Strategy: tls.connect throws synchronously on a port=0 socket attempt
|
|
2482
|
+
// when the option shape is rejected at the C++ layer with
|
|
2483
|
+
// ERR_INVALID_ARG_TYPE / ERR_TLS_INVALID_OPTION; if it makes it past
|
|
2484
|
+
// option-validation we destroy the half-built socket. We never actually
|
|
2485
|
+
// open a socket — the probe runs entirely in option-parsing.
|
|
2486
|
+
var _echFeatureProbe = null;
|
|
2487
|
+
function _isEchSupported() {
|
|
2488
|
+
if (_echFeatureProbe !== null) return _echFeatureProbe;
|
|
2489
|
+
// The cleanest probe is to read tls.connect.toString() — but Node
|
|
2490
|
+
// hides option parsing in C++. Instead we attempt to construct the
|
|
2491
|
+
// options object via tls.checkServerIdentity-adjacent surface: call
|
|
2492
|
+
// tls.connect with a sentinel `ech: Buffer.alloc(0)` and an
|
|
2493
|
+
// immediately-destroyed socket. Any non-throwing path = supported.
|
|
2494
|
+
var supported = false;
|
|
2495
|
+
try {
|
|
2496
|
+
var probe = tls.connect({
|
|
2497
|
+
host: "127.0.0.1",
|
|
2498
|
+
port: 1,
|
|
2499
|
+
ech: Buffer.alloc(0),
|
|
2500
|
+
lookup: function (_h, _o, cb) { cb(new Error("probe-abort")); },
|
|
2501
|
+
});
|
|
2502
|
+
supported = true;
|
|
2503
|
+
try { probe.destroy(); } catch (_e) { /* probe socket */ }
|
|
2504
|
+
} catch (e) {
|
|
2505
|
+
var msg = (e && (e.code || e.message)) || "";
|
|
2506
|
+
// ERR_INVALID_ARG_TYPE or ERR_TLS_* on `ech` = unsupported.
|
|
2507
|
+
if (/ech/i.test(msg) || /unknown option/i.test(msg)) supported = false;
|
|
2508
|
+
else supported = true; // unrelated throw (e.g. lookup): option accepted
|
|
2509
|
+
}
|
|
2510
|
+
_echFeatureProbe = supported;
|
|
2511
|
+
return supported;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
/**
|
|
2515
|
+
* @primitive b.network.tls.connectWithEch
|
|
2516
|
+
* @signature b.network.tls.connectWithEch(opts)
|
|
2517
|
+
* @since 0.8.53
|
|
2518
|
+
* @status stable
|
|
2519
|
+
* @related b.network.tls.parseEchConfigList, b.network.dns.queryHttps,
|
|
2520
|
+
* b.network.tls.checkServerIdentity9525
|
|
2521
|
+
*
|
|
2522
|
+
* Open a TLS-1.3 outbound connection with Encrypted Client Hello (ECH,
|
|
2523
|
+
* draft-ietf-tls-esni-22) when the destination publishes an `ech=`
|
|
2524
|
+
* SvcParam via SVCB/HTTPS records (RFC 9460 paragraph 2.4 / paragraph 9). The flow:
|
|
2525
|
+
*
|
|
2526
|
+
* 1. `b.network.dns.queryHttps(host)` to discover ECH config.
|
|
2527
|
+
* 2. If any record carries `ech=`, the parsed ECHConfigList is
|
|
2528
|
+
* attached to `tls.connect({ ech })` so the outer ClientHello
|
|
2529
|
+
* uses the published `public_name` SNI and the inner ClientHello
|
|
2530
|
+
* (real SNI, ALPN, etc.) is HPKE-encrypted under the published
|
|
2531
|
+
* public key.
|
|
2532
|
+
* 3. If no record carries `ech=`, or DNS fails, the function falls
|
|
2533
|
+
* back to a normal TLS connect (still TLSv1.3-floor + framework
|
|
2534
|
+
* PQC group preference). Operators get an `observability.event`
|
|
2535
|
+
* so the degradation is visible.
|
|
2536
|
+
* 4. If the running Node build does not support the `ech` connect
|
|
2537
|
+
* option, the function emits a one-shot warn and connects
|
|
2538
|
+
* without ECH — never throws on missing Node-side support.
|
|
2539
|
+
*
|
|
2540
|
+
* Returns the connected `tls.TLSSocket` once `secureConnect` fires.
|
|
2541
|
+
* `b.httpClient` will compose this in a follow-up release; this
|
|
2542
|
+
* primitive is the operator escape hatch for raw outbound TLS over
|
|
2543
|
+
* ECH (custom protocol clients, mTLS testing, ECH validation tools).
|
|
2544
|
+
*
|
|
2545
|
+
* @opts
|
|
2546
|
+
* {
|
|
2547
|
+
* host: string,
|
|
2548
|
+
* port: number,
|
|
2549
|
+
* alpn: string[],
|
|
2550
|
+
* ipFamily: 4 | 6,
|
|
2551
|
+
* timeoutMs: number,
|
|
2552
|
+
* servername: string,
|
|
2553
|
+
* ca: string|Buffer|Array,
|
|
2554
|
+
* checkServerIdentity: function,
|
|
2555
|
+
* echOverride: Buffer|string,
|
|
2556
|
+
* rejectUnauthorized: boolean,
|
|
2557
|
+
* }
|
|
2558
|
+
*
|
|
2559
|
+
* @example
|
|
2560
|
+
* var b = require("@blamejs/core");
|
|
2561
|
+
* var sock = await b.network.tls.connectWithEch({
|
|
2562
|
+
* host: "ech-target.example.com",
|
|
2563
|
+
* alpn: ["h2", "http/1.1"],
|
|
2564
|
+
* });
|
|
2565
|
+
* sock.write("GET / HTTP/1.1\r\nHost: ech-target.example.com\r\n\r\n");
|
|
2566
|
+
*/
|
|
2567
|
+
function connectWithEch(opts) {
|
|
2568
|
+
opts = opts || {};
|
|
2569
|
+
if (typeof opts !== "object" || Array.isArray(opts)) {
|
|
2570
|
+
throw new NetworkTlsError("tls/ech-bad-opts",
|
|
2571
|
+
"connectWithEch: opts must be a plain object");
|
|
2572
|
+
}
|
|
2573
|
+
validateOpts(opts,
|
|
2574
|
+
["host", "port", "alpn", "ipFamily", "timeoutMs", "servername", "ca",
|
|
2575
|
+
"checkServerIdentity", "echOverride", "rejectUnauthorized"],
|
|
2576
|
+
"network.tls.connectWithEch");
|
|
2577
|
+
validateOpts.requireNonEmptyString(opts.host, "connectWithEch: host",
|
|
2578
|
+
NetworkTlsError, "tls/ech-bad-opts");
|
|
2579
|
+
var port = opts.port === undefined ? 443 : opts.port; // allow:raw-byte-literal — HTTPS default port
|
|
2580
|
+
if (typeof port !== "number" || !isFinite(port) ||
|
|
2581
|
+
port <= 0 || port > 65535 || Math.floor(port) !== port) { // allow:raw-byte-literal — TCP port range
|
|
2582
|
+
throw new NetworkTlsError("tls/ech-bad-opts",
|
|
2583
|
+
"connectWithEch: port must be an integer in 1..65535");
|
|
2584
|
+
}
|
|
2585
|
+
if (opts.alpn !== undefined && !Array.isArray(opts.alpn)) {
|
|
2586
|
+
throw new NetworkTlsError("tls/ech-bad-opts",
|
|
2587
|
+
"connectWithEch: alpn must be an array of strings");
|
|
2588
|
+
}
|
|
2589
|
+
if (opts.ipFamily !== undefined && opts.ipFamily !== 4 && opts.ipFamily !== 6) {
|
|
2590
|
+
throw new NetworkTlsError("tls/ech-bad-opts",
|
|
2591
|
+
"connectWithEch: ipFamily must be 4 | 6 | undefined");
|
|
2592
|
+
}
|
|
2593
|
+
var timeoutMs = opts.timeoutMs === undefined
|
|
2594
|
+
? C.TIME.seconds(30) : opts.timeoutMs;
|
|
2595
|
+
if (typeof timeoutMs !== "number" || !isFinite(timeoutMs) || timeoutMs < 0) {
|
|
2596
|
+
throw new NetworkTlsError("tls/ech-bad-opts",
|
|
2597
|
+
"connectWithEch: timeoutMs must be a non-negative finite number");
|
|
2598
|
+
}
|
|
2599
|
+
if (opts.echOverride !== undefined &&
|
|
2600
|
+
!Buffer.isBuffer(opts.echOverride) &&
|
|
2601
|
+
typeof opts.echOverride !== "string") {
|
|
2602
|
+
throw new NetworkTlsError("tls/ech-bad-opts",
|
|
2603
|
+
"connectWithEch: echOverride must be a Buffer or base64 string");
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
return new Promise(function (resolve, reject) {
|
|
2607
|
+
function _doConnect(echConfigBuf, sourceLabel) {
|
|
2608
|
+
var nodeSupportsEch = _isEchSupported();
|
|
2609
|
+
var connectOpts = {
|
|
2610
|
+
host: opts.host,
|
|
2611
|
+
port: port,
|
|
2612
|
+
servername: opts.servername || opts.host,
|
|
2613
|
+
minVersion: "TLSv1.3",
|
|
2614
|
+
};
|
|
2615
|
+
if (Array.isArray(opts.alpn)) connectOpts.ALPNProtocols = opts.alpn.slice();
|
|
2616
|
+
if (opts.ipFamily !== undefined) connectOpts.family = opts.ipFamily;
|
|
2617
|
+
if (opts.ca !== undefined) connectOpts.ca = _normalizeCaInput(opts.ca);
|
|
2618
|
+
if (typeof opts.checkServerIdentity === "function") {
|
|
2619
|
+
connectOpts.checkServerIdentity = opts.checkServerIdentity;
|
|
2620
|
+
}
|
|
2621
|
+
if (opts.rejectUnauthorized === false) {
|
|
2622
|
+
connectOpts.rejectUnauthorized = false;
|
|
2623
|
+
}
|
|
2624
|
+
var echAttached = false;
|
|
2625
|
+
if (echConfigBuf && nodeSupportsEch) {
|
|
2626
|
+
connectOpts.ech = echConfigBuf;
|
|
2627
|
+
echAttached = true;
|
|
2628
|
+
} else if (echConfigBuf && !nodeSupportsEch) {
|
|
2629
|
+
// ECHConfig present but Node build can't honor it — degrade
|
|
2630
|
+
// gracefully with a one-shot warn so operators know they're
|
|
2631
|
+
// sending an outer-only ClientHello.
|
|
2632
|
+
try {
|
|
2633
|
+
observability().emit("network.tls.ech.unsupported", {
|
|
2634
|
+
host: opts.host, source: sourceLabel,
|
|
2635
|
+
});
|
|
2636
|
+
} catch (_e) { /* drop-silent */ }
|
|
2637
|
+
try {
|
|
2638
|
+
audit().safeEmit({
|
|
2639
|
+
action: "network.tls.ech.unsupported",
|
|
2640
|
+
outcome: "success", // Node lacks `ech` opt — degraded to non-ECH
|
|
2641
|
+
metadata: { host: opts.host, source: sourceLabel },
|
|
2642
|
+
});
|
|
2643
|
+
} catch (_e) { /* drop-silent */ }
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
var sock;
|
|
2647
|
+
try { sock = tls.connect(connectOpts); }
|
|
2648
|
+
catch (e) {
|
|
2649
|
+
reject(new NetworkTlsError("tls/ech-connect-failed",
|
|
2650
|
+
"connectWithEch: tls.connect threw: " + ((e && e.message) || String(e))));
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
var settled = false;
|
|
2654
|
+
var to = null;
|
|
2655
|
+
if (timeoutMs > 0) {
|
|
2656
|
+
to = setTimeout(function () {
|
|
2657
|
+
if (settled) return;
|
|
2658
|
+
settled = true;
|
|
2659
|
+
try { sock.destroy(); } catch (_e) { /* destroy best-effort */ }
|
|
2660
|
+
reject(new NetworkTlsError("tls/ech-timeout",
|
|
2661
|
+
"connectWithEch: handshake timed out after " + timeoutMs + "ms"));
|
|
2662
|
+
}, timeoutMs);
|
|
2663
|
+
if (typeof to.unref === "function") to.unref();
|
|
2664
|
+
}
|
|
2665
|
+
sock.once("secureConnect", function () {
|
|
2666
|
+
if (settled) return;
|
|
2667
|
+
settled = true;
|
|
2668
|
+
if (to) clearTimeout(to);
|
|
2669
|
+
try {
|
|
2670
|
+
observability().emit("network.tls.ech.connected", {
|
|
2671
|
+
host: opts.host, echAttached: echAttached, source: sourceLabel,
|
|
2672
|
+
});
|
|
2673
|
+
} catch (_e) { /* drop-silent */ }
|
|
2674
|
+
resolve(sock);
|
|
2675
|
+
});
|
|
2676
|
+
sock.once("error", function (e) {
|
|
2677
|
+
if (settled) return;
|
|
2678
|
+
settled = true;
|
|
2679
|
+
if (to) clearTimeout(to);
|
|
2680
|
+
reject(e);
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
if (Buffer.isBuffer(opts.echOverride) || typeof opts.echOverride === "string") {
|
|
2685
|
+
// Operator-provided ECHConfigList — skip the SVCB lookup, validate
|
|
2686
|
+
// shape, then connect.
|
|
2687
|
+
var override;
|
|
2688
|
+
try {
|
|
2689
|
+
var bufOverride = Buffer.isBuffer(opts.echOverride)
|
|
2690
|
+
? opts.echOverride
|
|
2691
|
+
: Buffer.from(opts.echOverride, "base64");
|
|
2692
|
+
parseEchConfigList(bufOverride); // validate-only
|
|
2693
|
+
override = bufOverride;
|
|
2694
|
+
} catch (e) {
|
|
2695
|
+
reject(e);
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
_doConnect(override, "override");
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
// Default: SVCB/HTTPS lookup. Per RFC 9460 §2.4 the prefix `_https.`
|
|
2703
|
+
// is the SVCB owner-name for an HTTPS origin; modern Node honors a
|
|
2704
|
+
// bare HTTPS QTYPE on the apex name though, which is what
|
|
2705
|
+
// queryHttps does. We use queryHttps directly.
|
|
2706
|
+
var dnsMod;
|
|
2707
|
+
try { dnsMod = networkDns(); }
|
|
2708
|
+
catch (e) {
|
|
2709
|
+
reject(new NetworkTlsError("tls/ech-dns-unavailable",
|
|
2710
|
+
"connectWithEch: network-dns module unavailable: " +
|
|
2711
|
+
((e && e.message) || String(e))));
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
dnsMod.queryHttps(opts.host).then(function (records) {
|
|
2715
|
+
var echBuf = null;
|
|
2716
|
+
for (var i = 0; i < records.length; i += 1) {
|
|
2717
|
+
var rec = records[i];
|
|
2718
|
+
if (rec && rec.params && Buffer.isBuffer(rec.params.ech) &&
|
|
2719
|
+
rec.params.ech.length > 0) {
|
|
2720
|
+
echBuf = rec.params.ech;
|
|
2721
|
+
break;
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
_doConnect(echBuf, echBuf ? "svcb" : "no-ech-record");
|
|
2725
|
+
}).catch(function (e) {
|
|
2726
|
+
// DNS failure is not fatal — fall back to non-ECH connect so the
|
|
2727
|
+
// operator still gets a working TLS session. Emit obs so the
|
|
2728
|
+
// operator sees the degradation.
|
|
2729
|
+
try {
|
|
2730
|
+
observability().emit("network.tls.ech.dns_failed", {
|
|
2731
|
+
host: opts.host, error: (e && e.message) || String(e),
|
|
2732
|
+
});
|
|
2733
|
+
} catch (_e) { /* drop-silent */ }
|
|
2734
|
+
_doConnect(null, "dns-failed");
|
|
2735
|
+
});
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
// ---- RFC 9525 strict server identity verification ----------------
|
|
2740
|
+
//
|
|
2741
|
+
// RFC 9525 §6 — PKIX name validation:
|
|
2742
|
+
// §6.1 The certificate's subjectAltName extension is the
|
|
2743
|
+
// authoritative source of identifiers. CN-fallback is
|
|
2744
|
+
// forbidden when SAN is present. RFC 9525 §6.4.4 explicitly
|
|
2745
|
+
// deprecates CN matching outright; legacy CN-only certs
|
|
2746
|
+
// (no SAN) are refused under strict mode.
|
|
2747
|
+
// §6.4.3 Wildcard `*.example.com` matches `foo.example.com` (one
|
|
2748
|
+
// left-most label) but NOT `foo.bar.example.com` (deeper
|
|
2749
|
+
// subdomain) and NOT `example.com` (the wildcard owner
|
|
2750
|
+
// itself). Wildcards in the middle (`foo.*.example.com`) or
|
|
2751
|
+
// partial wildcards (`f*o.example.com`) are refused.
|
|
2752
|
+
// §6.5 IP addresses match against iPAddress entries in SAN, never
|
|
2753
|
+
// dNSName entries. Textual IP literals do not get DNS-style
|
|
2754
|
+
// wildcard treatment.
|
|
2755
|
+
//
|
|
2756
|
+
// Operators pass `b.network.tls.checkServerIdentity9525` to
|
|
2757
|
+
// `tls.connect({ checkServerIdentity })` to swap Node's permissive
|
|
2758
|
+
// default for the strict policy.
|
|
2759
|
+
|
|
2760
|
+
function _normalizeAsciiHost(host) {
|
|
2761
|
+
// RFC 9525 §6.4 — comparisons are ASCII case-insensitive on the
|
|
2762
|
+
// A-label form. We don't perform IDNA conversion (operators that
|
|
2763
|
+
// need U-label hosts must pre-convert via punycode); raw non-ASCII
|
|
2764
|
+
// input is refused so we never silently match across encodings.
|
|
2765
|
+
if (typeof host !== "string" || host.length === 0) return null;
|
|
2766
|
+
for (var i = 0; i < host.length; i += 1) {
|
|
2767
|
+
var cc = host.charCodeAt(i);
|
|
2768
|
+
if (cc > 0x7f) return null; // allow:raw-byte-literal — ASCII upper bound codepoint
|
|
2769
|
+
}
|
|
2770
|
+
// Strip a trailing dot (FQDN absolute form) for matching.
|
|
2771
|
+
var h = host.toLowerCase();
|
|
2772
|
+
if (h.length > 1 && h.charAt(h.length - 1) === ".") h = h.slice(0, -1);
|
|
2773
|
+
return h;
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
function _matchDnsNamePattern(pattern, host) {
|
|
2777
|
+
// Both inputs must be ASCII-normalized. `pattern` is from the SAN;
|
|
2778
|
+
// `host` is the operator-supplied target host.
|
|
2779
|
+
pattern = _normalizeAsciiHost(pattern);
|
|
2780
|
+
if (!pattern || !host) return false;
|
|
2781
|
+
if (pattern.indexOf("*") === -1) {
|
|
2782
|
+
return pattern === host;
|
|
2783
|
+
}
|
|
2784
|
+
// Wildcards permitted only as the entire left-most label.
|
|
2785
|
+
var pLabels = pattern.split(".");
|
|
2786
|
+
var hLabels = host.split(".");
|
|
2787
|
+
if (pLabels.length !== hLabels.length) return false;
|
|
2788
|
+
if (pLabels.length < 3) return false; // refuse `*.tld` — too broad
|
|
2789
|
+
// Only the FIRST label may contain the wildcard, and it must be `*`
|
|
2790
|
+
// exactly (no partial like `f*o`).
|
|
2791
|
+
if (pLabels[0] !== "*") return false;
|
|
2792
|
+
for (var li = 1; li < pLabels.length; li += 1) {
|
|
2793
|
+
if (pLabels[li].indexOf("*") !== -1) return false;
|
|
2794
|
+
if (pLabels[li] !== hLabels[li]) return false;
|
|
2795
|
+
}
|
|
2796
|
+
// Left-most host label must be non-empty (no `*` matching empty).
|
|
2797
|
+
if (hLabels[0].length === 0) return false;
|
|
2798
|
+
return true;
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
function _parseSanString(rawSubjectAltName) {
|
|
2802
|
+
// Node exposes the SAN as a comma-separated string of typed entries:
|
|
2803
|
+
// "DNS:foo.example.com, DNS:*.example.com, IP Address:198.51.100.1,
|
|
2804
|
+
// IP Address:2001:db8::1"
|
|
2805
|
+
// The RFC 9525 verifier only consumes DNS / IP entries.
|
|
2806
|
+
var dns = [];
|
|
2807
|
+
var ips = [];
|
|
2808
|
+
if (typeof rawSubjectAltName !== "string" || rawSubjectAltName.length === 0) {
|
|
2809
|
+
return { dns: dns, ips: ips };
|
|
2810
|
+
}
|
|
2811
|
+
var entries = rawSubjectAltName.split(",");
|
|
2812
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
2813
|
+
var raw = entries[i].trim();
|
|
2814
|
+
var colon = raw.indexOf(":");
|
|
2815
|
+
if (colon === -1) continue;
|
|
2816
|
+
var kind = raw.slice(0, colon).trim();
|
|
2817
|
+
var val = raw.slice(colon + 1).trim();
|
|
2818
|
+
if (kind === "DNS") {
|
|
2819
|
+
dns.push(val);
|
|
2820
|
+
} else if (kind === "IP Address" || kind === "IP") {
|
|
2821
|
+
ips.push(val);
|
|
2822
|
+
}
|
|
2823
|
+
// Other GeneralName types (URI / email / dirName / OID-based) are
|
|
2824
|
+
// outside RFC 9525's HTTPS scope.
|
|
2825
|
+
}
|
|
2826
|
+
return { dns: dns, ips: ips };
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
function _normalizeIpForCompare(ip) {
|
|
2830
|
+
// Lower-case + strip embedded brackets so "[::1]" / "::1" / "::0001"
|
|
2831
|
+
// all compare equal.
|
|
2832
|
+
if (typeof ip !== "string") return null;
|
|
2833
|
+
var s = ip.trim();
|
|
2834
|
+
if (s.length >= 2 && s.charAt(0) === "[" && s.charAt(s.length - 1) === "]") {
|
|
2835
|
+
s = s.slice(1, -1);
|
|
2836
|
+
}
|
|
2837
|
+
// For IPv6 the canonical form is what `net.isIP` accepts; we
|
|
2838
|
+
// round-trip through Buffer comparison via net.isIPv4 / isIPv6.
|
|
2839
|
+
if (net.isIPv4(s)) return { family: 4, text: s };
|
|
2840
|
+
if (net.isIPv6(s)) {
|
|
2841
|
+
// Canonicalize by expanding to bytes and re-emitting lower-case.
|
|
2842
|
+
var parts = s.split("%"); // strip zone id
|
|
2843
|
+
var addr = parts[0];
|
|
2844
|
+
// Expand "::" then re-collapse via toString won't work in pure JS;
|
|
2845
|
+
// instead produce a 16-byte buffer for byte-equal comparison.
|
|
2846
|
+
var bytes = _ipv6ToBytes(addr);
|
|
2847
|
+
if (!bytes) return null;
|
|
2848
|
+
return { family: 6, text: addr.toLowerCase(), bytes: bytes };
|
|
2849
|
+
}
|
|
2850
|
+
return null;
|
|
2851
|
+
}
|
|
2852
|
+
function _ipv6ToBytes(addr) {
|
|
2853
|
+
// Minimal IPv6 → 16-byte parser. Splits on "::" once, parses each
|
|
2854
|
+
// hextet as base-16 uint16. Returns null on malformed input.
|
|
2855
|
+
if (typeof addr !== "string") return null;
|
|
2856
|
+
var halves;
|
|
2857
|
+
var doubleIdx = addr.indexOf("::");
|
|
2858
|
+
if (doubleIdx === -1) {
|
|
2859
|
+
halves = [addr.split(":"), []];
|
|
2860
|
+
} else {
|
|
2861
|
+
var leftStr = addr.slice(0, doubleIdx);
|
|
2862
|
+
var rightStr = addr.slice(doubleIdx + 2);
|
|
2863
|
+
halves = [
|
|
2864
|
+
leftStr.length ? leftStr.split(":") : [],
|
|
2865
|
+
rightStr.length ? rightStr.split(":") : [],
|
|
2866
|
+
];
|
|
2867
|
+
}
|
|
2868
|
+
var left = halves[0], right = halves[1];
|
|
2869
|
+
var fillCount = 8 - (left.length + right.length); // allow:raw-byte-literal — IPv6 has 8 hextets
|
|
2870
|
+
if (fillCount < 0) return null;
|
|
2871
|
+
var hextets = left.concat(new Array(fillCount).fill("0")).concat(right);
|
|
2872
|
+
if (hextets.length !== 8) return null; // allow:raw-byte-literal — IPv6 hextet count
|
|
2873
|
+
var bytes = Buffer.alloc(16); // allow:raw-byte-literal — IPv6 = 16 bytes
|
|
2874
|
+
for (var i = 0; i < 8; i += 1) { // allow:raw-byte-literal — IPv6 hextet count
|
|
2875
|
+
var h = hextets[i];
|
|
2876
|
+
if (!safeBuffer.IPV6_HEXTET_RE.test(h)) return null;
|
|
2877
|
+
var v = parseInt(h, 16); // allow:raw-byte-literal — hex radix
|
|
2878
|
+
bytes[i * 2] = (v >> 8) & 0xff; // allow:raw-byte-literal — uint8 mask + uint16-half shift
|
|
2879
|
+
bytes[i * 2 + 1] = v & 0xff; // allow:raw-byte-literal — uint8 mask
|
|
2880
|
+
}
|
|
2881
|
+
return bytes;
|
|
2882
|
+
}
|
|
2883
|
+
function _ipsEqual(sanIp, hostIp) {
|
|
2884
|
+
var a = _normalizeIpForCompare(sanIp);
|
|
2885
|
+
var b = _normalizeIpForCompare(hostIp);
|
|
2886
|
+
if (!a || !b) return false;
|
|
2887
|
+
if (a.family !== b.family) return false;
|
|
2888
|
+
if (a.family === 4) return a.text === b.text;
|
|
2889
|
+
// family === 6 — byte compare.
|
|
2890
|
+
if (!a.bytes || !b.bytes) return false;
|
|
2891
|
+
if (a.bytes.length !== b.bytes.length) return false;
|
|
2892
|
+
for (var i = 0; i < a.bytes.length; i += 1) {
|
|
2893
|
+
if (a.bytes[i] !== b.bytes[i]) return false;
|
|
2894
|
+
}
|
|
2895
|
+
return true;
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
/**
|
|
2899
|
+
* @primitive b.network.tls.checkServerIdentity9525
|
|
2900
|
+
* @signature b.network.tls.checkServerIdentity9525(host, cert)
|
|
2901
|
+
* @since 0.8.53
|
|
2902
|
+
* @status stable
|
|
2903
|
+
* @related b.network.tls.connectWithEch
|
|
2904
|
+
*
|
|
2905
|
+
* Drop-in replacement for Node's `tls.checkServerIdentity` that
|
|
2906
|
+
* implements RFC 9525 paragraph 6 strictly. Operators pass it to
|
|
2907
|
+
* `tls.connect({ checkServerIdentity })` (or to any framework primitive
|
|
2908
|
+
* that exposes `pkixStrict: true`).
|
|
2909
|
+
*
|
|
2910
|
+
* Differences vs Node's default matcher:
|
|
2911
|
+
*
|
|
2912
|
+
* - SAN-required when present is mandatory: a peer cert lacking
|
|
2913
|
+
* `subjectAltName` refuses with `tls/pkix-san-required` (RFC 9525
|
|
2914
|
+
* paragraph 6.4.4 forbids Common Name fallback).
|
|
2915
|
+
* - CN-only legacy certs surface a distinct
|
|
2916
|
+
* `tls/pkix-cn-fallback-refused` code so audit logs distinguish
|
|
2917
|
+
* "missing SAN" from "ancient CN-only cert still shipping".
|
|
2918
|
+
* - Wildcard matching is restricted to the entire leftmost label.
|
|
2919
|
+
* `*.example.com` matches `foo.example.com` but NOT
|
|
2920
|
+
* `foo.bar.example.com` and NOT `example.com`. Partial wildcards
|
|
2921
|
+
* like `f*o.example.com` and middle wildcards like
|
|
2922
|
+
* `foo.*.example.com` refuse.
|
|
2923
|
+
* - IP literals match `iPAddress` SAN entries only — never DNS
|
|
2924
|
+
* entries, never wildcards. IPv6 comparison is byte-equal after
|
|
2925
|
+
* canonicalization (zone-id stripped, `::` expanded).
|
|
2926
|
+
*
|
|
2927
|
+
* Returns `Error | undefined` — the `Error` shape Node expects; when
|
|
2928
|
+
* undefined, the connection is permitted to proceed.
|
|
2929
|
+
*
|
|
2930
|
+
* @example
|
|
2931
|
+
* var tls = require("node:tls");
|
|
2932
|
+
* var b = require("@blamejs/core");
|
|
2933
|
+
* var sock = tls.connect({
|
|
2934
|
+
* host: "internal.example.com",
|
|
2935
|
+
* port: 443,
|
|
2936
|
+
* checkServerIdentity: b.network.tls.checkServerIdentity9525,
|
|
2937
|
+
* });
|
|
2938
|
+
*/
|
|
2939
|
+
function checkServerIdentity9525(host, cert) {
|
|
2940
|
+
// Drop-in for tls.checkServerIdentity. Returns Error|undefined.
|
|
2941
|
+
// Node calls this with the post-handshake `cert` shape: subject,
|
|
2942
|
+
// subjectaltname, etc.
|
|
2943
|
+
if (typeof host !== "string" || host.length === 0) {
|
|
2944
|
+
return new NetworkTlsError("tls/pkix-hostname-mismatch",
|
|
2945
|
+
"checkServerIdentity9525: host must be a non-empty string");
|
|
2946
|
+
}
|
|
2947
|
+
if (!cert || typeof cert !== "object") {
|
|
2948
|
+
return new NetworkTlsError("tls/pkix-hostname-mismatch",
|
|
2949
|
+
"checkServerIdentity9525: peer cert object missing");
|
|
2950
|
+
}
|
|
2951
|
+
var hostIsIp = net.isIP(host) > 0;
|
|
2952
|
+
var hostNorm = hostIsIp ? host : _normalizeAsciiHost(host);
|
|
2953
|
+
if (!hostIsIp && !hostNorm) {
|
|
2954
|
+
return new NetworkTlsError("tls/pkix-hostname-mismatch",
|
|
2955
|
+
"checkServerIdentity9525: host '" + host + "' is not a valid ASCII " +
|
|
2956
|
+
"DNS name (pre-convert U-labels to A-labels with punycode)");
|
|
2957
|
+
}
|
|
2958
|
+
var rawSan = cert.subjectaltname;
|
|
2959
|
+
if (typeof rawSan !== "string" || rawSan.length === 0) {
|
|
2960
|
+
// RFC 9525 §6.4.4 forbids CN fallback. If there's no SAN we refuse,
|
|
2961
|
+
// never inspect cert.subject.CN — a CN-only cert violates the
|
|
2962
|
+
// modern PKIX baseline and the operator chose the strict checker.
|
|
2963
|
+
return new NetworkTlsError("tls/pkix-san-required",
|
|
2964
|
+
"checkServerIdentity9525: certificate has no subjectAltName " +
|
|
2965
|
+
"extension (RFC 9525 §6.4.4 forbids Common Name fallback)");
|
|
2966
|
+
}
|
|
2967
|
+
var san = _parseSanString(rawSan);
|
|
2968
|
+
if (hostIsIp) {
|
|
2969
|
+
if (san.ips.length === 0) {
|
|
2970
|
+
return new NetworkTlsError("tls/pkix-hostname-mismatch",
|
|
2971
|
+
"checkServerIdentity9525: host '" + host + "' is an IP literal " +
|
|
2972
|
+
"but the certificate's SAN contains no iPAddress entries");
|
|
2973
|
+
}
|
|
2974
|
+
for (var ii = 0; ii < san.ips.length; ii += 1) {
|
|
2975
|
+
if (_ipsEqual(san.ips[ii], host)) return undefined;
|
|
2976
|
+
}
|
|
2977
|
+
return new NetworkTlsError("tls/pkix-hostname-mismatch",
|
|
2978
|
+
"checkServerIdentity9525: host IP '" + host + "' does not match " +
|
|
2979
|
+
"any iPAddress SAN (" + san.ips.join(", ") + ")");
|
|
2980
|
+
}
|
|
2981
|
+
// DNS host — must match a dNSName SAN entry.
|
|
2982
|
+
if (san.dns.length === 0) {
|
|
2983
|
+
return new NetworkTlsError("tls/pkix-hostname-mismatch",
|
|
2984
|
+
"checkServerIdentity9525: certificate's SAN contains no dNSName " +
|
|
2985
|
+
"entries (host '" + host + "' cannot match an iPAddress-only cert)");
|
|
2986
|
+
}
|
|
2987
|
+
for (var di = 0; di < san.dns.length; di += 1) {
|
|
2988
|
+
if (_matchDnsNamePattern(san.dns[di], hostNorm)) return undefined;
|
|
2989
|
+
}
|
|
2990
|
+
return new NetworkTlsError("tls/pkix-hostname-mismatch",
|
|
2991
|
+
"checkServerIdentity9525: host '" + host + "' does not match any " +
|
|
2992
|
+
"dNSName SAN (" + san.dns.join(", ") + ")");
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
// Detect: did the caller pass a CN-only legacy cert? Surface a
|
|
2996
|
+
// distinct error code so operators can grep audit logs for the
|
|
2997
|
+
// fallback-refused shape vs a generic mismatch.
|
|
2998
|
+
function _refuseCnFallback(host, cert) {
|
|
2999
|
+
if (cert && cert.subject && typeof cert.subject.CN === "string" &&
|
|
3000
|
+
cert.subject.CN.length > 0 &&
|
|
3001
|
+
(typeof cert.subjectaltname !== "string" || cert.subjectaltname.length === 0)) {
|
|
3002
|
+
return new NetworkTlsError("tls/pkix-cn-fallback-refused",
|
|
3003
|
+
"checkServerIdentity9525: peer cert is CN-only (CN='" +
|
|
3004
|
+
cert.subject.CN + "'); RFC 9525 §6.4.4 refuses CN-fallback. " +
|
|
3005
|
+
"Reissue the certificate with a subjectAltName extension covering " +
|
|
3006
|
+
"host '" + host + "'.");
|
|
3007
|
+
}
|
|
3008
|
+
return null;
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
// Public combined verifier — applies both the SAN-required check and
|
|
3012
|
+
// the CN-fallback explicit refusal so operators get the more specific
|
|
3013
|
+
// of the two error codes when applicable. checkServerIdentity9525 is
|
|
3014
|
+
// the drop-in name; this internal helper is what `connect` wires in.
|
|
3015
|
+
function _checkServerIdentityStrict(host, cert) {
|
|
3016
|
+
var cnRefusal = _refuseCnFallback(host, cert);
|
|
3017
|
+
if (cnRefusal) return cnRefusal;
|
|
3018
|
+
return checkServerIdentity9525(host, cert);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
2280
3021
|
module.exports = {
|
|
2281
3022
|
addCa: addCa,
|
|
2282
3023
|
addCaBundle: addCaBundle,
|
|
@@ -2299,7 +3040,11 @@ module.exports = {
|
|
|
2299
3040
|
ct: ct,
|
|
2300
3041
|
pqc: pqc,
|
|
2301
3042
|
preferredGroups: preferredGroups,
|
|
3043
|
+
parseEchConfigList: parseEchConfigList,
|
|
3044
|
+
connectWithEch: connectWithEch,
|
|
3045
|
+
checkServerIdentity9525: checkServerIdentity9525,
|
|
2302
3046
|
TlsTrustError: TlsTrustError,
|
|
2303
3047
|
NetworkTlsError: NetworkTlsError,
|
|
2304
3048
|
_resetForTest: _resetForTest,
|
|
3049
|
+
_checkServerIdentityStrict: _checkServerIdentityStrict,
|
|
2305
3050
|
};
|