Commit d9a3f3d150f for nodejs
commit d9a3f3d150f7959f2d448d9a61aa11f5a75aa2b3
Author: Ulises Gascón <ulisesgascongonzalez@gmail.com>
Date: Tue Oct 20 18:43:42 2026 +0200
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 <ulisesgascongonzalez@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/66871
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
Reviewed-By: Bryan English <bryan@bryanenglish.com>
Reviewed-By: Tim Perry <pimterry@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc
index 0f26b1ceb54..05e8e5f85c0 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 <openssl/comp.h>
#endif
+#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/x509.h>
+#include <openssl/x509v3.h>
#ifdef __APPLE__
#include <Security/Security.h>
#endif
@@ -28,6 +31,8 @@
#include <wincrypt.h>
#endif
+#include <array>
+#include <cstring>
#include <set>
namespace node {
@@ -1374,6 +1379,250 @@ Local<FunctionTemplate> SecureContext::GetConstructorTemplate(
return tmpl;
}
+namespace {
+
+constexpr size_t kSpkiHashLength = 32; // SHA-256
+using SpkiHash = std::array<uint8_t, kSpkiHashLength>;
+
+// 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<SpkiHash>& BlockedSPKIsForTesting() {
+ static std::vector<SpkiHash> 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<size_t>(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<GENERAL_NAMES, GENERAL_NAMES_free> names(
+ static_cast<GENERAL_NAMES*>(
+ 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);
+#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);
+#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<SSL*>(
+ 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;
+}
+
+} // namespace
+
+void InstallCertVerifyCallback(SSL_CTX* ctx) {
+ SSL_CTX_set_cert_verify_callback(ctx, CertVerifyCallback, nullptr);
+}
+
+void SecureContext::AddBlockedSPKIForTesting(
+ const FunctionCallbackInfo<Value>& args) {
+ Environment* env = Environment::GetCurrent(args);
+ THROW_AND_RETURN_IF_NOT_BUFFER(env, args[0], "SPKI hash");
+ ArrayBufferOrViewContents<uint8_t> 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<Value>& args) {
+ Mutex::ScopedLock lock(BlockedSPKIsForTestingMutex());
+ BlockedSPKIsForTesting().clear();
+}
+
void SecureContext::Initialize(Environment* env, Local<Object> target) {
Local<Context> context = env->context();
SetConstructorFunction(context,
@@ -1401,6 +1650,12 @@ void SecureContext::Initialize(Environment* env, Local<Object> target) {
target,
"getCertificateCompressionAlgorithms",
GetCertificateCompressionAlgorithms);
+ SetMethod(
+ context, target, "addBlockedSPKIForTesting", AddBlockedSPKIForTesting);
+ SetMethod(context,
+ target,
+ "clearBlockedSPKIsForTesting",
+ ClearBlockedSPKIsForTesting);
}
void SecureContext::RegisterExternalReferences(
@@ -1448,6 +1703,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 +1841,7 @@ void SecureContext::Init(const FunctionCallbackInfo<Value>& 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 73aff5b628a..c2da04bbbb5 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<v8::Value> v);
class SecureContext final : public BaseObject {
@@ -154,6 +160,15 @@ class SecureContext final : public BaseObject {
template <bool primary>
static void GetCertificate(const v8::FunctionCallbackInfo<v8::Value>& 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<v8::Value>& args);
+ static void ClearBlockedSPKIsForTesting(
+ const v8::FunctionCallbackInfo<v8::Value>& 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 fbc5fc8c39c..ef258b0f330 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<GENERAL_NAMES, GENERAL_NAMES_free> names(
- static_cast<GENERAL_NAMES*>(
- 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 baf8647dda7..96f3ab459ec 100644
--- a/src/dtls/dtls_context.cc
+++ b/src/dtls/dtls_context.cc
@@ -6,6 +6,7 @@
#include <base_object-inl.h>
#include <crypto/crypto_bio.h>
#include <crypto/crypto_client_hello.h>
+#include <crypto/crypto_context.h>
#include <crypto/crypto_tls_certificates.h>
#include <crypto/crypto_util.h>
#include <env-inl.h>
@@ -215,6 +216,10 @@ void DTLSContext::New(const FunctionCallbackInfo<Value>& 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 b5106d27e12..081af891b99 100644
--- a/src/quic/tlscontext.cc
+++ b/src/quic/tlscontext.cc
@@ -2,6 +2,7 @@
#ifndef OPENSSL_NO_QUIC
#include <async_wrap-inl.h>
#include <base_object-inl.h>
+#include <crypto/crypto_context.h>
#include <crypto/crypto_tls_certificates.h>
#include <crypto/crypto_util.h>
#include <debug_utils-inl.h>
@@ -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 00000000000..a7d83e096a7
--- /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;
+ });
+});