Skip to content

Commit 01acf0e

Browse files
UlisesGasconaduh95
authored andcommitted
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: electron/electron#55126 Refs: golang/go#82345 Assisted-by: Claude, Codex Signed-off-by: Ulises Gascon <ulisesgascongonzalez@gmail.com> PR-URL: #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>
1 parent 1e39cd5 commit 01acf0e

5 files changed

Lines changed: 538 additions & 0 deletions

File tree

‎src/crypto/crypto_context.cc‎

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@
99
#include "ncrypto.h"
1010
#include "node.h"
1111
#include "node_buffer.h"
12+
#include "node_mutex.h"
1213
#include "node_options.h"
1314
#include "util.h"
1415
#include "v8.h"
1516

1617
#ifdef NODE_OPENSSL_HAS_CERT_COMP
1718
#include <openssl/comp.h>
1819
#endif
20+
#include <openssl/evp.h>
1921
#include <openssl/rand.h>
2022
#include <openssl/x509.h>
23+
#include <openssl/x509v3.h>
2124
#ifdef __APPLE__
2225
#include <Security/Security.h>
2326
#endif
@@ -27,6 +30,8 @@
2730
#include <wincrypt.h>
2831
#endif
2932

33+
#include <array>
34+
#include <cstring>
3035
#include <set>
3136

3237
namespace node {
@@ -1421,6 +1426,250 @@ Local<FunctionTemplate> SecureContext::GetConstructorTemplate(
14211426
return tmpl;
14221427
}
14231428

1429+
namespace {
1430+
1431+
constexpr size_t kSpkiHashLength = 32; // SHA-256
1432+
using SpkiHash = std::array<uint8_t, kSpkiHashLength>;
1433+
1434+
// SHA-256 hashes of SubjectPublicKeyInfos that must never appear in a
1435+
// verified chain, regardless of what the trust store says. Each entry is
1436+
// the digest of the DER-encoded SPKI (what i2d_X509_PUBKEY produces).
1437+
//
1438+
// These are the eMudhra/emSign intermediates whose private keys were
1439+
// reported compromised in October 2026. Node.js has no equivalent of the
1440+
// browsers' CRLSet/OneCRL and no default online revocation checking, so a
1441+
// chain through one of these keys would otherwise still verify as long as
1442+
// it reaches a bundled root. The same list is shipped by Go
1443+
// (CVE-2026-111943, golang/go#82345) and Electron (electron/electron#55126).
1444+
// Each entry is annotated with the certificate fingerprint and subject from
1445+
// the public revocation records, for cross-referencing with CCADB.
1446+
constexpr SpkiHash kBlockedSPKIs[] = {
1447+
// 0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19
1448+
// C=US, O=eMudhra Inc, CN=EM DV TLS CA - G3C
1449+
{0x49, 0xdd, 0x35, 0x3d, 0xc2, 0xb9, 0x19, 0xa3, 0x77, 0x06, 0x45,
1450+
0x11, 0x7d, 0x0e, 0x60, 0xc1, 0x61, 0x15, 0xe7, 0xd1, 0xbc, 0x25,
1451+
0x9e, 0xef, 0xe7, 0x34, 0xb0, 0x0e, 0xa4, 0x34, 0xfe, 0x5f},
1452+
// 432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3
1453+
// C=US, O=eMudhra Inc, CN=EM EV TLS CA - G3C
1454+
{0x5c, 0x06, 0x60, 0xa2, 0xb1, 0x64, 0x91, 0x0d, 0x8f, 0xf5, 0x89,
1455+
0xc1, 0x8e, 0x14, 0x55, 0x2e, 0x9e, 0x0e, 0xc2, 0x37, 0xdc, 0xef,
1456+
0xe2, 0xcb, 0x73, 0x6d, 0x41, 0xa4, 0x81, 0x1f, 0x81, 0xa0},
1457+
// cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f
1458+
// C=US, O=eMudhra Inc, CN=EM OV TLS CA - G3C
1459+
{0x63, 0xec, 0xef, 0xee, 0x43, 0x1d, 0x49, 0x52, 0x3b, 0xc1, 0x13,
1460+
0x6c, 0x4e, 0x72, 0xd0, 0x20, 0x6a, 0x47, 0x70, 0x86, 0xca, 0xf6,
1461+
0x06, 0xcd, 0x5c, 0xf0, 0x25, 0x68, 0x6c, 0x10, 0x9c, 0xf8},
1462+
// 4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1
1463+
// C=US, O=eMudhra Inc, CN=EM OV CS CA - G3C
1464+
{0x92, 0xc0, 0x63, 0xd6, 0x7e, 0x8f, 0x14, 0xdb, 0x71, 0xd2, 0x88,
1465+
0xe1, 0x12, 0x66, 0xff, 0xda, 0xf6, 0x9c, 0xbf, 0x11, 0x5f, 0x7b,
1466+
0xab, 0x14, 0xf3, 0xbc, 0x2f, 0x68, 0xd2, 0x5e, 0x0e, 0x30},
1467+
// 42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493
1468+
// C=NL, O=eMudhra BV, CN=EM DV TLS CA - G3B
1469+
{0xeb, 0xed, 0x64, 0x75, 0xc2, 0xc2, 0xd8, 0x0f, 0xcb, 0x19, 0x9f,
1470+
0x3f, 0xe8, 0xdd, 0x6e, 0x6b, 0x3c, 0xe6, 0x0b, 0x77, 0xeb, 0x15,
1471+
0xd4, 0xe4, 0x2c, 0x3d, 0xec, 0x0c, 0x6f, 0x5b, 0x51, 0x05},
1472+
// 9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4
1473+
// C=NL, O=eMudhra BV, CN=EM OV CS CA - G3B
1474+
{0xec, 0x4f, 0xfa, 0x1a, 0xd0, 0xac, 0x88, 0x08, 0x13, 0x14, 0xb7,
1475+
0xaa, 0xff, 0xef, 0x6a, 0x91, 0xf9, 0x7c, 0xd5, 0x72, 0x91, 0xbb,
1476+
0x5b, 0xa6, 0x86, 0xc5, 0x41, 0x5f, 0x5e, 0xb4, 0xf6, 0xb6},
1477+
// 98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b
1478+
// C=NL, O=eMudhra BV, CN=EM OV TLS CA - G3B
1479+
{0x43, 0xbf, 0xfa, 0x19, 0xd6, 0xef, 0x65, 0x14, 0x51, 0xf6, 0x84,
1480+
0x1e, 0x01, 0x99, 0x31, 0xe9, 0x6f, 0x6f, 0x2e, 0xae, 0xff, 0x28,
1481+
0xab, 0x4b, 0x6f, 0x1c, 0xe4, 0x57, 0x6d, 0xe2, 0x71, 0x7b},
1482+
// e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838
1483+
// C=NL, O=eMudhra BV, CN=EM EV TLS CA - G3B
1484+
{0xe0, 0x25, 0x4c, 0x7f, 0xcd, 0xc2, 0x65, 0xe2, 0xf0, 0xf8, 0xa4,
1485+
0x28, 0xda, 0xbb, 0x75, 0xab, 0x4a, 0xe0, 0x39, 0xb4, 0x87, 0x2d,
1486+
0x62, 0xf5, 0xee, 0x57, 0xf0, 0x87, 0x7d, 0x5a, 0x8a, 0x41},
1487+
};
1488+
1489+
// Extra entries registered by the test-only hooks on the crypto binding.
1490+
// The list is process-wide and TLS handshakes may run on worker threads, so
1491+
// every access goes through the mutex.
1492+
Mutex& BlockedSPKIsForTestingMutex() {
1493+
static Mutex mutex;
1494+
return mutex;
1495+
}
1496+
1497+
std::vector<SpkiHash>& BlockedSPKIsForTesting() {
1498+
static std::vector<SpkiHash> list;
1499+
return list;
1500+
}
1501+
1502+
bool IsBlockedSPKIForTesting(const SpkiHash& digest) {
1503+
Mutex::ScopedLock lock(BlockedSPKIsForTestingMutex());
1504+
for (const auto& blocked : BlockedSPKIsForTesting()) {
1505+
if (digest == blocked) return true;
1506+
}
1507+
return false;
1508+
}
1509+
1510+
enum class SpkiCheck { kAllowed, kBlocked, kError };
1511+
1512+
// Hashes the DER-encoded SubjectPublicKeyInfo of `cert` and looks it up in
1513+
// the blocklist. A failure to encode or hash is reported as kError rather
1514+
// than kAllowed so that it cannot be used to slip past the check.
1515+
SpkiCheck CheckSPKI(X509* cert) {
1516+
if (cert == nullptr) return SpkiCheck::kAllowed;
1517+
uint8_t* spki = nullptr;
1518+
int spki_len = i2d_X509_PUBKEY(X509_get_X509_PUBKEY(cert), &spki);
1519+
if (spki_len <= 0) return SpkiCheck::kError;
1520+
SpkiHash digest;
1521+
unsigned int digest_len = 0;
1522+
int ok = EVP_Digest(spki,
1523+
static_cast<size_t>(spki_len),
1524+
digest.data(),
1525+
&digest_len,
1526+
Digest::SHA256,
1527+
nullptr);
1528+
OPENSSL_free(spki);
1529+
if (ok != 1 || digest_len != digest.size()) return SpkiCheck::kError;
1530+
for (const auto& blocked : kBlockedSPKIs) {
1531+
if (digest == blocked) return SpkiCheck::kBlocked;
1532+
}
1533+
if (IsBlockedSPKIForTesting(digest)) return SpkiCheck::kBlocked;
1534+
return SpkiCheck::kAllowed;
1535+
}
1536+
1537+
// Checks every certificate in `certs`. On kBlocked or kError, `*offender`
1538+
// points at the certificate the result applies to and `*depth` is its index
1539+
// in `certs`.
1540+
SpkiCheck CheckSPKIs(const STACK_OF(X509) * certs,
1541+
X509** offender,
1542+
int* depth) {
1543+
if (certs == nullptr) return SpkiCheck::kAllowed;
1544+
for (int i = 0; i < sk_X509_num(certs); i++) {
1545+
X509* cert = sk_X509_value(certs, i);
1546+
SpkiCheck result = CheckSPKI(cert);
1547+
if (result != SpkiCheck::kAllowed) {
1548+
*offender = cert;
1549+
*depth = i;
1550+
return result;
1551+
}
1552+
}
1553+
return SpkiCheck::kAllowed;
1554+
}
1555+
1556+
// Installed with SSL_CTX_set_cert_verify_callback on every SSL_CTX Node.js
1557+
// creates. It wraps X509_verify_cert so that a chain through a blocked
1558+
// public key is reported as X509_V_ERR_CERT_REVOKED even when the trust
1559+
// store would accept it. The built chain is always checked; when
1560+
// verification did not fully succeed (TLSWrap's per-certificate callback
1561+
// continues past errors so JS can apply rejectUnauthorized, leaving the
1562+
// error set on the context), the leaf and every peer-sent certificate are
1563+
// checked too, so the blocklist takes precedence over the original error.
1564+
//
1565+
// A hit is reported like any other X509_verify_cert error: the error and
1566+
// offending certificate are recorded on the context and the per-certificate
1567+
// verify callback, if any, decides whether the handshake continues. OpenSSL
1568+
// reads the error back into the verify result either way, so
1569+
// socket.authorizationError reports CERT_REVOKED.
1570+
//
1571+
// This is the only cert-verify callback Node.js installs; keep any other
1572+
// per-chain adjustments here, since a second registration would replace it.
1573+
int CertVerifyCallback(X509_STORE_CTX* ctx, void*) {
1574+
#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(4, 1)
1575+
X509_VERIFY_PARAM* param = X509_STORE_CTX_get0_param(ctx);
1576+
const unsigned int flags = X509_VERIFY_PARAM_get_hostflags(param);
1577+
if (!(flags & (X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT |
1578+
X509_CHECK_FLAG_NEVER_CHECK_SUBJECT))) {
1579+
// tls.checkServerIdentity() falls back to the CN when no DNS SAN exists.
1580+
// OpenSSL 4.1 requires this flag to apply name constraints to that CN.
1581+
ncrypto::DeleteFnPtr<GENERAL_NAMES, GENERAL_NAMES_free> names(
1582+
static_cast<GENERAL_NAMES*>(
1583+
X509_get_ext_d2i(X509_STORE_CTX_get0_cert(ctx),
1584+
NID_subject_alt_name,
1585+
nullptr,
1586+
nullptr)));
1587+
bool has_dns_san = false;
1588+
for (int i = 0; names && i < sk_GENERAL_NAME_num(names.get()); i++) {
1589+
if (sk_GENERAL_NAME_value(names.get(), i)->type == GEN_DNS) {
1590+
has_dns_san = true;
1591+
break;
1592+
}
1593+
}
1594+
if (!has_dns_san) {
1595+
X509_VERIFY_PARAM_set_hostflags(
1596+
param, flags | X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT);
1597+
}
1598+
}
1599+
#endif
1600+
1601+
int ok = X509_verify_cert(ctx) > 0 ? 1 : 0;
1602+
const bool verified = ok == 1 && X509_STORE_CTX_get_error(ctx) == X509_V_OK;
1603+
1604+
// The depth is the offender's index in the built chain (0 = leaf), as
1605+
// X509_verify_cert would report it. For certificates found only in the
1606+
// peer-sent stack their index there is the closest equivalent.
1607+
X509* offender = nullptr;
1608+
int depth = 0;
1609+
SpkiCheck result =
1610+
CheckSPKIs(X509_STORE_CTX_get0_chain(ctx), &offender, &depth);
1611+
if (result == SpkiCheck::kAllowed && !verified) {
1612+
X509* leaf = X509_STORE_CTX_get0_cert(ctx);
1613+
result = CheckSPKI(leaf);
1614+
if (result != SpkiCheck::kAllowed) {
1615+
offender = leaf;
1616+
depth = 0;
1617+
} else {
1618+
result =
1619+
CheckSPKIs(X509_STORE_CTX_get0_untrusted(ctx), &offender, &depth);
1620+
}
1621+
}
1622+
if (result == SpkiCheck::kAllowed) return ok;
1623+
1624+
X509_STORE_CTX_set_error(ctx,
1625+
result == SpkiCheck::kBlocked
1626+
? X509_V_ERR_CERT_REVOKED
1627+
: X509_V_ERR_UNSPECIFIED);
1628+
#ifdef OPENSSL_IS_BORINGSSL
1629+
// BoringSSL has no setters for the error depth and current certificate.
1630+
// Node.js' own verify callbacks do not read them, so only the error is
1631+
// recorded there.
1632+
(void)depth;
1633+
(void)offender;
1634+
#else
1635+
X509_STORE_CTX_set_error_depth(ctx, depth);
1636+
X509_STORE_CTX_set_current_cert(ctx, offender);
1637+
#endif
1638+
// The per-certificate callback is the one carried by the SSL object; it is
1639+
// what the library installs on the store context before calling this hook.
1640+
SSL* ssl = static_cast<SSL*>(
1641+
X509_STORE_CTX_get_ex_data(ctx, SSL_get_ex_data_X509_STORE_CTX_idx()));
1642+
auto verify_cb = ssl != nullptr ? SSL_get_verify_callback(ssl) : nullptr;
1643+
if (verify_cb == nullptr) return 0;
1644+
return verify_cb(0, ctx) ? 1 : 0;
1645+
}
1646+
1647+
} // namespace
1648+
1649+
void InstallCertVerifyCallback(SSL_CTX* ctx) {
1650+
SSL_CTX_set_cert_verify_callback(ctx, CertVerifyCallback, nullptr);
1651+
}
1652+
1653+
void SecureContext::AddBlockedSPKIForTesting(
1654+
const FunctionCallbackInfo<Value>& args) {
1655+
Environment* env = Environment::GetCurrent(args);
1656+
THROW_AND_RETURN_IF_NOT_BUFFER(env, args[0], "SPKI hash");
1657+
ArrayBufferOrViewContents<uint8_t> hash(args[0]);
1658+
if (hash.size() != kSpkiHashLength) {
1659+
return THROW_ERR_OUT_OF_RANGE(env, "SPKI hash must be 32 bytes");
1660+
}
1661+
SpkiHash entry;
1662+
memcpy(entry.data(), hash.data(), entry.size());
1663+
Mutex::ScopedLock lock(BlockedSPKIsForTestingMutex());
1664+
BlockedSPKIsForTesting().push_back(entry);
1665+
}
1666+
1667+
void SecureContext::ClearBlockedSPKIsForTesting(
1668+
const FunctionCallbackInfo<Value>& args) {
1669+
Mutex::ScopedLock lock(BlockedSPKIsForTestingMutex());
1670+
BlockedSPKIsForTesting().clear();
1671+
}
1672+
14241673
void SecureContext::Initialize(Environment* env, Local<Object> target) {
14251674
Local<Context> context = env->context();
14261675
SetConstructorFunction(context,
@@ -1448,6 +1697,12 @@ void SecureContext::Initialize(Environment* env, Local<Object> target) {
14481697
target,
14491698
"getCertificateCompressionAlgorithms",
14501699
GetCertificateCompressionAlgorithms);
1700+
SetMethod(
1701+
context, target, "addBlockedSPKIForTesting", AddBlockedSPKIForTesting);
1702+
SetMethod(context,
1703+
target,
1704+
"clearBlockedSPKIsForTesting",
1705+
ClearBlockedSPKIsForTesting);
14511706
}
14521707

14531708
void SecureContext::RegisterExternalReferences(
@@ -1495,6 +1750,8 @@ void SecureContext::RegisterExternalReferences(
14951750
registry->Register(GetUserRootCertificates);
14961751
registry->Register(StartLoadingCertificatesOffThread);
14971752
registry->Register(GetCertificateCompressionAlgorithms);
1753+
registry->Register(AddBlockedSPKIForTesting);
1754+
registry->Register(ClearBlockedSPKIsForTesting);
14981755
}
14991756

15001757
SecureContext* SecureContext::Create(Environment* env) {
@@ -1631,6 +1888,7 @@ void SecureContext::Init(const FunctionCallbackInfo<Value>& args) {
16311888

16321889
env->external_memory_accounter()->Increase(env->isolate(), kExternalSize);
16331890
SSL_CTX_set_app_data(sc->ctx_.get(), sc);
1891+
InstallCertVerifyCallback(sc->ctx_.get());
16341892

16351893
// OpenSSL populates cert_comp_prefs with all available algorithms by
16361894
// default when compression libraries are linked. Clear them so that

‎src/crypto/crypto_context.h‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ X509_STORE* NewRootCertStore(Environment* env);
2727

2828
X509_STORE* GetOrCreateRootCertStore(Environment* env);
2929

30+
// Installs the certificate verification callback that applies the SPKI
31+
// blocklist (and, on OpenSSL 4.1+, the CN name-constraint handling) to every
32+
// chain verified through `ctx`. Must be called for every SSL_CTX Node.js
33+
// creates, since SSL_CTX_set_cert_verify_callback replaces any earlier hook.
34+
void InstallCertVerifyCallback(SSL_CTX* ctx);
35+
3036
ncrypto::BIOPointer LoadBIO(Environment* env, v8::Local<v8::Value> v);
3137

3238
class SecureContext final : public BaseObject {
@@ -160,6 +166,15 @@ class SecureContext final : public BaseObject {
160166
template <bool primary>
161167
static void GetCertificate(const v8::FunctionCallbackInfo<v8::Value>& args);
162168

169+
// Test-only hooks for the SPKI blocklist that every SSL_CTX applies
170+
// during certificate verification. Reachable through
171+
// internalBinding('crypto') with --expose-internals. The argument is the
172+
// SHA-256 of a DER-encoded SubjectPublicKeyInfo.
173+
static void AddBlockedSPKIForTesting(
174+
const v8::FunctionCallbackInfo<v8::Value>& args);
175+
static void ClearBlockedSPKIsForTesting(
176+
const v8::FunctionCallbackInfo<v8::Value>& args);
177+
163178
static int TicketKeyCallback(SSL* ssl,
164179
unsigned char* name,
165180
unsigned char* iv,

‎src/crypto/crypto_tls.cc‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,10 @@ int TLSExtStatusCallback(SSL* s, void* arg) {
350350
void ConfigureSecureContext(SecureContext* sc) {
351351
// OCSP stapling
352352
sc->ctx().setStatusCallback(TLSExtStatusCallback);
353+
// The cert-verify callback (SPKI blocklist and the OpenSSL 4.1 CN name
354+
// constraint handling) is installed once per SSL_CTX in
355+
// SecureContext::Init, so that it also covers contexts created without a
356+
// TLSWrap.
353357
}
354358

355359
inline bool Set(

‎src/quic/tlscontext.cc‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#ifndef OPENSSL_NO_QUIC
44
#include <async_wrap-inl.h>
55
#include <base_object-inl.h>
6+
#include <crypto/crypto_context.h>
67
#include <crypto/crypto_util.h>
78
#include <debug_utils-inl.h>
89
#include <env-inl.h>
@@ -534,6 +535,11 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
534535
if (options_.ca.empty()) {
535536
SSL_CTX_set_default_verify_paths(ctx.get());
536537
}
538+
539+
// QUIC builds its SSL_CTX here instead of through SecureContext, so the
540+
// chain verification hook shared with TLS has to be installed explicitly.
541+
crypto::InstallCertVerifyCallback(ctx.get());
542+
537543
if (options_.keylog) {
538544
SSL_CTX_set_keylog_callback(ctx.get(), OnKeylog);
539545
}

0 commit comments

Comments
 (0)