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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 258 additions & 0 deletions src/crypto/crypto_context.cc
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@
#include "ncrypto.h"
#include "node.h"
#include "node_buffer.h"
#include "node_mutex.h"
#include "node_options.h"
#include "util.h"
#include "v8.h"

#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
Expand All @@ -28,6 +31,8 @@
#include <wincrypt.h>
#endif

#include <array>
#include <cstring>
#include <set>

namespace node {
Expand Down Expand Up @@ -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());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This puts a mutex into every TLS handshake, to check a list which is guaranteed empty anywhere except our test suite. It's not a huge cost compared to a whole TLS handshake, but it's not free, especially if there's any actual contention (worker threads doing TLS).

Doesn't need to block this, but there might be nicer ways to test this without this list and mutex and extra internal APIs. We could just generate a fresh intermediate for our test suite for example and include its SPKI in the real revoked list. That'd be cheaper, no locking, and we could drop the internal test APIs etc that this adds. Having a revoked example we can test with is probably quite useful generally.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will work on a separate PR for this 👍

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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/crypto/crypto_context.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 4 additions & 33 deletions src/crypto/crypto_tls.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading