diff --git a/doc/api/quic.md b/doc/api/quic.md index 0c41b1e5c0d247..45aa2409fd1cc9 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -204,6 +204,34 @@ added: v23.8.0 True if `endpoint.destroy()` has been called. Read only. +### `endpoint.setSNIContexts(entries[, options])` + + + +* `entries` {object} An object mapping host names to TLS identity options. + Each entry must include `keys` and `certs`. +* `options` {object} + * `replace` {boolean} If `true`, replaces the entire SNI map. If `false` + (the default), merges the entries into the existing map. + +Replaces or updates the SNI TLS contexts for this endpoint. This allows +changing the TLS identity (key/certificate) used for specific host names +without restarting the endpoint. Existing sessions are unaffected — only +new sessions will use the updated contexts. + +```mjs +endpoint.setSNIContexts({ + 'api.example.com': { keys: [newApiKey], certs: [newApiCert] }, +}); + +// Replace the entire SNI map +endpoint.setSNIContexts({ + 'api.example.com': { keys: [newApiKey], certs: [newApiCert] }, +}, { replace: true }); +``` + ### `endpoint.stats` -* Type: {string} +* Type: {string} (client) | {string\[]} (server) + +The ALPN (Application-Layer Protocol Negotiation) identifier(s). + +For **client** sessions, this is a single string specifying the protocol +the client wants to use (e.g. `'h3'`). + +For **server** sessions, this is an array of protocol names in preference +order that the server supports (e.g. `['h3', 'h3-29']`). During the TLS +handshake, the server selects the first protocol from its list that the +client also supports. -The ALPN protocol identifier. +The negotiated ALPN determines which Application implementation is used +for the session. `'h3'` and `'h3-*'` variants select the HTTP/3 +application; all other values select the default application. -#### `sessionOptions.ca` +Default: `'h3'` + +#### `sessionOptions.ca` (client only) + +* Type: {Object} + +An object mapping host names to TLS identity options for Server Name +Indication (SNI) support. This is required for server sessions. The +special key `'*'` specifies the default/fallback identity used when +no other host name matches. Each entry may contain: + +* `keys` {KeyObject|KeyObject\[]} The TLS private keys. **Required.** +* `certs` {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]} + The TLS certificates. **Required.** +* `ca` {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]} + Optional CA certificate overrides. +* `crl` {ArrayBuffer|ArrayBufferView|ArrayBuffer\[]|ArrayBufferView\[]} + Optional certificate revocation lists. +* `verifyPrivateKey` {boolean} Verify the private key. Default: `false`. + +```mjs +const endpoint = await listen(callback, { + sni: { + '*': { keys: [defaultKey], certs: [defaultCert] }, + 'api.example.com': { keys: [apiKey], certs: [apiCert] }, + 'www.example.com': { keys: [wwwKey], certs: [wwwCert], ca: [customCA] }, + }, +}); +``` + +Shared TLS options (such as `ciphers`, `groups`, `keylog`, and `verifyClient`) +are specified at the top level of the session options and apply to all +identities. Each SNI entry overrides only the per-identity certificate +fields. + +The SNI map can be replaced at runtime using `endpoint.setSNIContexts()`, +which atomically swaps the map for new sessions while existing sessions +continue to use their original identity. #### `sessionOptions.tlsTrace` @@ -1338,7 +1425,7 @@ added: v23.8.0 True to require verification of TLS client certificate. -#### `sessionOptions.verifyPrivateKey` +#### `sessionOptions.verifyPrivateKey` (client only) + +[`sessionOptions.sni`]: #sessionoptionssni-server-only diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 53dfc9405015b0..fa0cfcf278538e 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -10,6 +10,7 @@ const { ArrayPrototypePush, BigInt, ObjectDefineProperties, + ObjectKeys, SafeSet, SymbolAsyncDispose, Uint8Array, @@ -32,7 +33,6 @@ let debug = require('internal/util/debuglog').debuglog('quic', (fn) => { const { Endpoint: Endpoint_, - Http3Application: Http3, setCallbacks, // The constants to be exposed to end users for various options. @@ -117,7 +117,6 @@ const { const kEmptyObject = { __proto__: null }; const { - kApplicationProvider, kBlocked, kConnect, kDatagram, @@ -1964,6 +1963,42 @@ class QuicEndpoint { return this.closed; } + /** + * Replace or merge SNI TLS contexts for this endpoint. Each entry + * in the map is a host name to TLS identity options object. If + * replace is true, the entire SNI map is replaced. Otherwise, the + * provided entries are merged into the existing map. + * @param {object} entries + * @param {{replace?: boolean}} [options] + */ + setSNIContexts(entries, options = kEmptyObject) { + QuicEndpoint.#assertIsQuicEndpoint(this); + if (this.#handle === undefined) { + throw new ERR_INVALID_STATE('Endpoint is destroyed'); + } + validateObject(entries, 'entries'); + const { replace = false } = options; + validateBoolean(replace, 'options.replace'); + + // Process each entry through the identity options validator, + // then build a full TLS options object (shared + identity). + const processed = { __proto__: null }; + for (const hostname of ObjectKeys(entries)) { + validateString(hostname, 'entries key'); + const identity = processIdentityOptions(entries[hostname], + `entries['${hostname}']`); + if (identity.keys.length === 0) { + throw new ERR_MISSING_ARGS(`entries['${hostname}'].keys`); + } + if (identity.certs === undefined) { + throw new ERR_MISSING_ARGS(`entries['${hostname}'].certs`); + } + processed[hostname] = identity; + } + + this.#handle.setSNIContexts(processed, replace); + } + #maybeGetCloseError(context, status) { switch (context) { case kCloseContextClose: { @@ -2104,48 +2139,26 @@ function processEndpointOption(endpoint) { } /** - * @param {SessionOptions} tls - * @param {boolean} forServer + * Validate and extract identity options (keys, certs, ca, crl) from + * an SNI entry. + * @param {object} identity + * @param {string} label * @returns {object} */ -function processTlsOptions(tls, forServer) { +function processIdentityOptions(identity, label) { const { - servername, - protocol, - ciphers = DEFAULT_CIPHERS, - groups = DEFAULT_GROUPS, - keylog = false, - verifyClient = false, - tlsTrace = false, - verifyPrivateKey = false, keys, certs, ca, crl, - } = tls; - - if (servername !== undefined) { - validateString(servername, 'options.servername'); - } - if (protocol !== undefined) { - validateString(protocol, 'options.protocol'); - } - if (ciphers !== undefined) { - validateString(ciphers, 'options.ciphers'); - } - if (groups !== undefined) { - validateString(groups, 'options.groups'); - } - validateBoolean(keylog, 'options.keylog'); - validateBoolean(verifyClient, 'options.verifyClient'); - validateBoolean(tlsTrace, 'options.tlsTrace'); - validateBoolean(verifyPrivateKey, 'options.verifyPrivateKey'); + verifyPrivateKey = false, + } = identity; if (certs !== undefined) { const certInputs = ArrayIsArray(certs) ? certs : [certs]; for (const cert of certInputs) { if (!isArrayBufferView(cert) && !isArrayBuffer(cert)) { - throw new ERR_INVALID_ARG_TYPE('options.certs', + throw new ERR_INVALID_ARG_TYPE(`${label}.certs`, ['ArrayBufferView', 'ArrayBuffer'], cert); } } @@ -2155,7 +2168,7 @@ function processTlsOptions(tls, forServer) { const caInputs = ArrayIsArray(ca) ? ca : [ca]; for (const caCert of caInputs) { if (!isArrayBufferView(caCert) && !isArrayBuffer(caCert)) { - throw new ERR_INVALID_ARG_TYPE('options.ca', + throw new ERR_INVALID_ARG_TYPE(`${label}.ca`, ['ArrayBufferView', 'ArrayBuffer'], caCert); } } @@ -2165,7 +2178,7 @@ function processTlsOptions(tls, forServer) { const crlInputs = ArrayIsArray(crl) ? crl : [crl]; for (const crlCert of crlInputs) { if (!isArrayBufferView(crlCert) && !isArrayBuffer(crlCert)) { - throw new ERR_INVALID_ARG_TYPE('options.crl', + throw new ERR_INVALID_ARG_TYPE(`${label}.crl`, ['ArrayBufferView', 'ArrayBuffer'], crlCert); } } @@ -2177,39 +2190,165 @@ function processTlsOptions(tls, forServer) { for (const key of keyInputs) { if (isKeyObject(key)) { if (key.type !== 'private') { - throw new ERR_INVALID_ARG_VALUE('options.keys', key, 'must be a private key'); + throw new ERR_INVALID_ARG_VALUE(`${label}.keys`, key, + 'must be a private key'); } ArrayPrototypePush(keyHandles, key[kKeyObjectHandle]); } else { - throw new ERR_INVALID_ARG_TYPE('options.keys', 'KeyObject', key); + throw new ERR_INVALID_ARG_TYPE(`${label}.keys`, 'KeyObject', key); } } } - // For a server we require key and cert at least - if (forServer) { - if (keyHandles.length === 0) { - throw new ERR_MISSING_ARGS('options.keys'); + validateBoolean(verifyPrivateKey, `${label}.verifyPrivateKey`); + + return { + __proto__: null, + keys: keyHandles, + certs, + ca, + crl, + verifyPrivateKey, + }; +} + +/** + * @param {object} tls + * @param {boolean} forServer + * @returns {object} + */ +function processTlsOptions(tls, forServer) { + const { + servername, + alpn, + ciphers = DEFAULT_CIPHERS, + groups = DEFAULT_GROUPS, + keylog = false, + verifyClient = false, + tlsTrace = false, + sni, + // Client-only: identity options are specified directly (no sni map) + keys, + certs, + ca, + crl, + verifyPrivateKey = false, + } = tls; + + if (servername !== undefined) { + validateString(servername, 'options.servername'); + } + if (ciphers !== undefined) { + validateString(ciphers, 'options.ciphers'); + } + if (groups !== undefined) { + validateString(groups, 'options.groups'); + } + validateBoolean(keylog, 'options.keylog'); + validateBoolean(verifyClient, 'options.verifyClient'); + validateBoolean(tlsTrace, 'options.tlsTrace'); + + // Encode the ALPN option to wire format (length-prefixed protocol names). + // Server: array of protocol names. Client: single protocol name. + // If not specified, the C++ default (h3) is used. + let encodedAlpn; + if (alpn !== undefined) { + const protocols = forServer ? + (ArrayIsArray(alpn) ? alpn : [alpn]) : + [alpn]; + if (!forServer) { + validateString(alpn, 'options.alpn'); + } + let totalLen = 0; + for (let i = 0; i < protocols.length; i++) { + validateString(protocols[i], `options.alpn[${i}]`); + if (protocols[i].length === 0 || protocols[i].length > 255) { + throw new ERR_INVALID_ARG_VALUE(`options.alpn[${i}]`, protocols[i], + 'must be between 1 and 255 characters'); + } + totalLen += 1 + protocols[i].length; } - if (certs === undefined) { - throw new ERR_MISSING_ARGS('options.certs'); + // Build wire format: [len1][name1][len2][name2]... + const buf = Buffer.allocUnsafe(totalLen); + let offset = 0; + for (let i = 0; i < protocols.length; i++) { + buf[offset++] = protocols[i].length; + buf.write(protocols[i], offset, 'ascii'); + offset += protocols[i].length; } + encodedAlpn = buf.toString('latin1'); } - return { + // Shared TLS options (same for all identities on the endpoint). + const shared = { __proto__: null, servername, - protocol, + alpn: encodedAlpn, ciphers, groups, keylog, verifyClient, tlsTrace, - verifyPrivateKey, - keys: keyHandles, - certs, - ca, - crl, + }; + + // For servers, identity options come from the sni map. + // The '*' entry is the default/fallback identity. + if (forServer) { + if (sni === undefined || typeof sni !== 'object') { + throw new ERR_MISSING_ARGS('options.sni'); + } + if (sni['*'] === undefined) { + throw new ERR_MISSING_ARGS("options.sni['*']"); + } + + // Process the default ('*') identity into the main tls options. + const defaultIdentity = processIdentityOptions(sni['*'], "options.sni['*']"); + if (defaultIdentity.keys.length === 0) { + throw new ERR_MISSING_ARGS("options.sni['*'].keys"); + } + if (defaultIdentity.certs === undefined) { + throw new ERR_MISSING_ARGS("options.sni['*'].certs"); + } + + // Build the SNI entries (excluding '*') as full TLS options objects. + // Each inherits the shared options and overrides the identity fields. + const sniEntries = { __proto__: null }; + for (const hostname of ObjectKeys(sni)) { + if (hostname === '*') continue; + validateString(hostname, 'options.sni key'); + const identity = processIdentityOptions(sni[hostname], + `options.sni['${hostname}']`); + if (identity.keys.length === 0) { + throw new ERR_MISSING_ARGS(`options.sni['${hostname}'].keys`); + } + if (identity.certs === undefined) { + throw new ERR_MISSING_ARGS(`options.sni['${hostname}'].certs`); + } + // Build a full TLS options object: shared + identity. + sniEntries[hostname] = { + __proto__: null, + ...shared, + ...identity, + }; + } + + return { + __proto__: null, + ...shared, + ...defaultIdentity, + sni: sniEntries, + }; + } + + // For clients, identity options are specified directly (no sni map). + const clientIdentity = processIdentityOptions({ + keys, certs, ca, crl, verifyPrivateKey, + }, 'options'); + + return { + __proto__: null, + ...shared, + ...clientIdentity, }; } @@ -2247,17 +2386,12 @@ function processSessionOptions(options, config = {}) { maxStreamWindow, maxWindow, cc, - [kApplicationProvider]: provider, } = options; const { forServer = false, } = config; - if (provider !== undefined) { - validateObject(provider, 'options[kApplicationProvider]'); - } - if (cc !== undefined) { validateOneOf(cc, 'options.cc', [CC_ALGO_RENO, CC_ALGO_BBR, CC_ALGO_CUBIC]); } @@ -2279,7 +2413,6 @@ function processSessionOptions(options, config = {}) { maxStreamWindow, maxWindow, sessionTicket, - provider, cc, }; } @@ -2381,7 +2514,6 @@ module.exports = { QuicEndpoint, QuicSession, QuicStream, - Http3, CC_ALGO_RENO, CC_ALGO_CUBIC, CC_ALGO_BBR, diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index 2c768efa881658..f8075457825630 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -62,6 +62,7 @@ const { IDX_STATE_SESSION_STREAM_OPEN_ALLOWED, IDX_STATE_SESSION_PRIORITY_SUPPORTED, IDX_STATE_SESSION_WRAPPED, + IDX_STATE_SESSION_APPLICATION_TYPE, IDX_STATE_SESSION_LAST_DATAGRAM_ID, IDX_STATE_ENDPOINT_BOUND, @@ -99,6 +100,7 @@ assert(IDX_STATE_SESSION_HANDSHAKE_CONFIRMED !== undefined); assert(IDX_STATE_SESSION_STREAM_OPEN_ALLOWED !== undefined); assert(IDX_STATE_SESSION_PRIORITY_SUPPORTED !== undefined); assert(IDX_STATE_SESSION_WRAPPED !== undefined); +assert(IDX_STATE_SESSION_APPLICATION_TYPE !== undefined); assert(IDX_STATE_SESSION_LAST_DATAGRAM_ID !== undefined); assert(IDX_STATE_ENDPOINT_BOUND !== undefined); assert(IDX_STATE_ENDPOINT_RECEIVING !== undefined); @@ -347,6 +349,12 @@ class QuicSessionState { return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_WRAPPED); } + /** @type {number} */ + get applicationType() { + if (this.#handle.byteLength === 0) return undefined; + return DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_APPLICATION_TYPE); + } + /** @type {bigint} */ get lastDatagramId() { if (this.#handle.byteLength === 0) return undefined; @@ -407,6 +415,7 @@ class QuicSessionState { isStreamOpenAllowed: this.isStreamOpenAllowed, isPrioritySupported: this.isPrioritySupported, isWrapped: this.isWrapped, + applicationType: this.applicationType, lastDatagramId: this.lastDatagramId, }, opts)}`; } diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js index 1fc315ed8c4a72..1a6c56b1a0ae9d 100644 --- a/lib/internal/quic/symbols.js +++ b/lib/internal/quic/symbols.js @@ -23,7 +23,6 @@ const { // Symbols used to hide various private properties and methods from the // public API. -const kApplicationProvider = Symbol('kApplicationProvider'); const kBlocked = Symbol('kBlocked'); const kConnect = Symbol('kConnect'); const kDatagram = Symbol('kDatagram'); @@ -50,7 +49,6 @@ const kWantsHeaders = Symbol('kWantsHeaders'); const kWantsTrailers = Symbol('kWantsTrailers'); module.exports = { - kApplicationProvider, kBlocked, kConnect, kDatagram, diff --git a/node.gyp b/node.gyp index b245011181d660..ed699e0d4c03f1 100644 --- a/node.gyp +++ b/node.gyp @@ -269,6 +269,7 @@ 'src/node_mem.h', 'src/node_mem-inl.h', 'src/node_messaging.h', + 'src/node_hash.h', 'src/node_metadata.h', 'src/node_mutex.h', 'src/node_diagnostics_channel.h', diff --git a/src/node_hash.h b/src/node_hash.h new file mode 100644 index 00000000000000..d0bbf6a4896869 --- /dev/null +++ b/src/node_hash.h @@ -0,0 +1,212 @@ +#pragma once + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +// Fast, high-quality hash function for byte sequences. +// +// Provides HashBytes() for use in hash tables. Uses native-width integer +// loads and a 128-bit multiply-and-fold mixer for excellent avalanche +// properties on short byte sequences (network identifiers, addresses, +// tokens). +// +// Based on rapidhash V3 by Nicolas De Carli, which evolved from wyhash +// by Wang Yi. Both use the same core mixing primitive (MUM: multiply, +// then XOR the high and low halves of the 128-bit result). +// +// rapidhash: https://github.com/Nicoshev/rapidhash +// Copyright (C) 2025 Nicolas De Carli — MIT License +// wyhash: https://github.com/wangyi-fudan/wyhash +// Wang Yi — public domain (The Unlicense) +// +// The implementation here uses rapidhash's read strategy (native-width +// overlapping reads, optimized for short inputs) and secret constants. +// The core mixing function (rapid_mum/rapid_mix) is identical to +// wyhash's wymum/wymix. + +#include +#include +#include + +#if defined(_MSC_VER) +#include +#if defined(_M_X64) && !defined(_M_ARM64EC) +#pragma intrinsic(_umul128) +#endif +#endif + +namespace node { + +namespace hash_detail { + +// 128-bit multiply, then XOR the high and low halves. +// This is the core mixing function ("rapid_mum" / "wymum"). +// On 64-bit platforms with __int128, this compiles to a single +// mul instruction + shift + xor. +inline uint64_t RapidMix(uint64_t a, uint64_t b) { +#ifdef __SIZEOF_INT128__ + __uint128_t r = static_cast<__uint128_t>(a) * b; + a = static_cast(r); + b = static_cast(r >> 64); +#elif defined(_MSC_VER) && (defined(_WIN64) || defined(_M_HYBRID_CHPE_ARM64)) +#if defined(_M_X64) + a = _umul128(a, b, &b); +#else + uint64_t hi = __umulh(a, b); + a = a * b; + b = hi; +#endif +#else + // Portable 64x64 -> 128-bit multiply fallback for 32-bit platforms. + uint64_t ha = a >> 32, hb = b >> 32; + uint64_t la = static_cast(a), lb = static_cast(b); + uint64_t rh = ha * hb, rm0 = ha * lb, rm1 = hb * la, rl = la * lb; + uint64_t t = rl + (rm0 << 32); + uint64_t lo = t + (rm1 << 32); + uint64_t hi = rh + (rm0 >> 32) + (rm1 >> 32) + (t < rl) + (lo < t); + a = lo; + b = hi; +#endif + return a ^ b; +} + +// Inline 128-bit multiply WITHOUT the final XOR (used in the +// penultimate mixing step where a and b are updated separately). +inline void RapidMum(uint64_t* a, uint64_t* b) { +#ifdef __SIZEOF_INT128__ + __uint128_t r = static_cast<__uint128_t>(*a) * (*b); + *a = static_cast(r); + *b = static_cast(r >> 64); +#elif defined(_MSC_VER) && (defined(_WIN64) || defined(_M_HYBRID_CHPE_ARM64)) +#if defined(_M_X64) + *a = _umul128(*a, *b, b); +#else + uint64_t hi = __umulh(*a, *b); + *a = (*a) * (*b); + *b = hi; +#endif +#else + uint64_t ha = *a >> 32, hb = *b >> 32; + uint64_t la = static_cast(*a), lb = static_cast(*b); + uint64_t rh = ha * hb, rm0 = ha * lb, rm1 = hb * la, rl = la * lb; + uint64_t t = rl + (rm0 << 32); + *a = t + (rm1 << 32); + *b = rh + (rm0 >> 32) + (rm1 >> 32) + (t < rl) + (*a < t); +#endif +} + +// Read functions. The compiler optimizes small fixed-size memcpy calls +// to single load instructions — no actual byte-by-byte copy occurs. +inline uint64_t RapidRead64(const uint8_t* p) { + uint64_t v; + memcpy(&v, p, sizeof(uint64_t)); + return v; +} + +inline uint64_t RapidRead32(const uint8_t* p) { + uint32_t v; + memcpy(&v, p, sizeof(uint32_t)); + return v; +} + +// Default rapidhash secret parameters. +constexpr uint64_t kSecret[8] = {0x2d358dccaa6c78a5ULL, + 0x8bb84b93962eacc9ULL, + 0x4b33a62ed433d4a3ULL, + 0x4d5a2da51de1aa47ULL, + 0xa0761d6478bd642fULL, + 0xe7037ed1a0b428dbULL, + 0x90ed1765281c388cULL, + 0xaaaaaaaaaaaaaaaaULL}; + +} // namespace hash_detail + +// Hash a contiguous byte range. Optimized for short inputs (≤48 bytes) +// which is the common case for network identifiers and addresses. For +// inputs >48 bytes, falls through to a loop processing 48-byte chunks. +inline size_t HashBytes(const void* data, size_t len) { + const uint8_t* p = static_cast(data); + + // Seed initialization. + uint64_t seed = hash_detail::RapidMix(0 ^ hash_detail::kSecret[2], + hash_detail::kSecret[1]); + uint64_t a = 0; + uint64_t b = 0; + size_t i = len; + + if (len <= 16) { + if (len >= 4) { + // Mix length into seed for better distribution of + // different-length inputs with shared prefixes. + seed ^= len; + if (len >= 8) { + // 8-16 bytes: two native 64-bit reads (overlapping from end). + a = hash_detail::RapidRead64(p); + b = hash_detail::RapidRead64(p + len - 8); + } else { + // 4-7 bytes: two 32-bit reads (overlapping from end). + a = hash_detail::RapidRead32(p); + b = hash_detail::RapidRead32(p + len - 4); + } + } else if (len > 0) { + // 1-3 bytes: spread bytes across two values for mixing. + a = (static_cast(p[0]) << 45) | p[len - 1]; + b = p[len >> 1]; + } else { + a = b = 0; + } + } else if (len <= 48) { + // 17-48 bytes: process in 16-byte chunks, then read the tail. + seed = hash_detail::RapidMix( + hash_detail::RapidRead64(p) ^ hash_detail::kSecret[2], + hash_detail::RapidRead64(p + 8) ^ seed); + if (len > 32) { + seed = hash_detail::RapidMix( + hash_detail::RapidRead64(p + 16) ^ hash_detail::kSecret[2], + hash_detail::RapidRead64(p + 24) ^ seed); + } + a = hash_detail::RapidRead64(p + len - 16) ^ len; + b = hash_detail::RapidRead64(p + len - 8); + } else { + // >48 bytes: process 48-byte chunks with three parallel mix lanes. + uint64_t see1 = seed; + uint64_t see2 = seed; + do { + seed = hash_detail::RapidMix( + hash_detail::RapidRead64(p) ^ hash_detail::kSecret[0], + hash_detail::RapidRead64(p + 8) ^ seed); + see1 = hash_detail::RapidMix( + hash_detail::RapidRead64(p + 16) ^ hash_detail::kSecret[1], + hash_detail::RapidRead64(p + 24) ^ see1); + see2 = hash_detail::RapidMix( + hash_detail::RapidRead64(p + 32) ^ hash_detail::kSecret[2], + hash_detail::RapidRead64(p + 40) ^ see2); + p += 48; + i -= 48; + } while (i > 48); + seed ^= see1 ^ see2; + // Process remaining 17-48 bytes. + if (i > 16) { + seed = hash_detail::RapidMix( + hash_detail::RapidRead64(p) ^ hash_detail::kSecret[2], + hash_detail::RapidRead64(p + 8) ^ seed); + if (i > 32) { + seed = hash_detail::RapidMix( + hash_detail::RapidRead64(p + 16) ^ hash_detail::kSecret[2], + hash_detail::RapidRead64(p + 24) ^ seed); + } + } + a = hash_detail::RapidRead64(p + i - 16) ^ i; + b = hash_detail::RapidRead64(p + i - 8); + } + + // Final mix. + a ^= hash_detail::kSecret[1]; + b ^= seed; + hash_detail::RapidMum(&a, &b); + return static_cast(hash_detail::RapidMix( + a ^ hash_detail::kSecret[7], b ^ hash_detail::kSecret[1] ^ len)); +} + +} // namespace node + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/node_sockaddr-inl.h b/src/node_sockaddr-inl.h index 475395e602b8f2..bc055da535c2d4 100644 --- a/src/node_sockaddr-inl.h +++ b/src/node_sockaddr-inl.h @@ -16,14 +16,6 @@ namespace node { static constexpr uint32_t kLabelMask = 0xFFFFF; -inline void hash_combine(size_t* seed) { } - -template -inline void hash_combine(size_t* seed, const T& value, Args... rest) { - *seed ^= std::hash{}(value) + 0x9e3779b9 + (*seed << 6) + (*seed >> 2); - hash_combine(seed, rest...); -} - bool SocketAddress::is_numeric_host(const char* hostname) { return is_numeric_host(hostname, AF_INET) || is_numeric_host(hostname, AF_INET6); diff --git a/src/node_sockaddr.cc b/src/node_sockaddr.cc index f45c77cb60dd0c..c869d423b254cc 100644 --- a/src/node_sockaddr.cc +++ b/src/node_sockaddr.cc @@ -4,6 +4,7 @@ #include "memory_tracker-inl.h" #include "nbytes.h" #include "node_errors.h" +#include "node_hash.h" #include "node_sockaddr-inl.h" // NOLINT(build/include_inline) #include "uv.h" @@ -77,26 +78,28 @@ bool SocketAddress::New( } size_t SocketAddress::Hash::operator()(const SocketAddress& addr) const { - size_t hash = 0; + // Hash only the meaningful bytes (family + port + address), not the + // full 128-byte sockaddr_storage. switch (addr.family()) { case AF_INET: { const sockaddr_in* ipv4 = reinterpret_cast(addr.raw()); - hash_combine(&hash, ipv4->sin_port, ipv4->sin_addr.s_addr); - break; + uint8_t buf[6]; + memcpy(buf, &ipv4->sin_port, 2); + memcpy(buf + 2, &ipv4->sin_addr, 4); + return HashBytes(buf, sizeof(buf)); } case AF_INET6: { const sockaddr_in6* ipv6 = reinterpret_cast(addr.raw()); - const uint64_t* a = - reinterpret_cast(&ipv6->sin6_addr); - hash_combine(&hash, ipv6->sin6_port, a[0], a[1]); - break; + uint8_t buf[18]; + memcpy(buf, &ipv6->sin6_port, 2); + memcpy(buf + 2, &ipv6->sin6_addr, 16); + return HashBytes(buf, sizeof(buf)); } default: UNREACHABLE(); } - return hash; } SocketAddress SocketAddress::FromSockName(const uv_tcp_t& handle) { diff --git a/src/quic/application.cc b/src/quic/application.cc index 6a7865e9f2e662..81c1c0ebe5f49c 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -464,6 +464,10 @@ class DefaultApplication final : public Session::Application { // of the namespace. using Application::Application; // NOLINT + Session::Application::Type type() const override { + return Session::Application::Type::DEFAULT; + } + error_code GetNoErrorCode() const override { return 0; } bool ReceiveStreamData(int64_t stream_id, @@ -512,13 +516,6 @@ class DefaultApplication final : public Session::Application { if (!session().max_data_left()) return 0; if (stream_queue_.IsEmpty()) return 0; - const auto get_length = [](auto vec, size_t count) { - CHECK_NOT_NULL(vec); - size_t len = 0; - for (size_t n = 0; n < count; n++) len += vec[n].len; - return len; - }; - Stream* stream = stream_queue_.PopFront(); CHECK_NOT_NULL(stream); stream_data->stream.reset(stream); @@ -601,14 +598,9 @@ class DefaultApplication final : public Session::Application { Stream::Queue stream_queue_; }; -std::unique_ptr Session::SelectApplication( - Session* session, const Config& config) { - if (config.options.application_provider) { - return config.options.application_provider->Create(session); - } - - return std::make_unique(session, - Application_Options::kDefault); +std::unique_ptr CreateDefaultApplication( + Session* session, const Session::Application_Options& options) { + return std::make_unique(session, options); } } // namespace quic diff --git a/src/quic/application.h b/src/quic/application.h index 83d8d1fe032073..11ee977c44967c 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -17,9 +17,19 @@ class Session::Application : public MemoryRetainer { public: using Options = Session::Application_Options; + // The type of Application, exposed via the session state so JS + // can observe which Application was selected after ALPN negotiation. + enum class Type : uint8_t { + NONE = 0, // Not yet selected (server pre-negotiation) + DEFAULT = 1, // DefaultApplication (non-h3 ALPN) + HTTP3 = 2, // Http3ApplicationImpl (h3 / h3-XX ALPN) + }; + Application(Session* session, const Options& options); DISALLOW_COPY_AND_MOVE(Application) + virtual Type type() const = 0; + virtual bool Start(); virtual error_code GetNoErrorCode() const = 0; @@ -169,6 +179,10 @@ struct Session::Application::StreamData final { std::string ToString() const; }; +// Create a DefaultApplication for the given session. +std::unique_ptr CreateDefaultApplication( + Session* session, const Session::Application_Options& options); + } // namespace node::quic #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 48827838bcb2b2..05751d0fbcd01a 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -24,7 +24,6 @@ class Packet; // The FunctionTemplates the BindingData will store for us. #define QUIC_CONSTRUCTORS(V) \ V(endpoint) \ - V(http3application) \ V(logstream) \ V(session) \ V(stream) \ @@ -59,7 +58,7 @@ class Packet; V(ack_delay_exponent, "ackDelayExponent") \ V(active_connection_id_limit, "activeConnectionIDLimit") \ V(address_lru_size, "addressLRUSize") \ - V(application_provider, "provider") \ + V(application, "application") \ V(bbr, "bbr") \ V(ca, "ca") \ V(cc_algorithm, "cc") \ @@ -78,7 +77,6 @@ class Packet; V(groups, "groups") \ V(handshake_timeout, "handshakeTimeout") \ V(http3_alpn, &NGHTTP3_ALPN_H3[1]) \ - V(http3application, "Http3Application") \ V(initial_max_data, "initialMaxData") \ V(initial_max_stream_data_bidi_local, "initialMaxStreamDataBidiLocal") \ V(initial_max_stream_data_bidi_remote, "initialMaxStreamDataBidiRemote") \ @@ -105,7 +103,7 @@ class Packet; V(max_window, "maxWindow") \ V(min_version, "minVersion") \ V(preferred_address_strategy, "preferredAddressPolicy") \ - V(protocol, "protocol") \ + V(alpn, "alpn") \ V(qlog, "qlog") \ V(qpack_blocked_streams, "qpackBlockedStreams") \ V(qpack_encoder_max_dtable_capacity, "qpackEncoderMaxDTableCapacity") \ @@ -117,6 +115,7 @@ class Packet; V(rx_loss, "rxDiagnosticLoss") \ V(servername, "servername") \ V(session, "Session") \ + V(sni, "sni") \ V(stream, "Stream") \ V(success, "success") \ V(tls_options, "tls") \ diff --git a/src/quic/cid.cc b/src/quic/cid.cc index 16db80485f108b..2238ab17f836a6 100644 --- a/src/quic/cid.cc +++ b/src/quic/cid.cc @@ -3,6 +3,7 @@ #ifndef OPENSSL_NO_QUIC #include #include +#include #include #include #include "cid.h" @@ -85,16 +86,7 @@ const CID CID::kInvalid{}; // CID::Hash size_t CID::Hash::operator()(const CID& cid) const { - // Uses the Boost hash_combine strategy: XOR each byte with the golden - // ratio constant 0x9e3779b9 (derived from the fractional part of the - // golden ratio, (sqrt(5)-1)/2 * 2^32) plus bit-shifted accumulator - // state. This provides good avalanche properties for short byte - // sequences like connection IDs (1-20 bytes). - size_t hash = 0; - for (size_t n = 0; n < cid.length(); n++) { - hash ^= cid.ptr_->data[n] + 0x9e3779b9 + (hash << 6) + (hash >> 2); - } - return hash; + return HashBytes(cid.ptr_->data, cid.length()); } // ============================================================================ diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index 6f37449a9d7225..8d801de5f94b79 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -24,6 +24,7 @@ namespace node { +using v8::Array; using v8::ArrayBufferView; using v8::BackingStore; using v8::HandleScope; @@ -504,12 +505,12 @@ JS_CONSTRUCTOR_IMPL(Endpoint, endpoint_constructor_template, { SetProtoMethod(isolate, tmpl, "connect", DoConnect); SetProtoMethod(isolate, tmpl, "markBusy", MarkBusy); SetProtoMethod(isolate, tmpl, "ref", Ref); + SetProtoMethod(isolate, tmpl, "setSNIContexts", DoSetSNIContexts); SetProtoMethodNoSideEffect(isolate, tmpl, "address", LocalAddress); }) void Endpoint::InitPerIsolate(IsolateData* data, Local target) { // TODO(@jasnell): Implement the per-isolate state - Http3Application::InitPerIsolate(data, target); } void Endpoint::InitPerContext(Realm* realm, Local target) { @@ -565,8 +566,6 @@ void Endpoint::InitPerContext(Realm* realm, Local target) { NODE_DEFINE_CONSTANT(target, CLOSECONTEXT_SEND_FAILURE); NODE_DEFINE_CONSTANT(target, CLOSECONTEXT_START_FAILURE); - Http3Application::InitPerContext(realm, target); - SetConstructorFunction(realm->context(), target, "Endpoint", @@ -578,6 +577,7 @@ void Endpoint::RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(DoConnect); registry->Register(DoListen); registry->Register(DoCloseGracefully); + registry->Register(DoSetSNIContexts); registry->Register(LocalAddress); registry->Register(Ref); registry->Register(MarkBusy); @@ -914,6 +914,15 @@ void Endpoint::Listen(const Session::Options& options) { return; } + // Create additional TLS contexts for SNI entries (virtual hosts). + for (const auto& [hostname, sni_options] : options.sni) { + if (!context->AddSNIContext(env(), hostname, sni_options)) { + THROW_ERR_INVALID_STATE( + env(), "Failed to create TLS context for SNI host '%s'", hostname); + return; + } + } + server_state_ = { options, std::move(context), @@ -1753,6 +1762,75 @@ JS_METHOD_IMPL(Endpoint::Ref) { } } +JS_METHOD_IMPL(Endpoint::DoSetSNIContexts) { + auto env = Environment::GetCurrent(args); + Endpoint* endpoint; + ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); + + if (!endpoint->server_state_.has_value()) { + THROW_ERR_INVALID_STATE(env, "Endpoint is not listening"); + return; + } + + if (args.Length() < 1 || !args[0]->IsObject()) { + THROW_ERR_INVALID_ARG_TYPE(env, "entries must be an object"); + return; + } + + bool replace = args.Length() > 1 && args[1]->IsTrue(); + + auto entries_obj = args[0].As(); + Local hostnames; + if (!entries_obj->GetOwnPropertyNames(env->context()).ToLocal(&hostnames)) { + return; + } + + if (replace) { + std::unordered_map entries; + for (uint32_t i = 0; i < hostnames->Length(); i++) { + Local key; + Local entry_val; + if (!hostnames->Get(env->context(), i).ToLocal(&key) || + !key->IsString() || + !entries_obj->Get(env->context(), key).ToLocal(&entry_val)) { + return; + } + Utf8Value hostname(env->isolate(), key); + auto entry_options = TLSContext::Options::From(env, entry_val); + if (entry_options.IsNothing()) return; + entries[std::string(*hostname, hostname.length())] = + entry_options.FromJust(); + } + + if (!endpoint->server_state_->tls_context->SetSNIContexts(env, entries)) { + THROW_ERR_INVALID_STATE(env, "Failed to set SNI contexts"); + return; + } + } else { + for (uint32_t i = 0; i < hostnames->Length(); i++) { + Local key; + Local entry_val; + if (!hostnames->Get(env->context(), i).ToLocal(&key) || + !key->IsString() || + !entries_obj->Get(env->context(), key).ToLocal(&entry_val)) { + return; + } + Utf8Value hostname(env->isolate(), key); + auto entry_options = TLSContext::Options::From(env, entry_val); + if (entry_options.IsNothing()) return; + + if (!endpoint->server_state_->tls_context->AddSNIContext( + env, + std::string(*hostname, hostname.length()), + entry_options.FromJust())) { + THROW_ERR_INVALID_STATE( + env, "Failed to add SNI context for '%s'", *hostname); + return; + } + } + } +} + } // namespace quic } // namespace node #endif // OPENSSL_NO_QUIC diff --git a/src/quic/endpoint.h b/src/quic/endpoint.h index 1e4efb8c56e92c..fa003d3aed2481 100644 --- a/src/quic/endpoint.h +++ b/src/quic/endpoint.h @@ -359,6 +359,8 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { // packets. JS_METHOD(DoCloseGracefully); + JS_METHOD(DoSetSNIContexts); + // Get the local address of the Endpoint. // @return node::SocketAddress - The local address of the Endpoint. JS_METHOD(LocalAddress); diff --git a/src/quic/http3.cc b/src/quic/http3.cc index 3319d1de54e3c4..2a21c0cf321970 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -1,7 +1,6 @@ #if HAVE_OPENSSL && HAVE_QUIC #include "guard.h" #ifndef OPENSSL_NO_QUIC -#include "http3.h" #include #include #include @@ -15,80 +14,17 @@ #include "application.h" #include "bindingdata.h" #include "defs.h" +#include "http3.h" #include "session.h" #include "sessionticket.h" namespace node { +using v8::Array; using v8::Local; -using v8::Object; -using v8::ObjectTemplate; namespace quic { -// ============================================================================ - -JS_CONSTRUCTOR_IMPL(Http3Application, http3application_constructor_template, { - JS_NEW_CONSTRUCTOR(); - JS_CLASS(http3application); -}) - -void Http3Application::InitPerIsolate(IsolateData* isolate_data, - Local target) { - // TODO(@jasnell): Implement the per-isolate state -} - -void Http3Application::InitPerContext(Realm* realm, Local target) { - SetConstructorFunction(realm->context(), - target, - "Http3Application", - GetConstructorTemplate(realm->env())); -} - -void Http3Application::RegisterExternalReferences( - ExternalReferenceRegistry* registry) { - registry->Register(New); -} - -Http3Application::Http3Application(Environment* env, - Local object, - const Session::Application::Options& options) - : ApplicationProvider(env, object), options_(options) { - MakeWeak(); -} - -JS_METHOD_IMPL(Http3Application::New) { - Environment* env = Environment::GetCurrent(args); - CHECK(args.IsConstructCall()); - - JS_NEW_INSTANCE(env, obj); - - Session::Application::Options options; - if (!args[0]->IsUndefined() && - !Session::Application::Options::From(env, args[0]).To(&options)) { - return; - } - - if (auto app = MakeBaseObject(env, obj, options)) { - args.GetReturnValue().Set(app->object()); - } -} - -void Http3Application::MemoryInfo(MemoryTracker* tracker) const { - tracker->TrackField("options", options_); -} - -std::string Http3Application::ToString() const { - DebugIndentScope indent; - auto prefix = indent.Prefix(); - std::string res("{"); - res += prefix + "options: " + options_.ToString(); - res += indent.Close(); - return res; -} - -// ============================================================================ - struct Http3HeadersTraits { using nv_t = nghttp3_nv; }; @@ -155,6 +91,10 @@ class Http3ApplicationImpl final : public Session::Application { session->set_priority_supported(); } + Session::Application::Type type() const override { + return Session::Application::Type::HTTP3; + } + error_code GetNoErrorCode() const override { return NGHTTP3_H3_NO_ERROR; } bool Start() override { @@ -388,7 +328,7 @@ class Http3ApplicationImpl final : public Session::Application { bool SendHeaders(const Stream& stream, HeadersKind kind, - const Local& headers, + const Local& headers, HeadersFlags flags = HeadersFlags::NONE) override { Session::SendPendingDataScope send_scope(&session()); Http3Headers nva(env(), headers); @@ -1062,10 +1002,10 @@ class Http3ApplicationImpl final : public Session::Application { nullptr}; }; -std::unique_ptr Http3Application::Create( - Session* session) { +std::unique_ptr CreateHttp3Application( + Session* session, const Session::Application_Options& options) { Debug(session, "Selecting HTTP/3 application"); - return std::make_unique(session, options_); + return std::make_unique(session, options); } } // namespace quic diff --git a/src/quic/http3.h b/src/quic/http3.h index ff5a3d85a510ae..b49f3daf8b1621 100644 --- a/src/quic/http3.h +++ b/src/quic/http3.h @@ -2,35 +2,16 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include -#include -#include +#include #include "session.h" namespace node::quic { -// Provides an implementation of the HTTP/3 Application implementation -class Http3Application final : public Session::ApplicationProvider { - public: - JS_CONSTRUCTOR(Http3Application); - JS_BINDING_INIT_BOILERPLATE(); - Http3Application(Environment* env, - v8::Local object, - const Session::Application_Options& options); - - std::unique_ptr Create(Session* session) override; - - void MemoryInfo(MemoryTracker* tracker) const override; - SET_SELF_SIZE(Http3Application) - SET_MEMORY_INFO_NAME(Http3Application) - - std::string ToString() const; - - private: - JS_METHOD(New); - - Session::Application_Options options_; -}; +// Create an HTTP/3 Application implementation for the given session. +// Uses the Application_Options from the session's config for HTTP/3 +// specific settings (qpack, max header length, etc.). +std::unique_ptr CreateHttp3Application( + Session* session, const Session::Application_Options& options); } // namespace node::quic diff --git a/src/quic/session.cc b/src/quic/session.cc index 9f71856da2f9a0..4877c1789d3fa1 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -71,6 +71,7 @@ namespace quic { V(STREAM_OPEN_ALLOWED, stream_open_allowed, uint8_t) \ V(PRIORITY_SUPPORTED, priority_supported, uint8_t) \ V(WRAPPED, wrapped, uint8_t) \ + V(APPLICATION_TYPE, application_type, uint8_t) \ V(LAST_DATAGRAM_ID, last_datagram_id, datagram_id) #define SESSION_STATS(V) \ @@ -252,33 +253,6 @@ bool SetOption(Environment* env, return true; } -template Opt::*member> -bool SetOption(Environment* env, - Opt* options, - const Local& object, - const Local& name) { - Local value; - if (!object->Get(env->context(), name).ToLocal(&value)) { - return false; - } - if (!value->IsUndefined()) { - // We currently only support Http3Application for this option. - if (!Http3Application::HasInstance(env, value)) { - THROW_ERR_INVALID_ARG_TYPE(env, - "Application must be an Http3Application"); - return false; - } - Http3Application* app; - ASSIGN_OR_RETURN_UNWRAP(&app, value.As(), false); - CHECK_NOT_NULL(app); - auto& assigned = options->*member = - BaseObjectPtr(app); - assigned->Detach(); - } - return true; -} - template bool SetOption(Environment* env, Opt* options, @@ -462,14 +436,63 @@ Maybe Session::Options::From(Environment* env, if (!SET(version) || !SET(min_version) || !SET(preferred_address_strategy) || !SET(transport_params) || !SET(tls_options) || !SET(qlog) || - !SET(application_provider) || !SET(handshake_timeout) || - !SET(max_stream_window) || !SET(max_window) || !SET(max_payload_size) || - !SET(unacknowledged_packet_threshold) || !SET(cc_algorithm)) { + !SET(handshake_timeout) || !SET(max_stream_window) || !SET(max_window) || + !SET(max_payload_size) || !SET(unacknowledged_packet_threshold) || + !SET(cc_algorithm)) { return Nothing(); } #undef SET + // Parse the application-specific options (HTTP/3 qpack settings, etc.). + // These are used if the negotiated ALPN selects Http3ApplicationImpl. + { + Local app_val; + if (params->Get(env->context(), state.application_string()) + .ToLocal(&app_val) && + !app_val->IsUndefined()) { + if (!Application_Options::From(env, app_val) + .To(&options.application_options)) { + return Nothing(); + } + } + } + + // Parse the SNI map from the tls options. + { + Local tls_val; + if (params->Get(env->context(), state.tls_options_string()) + .ToLocal(&tls_val) && + tls_val->IsObject()) { + Local sni_val; + if (tls_val.As() + ->Get(env->context(), state.sni_string()) + .ToLocal(&sni_val) && + sni_val->IsObject()) { + auto sni_obj = sni_val.As(); + Local hostnames; + if (sni_obj->GetOwnPropertyNames(env->context()).ToLocal(&hostnames)) { + for (uint32_t i = 0; i < hostnames->Length(); i++) { + Local key; + Local entry_val; + if (!hostnames->Get(env->context(), i).ToLocal(&key) || + !key->IsString() || + !sni_obj->Get(env->context(), key).ToLocal(&entry_val)) { + continue; + } + Utf8Value hostname(env->isolate(), key); + auto entry_options = TLSContext::Options::From(env, entry_val); + if (entry_options.IsNothing()) { + return Nothing(); + } + options.sni[std::string(*hostname, hostname.length())] = + entry_options.FromJust(); + } + } + } + } + } + // TODO(@jasnell): Later we will also support setting the CID::Factory. // For now, we're just using the default random factory. @@ -559,7 +582,6 @@ struct Session::Impl final : public MemoryRetainer { config_(config), local_address_(config.local_address), remote_address_(config.remote_address), - application_(SelectApplication(session, config_)), timer_(session_->env(), [this] { session_->OnTimeout(); }) { timer_.Unref(); } @@ -1295,7 +1317,8 @@ Session::SendPendingDataScope::SendPendingDataScope( Session::SendPendingDataScope::~SendPendingDataScope() { if (session->is_destroyed()) return; DCHECK_GE(session->impl_->send_scope_depth_, 1); - if (--session->impl_->send_scope_depth_ == 0) { + if (--session->impl_->send_scope_depth_ == 0 && + session->impl_->application_) { session->application().SendPendingData(); } } @@ -1323,6 +1346,16 @@ Session::Session(Endpoint* endpoint, connection_(InitConnection()), tls_session_(tls_context->NewSession(this, session_ticket)) { DCHECK(impl_); + + // For clients, select the Application immediately — the ALPN is + // known upfront from the options. For servers, application_ stays + // null until OnSelectAlpn fires during the TLS handshake. + if (config.side == Side::CLIENT) { + auto app = + SelectApplicationFromAlpn(DecodeAlpn(config.options.tls_options.alpn)); + if (app) SetApplication(std::move(app)); + } + MakeWeak(); Debug(this, "Session created."); auto& binding = BindingData::Get(env()); @@ -1536,9 +1569,37 @@ TLSSession& Session::tls_session() const { Session::Application& Session::application() const { DCHECK(!is_destroyed()); + DCHECK(impl_->application_); return *impl_->application_; } +std::string_view Session::DecodeAlpn(std::string_view wire) { + // ALPN wire format is length-prefixed: [len][name]. Extract the first entry. + if (wire.size() >= 2) { + uint8_t len = static_cast(wire[0]); + if (len > 0 && static_cast(len + 1) <= wire.size()) { + return wire.substr(1, len); + } + } + return {}; +} + +std::unique_ptr Session::SelectApplicationFromAlpn( + std::string_view alpn) { + // h3 and h3-XX variants use Http3ApplicationImpl. + // Everything else uses DefaultApplication. + if (alpn == "h3" || (alpn.size() > 3 && alpn.substr(0, 3) == "h3-")) { + return CreateHttp3Application(this, config().options.application_options); + } + return CreateDefaultApplication(this, config().options.application_options); +} + +void Session::SetApplication(std::unique_ptr app) { + DCHECK(!impl_->application_); + impl_->state_->application_type = static_cast(app->type()); + impl_->application_ = std::move(app); +} + const SocketAddress& Session::remote_address() const { DCHECK(!is_destroyed()); return impl_->remote_address_; @@ -2419,7 +2480,9 @@ bool Session::HandshakeCompleted() { // If early data was attempted but rejected by the server, // tell ngtcp2 so it can retransmit the data as 1-RTT. - if (!is_server() && !tls_session().early_data_was_accepted()) + // The status of early data will only be rejected if an + // attempt was actually made to send early data. + if (!is_server() && tls_session().early_data_was_rejected()) ngtcp2_conn_tls_early_data_rejected(*this); // When in a server session, handshake completed == handshake confirmed. @@ -2648,6 +2711,7 @@ void Session::EmitHandshakeComplete() { Undefined(isolate), // Cipher version Undefined(isolate), // Validation error reason Undefined(isolate), // Validation error code + Boolean::New(isolate, tls_session().early_data_was_attempted()), Boolean::New(isolate, tls_session().early_data_was_accepted())}; auto& tls = tls_session(); diff --git a/src/quic/session.h b/src/quic/session.h index 0dd9ea9aa5cbb5..92055e856fac60 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -99,17 +99,19 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // of a QUIC Session. class Application; - // The ApplicationProvider optionally supplies the underlying application - // protocol handler used by a session. The ApplicationProvider is supplied - // in the *internal* options (that is, it is not exposed as a public, user - // facing API. If the ApplicationProvider is not specified, then the - // DefaultApplication is used (see application.cc). - class ApplicationProvider : public BaseObject { - public: - using BaseObject::BaseObject; - virtual std::unique_ptr Create(Session* session) = 0; - }; - + // Decode the first ALPN protocol name from wire format (length-prefixed). + static std::string_view DecodeAlpn(std::string_view wire); + + // Select the Application implementation based on the negotiated ALPN. + // h3 (and h3-XX variants) map to Http3ApplicationImpl; all others map + // to DefaultApplication. Sets the application_type state field. + std::unique_ptr SelectApplicationFromAlpn(std::string_view alpn); + + // Install the Application on the session. Called at construction for + // clients (ALPN known upfront) or from OnSelectAlpn for servers + // (ALPN negotiated during handshake). Must be called before any + // application data is received. + void SetApplication(std::unique_ptr app); // The options used to configure a session. Most of these deal directly with // the transport parameters that are exchanged with the remote peer during // handshake. @@ -129,6 +131,7 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { TransportParams::Options transport_params = TransportParams::Options::kDefault; TLSContext::Options tls_options = TLSContext::Options::kDefault; + std::unordered_map sni; // A reference to the CID::Factory used to generate CID instances // for this session. @@ -137,9 +140,9 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // so that it cannot be garbage collected. BaseObjectPtr cid_factory_ref; - // If the application provider is specified, it will be used to create - // the underlying Application instance for the session. - BaseObjectPtr application_provider; + // Application-specific options (used for HTTP/3 if the negotiated + // ALPN selects Http3ApplicationImpl). + Application_Options application_options = Application_Options::kDefault; // When true, QLog output will be enabled for the session. bool qlog = false; @@ -497,9 +500,6 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void HandshakeConfirmed(); void SelectPreferredAddress(PreferredAddress* preferredAddress); - static std::unique_ptr SelectApplication(Session* session, - const Config& config); - QuicConnectionPointer InitConnection(); Side side_; diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index f70db3b3671726..358256329984b4 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -12,7 +12,9 @@ #include #include #include +#include #include +#include "application.h" #include "bindingdata.h" #include "defs.h" #include "session.h" @@ -181,8 +183,16 @@ OSSLContext::~OSSLContext() { void OSSLContext::reset() { if (ctx_) { - SSL_set_app_data(*this, nullptr); - ngtcp2_conn_set_tls_native_handle(connection_, nullptr); + // The SSL object inside the ngtcp2 ctx may not have been set if + // SSL creation failed. Guard against null before clearing app data. + if (SSL* ssl = ngtcp2_crypto_ossl_ctx_get_ssl(ctx_); ssl != nullptr) { + SSL_set_app_data(ssl, nullptr); + } + // connection_ is set during Initialize(). If Initialize() was + // never called (e.g. SSL creation failed), it's still nullptr. + if (connection_ != nullptr) { + ngtcp2_conn_set_tls_native_handle(connection_, nullptr); + } ngtcp2_crypto_ossl_ctx_del(ctx_); ctx_ = nullptr; connection_ = nullptr; @@ -238,12 +248,14 @@ bool OSSLContext::set_alpn_protocols(std::string_view protocols) const { } bool OSSLContext::set_hostname(std::string_view hostname) const { - if (!hostname.empty()) { - SSL_set_tlsext_host_name(*this, hostname.data()); - } else { - SSL_set_tlsext_host_name(*this, "localhost"); - } - return true; + // SSL_set_tlsext_host_name is a macro that casts to void* internally. + // The std::string constructed here guarantees null-termination for + // the underlying SSL_ctrl C API. + std::string name(hostname.empty() ? "localhost" : hostname); + return SSL_ctrl(*this, + SSL_CTRL_SET_TLSEXT_HOSTNAME, + TLSEXT_NAMETYPE_host_name, + const_cast(name.c_str())) == 1; } bool OSSLContext::set_early_data_enabled() const { @@ -258,6 +270,14 @@ bool OSSLContext::get_early_data_accepted() const { return SSL_get_early_data_status(*this) == SSL_EARLY_DATA_ACCEPTED; } +bool OSSLContext::get_early_data_rejected() const { + return SSL_get_early_data_status(*this) == SSL_EARLY_DATA_REJECTED; +} + +bool OSSLContext::get_early_data_attempted() const { + return SSL_get_early_data_status(*this) != SSL_EARLY_DATA_NOT_SENT; +} + bool OSSLContext::set_session_ticket(const ncrypto::SSLSessionPointer& ticket) { if (!ticket) return false; if (SSL_set_session(*this, ticket.get()) != 1) return false; @@ -302,20 +322,14 @@ int TLSContext::OnSelectAlpn(SSL* ssl, const unsigned char* in, unsigned int inlen, void* arg) { - static constexpr size_t kMaxAlpnLen = 255; - auto& session = TLSSession::From(ssl); + auto& tls_session = TLSSession::From(ssl); - const auto& requested = session.context().options().protocol; - if (requested.length() > kMaxAlpnLen) return SSL_TLSEXT_ERR_NOACK; + const auto& requested = tls_session.context().options().alpn; + if (requested.empty()) return SSL_TLSEXT_ERR_NOACK; - // The Session supports exactly one ALPN identifier. If that does not match - // any of the ALPN identifiers provided in the client request, then we fail - // here. Note that this will not fail the TLS handshake, so we have to check - // later if the ALPN matches the expected identifier or not. - // - // TODO(@jasnell): We might eventually want to support the ability to - // negotiate multiple possible ALPN's on a single endpoint/session but for - // now, we only support one. + // The requested ALPN string is in wire format (one or more + // length-prefixed protocol names). SSL_select_next_proto finds the + // first match between the server's list and the client's list. if (SSL_select_next_proto( const_cast(out), outlen, @@ -323,11 +337,30 @@ int TLSContext::OnSelectAlpn(SSL* ssl, requested.length(), in, inlen) == OPENSSL_NPN_NO_OVERLAP) { - Debug(&session.session(), "ALPN negotiation failed"); + Debug(&tls_session.session(), "ALPN negotiation failed"); return SSL_TLSEXT_ERR_NOACK; } - Debug(&session.session(), "ALPN negotiation succeeded"); + // ALPN negotiated successfully. *out/*outlen point to the selected + // protocol name (without the length prefix). Select the Application + // implementation based on the negotiated ALPN. This must happen now + // because early data (0-RTT) may arrive in the same ngtcp2_conn_read_pkt + // call and needs the Application to be ready. + std::string_view negotiated(reinterpret_cast(*out), *outlen); + Debug(&tls_session.session(), + "ALPN negotiation succeeded: %s", + std::string(negotiated).c_str()); + + auto& session = tls_session.session(); + auto app = session.SelectApplicationFromAlpn(negotiated); + if (!app) { + Debug(&session, + "Failed to create Application for ALPN %s", + std::string(negotiated).c_str()); + return SSL_TLSEXT_ERR_NOACK; + } + session.SetApplication(std::move(app)); + return SSL_TLSEXT_ERR_OK; } @@ -403,8 +436,10 @@ std::unique_ptr TLSContext::NewSession( Session* session, const std::optional& maybeSessionTicket) { // Passing a session ticket only makes sense with a client session. CHECK_IMPLIES(session->is_server(), !maybeSessionTicket.has_value()); - return std::make_unique( + auto tls_session = std::make_unique( session, shared_from_this(), maybeSessionTicket); + if (!tls_session->is_valid()) return nullptr; + return tls_session; } SSLCtxPointer TLSContext::Initialize(Environment* env) { @@ -427,7 +462,6 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { // so we disable OpenSSL's built-in anti-replay. SSL_CTX_set_options(ctx.get(), (SSL_OP_ALL & ~SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS) | - SSL_OP_SINGLE_ECDH_USE | SSL_OP_CIPHER_SERVER_PREFERENCE | SSL_OP_NO_ANTI_REPLAY); @@ -452,11 +486,14 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { SessionTicket::DecryptedCallback, nullptr), 1); + + SSL_CTX_set_tlsext_servername_callback(ctx.get(), OnSNI); + SSL_CTX_set_tlsext_servername_arg(ctx.get(), this); break; } case Side::CLIENT: { ctx = SSLCtxPointer::NewClient(); - + SSL_CTX_set_mode(ctx.get(), SSL_MODE_RELEASE_BUFFERS); SSL_CTX_set_session_cache_mode( ctx.get(), SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL); SSL_CTX_sess_set_new_cb(ctx.get(), OnNewSession); @@ -464,8 +501,15 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { } } - SSL_CTX_set_default_verify_paths(ctx.get()); - SSL_CTX_set_keylog_callback(ctx.get(), OnKeylog); + // Only load system CA certificates if no custom CAs are provided. + // SSL_CTX_set_default_verify_paths involves filesystem I/O to read + // the system CA bundle. + if (options_.ca.empty()) { + SSL_CTX_set_default_verify_paths(ctx.get()); + } + if (options_.keylog) { + SSL_CTX_set_keylog_callback(ctx.get(), OnKeylog); + } if (SSL_CTX_set_ciphersuites(ctx.get(), options_.ciphers.c_str()) != 1) { validation_error_ = "Invalid cipher suite"; @@ -580,8 +624,46 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { return ctx; } +int TLSContext::OnSNI(SSL* ssl, int* ad, void* arg) { + auto* default_ctx = static_cast(arg); + const char* servername = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name); + if (servername != nullptr) { + auto it = default_ctx->sni_contexts_.find(servername); + if (it != default_ctx->sni_contexts_.end()) { + SSL_set_SSL_CTX(ssl, it->second->ctx_.get()); + } + } + return SSL_TLSEXT_ERR_OK; +} + +bool TLSContext::AddSNIContext(Environment* env, + const std::string& hostname, + const Options& options) { + DCHECK_EQ(side_, Side::SERVER); + auto ctx = std::make_shared(env, Side::SERVER, options); + if (!*ctx) return false; + sni_contexts_[hostname] = std::move(ctx); + return true; +} + +bool TLSContext::SetSNIContexts( + Environment* env, const std::unordered_map& entries) { + DCHECK_EQ(side_, Side::SERVER); + std::unordered_map> new_contexts; + for (const auto& [hostname, options] : entries) { + auto ctx = std::make_shared(env, Side::SERVER, options); + if (!*ctx) return false; + new_contexts[hostname] = std::move(ctx); + } + sni_contexts_ = std::move(new_contexts); + return true; +} + void TLSContext::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackField("options", options_); + tracker->TrackFieldWithSize( + "sni_contexts", + sni_contexts_.size() * sizeof(std::shared_ptr)); } Maybe TLSContext::Options::From(Environment* env, @@ -613,12 +695,11 @@ Maybe TLSContext::Options::From(Environment* env, env, &options, params, state.name##_string()) if (!SET(verify_client) || !SET(reject_unauthorized) || - !SET(enable_early_data) || !SET(enable_tls_trace) || - !SET(enable_tls_trace) || !SET(protocol) || !SET(servername) || - !SET(ciphers) || !SET(groups) || !SET(verify_private_key) || - !SET(keylog) || !SET_VECTOR(crypto::KeyObjectData, keys) || - !SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) || - !SET_VECTOR(Store, crl)) { + !SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) || + !SET(servername) || !SET(ciphers) || !SET(groups) || + !SET(verify_private_key) || !SET(keylog) || + !SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) || + !SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) { return Nothing(); } @@ -629,7 +710,7 @@ std::string TLSContext::Options::ToString() const { DebugIndentScope indent; auto prefix = indent.Prefix(); std::string res("{"); - res += prefix + "protocol: " + protocol; + res += prefix + "alpn: " + alpn; res += prefix + "servername: " + servername; res += prefix + "keylog: " + (keylog ? std::string("yes") : std::string("no")); @@ -694,6 +775,16 @@ bool TLSSession::early_data_was_accepted() const { return ossl_context_.get_early_data_accepted(); } +bool TLSSession::early_data_was_rejected() const { + CHECK_NE(ngtcp2_conn_get_handshake_completed(*session_), 0); + return ossl_context_.get_early_data_rejected(); +} + +bool TLSSession::early_data_was_attempted() const { + CHECK_NE(ngtcp2_conn_get_handshake_completed(*session_), 0); + return ossl_context_.get_early_data_attempted(); +} + void TLSSession::Initialize( const std::optional& maybeSessionTicket) { auto& ctx = context(); @@ -729,7 +820,7 @@ void TLSSession::Initialize( return; }; - if (!ossl_context_.set_alpn_protocols(options.protocol)) { + if (!ossl_context_.set_alpn_protocols(options.alpn)) { validation_error_ = "Failed to set ALPN protocols"; ossl_context_.reset(); return; @@ -742,7 +833,7 @@ void TLSSession::Initialize( } if (maybeSessionTicket.has_value()) { - auto sessionTicket = maybeSessionTicket.value(); + const auto& sessionTicket = *maybeSessionTicket; uv_buf_t buf = sessionTicket.ticket(); SSLSessionPointer ticket = crypto::GetTLSSession( reinterpret_cast(buf.base), buf.len); @@ -766,13 +857,23 @@ void TLSSession::Initialize( } } - TransportParams tp(ngtcp2_conn_get_local_transport_params(*session_)); - Store store = tp.Encode(session_->env()); - if (store && store.length() > 0) { - if (!ossl_context_.set_transport_params(store)) { - validation_error_ = "Failed to set transport parameters"; - ossl_context_.reset(); - return; + // Encode transport parameters directly into a stack buffer to avoid + // a heap allocation. Transport parameters are typically < 256 bytes. + { + TransportParams tp(ngtcp2_conn_get_local_transport_params(*session_)); + // Preflight to get the encoded size. + ssize_t size = tp.EncodedSize(); + if (size > 0) { + MaybeStackBuffer buf(size); + ssize_t written = tp.EncodeInto(buf.out(), size); + if (written > 0) { + ngtcp2_vec vec = {buf.out(), static_cast(written)}; + if (!ossl_context_.set_transport_params(vec)) { + validation_error_ = "Failed to set transport parameters"; + ossl_context_.reset(); + return; + } + } } } } @@ -839,10 +940,8 @@ const std::string TLSSession::protocol() const { } bool TLSSession::InitiateKeyUpdate() { - if (in_key_update_) return false; - auto leave = OnScopeLeave([this] { in_key_update_ = false; }); - in_key_update_ = true; - + // ngtcp2 internally tracks key update state and will return an error + // if a key update is already in progress. Debug(session_, "Initiating key update"); return ngtcp2_conn_initiate_key_update(*session_, uv_hrtime()) == 0; } diff --git a/src/quic/tlscontext.h b/src/quic/tlscontext.h index 4b64b6c7238b30..a667b8980da549 100644 --- a/src/quic/tlscontext.h +++ b/src/quic/tlscontext.h @@ -9,6 +9,7 @@ #include #include #include +#include #include "bindingdata.h" #include "data.h" #include "defs.h" @@ -54,6 +55,8 @@ class OSSLContext final { bool set_transport_params(const ngtcp2_vec& tp) const; bool get_early_data_accepted() const; + bool get_early_data_rejected() const; + bool get_early_data_attempted() const; // Sets the session ticket for 0-RTT resumption. Returns true if the // ticket was set successfully and the ticket supports early data. @@ -96,7 +99,8 @@ class TLSSession final : public MemoryRetainer { const std::optional& maybeSessionTicket); DISALLOW_COPY_AND_MOVE(TLSSession) - inline operator bool() const { return ossl_context_; } + inline bool is_valid() const { return ossl_context_; } + inline operator bool() const { return is_valid(); } inline Session& session() const { return *session_; } inline TLSContext& context() const { return *context_; } @@ -104,6 +108,8 @@ class TLSSession final : public MemoryRetainer { // accepted by the TLS session. This will assert if the handshake has // not been completed. bool early_data_was_accepted() const; + bool early_data_was_rejected() const; + bool early_data_was_attempted() const; v8::MaybeLocal cert(Environment* env) const; v8::MaybeLocal peer_cert(Environment* env) const; @@ -154,7 +160,6 @@ class TLSSession final : public MemoryRetainer { Session* session_; ncrypto::BIOPointer bio_trace_; std::string validation_error_ = ""; - bool in_key_update_ = false; }; // The TLSContext is used to create a TLSSession. For the client, there is @@ -175,9 +180,10 @@ class TLSContext final : public MemoryRetainer, // the client. std::string servername = "localhost"; - // The ALPN (protocol name) to use for this session. This option is only - // used by the client. - std::string protocol = NGHTTP3_ALPN_H3; + // The ALPN protocol identifier(s) in wire format (length-prefixed, + // concatenated). For clients this is a single entry. For servers + // this may contain multiple entries in preference order. + std::string alpn = NGHTTP3_ALPN_H3; // The list of TLS ciphers to use for this session. std::string ciphers = DEFAULT_CIPHERS; @@ -261,6 +267,13 @@ class TLSContext final : public MemoryRetainer, std::unique_ptr NewSession( Session* session, const std::optional& maybeSessionTicket); + bool AddSNIContext(Environment* env, + const std::string& hostname, + const Options& options); + + bool SetSNIContexts(Environment* env, + const std::unordered_map& entries); + inline Side side() const { return side_; } inline const Options& options() const { return options_; } inline operator bool() const { return ctx_ != nullptr; } @@ -287,6 +300,7 @@ class TLSContext final : public MemoryRetainer, unsigned int inlen, void* arg); static int OnVerifyClientCertificate(int preverify_ok, X509_STORE_CTX* ctx); + static int OnSNI(SSL* ssl, int* ad, void* arg); Side side_; Options options_; @@ -294,6 +308,7 @@ class TLSContext final : public MemoryRetainer, ncrypto::X509Pointer issuer_; std::string validation_error_ = ""; ncrypto::SSLCtxPointer ctx_; + std::unordered_map> sni_contexts_; friend class TLSSession; }; diff --git a/src/quic/tokens.cc b/src/quic/tokens.cc index 1019b43a534809..761c4a63d5ad6b 100644 --- a/src/quic/tokens.cc +++ b/src/quic/tokens.cc @@ -3,6 +3,7 @@ #ifndef OPENSSL_NO_QUIC #include #include +#include #include #include #include @@ -126,13 +127,8 @@ std::string StatelessResetToken::ToString() const { size_t StatelessResetToken::Hash::operator()( const StatelessResetToken& token) const { - // See CID::Hash for details on this hash combine strategy. - size_t hash = 0; - if (token.ptr_ == nullptr) return hash; - for (size_t n = 0; n < kStatelessTokenLen; n++) { - hash ^= token.ptr_[n] + 0x9e3779b9 + (hash << 6) + (hash >> 2); - } - return hash; + if (token.ptr_ == nullptr) return 0; + return HashBytes(token.ptr_, kStatelessTokenLen); } StatelessResetToken StatelessResetToken::kInvalid; @@ -204,7 +200,9 @@ std::optional RetryToken::Validate(uint32_t version, const CID& dcid, const TokenSecret& token_secret, uint64_t verification_expiration) { - if (ptr_.base == nullptr || ptr_.len == 0) return std::nullopt; + if (ptr_.base == nullptr || ptr_.len == 0 || verification_expiration == 0) { + return std::nullopt; + } ngtcp2_cid ocid; int ret = ngtcp2_crypto_verify_retry_token( &ocid, @@ -270,7 +268,9 @@ bool RegularToken::Validate(uint32_t version, const SocketAddress& addr, const TokenSecret& token_secret, uint64_t verification_expiration) { - if (ptr_.base == nullptr || ptr_.len == 0) return false; + if (ptr_.base == nullptr || ptr_.len == 0 || verification_expiration == 0) { + return false; + } return ngtcp2_crypto_verify_regular_token( ptr_.base, ptr_.len, diff --git a/src/quic/transportparams.cc b/src/quic/transportparams.cc index 40460e86ca1ed1..da665ea01bf35a 100644 --- a/src/quic/transportparams.cc +++ b/src/quic/transportparams.cc @@ -1,7 +1,6 @@ #if HAVE_OPENSSL && HAVE_QUIC #include "guard.h" #ifndef OPENSSL_NO_QUIC -#include "transportparams.h" #include #include #include @@ -12,6 +11,7 @@ #include "endpoint.h" #include "session.h" #include "tokens.h" +#include "transportparams.h" namespace node { @@ -219,6 +219,20 @@ Store TransportParams::Encode(Environment* env, Version version) const { return Store(std::move(result), static_cast(size)); } +ssize_t TransportParams::EncodedSize(Version version) const { + if (ptr_ == nullptr) return 0; + return ngtcp2_transport_params_encode_versioned( + nullptr, 0, static_cast(version), ¶ms_); +} + +ssize_t TransportParams::EncodeInto(uint8_t* buf, + size_t len, + Version version) const { + if (ptr_ == nullptr) return -1; + return ngtcp2_transport_params_encode_versioned( + buf, len, static_cast(version), ¶ms_); +} + void TransportParams::SetPreferredAddress(const SocketAddress& address) { DCHECK(ptr_ == ¶ms_); params_.preferred_addr_present = 1; diff --git a/src/quic/transportparams.h b/src/quic/transportparams.h index 67e1e5deec00fb..45ee0d49e79a15 100644 --- a/src/quic/transportparams.h +++ b/src/quic/transportparams.h @@ -162,6 +162,15 @@ class TransportParams final { // not be encoded, an empty Store will be returned. Store Encode(Environment* env, Version version = Version::V1) const; + // Returns the encoded size in bytes, or 0 on error. + ssize_t EncodedSize(Version version = Version::V1) const; + + // Encode into a caller-provided buffer. Returns the number of bytes + // written, or a negative value on error. + ssize_t EncodeInto(uint8_t* buf, + size_t len, + Version version = Version::V1) const; + private: void SetPreferredAddress(const SocketAddress& address); void GeneratePreferredAddressToken(Session* session); diff --git a/test/parallel/test-quic-alpn-h3.mjs b/test/parallel/test-quic-alpn-h3.mjs new file mode 100644 index 00000000000000..9a473352d7ed87 --- /dev/null +++ b/test/parallel/test-quic-alpn-h3.mjs @@ -0,0 +1,44 @@ +// Flags: --experimental-quic --no-warnings + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); + +// Test h3 ALPN negotiation with Http3ApplicationImpl. +// Both server and client use the default ALPN (h3). + +const serverOpened = Promise.withResolvers(); +const clientOpened = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.opened.then(mustCall((info) => { + assert.strictEqual(info.protocol, 'h3'); + serverOpened.resolve(); + serverSession.close(); + })); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, +}); + +assert.ok(serverEndpoint.address !== undefined); + +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', +}); +clientSession.opened.then(mustCall((info) => { + assert.strictEqual(info.protocol, 'h3'); + clientOpened.resolve(); +})); + +await Promise.all([serverOpened.promise, clientOpened.promise]); +clientSession.close(); diff --git a/test/parallel/test-quic-alpn.mjs b/test/parallel/test-quic-alpn.mjs new file mode 100644 index 00000000000000..b5eedf65373e1c --- /dev/null +++ b/test/parallel/test-quic-alpn.mjs @@ -0,0 +1,47 @@ +// Flags: --experimental-quic --no-warnings + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); + +// Server offers multiple ALPNs. Client requests one that the server supports. +// Verify the negotiated protocol matches on both sides. + +const serverOpened = Promise.withResolvers(); +const clientOpened = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.opened.then(mustCall((info) => { + // The server should negotiate proto-b (client's choice from server's list) + assert.strictEqual(info.protocol, 'proto-b'); + serverOpened.resolve(); + serverSession.close(); + })); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: ['proto-a', 'proto-b', 'proto-c'], +}); + +assert.ok(serverEndpoint.address !== undefined); + +const clientSession = await connect(serverEndpoint.address, { + alpn: 'proto-b', + servername: 'localhost', +}); +clientSession.opened.then(mustCall((info) => { + assert.strictEqual(info.protocol, 'proto-b'); + clientOpened.resolve(); +})); + +await Promise.all([serverOpened.promise, clientOpened.promise]); +clientSession.close(); diff --git a/test/parallel/test-quic-handshake-ipv6-only.mjs b/test/parallel/test-quic-handshake-ipv6-only.mjs index 8e0ee08c30c83a..646cd9e4765e97 100644 --- a/test/parallel/test-quic-handshake-ipv6-only.mjs +++ b/test/parallel/test-quic-handshake-ipv6-only.mjs @@ -16,14 +16,14 @@ if (!hasIPv6) { const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); -const keys = createPrivateKey(fixtures.readKey('agent1-key.pem')); -const certs = fixtures.readKey('agent1-cert.pem'); +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); const check = { // The SNI value servername: 'localhost', // The selected ALPN protocol - protocol: 'h3', + protocol: 'quic-test', // The negotiated cipher suite cipher: 'TLS_AES_128_GCM_SHA256', cipherVersion: 'TLSv1.3', @@ -40,26 +40,31 @@ const serverEndpoint = await listen(mustCall((serverSession) => { serverOpened.resolve(); serverSession.close(); }).then(mustCall()); -}), { keys, certs, endpoint: { - address: { - address: '::1', - family: 'ipv6', +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: ['quic-test'], + endpoint: { + address: { + address: '::1', + family: 'ipv6', + }, + ipv6Only: true, }, - ipv6Only: true, -} }); +}); // Buffer is not detached. -assert.strictEqual(certs.buffer.detached, false); +assert.strictEqual(cert.buffer.detached, false); // The server must have an address to connect to after listen resolves. assert.ok(serverEndpoint.address !== undefined); const clientSession = await connect(serverEndpoint.address, { + alpn: 'quic-test', endpoint: { address: { address: '::', family: 'ipv6', }, - } + }, }); clientSession.opened.then((info) => { assert.partialDeepStrictEqual(info, check); diff --git a/test/parallel/test-quic-handshake.mjs b/test/parallel/test-quic-handshake.mjs index c0097a2cf9bffd..7374d4c929398e 100644 --- a/test/parallel/test-quic-handshake.mjs +++ b/test/parallel/test-quic-handshake.mjs @@ -12,14 +12,14 @@ if (!hasQuic) { const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); -const keys = createPrivateKey(fixtures.readKey('agent1-key.pem')); -const certs = fixtures.readKey('agent1-cert.pem'); +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); const check = { // The SNI value servername: 'localhost', // The selected ALPN protocol - protocol: 'h3', + protocol: 'quic-test', // The negotiated cipher suite cipher: 'TLS_AES_128_GCM_SHA256', cipherVersion: 'TLSv1.3', @@ -36,15 +36,20 @@ const serverEndpoint = await listen(mustCall((serverSession) => { serverOpened.resolve(); serverSession.close(); }).then(mustCall()); -}), { keys, certs }); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: ['quic-test'], +}); // Buffer is not detached. -assert.strictEqual(certs.buffer.detached, false); +assert.strictEqual(cert.buffer.detached, false); // The server must have an address to connect to after listen resolves. assert.ok(serverEndpoint.address !== undefined); -const clientSession = await connect(serverEndpoint.address); +const clientSession = await connect(serverEndpoint.address, { + alpn: 'quic-test', +}); clientSession.opened.then((info) => { assert.partialDeepStrictEqual(info, check); clientOpened.resolve(); diff --git a/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs b/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs index 1b5eecc7b92276..68aa8332dccede 100644 --- a/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs +++ b/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs @@ -14,8 +14,9 @@ const { listen, QuicEndpoint } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { getQuicEndpointState } = (await import('internal/quic/quic')).default; -const keys = createPrivateKey(fixtures.readKey('agent1-key.pem')); -const certs = fixtures.readKey('agent1-cert.pem'); +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); +const sni = { '*': { keys: [key], certs: [cert] } }; const endpoint = new QuicEndpoint(); const state = getQuicEndpointState(endpoint); @@ -25,25 +26,25 @@ assert.ok(!state.isListening); assert.strictEqual(endpoint.address, undefined); -await assert.rejects(listen(123, { keys, certs, endpoint }), { +await assert.rejects(listen(123, { sni, endpoint }), { code: 'ERR_INVALID_ARG_TYPE', }); // Buffer is not detached. -assert.strictEqual(certs.buffer.detached, false); +assert.strictEqual(cert.buffer.detached, false); await assert.rejects(listen(() => {}, 123), { code: 'ERR_INVALID_ARG_TYPE', }); -await listen(() => {}, { keys, certs, endpoint }); +await listen(() => {}, { sni, endpoint }); // Buffer is not detached. -assert.strictEqual(certs.buffer.detached, false); +assert.strictEqual(cert.buffer.detached, false); -await assert.rejects(listen(() => {}, { keys, certs, endpoint }), { +await assert.rejects(listen(() => {}, { sni, endpoint }), { code: 'ERR_INVALID_STATE', }); // Buffer is not detached. -assert.strictEqual(certs.buffer.detached, false); +assert.strictEqual(cert.buffer.detached, false); assert.ok(state.isBound); assert.ok(state.isReceiving); @@ -63,11 +64,11 @@ assert.strictEqual(endpoint.closed, endpoint.close()); await endpoint.closed; assert.ok(endpoint.destroyed); -await assert.rejects(listen(() => {}, { keys, certs, endpoint }), { +await assert.rejects(listen(() => {}, { sni, endpoint }), { code: 'ERR_INVALID_STATE', }); // Buffer is not detached. -assert.strictEqual(certs.buffer.detached, false); +assert.strictEqual(cert.buffer.detached, false); assert.throws(() => { endpoint.busy = true; }, { code: 'ERR_INVALID_STATE', diff --git a/test/parallel/test-quic-sni.mjs b/test/parallel/test-quic-sni.mjs new file mode 100644 index 00000000000000..b2fe9968eee746 --- /dev/null +++ b/test/parallel/test-quic-sni.mjs @@ -0,0 +1,52 @@ +// Flags: --experimental-quic --no-warnings + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +// Use two different keys/certs for the default and SNI host. +const defaultKey = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const defaultCert = fixtures.readKey('agent1-cert.pem'); +const sniKey = createPrivateKey(fixtures.readKey('agent2-key.pem')); +const sniCert = fixtures.readKey('agent2-cert.pem'); + +// Server with SNI: default ('*') uses agent1, 'localhost' uses agent2. +const serverOpened = Promise.withResolvers(); +const clientOpened = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.opened.then((info) => { + // The server should see the client's requested servername. + assert.strictEqual(info.servername, 'localhost'); + serverOpened.resolve(); + serverSession.close(); + }).then(mustCall()); +}), { + sni: { + '*': { keys: [defaultKey], certs: [defaultCert] }, + 'localhost': { keys: [sniKey], certs: [sniCert] }, + }, + alpn: ['quic-test'], +}); + +assert.ok(serverEndpoint.address !== undefined); + +// Client connects with servername 'localhost' — should match the SNI entry. +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + alpn: 'quic-test', +}); +clientSession.opened.then((info) => { + assert.strictEqual(info.servername, 'localhost'); + clientOpened.resolve(); +}).then(mustCall()); + +await Promise.all([serverOpened.promise, clientOpened.promise]); +clientSession.close();