From 71af0a90c1606c47180ecf35beae62cf9b46b00e Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Tue, 20 Oct 2026 11:14:07 +0200 Subject: [PATCH 1/2] tls: reject chains containing blocked public keys Node.js embeds the Mozilla root store but has no equivalent of the browsers' CRLSet/OneCRL and no default online revocation checking, so a certificate chain through a compromised intermediate still verifies as long as it reaches a trusted root. Removing the affected roots would over-block, since other legitimate intermediates chain to them. Add a small compiled-in blocklist of SubjectPublicKeyInfo SHA-256 hashes and check every verified chain against it from a single SSL_CTX_set_cert_verify_callback hook. A hit is reported as X509_V_ERR_CERT_REVOKED through the normal verification path, so it honours rejectUnauthorized and surfaces as authorizationError on both client and server sockets. The hook is installed for every SSL_CTX Node.js creates: SecureContext, QUIC and DTLS. The OpenSSL 4.1 CN name constraint handling previously registered from crypto_tls.cc moves into the same hook, since OpenSSL allows only one. The initial entries are the eight eMudhra/emSign intermediates whose private keys were reported compromised in October 2026. The same list is shipped by Go (CVE-2026-111943) and Electron. Sessions resumed from before this change are not re-verified, as is standard for TLS resumption; the exposure ends with the session timeout. Test-only hooks on the crypto binding, reachable with --expose-internals, allow the test to block a fixture key and exercise the full handshake. Refs: https://github.com/electron/electron/pull/55126 Refs: https://github.com/golang/go/issues/82345 Assisted-by: Claude, Codex Signed-off-by: Ulises Gascon --- src/crypto/crypto_context.cc | 246 ++++++++++++++++++++++ src/crypto/crypto_context.h | 15 ++ src/crypto/crypto_tls.cc | 37 +--- src/dtls/dtls_context.cc | 5 + src/quic/tlscontext.cc | 5 + test/parallel/test-tls-spki-blocklist.js | 255 +++++++++++++++++++++++ 6 files changed, 530 insertions(+), 33 deletions(-) create mode 100644 test/parallel/test-tls-spki-blocklist.js diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc index 0f26b1ceb543..d7c321810998 100644 --- a/src/crypto/crypto_context.cc +++ b/src/crypto/crypto_context.cc @@ -10,6 +10,7 @@ #include "ncrypto.h" #include "node.h" #include "node_buffer.h" +#include "node_mutex.h" #include "node_options.h" #include "util.h" #include "v8.h" @@ -17,8 +18,10 @@ #ifdef NODE_OPENSSL_HAS_CERT_COMP #include #endif +#include #include #include +#include #ifdef __APPLE__ #include #endif @@ -28,6 +31,8 @@ #include #endif +#include +#include #include namespace node { @@ -1374,6 +1379,238 @@ Local SecureContext::GetConstructorTemplate( return tmpl; } +namespace { + +constexpr size_t kSpkiHashLength = 32; // SHA-256 +using SpkiHash = std::array; + +// SHA-256 hashes of SubjectPublicKeyInfos that must never appear in a +// verified chain, regardless of what the trust store says. Each entry is +// the digest of the DER-encoded SPKI (what i2d_X509_PUBKEY produces). +// +// These are the eMudhra/emSign intermediates whose private keys were +// reported compromised in October 2026. Node.js has no equivalent of the +// browsers' CRLSet/OneCRL and no default online revocation checking, so a +// chain through one of these keys would otherwise still verify as long as +// it reaches a bundled root. The same list is shipped by Go +// (CVE-2026-111943, golang/go#82345) and Electron (electron/electron#55126). +// Each entry is annotated with the certificate fingerprint and subject from +// the public revocation records, for cross-referencing with CCADB. +constexpr SpkiHash kBlockedSPKIs[] = { + // 0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19 + // C=US, O=eMudhra Inc, CN=EM DV TLS CA - G3C + {0x49, 0xdd, 0x35, 0x3d, 0xc2, 0xb9, 0x19, 0xa3, 0x77, 0x06, 0x45, + 0x11, 0x7d, 0x0e, 0x60, 0xc1, 0x61, 0x15, 0xe7, 0xd1, 0xbc, 0x25, + 0x9e, 0xef, 0xe7, 0x34, 0xb0, 0x0e, 0xa4, 0x34, 0xfe, 0x5f}, + // 432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3 + // C=US, O=eMudhra Inc, CN=EM EV TLS CA - G3C + {0x5c, 0x06, 0x60, 0xa2, 0xb1, 0x64, 0x91, 0x0d, 0x8f, 0xf5, 0x89, + 0xc1, 0x8e, 0x14, 0x55, 0x2e, 0x9e, 0x0e, 0xc2, 0x37, 0xdc, 0xef, + 0xe2, 0xcb, 0x73, 0x6d, 0x41, 0xa4, 0x81, 0x1f, 0x81, 0xa0}, + // cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f + // C=US, O=eMudhra Inc, CN=EM OV TLS CA - G3C + {0x63, 0xec, 0xef, 0xee, 0x43, 0x1d, 0x49, 0x52, 0x3b, 0xc1, 0x13, + 0x6c, 0x4e, 0x72, 0xd0, 0x20, 0x6a, 0x47, 0x70, 0x86, 0xca, 0xf6, + 0x06, 0xcd, 0x5c, 0xf0, 0x25, 0x68, 0x6c, 0x10, 0x9c, 0xf8}, + // 4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1 + // C=US, O=eMudhra Inc, CN=EM OV CS CA - G3C + {0x92, 0xc0, 0x63, 0xd6, 0x7e, 0x8f, 0x14, 0xdb, 0x71, 0xd2, 0x88, + 0xe1, 0x12, 0x66, 0xff, 0xda, 0xf6, 0x9c, 0xbf, 0x11, 0x5f, 0x7b, + 0xab, 0x14, 0xf3, 0xbc, 0x2f, 0x68, 0xd2, 0x5e, 0x0e, 0x30}, + // 42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493 + // C=NL, O=eMudhra BV, CN=EM DV TLS CA - G3B + {0xeb, 0xed, 0x64, 0x75, 0xc2, 0xc2, 0xd8, 0x0f, 0xcb, 0x19, 0x9f, + 0x3f, 0xe8, 0xdd, 0x6e, 0x6b, 0x3c, 0xe6, 0x0b, 0x77, 0xeb, 0x15, + 0xd4, 0xe4, 0x2c, 0x3d, 0xec, 0x0c, 0x6f, 0x5b, 0x51, 0x05}, + // 9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4 + // C=NL, O=eMudhra BV, CN=EM OV CS CA - G3B + {0xec, 0x4f, 0xfa, 0x1a, 0xd0, 0xac, 0x88, 0x08, 0x13, 0x14, 0xb7, + 0xaa, 0xff, 0xef, 0x6a, 0x91, 0xf9, 0x7c, 0xd5, 0x72, 0x91, 0xbb, + 0x5b, 0xa6, 0x86, 0xc5, 0x41, 0x5f, 0x5e, 0xb4, 0xf6, 0xb6}, + // 98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b + // C=NL, O=eMudhra BV, CN=EM OV TLS CA - G3B + {0x43, 0xbf, 0xfa, 0x19, 0xd6, 0xef, 0x65, 0x14, 0x51, 0xf6, 0x84, + 0x1e, 0x01, 0x99, 0x31, 0xe9, 0x6f, 0x6f, 0x2e, 0xae, 0xff, 0x28, + 0xab, 0x4b, 0x6f, 0x1c, 0xe4, 0x57, 0x6d, 0xe2, 0x71, 0x7b}, + // e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838 + // C=NL, O=eMudhra BV, CN=EM EV TLS CA - G3B + {0xe0, 0x25, 0x4c, 0x7f, 0xcd, 0xc2, 0x65, 0xe2, 0xf0, 0xf8, 0xa4, + 0x28, 0xda, 0xbb, 0x75, 0xab, 0x4a, 0xe0, 0x39, 0xb4, 0x87, 0x2d, + 0x62, 0xf5, 0xee, 0x57, 0xf0, 0x87, 0x7d, 0x5a, 0x8a, 0x41}, +}; + +// Extra entries registered by the test-only hooks on the crypto binding. +// The list is process-wide and TLS handshakes may run on worker threads, so +// every access goes through the mutex. +Mutex& BlockedSPKIsForTestingMutex() { + static Mutex mutex; + return mutex; +} + +std::vector& BlockedSPKIsForTesting() { + static std::vector list; + return list; +} + +bool IsBlockedSPKIForTesting(const SpkiHash& digest) { + Mutex::ScopedLock lock(BlockedSPKIsForTestingMutex()); + for (const auto& blocked : BlockedSPKIsForTesting()) { + if (digest == blocked) return true; + } + return false; +} + +enum class SpkiCheck { kAllowed, kBlocked, kError }; + +// Hashes the DER-encoded SubjectPublicKeyInfo of `cert` and looks it up in +// the blocklist. A failure to encode or hash is reported as kError rather +// than kAllowed so that it cannot be used to slip past the check. +SpkiCheck CheckSPKI(X509* cert) { + if (cert == nullptr) return SpkiCheck::kAllowed; + uint8_t* spki = nullptr; + int spki_len = i2d_X509_PUBKEY(X509_get_X509_PUBKEY(cert), &spki); + if (spki_len <= 0) return SpkiCheck::kError; + SpkiHash digest; + unsigned int digest_len = 0; + int ok = EVP_Digest(spki, + static_cast(spki_len), + digest.data(), + &digest_len, + Digest::SHA256, + nullptr); + OPENSSL_free(spki); + if (ok != 1 || digest_len != digest.size()) return SpkiCheck::kError; + for (const auto& blocked : kBlockedSPKIs) { + if (digest == blocked) return SpkiCheck::kBlocked; + } + if (IsBlockedSPKIForTesting(digest)) return SpkiCheck::kBlocked; + return SpkiCheck::kAllowed; +} + +// Checks every certificate in `certs`. On kBlocked or kError, `*offender` +// points at the certificate the result applies to and `*depth` is its index +// in `certs`. +SpkiCheck CheckSPKIs(const STACK_OF(X509) * certs, + X509** offender, + int* depth) { + if (certs == nullptr) return SpkiCheck::kAllowed; + for (int i = 0; i < sk_X509_num(certs); i++) { + X509* cert = sk_X509_value(certs, i); + SpkiCheck result = CheckSPKI(cert); + if (result != SpkiCheck::kAllowed) { + *offender = cert; + *depth = i; + return result; + } + } + return SpkiCheck::kAllowed; +} + +// Installed with SSL_CTX_set_cert_verify_callback on every SSL_CTX Node.js +// creates. It wraps X509_verify_cert so that a chain through a blocked +// public key is reported as X509_V_ERR_CERT_REVOKED even when the trust +// store would accept it. The built chain is always checked; when +// verification did not fully succeed (TLSWrap's per-certificate callback +// continues past errors so JS can apply rejectUnauthorized, leaving the +// error set on the context), the leaf and every peer-sent certificate are +// checked too, so the blocklist takes precedence over the original error. +// +// A hit is reported like any other X509_verify_cert error: the error and +// offending certificate are recorded on the context and the per-certificate +// verify callback, if any, decides whether the handshake continues. OpenSSL +// reads the error back into the verify result either way, so +// socket.authorizationError reports CERT_REVOKED. +// +// This is the only cert-verify callback Node.js installs; keep any other +// per-chain adjustments here, since a second registration would replace it. +int CertVerifyCallback(X509_STORE_CTX* ctx, void*) { +#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(4, 1) + X509_VERIFY_PARAM* param = X509_STORE_CTX_get0_param(ctx); + const unsigned int flags = X509_VERIFY_PARAM_get_hostflags(param); + if (!(flags & (X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT | + X509_CHECK_FLAG_NEVER_CHECK_SUBJECT))) { + // tls.checkServerIdentity() falls back to the CN when no DNS SAN exists. + // OpenSSL 4.1 requires this flag to apply name constraints to that CN. + ncrypto::DeleteFnPtr names( + static_cast( + X509_get_ext_d2i(X509_STORE_CTX_get0_cert(ctx), + NID_subject_alt_name, + nullptr, + nullptr))); + bool has_dns_san = false; + for (int i = 0; names && i < sk_GENERAL_NAME_num(names.get()); i++) { + if (sk_GENERAL_NAME_value(names.get(), i)->type == GEN_DNS) { + has_dns_san = true; + break; + } + } + if (!has_dns_san) { + X509_VERIFY_PARAM_set_hostflags( + param, flags | X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT); + } + } +#endif + + int ok = X509_verify_cert(ctx) > 0 ? 1 : 0; + const bool verified = ok == 1 && X509_STORE_CTX_get_error(ctx) == X509_V_OK; + + // The depth is the offender's index in the built chain (0 = leaf), as + // X509_verify_cert would report it. For certificates found only in the + // peer-sent stack their index there is the closest equivalent. + X509* offender = nullptr; + int depth = 0; + SpkiCheck result = + CheckSPKIs(X509_STORE_CTX_get0_chain(ctx), &offender, &depth); + if (result == SpkiCheck::kAllowed && !verified) { + X509* leaf = X509_STORE_CTX_get0_cert(ctx); + result = CheckSPKI(leaf); + if (result != SpkiCheck::kAllowed) { + offender = leaf; + depth = 0; + } else { + result = + CheckSPKIs(X509_STORE_CTX_get0_untrusted(ctx), &offender, &depth); + } + } + if (result == SpkiCheck::kAllowed) return ok; + + X509_STORE_CTX_set_error(ctx, + result == SpkiCheck::kBlocked + ? X509_V_ERR_CERT_REVOKED + : X509_V_ERR_UNSPECIFIED); + X509_STORE_CTX_set_error_depth(ctx, depth); + X509_STORE_CTX_set_current_cert(ctx, offender); + X509_STORE_CTX_verify_cb verify_cb = X509_STORE_CTX_get_verify_cb(ctx); + if (verify_cb == nullptr) return 0; + return verify_cb(0, ctx) ? 1 : 0; +} + +} // namespace + +void InstallCertVerifyCallback(SSL_CTX* ctx) { + SSL_CTX_set_cert_verify_callback(ctx, CertVerifyCallback, nullptr); +} + +void SecureContext::AddBlockedSPKIForTesting( + const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_IF_NOT_BUFFER(env, args[0], "SPKI hash"); + ArrayBufferOrViewContents hash(args[0]); + if (hash.size() != kSpkiHashLength) { + return THROW_ERR_OUT_OF_RANGE(env, "SPKI hash must be 32 bytes"); + } + SpkiHash entry; + memcpy(entry.data(), hash.data(), entry.size()); + Mutex::ScopedLock lock(BlockedSPKIsForTestingMutex()); + BlockedSPKIsForTesting().push_back(entry); +} + +void SecureContext::ClearBlockedSPKIsForTesting( + const FunctionCallbackInfo& args) { + Mutex::ScopedLock lock(BlockedSPKIsForTestingMutex()); + BlockedSPKIsForTesting().clear(); +} + void SecureContext::Initialize(Environment* env, Local target) { Local context = env->context(); SetConstructorFunction(context, @@ -1401,6 +1638,12 @@ void SecureContext::Initialize(Environment* env, Local target) { target, "getCertificateCompressionAlgorithms", GetCertificateCompressionAlgorithms); + SetMethod( + context, target, "addBlockedSPKIForTesting", AddBlockedSPKIForTesting); + SetMethod(context, + target, + "clearBlockedSPKIsForTesting", + ClearBlockedSPKIsForTesting); } void SecureContext::RegisterExternalReferences( @@ -1448,6 +1691,8 @@ void SecureContext::RegisterExternalReferences( registry->Register(GetUserRootCertificates); registry->Register(StartLoadingCertificatesOffThread); registry->Register(GetCertificateCompressionAlgorithms); + registry->Register(AddBlockedSPKIForTesting); + registry->Register(ClearBlockedSPKIsForTesting); } SecureContext* SecureContext::Create(Environment* env) { @@ -1584,6 +1829,7 @@ void SecureContext::Init(const FunctionCallbackInfo& args) { env->external_memory_accounter()->Increase(env->isolate(), kExternalSize); SSL_CTX_set_app_data(sc->ctx_.get(), sc); + InstallCertVerifyCallback(sc->ctx_.get()); // OpenSSL populates cert_comp_prefs with all available algorithms by // default when compression libraries are linked. Clear them so that diff --git a/src/crypto/crypto_context.h b/src/crypto/crypto_context.h index 73aff5b628a1..c2da04bbbb55 100644 --- a/src/crypto/crypto_context.h +++ b/src/crypto/crypto_context.h @@ -27,6 +27,12 @@ X509_STORE* NewRootCertStore(Environment* env); X509_STORE* GetOrCreateRootCertStore(Environment* env); +// Installs the certificate verification callback that applies the SPKI +// blocklist (and, on OpenSSL 4.1+, the CN name-constraint handling) to every +// chain verified through `ctx`. Must be called for every SSL_CTX Node.js +// creates, since SSL_CTX_set_cert_verify_callback replaces any earlier hook. +void InstallCertVerifyCallback(SSL_CTX* ctx); + ncrypto::BIOPointer LoadBIO(Environment* env, v8::Local v); class SecureContext final : public BaseObject { @@ -154,6 +160,15 @@ class SecureContext final : public BaseObject { template static void GetCertificate(const v8::FunctionCallbackInfo& args); + // Test-only hooks for the SPKI blocklist that every SSL_CTX applies + // during certificate verification. Reachable through + // internalBinding('crypto') with --expose-internals. The argument is the + // SHA-256 of a DER-encoded SubjectPublicKeyInfo. + static void AddBlockedSPKIForTesting( + const v8::FunctionCallbackInfo& args); + static void ClearBlockedSPKIsForTesting( + const v8::FunctionCallbackInfo& args); + static int TicketKeyCallback(SSL* ssl, unsigned char* name, unsigned char* iv, diff --git a/src/crypto/crypto_tls.cc b/src/crypto/crypto_tls.cc index fbc5fc8c39c9..ef258b0f3308 100644 --- a/src/crypto/crypto_tls.cc +++ b/src/crypto/crypto_tls.cc @@ -73,36 +73,6 @@ namespace crypto { namespace { -#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(4, 1) -int VerifyCertChain(X509_STORE_CTX* ctx, void*) { - X509_VERIFY_PARAM* param = X509_STORE_CTX_get0_param(ctx); - const unsigned int flags = X509_VERIFY_PARAM_get_hostflags(param); - if (!(flags & (X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT | - X509_CHECK_FLAG_NEVER_CHECK_SUBJECT))) { - // tls.checkServerIdentity() falls back to the CN when no DNS SAN exists. - // OpenSSL 4.1 requires this flag to apply name constraints to that CN. - ncrypto::DeleteFnPtr names( - static_cast( - X509_get_ext_d2i(X509_STORE_CTX_get0_cert(ctx), - NID_subject_alt_name, - nullptr, - nullptr))); - bool has_dns_san = false; - for (int i = 0; names && i < sk_GENERAL_NAME_num(names.get()); i++) { - if (sk_GENERAL_NAME_value(names.get(), i)->type == GEN_DNS) { - has_dns_san = true; - break; - } - } - if (!has_dns_san) { - X509_VERIFY_PARAM_set_hostflags( - param, flags | X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT); - } - } - return X509_verify_cert(ctx) > 0 ? 1 : 0; -} -#endif - // Our custom implementation of the certificate verify callback // used when establishing a TLS handshake. Because we cannot perform // I/O quickly enough with X509_STORE_CTX_ APIs in this callback, @@ -360,9 +330,10 @@ int TLSExtStatusCallback(SSL* s, void* arg) { void ConfigureSecureContext(SecureContext* sc) { // OCSP stapling sc->ctx().setStatusCallback(TLSExtStatusCallback); -#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(4, 1) - SSL_CTX_set_cert_verify_callback(sc->ctx().get(), VerifyCertChain, nullptr); -#endif + // The cert-verify callback (SPKI blocklist and the OpenSSL 4.1 CN name + // constraint handling) is installed once per SSL_CTX in + // SecureContext::Init, so that it also covers contexts created without a + // TLSWrap. } inline bool Set( diff --git a/src/dtls/dtls_context.cc b/src/dtls/dtls_context.cc index baf8647dda7d..96f3ab459ec8 100644 --- a/src/dtls/dtls_context.cc +++ b/src/dtls/dtls_context.cc @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -215,6 +216,10 @@ void DTLSContext::New(const FunctionCallbackInfo& args) { "Failed to create DTLS SSL_CTX"); } + // DTLS builds its SSL_CTX here instead of through SecureContext, so the + // chain verification hook shared with TLS has to be installed explicitly. + crypto::InstallCertVerifyCallback(ctx.get()); + // Default to DTLS 1.2 only. DTLS 1.0 (based on TLS 1.1) is deprecated // by RFC 8996 and lacks AEAD cipher suites. // diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index b5106d27e127..081af891b990 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -2,6 +2,7 @@ #ifndef OPENSSL_NO_QUIC #include #include +#include #include #include #include @@ -577,6 +578,10 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { } } + // QUIC builds its SSL_CTX here instead of through SecureContext, so the + // chain verification hook shared with TLS has to be installed explicitly. + crypto::InstallCertVerifyCallback(ctx.get()); + if (options_.keylog) { SSL_CTX_set_keylog_callback(ctx.get(), OnKeylog); } diff --git a/test/parallel/test-tls-spki-blocklist.js b/test/parallel/test-tls-spki-blocklist.js new file mode 100644 index 000000000000..a7d83e096a77 --- /dev/null +++ b/test/parallel/test-tls-spki-blocklist.js @@ -0,0 +1,255 @@ +// Flags: --expose-internals +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +// Every SSL_CTX rejects a certificate chain that contains a public key whose +// SubjectPublicKeyInfo SHA-256 hash is on the compiled-in blocklist, and +// reports the failure as CERT_REVOKED. The blocklist is checked against the +// verified chain, so a blocked intermediate is caught even though the root it +// chains to is fully trusted. Like any other verification error, the result +// is surfaced through socket.authorizationError and honours rejectUnauthorized +// on both the client and the server side. +// +// The fixtures give a chain of agent6 -> ca3 (intermediate) -> ca1 (root); +// agent6-cert.pem already bundles ca3. agent1 is signed directly by ca1 and +// serves as an unaffected server certificate for the client-certificate +// cases. The test blocks the SPKI of ca3 through the test-only hooks on the +// crypto binding. + +const assert = require('assert'); +const { createHash, X509Certificate } = require('crypto'); +const { once } = require('events'); +const { describe, it, beforeEach, afterEach } = require('node:test'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { internalBinding } = require('internal/test/binding'); + +const { + addBlockedSPKIForTesting, + clearBlockedSPKIsForTesting, +} = internalBinding('crypto'); + +function spkiSha256(pem) { + const spki = new X509Certificate(pem).publicKey.export({ + type: 'spki', + format: 'der', + }); + return createHash('sha256').update(spki).digest(); +} + +const rootCert = fixtures.readKey('ca1-cert.pem'); +const intermediateCert = fixtures.readKey('ca3-cert.pem'); +const leafKey = fixtures.readKey('agent6-key.pem'); +const leafChain = fixtures.readKey('agent6-cert.pem'); +const unaffectedKey = fixtures.readKey('agent1-key.pem'); +const unaffectedCert = fixtures.readKey('agent1-cert.pem'); +// ca2 is unrelated to the agent6 chain; appended to the server's certificate +// it is sent to the peer but never becomes part of the verified chain. +const unrelatedCert = fixtures.readKey('ca2-cert.pem'); + +const intermediateSpkiHash = spkiSha256(intermediateCert); +const leafSpkiHash = spkiSha256(leafChain); +const unrelatedSpkiHash = spkiSha256(unrelatedCert); + +describe('SPKI blocklist on the server certificate', function() { + let server; + let client; + let opts; + + beforeEach(async function() { + server = tls.createServer({ + key: leafKey, + cert: leafChain, + }, (socket) => socket.resume()); + server.listen(0); + await once(server, 'listening'); + + opts = { + port: server.address().port, + ca: rootCert, + checkServerIdentity() {}, + }; + }); + + afterEach(async function() { + clearBlockedSPKIsForTesting(); + client?.destroy(); + server?.close(); + }); + + it('accepts the chain when nothing is blocked', async function() { + client = tls.connect(opts); + await once(client, 'secureConnect'); + assert.strictEqual(client.authorized, true); + }); + + it('rejects a chain through a blocked intermediate', async function() { + addBlockedSPKIForTesting(intermediateSpkiHash); + await assert.rejects(async () => { + client = tls.connect(opts); + await once(client, 'secureConnect'); + }, { code: 'CERT_REVOKED' }); + }); + + it('rejects a chain whose leaf key is blocked', async function() { + addBlockedSPKIForTesting(leafSpkiHash); + await assert.rejects(async () => { + client = tls.connect(opts); + await once(client, 'secureConnect'); + }, { code: 'CERT_REVOKED' }); + }); + + it('reports CERT_REVOKED without rejectUnauthorized', async function() { + addBlockedSPKIForTesting(intermediateSpkiHash); + client = tls.connect({ ...opts, rejectUnauthorized: false }); + await once(client, 'secureConnect'); + assert.strictEqual(client.authorized, false); + assert.strictEqual(client.authorizationError, 'CERT_REVOKED'); + }); + + it('takes precedence over an untrusted-root error', async function() { + // With an empty trust store the chain cannot reach a root, which would + // normally be reported as UNABLE_TO_GET_ISSUER_CERT_LOCALLY. The + // blocked key must win over that error. + addBlockedSPKIForTesting(intermediateSpkiHash); + await assert.rejects(async () => { + client = tls.connect({ ...opts, ca: [] }); + await once(client, 'secureConnect'); + }, { code: 'CERT_REVOKED' }); + }); + + it('accepts the chain again once the blocklist is cleared', async function() { + addBlockedSPKIForTesting(intermediateSpkiHash); + clearBlockedSPKIsForTesting(); + client = tls.connect(opts); + await once(client, 'secureConnect'); + assert.strictEqual(client.authorized, true); + }); + + it('validates the hook arguments', function() { + assert.throws(() => addBlockedSPKIForTesting('not a buffer'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => addBlockedSPKIForTesting(Buffer.alloc(31)), { + code: 'ERR_OUT_OF_RANGE', + }); + }); +}); + +describe('SPKI blocklist on certificates outside the verified chain', function() { + let server; + let client; + + beforeEach(async function() { + server = tls.createServer({ + key: leafKey, + cert: `${leafChain}\n${unrelatedCert}`, + }, (socket) => socket.resume()); + server.listen(0); + await once(server, 'listening'); + }); + + afterEach(async function() { + clearBlockedSPKIsForTesting(); + client?.destroy(); + server?.close(); + }); + + it('ignores an unused peer-sent certificate when the chain verifies', async function() { + addBlockedSPKIForTesting(unrelatedSpkiHash); + client = tls.connect({ + port: server.address().port, + ca: rootCert, + checkServerIdentity() {}, + }); + await once(client, 'secureConnect'); + assert.strictEqual(client.authorized, true); + }); + + it('checks every peer-sent certificate when the chain does not verify', async function() { + // With an empty trust store the chain is incomplete, so the fallback + // scan of everything the peer sent applies and finds the blocked key. + addBlockedSPKIForTesting(unrelatedSpkiHash); + await assert.rejects(async () => { + client = tls.connect({ + port: server.address().port, + ca: [], + checkServerIdentity() {}, + }); + await once(client, 'secureConnect'); + }, { code: 'CERT_REVOKED' }); + }); +}); + +describe('SPKI blocklist on the client certificate', function() { + let server; + let client; + + function listen(serverOpts) { + server = tls.createServer({ + key: unaffectedKey, + cert: unaffectedCert, + ca: rootCert, + requestCert: true, + ...serverOpts, + }, (socket) => socket.resume()); + server.listen(0); + return once(server, 'listening'); + } + + function connect() { + client = tls.connect({ + port: server.address().port, + key: leafKey, + cert: leafChain, + ca: rootCert, + checkServerIdentity() {}, + }); + return client; + } + + afterEach(async function() { + clearBlockedSPKIsForTesting(); + client?.destroy(); + server?.close(); + }); + + it('accepts the client chain when nothing is blocked', async function() { + await listen({ rejectUnauthorized: true }); + const connection = once(server, 'secureConnection'); + connect(); + const [socket] = await connection; + assert.strictEqual(socket.authorized, true); + }); + + it('reports CERT_REVOKED without rejectUnauthorized', async function() { + addBlockedSPKIForTesting(intermediateSpkiHash); + await listen({ rejectUnauthorized: false }); + const connection = once(server, 'secureConnection'); + connect(); + const [socket] = await connection; + assert.strictEqual(socket.authorized, false); + assert.strictEqual(socket.authorizationError, 'CERT_REVOKED'); + }); + + it('rejects the client chain with rejectUnauthorized', async function() { + addBlockedSPKIForTesting(intermediateSpkiHash); + await listen({ rejectUnauthorized: true }); + server.on('secureConnection', common.mustNotCall()); + const clientError = once(server, 'tlsClientError'); + // The client may see either a clean close or a reset depending on + // timing, so accept both and only wait for it to go away. + const clientClosed = new Promise((resolve) => { + connect().on('error', () => {}).once('close', resolve); + }); + // The server destroys the socket during 'secure', which surfaces as a + // reset rather than a handshake failure; the reason is kept on the socket. + const [err, socket] = await clientError; + assert.strictEqual(err.code, 'ECONNRESET'); + assert.strictEqual(socket.authorized, false); + assert.strictEqual(socket.authorizationError, 'CERT_REVOKED'); + await clientClosed; + }); +}); From 8417eb1dd788c2c65da7662ae0b0a6b9167ec581 Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Tue, 20 Oct 2026 13:00:21 +0200 Subject: [PATCH 2/2] fixup! tls: reject chains containing blocked public keys Use only X509_STORE_CTX APIs that BoringSSL also provides. BoringSSL lacks X509_STORE_CTX_set_error_depth, X509_STORE_CTX_set_current_cert and X509_STORE_CTX_get_verify_cb, which broke the aarch64 shared BoringSSL build. Fetch the per-certificate callback from the SSL object instead and skip the two setters on BoringSSL, where Node.js' own callbacks never read them. Assisted-by: Claude Signed-off-by: Ulises Gascon --- src/crypto/crypto_context.cc | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc index d7c321810998..05e8e5f85c0b 100644 --- a/src/crypto/crypto_context.cc +++ b/src/crypto/crypto_context.cc @@ -1578,9 +1578,21 @@ int CertVerifyCallback(X509_STORE_CTX* ctx, void*) { result == SpkiCheck::kBlocked ? X509_V_ERR_CERT_REVOKED : X509_V_ERR_UNSPECIFIED); +#ifdef OPENSSL_IS_BORINGSSL + // BoringSSL has no setters for the error depth and current certificate. + // Node.js' own verify callbacks do not read them, so only the error is + // recorded there. + (void)depth; + (void)offender; +#else X509_STORE_CTX_set_error_depth(ctx, depth); X509_STORE_CTX_set_current_cert(ctx, offender); - X509_STORE_CTX_verify_cb verify_cb = X509_STORE_CTX_get_verify_cb(ctx); +#endif + // The per-certificate callback is the one carried by the SSL object; it is + // what the library installs on the store context before calling this hook. + SSL* ssl = static_cast( + X509_STORE_CTX_get_ex_data(ctx, SSL_get_ex_data_X509_STORE_CTX_idx())); + auto verify_cb = ssl != nullptr ? SSL_get_verify_callback(ssl) : nullptr; if (verify_cb == nullptr) return 0; return verify_cb(0, ctx) ? 1 : 0; }