@blamejs/core 0.8.52 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/index.js +8 -0
  3. package/lib/audit.js +4 -0
  4. package/lib/auth/fido-mds3.js +624 -0
  5. package/lib/auth/passkey.js +214 -2
  6. package/lib/auth-bot-challenge.js +1 -1
  7. package/lib/credential-hash.js +2 -2
  8. package/lib/framework-error.js +55 -0
  9. package/lib/guard-cidr.js +2 -1
  10. package/lib/guard-jwt.js +2 -2
  11. package/lib/guard-oauth.js +2 -2
  12. package/lib/http-client-cache.js +916 -0
  13. package/lib/http-client.js +242 -0
  14. package/lib/mail-arf.js +343 -0
  15. package/lib/mail-auth.js +265 -40
  16. package/lib/mail-bimi.js +948 -33
  17. package/lib/mail-bounce.js +386 -4
  18. package/lib/mail-mdn.js +424 -0
  19. package/lib/mail-unsubscribe.js +265 -25
  20. package/lib/mail.js +403 -21
  21. package/lib/middleware/bearer-auth.js +1 -1
  22. package/lib/middleware/clear-site-data.js +122 -0
  23. package/lib/middleware/dpop.js +1 -1
  24. package/lib/middleware/index.js +9 -0
  25. package/lib/middleware/nel.js +214 -0
  26. package/lib/middleware/security-headers.js +56 -4
  27. package/lib/middleware/speculation-rules.js +323 -0
  28. package/lib/mime-parse.js +198 -0
  29. package/lib/network-dns.js +890 -27
  30. package/lib/network-tls.js +745 -0
  31. package/lib/object-store/sigv4.js +54 -0
  32. package/lib/public-suffix.js +414 -0
  33. package/lib/safe-buffer.js +7 -0
  34. package/lib/safe-json.js +1 -1
  35. package/lib/static.js +120 -0
  36. package/lib/storage.js +11 -0
  37. package/lib/vendor/MANIFEST.json +33 -0
  38. package/lib/vendor/bimi-trust-anchors.pem +33 -0
  39. package/lib/vendor/public-suffix-list.dat +16376 -0
  40. package/package.json +1 -1
  41. package/sbom.cyclonedx.json +6 -6
@@ -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
  };