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;
+  });
+});