Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions doc/api/quic.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,12 @@ To avoid this, servers should use compact certificate chains:
large RSA intermediates. The choice of CA directly affects handshake latency.

Certificate compression ([RFC 8879][]) can also address this issue by
compressing the certificate chain during the handshake. However, Node.js does
not currently support TLS certificate compression.
compressing the certificate chain during the handshake, often keeping the
server's Certificate message within the amplification limit and avoiding the
extra round trip. Certificate compression is opt-in via the
[`certificateCompression`][] TLS option and is disabled by default. When
enabled, it applies to both the server's certificate and, for mutual TLS,
the client's certificate.

### Rate limiting

Expand Down Expand Up @@ -2918,6 +2922,34 @@ added: v23.8.0
The TLS certificates to use for client sessions. For server sessions,
certificates are specified per-identity in the [`sessionOptions.sni`][] map.

#### `sessionOptions.certificateCompression`

<!-- YAML
added: REPLACEME
-->

* Type: {string\[]} One or more of `'zlib'`, `'brotli'`, or `'zstd'`, in
preference order.

Enables TLS certificate compression ([RFC 8879][]) for this session. When
omitted, certificate compression is disabled.

On the server side, the certificate chain is compressed using the first
listed algorithm that the client advertises support for. On the client side,
the listed algorithms are advertised to the server so that the server may
compress its certificate. When client authentication is in use, the option
also controls compression of the client's certificate.

Compressing the certificate chain is especially useful for QUIC because it
reduces the size of the server's first flight, which is bounded by the
anti-amplification limit (see [Certificate size and handshake
performance][]). Certificate compression requires TLS 1.3, which QUIC always
uses.

At most three algorithms may be specified. The option is silently ignored if
Node.js was built against a shared OpenSSL that lacks certificate compression
support.

#### `sessionOptions.ciphers`

<!-- YAML
Expand Down Expand Up @@ -4427,6 +4459,7 @@ throughput issues caused by flow control.

[Aborting a stream]: #aborting-a-stream
[Callback error handling]: #callback-error-handling
[Certificate size and handshake performance]: #certificate-size-and-handshake-performance
[JSON-SEQ]: https://www.rfc-editor.org/rfc/rfc7464
[NSS Key Log Format]: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
[Permission Model]: permissions.md#permission-model
Expand Down Expand Up @@ -4456,6 +4489,7 @@ throughput issues caused by flow control.
[`application.enableConnectProtocol`]: #sessionoptionsapplication
[`application.enableDatagrams`]: #sessionoptionsapplication
[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication
[`certificateCompression`]: #sessionoptionscertificatecompression
[`crypto.X509Certificate`]: crypto.md#class-x509certificate
[`endpoint.busy`]: #endpointbusy
[`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost
Expand Down
42 changes: 42 additions & 0 deletions lib/internal/quic/quic.js
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,9 @@ const endpointRegistry = new SafeSet();
* of protocol names in preference order.
* @property {string} [ciphers] The TLS ciphers
* @property {string} [groups] The TLS key-exchange groups
* @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The
* TLS certificate compression algorithms to enable (RFC 8879), in
* preference order. Certificate compression is disabled when omitted.
* @property {boolean} [keylog] Enable TLS key logging
* @property {boolean} [verifyClient] Verify the client certificate (server only)
* @property {boolean} [tlsTrace] Enable TLS tracing
Expand Down Expand Up @@ -4938,6 +4941,7 @@ function processTlsOptions(tls, forServer) {
rejectUnauthorized = true,
enableEarlyData = true,
tlsTrace = false,
certificateCompression,
sni,
// Client-only: identity options are specified directly (no sni map)
keys,
Expand All @@ -4962,6 +4966,43 @@ function processTlsOptions(tls, forServer) {
validateBoolean(enableEarlyData, 'options.enableEarlyData');
validateBoolean(tlsTrace, 'options.tlsTrace');

// Encode the certificate compression (RFC 8879) preference. The list of
// algorithm names is packed into a single Uint32 for a cheap JS->C++
// crossing, matching the encoding used by the node:tls implementation:
// bits 0-7 : number of algorithms (1..3)
// bits 8-15: algorithm id at position 0
// bits 16-23: algorithm id at position 1
// bits 24-31: algorithm id at position 2
// IDs match OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3).
// A value of 0 (the default) leaves certificate compression disabled.
let packedCertificateCompression = 0;
if (certificateCompression !== undefined) {
if (!ArrayIsArray(certificateCompression)) {
throw new ERR_INVALID_ARG_TYPE('options.certificateCompression',
'Array', certificateCompression);
}
if (certificateCompression.length > 3) {
throw new ERR_INVALID_ARG_VALUE('options.certificateCompression',
certificateCompression,
'can specify at most 3 algorithms');
}
let packed = certificateCompression.length;
for (let i = 0; i < certificateCompression.length; i++) {
const algoName = certificateCompression[i];
let id;
if (algoName === 'zlib') id = 1;
else if (algoName === 'brotli') id = 2;
else if (algoName === 'zstd') id = 3;
else {
throw new ERR_INVALID_ARG_VALUE(
`options.certificateCompression[${i}]`, algoName,
"must be 'zlib', 'brotli', or 'zstd'");
}
packed |= id << (8 * (i + 1));
}
packedCertificateCompression = packed;
}

// 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.
Expand Down Expand Up @@ -5027,6 +5068,7 @@ function processTlsOptions(tls, forServer) {
rejectUnauthorized,
enableEarlyData,
tlsTrace,
certificateCompression: packedCertificateCompression,
ca,
crl,
};
Expand Down
23 changes: 6 additions & 17 deletions lib/internal/vfs/streams.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,7 @@ class VirtualReadStream extends Readable {
this.#autoClose = options.autoClose !== false;

if (fd !== null && fd !== undefined) {
// Use the already-open file descriptor
this.#fd = fd;
process.nextTick(() => {
this.emit('open', this.#fd);
this.emit('ready');
});
} else {
// Open the file on next tick so listeners can be attached.
// Note: #openFile will not throw - if it fails, the stream is destroyed.
process.nextTick(() => this.#openFile());
}
}

Expand All @@ -101,18 +92,16 @@ class VirtualReadStream extends Readable {
return this.#path;
}

/**
* Opens the virtual file.
* Events are emitted synchronously within this method, which runs
* asynchronously via process.nextTick - matching real fs behavior.
*/
#openFile() {
_construct(callback) {
try {
this.#fd = this.#vfs.openSync(this.#path);
if (this.#fd === null) {
this.#fd = this.#vfs.openSync(this.#path);
}
callback();
this.emit('open', this.#fd);
this.emit('ready');
} catch (err) {
this.destroy(err);
callback(err);
}
}

Expand Down
33 changes: 12 additions & 21 deletions lib/internal/webstreams/writablestream.js
Original file line number Diff line number Diff line change
Expand Up @@ -757,13 +757,15 @@ function writableStreamClose(stream) {
return promise;
}

function writableStreamUpdateBackpressure(stream, backpressure) {
const streamState = stream[kState];
assert(streamState.state === 'writable');
assert(!streamState.closeQueuedOrInFlight);
const {
writer,
} = streamState;
// Callers invoke this only in the 'writable' state with no close queued or
// in flight (the two conditions the spec's assertions check), so the
// backpressure value is derived here from the desired size instead of being
// computed and passed in by each caller.
function writableStreamUpdateBackpressure(controller, streamState) {
const controllerState = controller[kState];
const backpressure =
controllerState.highWaterMark - controllerState.queueTotalSize <= 0;
const writer = streamState.writer;
if (writer !== undefined && streamState.backpressure !== backpressure) {
if (backpressure) {
// The spec replaces [[readyPromise]] with a fresh pending promise;
Expand Down Expand Up @@ -1100,9 +1102,7 @@ function writableStreamDefaultControllerWrite(controller, chunk, chunkSize) {
const streamState = stream[kState];
if (!streamState.closeQueuedOrInFlight &&
streamState.state === 'writable') {
writableStreamUpdateBackpressure(
stream,
writableStreamDefaultControllerGetBackpressure(controller));
writableStreamUpdateBackpressure(controller, streamState);
}
writableStreamDefaultControllerAdvanceQueueIfNeeded(controller);
}
Expand All @@ -1128,9 +1128,7 @@ function writableStreamDefaultControllerProcessWrite(controller, chunk) {
dequeueValue(controller);
if (!streamState.closeQueuedOrInFlight &&
state === 'writable') {
writableStreamUpdateBackpressure(
stream,
writableStreamDefaultControllerGetBackpressure(controller));
writableStreamUpdateBackpressure(controller, streamState);
}
writableStreamDefaultControllerAdvanceQueueIfNeeded(controller);
};
Expand Down Expand Up @@ -1229,10 +1227,6 @@ function writableStreamDefaultControllerClearAlgorithms(controller) {
controller[kState].sizeAlgorithm = undefined;
}

function writableStreamDefaultControllerGetBackpressure(controller) {
return writableStreamDefaultControllerGetDesiredSize(controller) <= 0;
}

function writableStreamDefaultControllerAdvanceQueueIfNeeded(controller) {
const {
queue,
Expand Down Expand Up @@ -1317,9 +1311,7 @@ function setupWritableStreamDefaultController(
};
stream[kState].controller = controller;

writableStreamUpdateBackpressure(
stream,
writableStreamDefaultControllerGetBackpressure(controller));
writableStreamUpdateBackpressure(controller, stream[kState]);

const startResult = startAlgorithm();

Expand Down Expand Up @@ -1402,7 +1394,6 @@ module.exports = {
writableStreamDefaultControllerError,
writableStreamDefaultControllerClose,
writableStreamDefaultControllerClearAlgorithms,
writableStreamDefaultControllerGetBackpressure,
writableStreamDefaultControllerAdvanceQueueIfNeeded,
setupWritableStreamDefaultControllerFromSink,
setupWritableStreamDefaultController,
Expand Down
14 changes: 13 additions & 1 deletion src/node_file.cc
Original file line number Diff line number Diff line change
Expand Up @@ -3680,6 +3680,12 @@ std::vector<std::string> normalizePathToArray(
const std::filesystem::path& path) {
std::vector<std::string> parts;
std::filesystem::path absPath = std::filesystem::absolute(path);
#ifdef _WIN32
auto wstr = absPath.wstring();
if (wstr.starts_with(L"\\\\?\\")) {
absPath = std::filesystem::path(wstr.substr(4));
}
#endif
for (const auto& part : absPath) {
if (!part.empty()) parts.push_back(part.string());
}
Expand Down Expand Up @@ -3818,6 +3824,12 @@ static void CpSyncCopyDir(const FunctionCallbackInfo<Value>& args) {
}
auto symlink_target_absolute = std::filesystem::weakly_canonical(
std::filesystem::absolute(src / symlink_target));
#ifdef _WIN32
auto wstr = symlink_target_absolute.wstring();
if (wstr.starts_with(L"\\\\?\\")) {
symlink_target_absolute = std::filesystem::path(wstr.substr(4));
}
#endif
if (dir_entry.is_directory()) {
std::filesystem::create_directory_symlink(
symlink_target_absolute, dest_file_path, error);
Expand All @@ -3841,7 +3853,7 @@ static void CpSyncCopyDir(const FunctionCallbackInfo<Value>& args) {
std::filesystem::copy_file(
dir_entry.path(), dest_file_path, file_copy_opts, error);
if (error) {
if (error.value() == EEXIST) {
if (error == std::errc::file_exists) {
THROW_ERR_FS_CP_EEXIST(isolate,
"[ERR_FS_CP_EEXIST]: Target already exists: "
"cp returned EEXIST (%s already exists)",
Expand Down
1 change: 1 addition & 0 deletions src/quic/bindingdata.h
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class SessionManager;
V(bbr, "bbr") \
V(ca, "ca") \
V(cc_algorithm, "cc") \
V(certificate_compression, "certificateCompression") \
V(certs, "certs") \
V(code, "code") \
V(ciphers, "ciphers") \
Expand Down
53 changes: 50 additions & 3 deletions src/quic/tlscontext.cc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
#include <ngtcp2/ngtcp2_crypto_ossl.h>
#include <node_sockaddr-inl.h>
#include <openssl/ssl.h>
#ifdef NODE_OPENSSL_HAS_CERT_COMP
#include <openssl/comp.h>
#endif
#include <util-inl.h>
#include <v8.h>
#include "application.h"
Expand Down Expand Up @@ -594,6 +597,48 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
}
}

// TLS certificate compression (RFC 8879). OpenSSL enables all available
// algorithms by default once compression libraries are linked, so we
// always clear the preference first to keep certificate compression
// opt-in (matching the behavior of node:tls). When the
// certificateCompression option is set, apply the requested algorithms
// and pre-compress the certificate(s) loaded above. QUIC always uses
// TLS 1.3, which is the minimum required for certificate compression.
#ifdef NODE_OPENSSL_HAS_CERT_COMP
{
ClearErrorOnReturn clear_error_on_return;
SSL_CTX_set1_cert_comp_preference(ctx.get(), nullptr, 0);

// The JS layer packs (length | alg0<<8 | alg1<<16 | alg2<<24) into a
// single uint32. IDs match TLSEXT_comp_cert_zlib (1), _brotli (2),
// _zstd (3).
const uint32_t packed = options_.certificate_compression;
const size_t len = packed & 0xff;
if (len > 0) {
// TLSEXT_comp_cert_limit bounds the zero-terminated algs array; the
// number of usable algorithms is one fewer.
constexpr size_t kMaxCompAlgs = TLSEXT_comp_cert_limit - 1;
if (len > kMaxCompAlgs) {
validation_error_ = "Invalid certificate compression preference";
return SSLCtxPointer();
}
int algs[kMaxCompAlgs];
for (size_t i = 0; i < len; i++) {
algs[i] = (packed >> (8 * (i + 1))) & 0xff;
}
if (!SSL_CTX_set1_cert_comp_preference(ctx.get(), algs, len)) {
validation_error_ = "Failed to set certificate compression preference";
return SSLCtxPointer();
}
// Pre-compress the loaded certificate(s) for the preferred algorithms.
// Returns 0 when no certificate is loaded (e.g. a client context) or
// when compression did not reduce the size; both are non-fatal.
constexpr int kCompressAllAlgs = 0;
SSL_CTX_compress_certs(ctx.get(), kCompressAllAlgs);
}
}
#endif // NODE_OPENSSL_HAS_CERT_COMP

{
ClearErrorOnReturn clear_error_on_return;
for (const auto& key : options_.keys) {
Expand Down Expand Up @@ -730,9 +775,9 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
!SET(servername) || !SET(ciphers) || !SET(groups) ||
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
!SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
!SET_VECTOR(Store, crl)) {
!SET(certificate_compression) || !SET(authoritative) ||
!SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
!SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
return Nothing<Options>();
}

Expand Down Expand Up @@ -761,6 +806,8 @@ std::string TLSContext::Options::ToString() const {
(verify_private_key ? std::string("yes") : std::string("no"));
res += prefix + "ciphers: " + ciphers;
res += prefix + "groups: " + groups;
res += prefix +
"certificate compression: " + std::to_string(certificate_compression);
res += prefix + "keys: " + std::to_string(keys.size());
res += prefix + "certs: " + std::to_string(certs.size());
res += prefix + "ca: " + std::to_string(ca.size());
Expand Down
10 changes: 10 additions & 0 deletions src/quic/tlscontext.h
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@ class TLSContext final : public MemoryRetainer,
// The list of TLS groups to use for this session.
std::string groups = DEFAULT_GROUPS;

// TLS certificate compression (RFC 8879) preference, packed by the JS
// layer as (length | alg0<<8 | alg1<<16 | alg2<<24). Algorithm IDs match
// OpenSSL's TLSEXT_comp_cert_zlib (1), _brotli (2), _zstd (3). A value of
// 0 means certificate compression is disabled (the default). Certificate
// compression is particularly valuable for QUIC because it reduces the
// size of the server's Certificate message, which is otherwise likely to
// exceed the anti-amplification limit and force an extra handshake round
// trip. JavaScript option name "certificateCompression".
uint32_t certificate_compression = 0;

// When true, enables keylog output for the session.
bool keylog = false;

Expand Down
Loading
Loading