crypto: added support for reading certificates from macOS system store

PR-URL: https://github.com/nodejs/node/pull/56599
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Joyee Cheung <joyeec9h3@gmail.com>
This commit is contained in:
Tim Jacomb 2025-01-28 15:54:50 +00:00 committed by GitHub
parent 64ee8a0258
commit efe698ee93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 388 additions and 9 deletions

View file

@ -2861,6 +2861,13 @@ The following values are valid for `mode`:
* `silent`: If supported by the OS, mapping will be attempted. Failure to map
will be ignored and will not be reported.
### `--use-system-ca`
Node.js uses the trusted CA certificates present in the system store along with
the `--use-bundled-ca`, `--use-openssl-ca` options.
This option is available to macOS only.
### `--v8-options`
<!-- YAML
@ -3260,6 +3267,7 @@ one is included in the list below.
* `--use-bundled-ca`
* `--use-largepages`
* `--use-openssl-ca`
* `--use-system-ca`
* `--v8-pool-size`
* `--watch-path`
* `--watch-preserve-output`

View file

@ -2400,6 +2400,9 @@ from the bundled Mozilla CA store as supplied by the current Node.js version.
The bundled CA store, as supplied by Node.js, is a snapshot of Mozilla CA store
that is fixed at release time. It is identical on all supported platforms.
On macOS if `--use-system-ca` is passed then trusted certificates
from the user and system keychains are also included.
## `tls.DEFAULT_ECDH_CURVE`
<!-- YAML

View file

@ -238,8 +238,9 @@
[ 'OS=="mac"', {
# linking Corefoundation is needed since certain macOS debugging tools
# like Instruments require it for some features
'libraries': [ '-framework CoreFoundation' ],
# like Instruments require it for some features. Security is needed for
# --use-system-ca.
'libraries': [ '-framework CoreFoundation -framework Security' ],
'defines!': [
'NODE_PLATFORM="mac"',
],

View file

@ -18,6 +18,9 @@
#ifndef OPENSSL_NO_ENGINE
#include <openssl/engine.h>
#endif // !OPENSSL_NO_ENGINE
#ifdef __APPLE__
#include <Security/Security.h>
#endif
namespace node {
@ -232,6 +235,306 @@ unsigned long LoadCertsFromFile( // NOLINT(runtime/int)
}
}
// Indicates the trust status of a certificate.
enum class TrustStatus {
// Trust status is unknown / uninitialized.
UNKNOWN,
// Certificate inherits trust value from its issuer. If the certificate is the
// root of the chain, this implies distrust.
UNSPECIFIED,
// Certificate is a trust anchor.
TRUSTED,
// Certificate is blocked / explicitly distrusted.
DISTRUSTED
};
bool isSelfIssued(X509* cert) {
auto subject = X509_get_subject_name(cert);
auto issuer = X509_get_issuer_name(cert);
return X509_NAME_cmp(subject, issuer) == 0;
}
#ifdef __APPLE__
// This code is loosely based on
// https://github.com/chromium/chromium/blob/54bd8e3/net/cert/internal/trust_store_mac.cc
// Copyright 2015 The Chromium Authors
// Licensed under a BSD-style license
// See https://chromium.googlesource.com/chromium/src/+/HEAD/LICENSE for
// details.
TrustStatus IsTrustDictionaryTrustedForPolicy(CFDictionaryRef trust_dict,
bool is_self_issued) {
// Trust settings may be scoped to a single application
// skip as this is not supported
if (CFDictionaryContainsKey(trust_dict, kSecTrustSettingsApplication)) {
return TrustStatus::UNSPECIFIED;
}
// Trust settings may be scoped using policy-specific constraints. For
// example, SSL trust settings might be scoped to a single hostname, or EAP
// settings specific to a particular WiFi network.
// As this is not presently supported, skip any policy-specific trust
// settings.
if (CFDictionaryContainsKey(trust_dict, kSecTrustSettingsPolicyString)) {
return TrustStatus::UNSPECIFIED;
}
// If the trust settings are scoped to a specific policy (via
// kSecTrustSettingsPolicy), ensure that the policy is the same policy as
// |kSecPolicyAppleSSL|. If there is no kSecTrustSettingsPolicy key, it's
// considered a match for all policies.
if (CFDictionaryContainsKey(trust_dict, kSecTrustSettingsPolicy)) {
SecPolicyRef policy_ref = reinterpret_cast<SecPolicyRef>(const_cast<void*>(
CFDictionaryGetValue(trust_dict, kSecTrustSettingsPolicy)));
if (!policy_ref) {
return TrustStatus::UNSPECIFIED;
}
CFDictionaryRef policy_dict(SecPolicyCopyProperties(policy_ref));
// kSecPolicyOid is guaranteed to be present in the policy dictionary.
CFStringRef policy_oid = reinterpret_cast<CFStringRef>(
const_cast<void*>(CFDictionaryGetValue(policy_dict, kSecPolicyOid)));
if (!CFEqual(policy_oid, kSecPolicyAppleSSL)) {
return TrustStatus::UNSPECIFIED;
}
}
int trust_settings_result = kSecTrustSettingsResultTrustRoot;
if (CFDictionaryContainsKey(trust_dict, kSecTrustSettingsResult)) {
CFNumberRef trust_settings_result_ref =
reinterpret_cast<CFNumberRef>(const_cast<void*>(
CFDictionaryGetValue(trust_dict, kSecTrustSettingsResult)));
if (!trust_settings_result_ref ||
!CFNumberGetValue(trust_settings_result_ref,
kCFNumberIntType,
&trust_settings_result)) {
return TrustStatus::UNSPECIFIED;
}
if (trust_settings_result == kSecTrustSettingsResultDeny) {
return TrustStatus::DISTRUSTED;
}
// This is a bit of a hack: if the cert is self-issued allow either
// kSecTrustSettingsResultTrustRoot or kSecTrustSettingsResultTrustAsRoot on
// the basis that SecTrustSetTrustSettings should not allow creating an
// invalid trust record in the first place. (The spec is that
// kSecTrustSettingsResultTrustRoot can only be applied to root(self-signed)
// certs and kSecTrustSettingsResultTrustAsRoot is used for other certs.)
// This hack avoids having to check the signature on the cert which is slow
// if using the platform APIs, and may require supporting MD5 signature
// algorithms on some older OSX versions or locally added roots, which is
// undesirable in the built-in signature verifier.
if (is_self_issued) {
return trust_settings_result == kSecTrustSettingsResultTrustRoot ||
trust_settings_result == kSecTrustSettingsResultTrustAsRoot
? TrustStatus::TRUSTED
: TrustStatus::UNSPECIFIED;
}
// kSecTrustSettingsResultTrustAsRoot can only be applied to non-root certs.
return (trust_settings_result == kSecTrustSettingsResultTrustAsRoot)
? TrustStatus::TRUSTED
: TrustStatus::UNSPECIFIED;
}
return TrustStatus::UNSPECIFIED;
}
TrustStatus IsTrustSettingsTrustedForPolicy(CFArrayRef trust_settings,
bool is_self_issued) {
// The trust_settings parameter can return a valid but empty CFArrayRef.
// This empty trust-settings array means “always trust this certificate”
// with an overall trust setting for the certificate of
// kSecTrustSettingsResultTrustRoot
if (CFArrayGetCount(trust_settings) == 0) {
return is_self_issued ? TrustStatus::TRUSTED : TrustStatus::UNSPECIFIED;
}
for (CFIndex i = 0; i < CFArrayGetCount(trust_settings); ++i) {
CFDictionaryRef trust_dict = reinterpret_cast<CFDictionaryRef>(
const_cast<void*>(CFArrayGetValueAtIndex(trust_settings, i)));
TrustStatus trust =
IsTrustDictionaryTrustedForPolicy(trust_dict, is_self_issued);
if (trust == TrustStatus::DISTRUSTED || trust == TrustStatus::TRUSTED) {
return trust;
}
}
return TrustStatus::UNSPECIFIED;
}
bool IsCertificateTrustValid(SecCertificateRef ref) {
SecTrustRef sec_trust = nullptr;
CFMutableArrayRef subj_certs =
CFArrayCreateMutable(nullptr, 1, &kCFTypeArrayCallBacks);
CFArraySetValueAtIndex(subj_certs, 0, ref);
SecPolicyRef policy = SecPolicyCreateSSL(false, nullptr);
OSStatus ortn =
SecTrustCreateWithCertificates(subj_certs, policy, &sec_trust);
bool result = false;
if (ortn) {
/* should never happen */
} else {
result = SecTrustEvaluateWithError(sec_trust, nullptr);
}
if (policy) {
CFRelease(policy);
}
if (sec_trust) {
CFRelease(sec_trust);
}
if (subj_certs) {
CFRelease(subj_certs);
}
return result;
}
bool IsCertificateTrustedForPolicy(X509* cert, SecCertificateRef ref) {
OSStatus err;
bool trust_evaluated = false;
bool is_self_issued = isSelfIssued(cert);
// Evaluate user trust domain, then admin. User settings can override
// admin (and both override the system domain, but we don't check that).
for (const auto& trust_domain :
{kSecTrustSettingsDomainUser, kSecTrustSettingsDomainAdmin}) {
CFArrayRef trust_settings = nullptr;
err = SecTrustSettingsCopyTrustSettings(ref, trust_domain, &trust_settings);
if (err != errSecSuccess && err != errSecItemNotFound) {
fprintf(stderr,
"ERROR: failed to copy trust settings of system certificate%d\n",
err);
continue;
}
if (err == errSecSuccess && trust_settings != nullptr) {
TrustStatus result =
IsTrustSettingsTrustedForPolicy(trust_settings, is_self_issued);
if (result != TrustStatus::UNSPECIFIED) {
CFRelease(trust_settings);
return result == TrustStatus::TRUSTED;
}
}
// An empty trust settings array isnt the same as no trust settings,
// where the trust_settings parameter returns NULL.
// No trust-settings array means
// “this certificate must be verifiable using a known trusted certificate”.
if (trust_settings == nullptr && !trust_evaluated) {
bool result = IsCertificateTrustValid(ref);
if (result) {
return true;
}
// no point re-evaluating this in the admin domain
trust_evaluated = true;
} else if (trust_settings) {
CFRelease(trust_settings);
}
}
return false;
}
void ReadMacOSKeychainCertificates(
std::vector<std::string>* system_root_certificates) {
CFTypeRef search_keys[] = {kSecClass, kSecMatchLimit, kSecReturnRef};
CFTypeRef search_values[] = {
kSecClassCertificate, kSecMatchLimitAll, kCFBooleanTrue};
CFDictionaryRef search = CFDictionaryCreate(kCFAllocatorDefault,
search_keys,
search_values,
3,
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks);
CFArrayRef curr_anchors = nullptr;
OSStatus ortn =
SecItemCopyMatching(search, reinterpret_cast<CFTypeRef*>(&curr_anchors));
CFRelease(search);
if (ortn) {
fprintf(stderr, "ERROR: SecItemCopyMatching failed %d\n", ortn);
}
CFIndex count = CFArrayGetCount(curr_anchors);
std::vector<X509*> system_root_certificates_X509;
for (int i = 0; i < count; ++i) {
SecCertificateRef cert_ref = reinterpret_cast<SecCertificateRef>(
const_cast<void*>(CFArrayGetValueAtIndex(curr_anchors, i)));
CFDataRef der_data = SecCertificateCopyData(cert_ref);
if (!der_data) {
fprintf(stderr, "ERROR: SecCertificateCopyData failed\n");
continue;
}
auto data_buffer_pointer = CFDataGetBytePtr(der_data);
X509* cert =
d2i_X509(nullptr, &data_buffer_pointer, CFDataGetLength(der_data));
CFRelease(der_data);
bool is_valid = IsCertificateTrustedForPolicy(cert, cert_ref);
if (is_valid) {
system_root_certificates_X509.emplace_back(cert);
}
}
CFRelease(curr_anchors);
for (size_t i = 0; i < system_root_certificates_X509.size(); i++) {
ncrypto::X509View x509_view(system_root_certificates_X509[i]);
auto pem_bio = x509_view.toPEM();
if (!pem_bio) {
fprintf(stderr,
"Warning: converting system certificate to PEM format failed\n");
continue;
}
char* pem_data = nullptr;
auto pem_size = BIO_get_mem_data(pem_bio.get(), &pem_data);
if (pem_size <= 0 || !pem_data) {
fprintf(
stderr,
"Warning: cannot read PEM-encoded data from system certificate\n");
continue;
}
std::string certificate_string_pem(pem_data, pem_size);
system_root_certificates->emplace_back(certificate_string_pem);
}
}
#endif // __APPLE__
void ReadSystemStoreCertificates(
std::vector<std::string>* system_root_certificates) {
#ifdef __APPLE__
ReadMacOSKeychainCertificates(system_root_certificates);
#endif
}
std::vector<std::string> getCombinedRootCertificates() {
std::vector<std::string> combined_root_certs;
for (size_t i = 0; i < arraysize(root_certs); i++) {
combined_root_certs.emplace_back(root_certs[i]);
}
if (per_process::cli_options->use_system_ca) {
ReadSystemStoreCertificates(&combined_root_certs);
}
return combined_root_certs;
}
X509_STORE* NewRootCertStore() {
static std::vector<X509*> root_certs_vector;
static bool root_certs_vector_loaded = false;
@ -240,12 +543,17 @@ X509_STORE* NewRootCertStore() {
if (!root_certs_vector_loaded) {
if (per_process::cli_options->ssl_openssl_cert_store == false) {
for (size_t i = 0; i < arraysize(root_certs); i++) {
X509* x509 = PEM_read_bio_X509(
NodeBIO::NewFixed(root_certs[i], strlen(root_certs[i])).get(),
nullptr, // no re-use of X509 structure
NoPasswordCallback,
nullptr); // no callback data
std::vector<std::string> combined_root_certs =
getCombinedRootCertificates();
for (size_t i = 0; i < combined_root_certs.size(); i++) {
X509* x509 =
PEM_read_bio_X509(NodeBIO::NewFixed(combined_root_certs[i].data(),
combined_root_certs[i].length())
.get(),
nullptr, // no re-use of X509 structure
NoPasswordCallback,
nullptr); // no callback data
// Parse errors from the built-in roots are fatal.
CHECK_NOT_NULL(x509);

View file

@ -1120,6 +1120,10 @@ PerProcessOptionsParser::PerProcessOptionsParser(
,
&PerProcessOptions::use_openssl_ca,
kAllowedInEnvvar);
AddOption("--use-system-ca",
"use system's CA store",
&PerProcessOptions::use_system_ca,
kAllowedInEnvvar);
AddOption("--use-bundled-ca",
"use bundled CA store"
#if !defined(NODE_OPENSSL_CERT_STORE)

View file

@ -341,6 +341,7 @@ class PerProcessOptions : public Options {
bool ssl_openssl_cert_store = false;
#endif
bool use_openssl_ca = false;
bool use_system_ca = false;
bool use_bundled_ca = false;
bool enable_fips_crypto = false;
bool force_fips_crypto = false;

View file

@ -19,6 +19,9 @@ test-fs-read-stream-concurrent-reads: PASS, FLAKY
# https://github.com/nodejs/build/issues/3043
test-snapshot-incompatible: SKIP
# Requires manual setup for certificates to be trusted by the system
test-native-certs-macos: SKIP
[$system==win32]
# https://github.com/nodejs/node/issues/54808
test-async-context-frame: PASS, FLAKY

View file

@ -67,6 +67,9 @@ if (common.hasCrypto) {
expectNoWorker('--use-bundled-ca', 'B\n');
if (!hasOpenSSL3)
expectNoWorker('--openssl-config=_ossl_cfg', 'B\n');
if (common.isMacOS) {
expectNoWorker('--use-system-ca', 'B\n');
}
}
// V8 options

View file

@ -26,7 +26,7 @@ function validateNodePrintHelp() {
const cliHelpOptions = [
{ compileConstant: HAVE_OPENSSL,
flags: [ '--openssl-config=...', '--tls-cipher-list=...',
'--use-bundled-ca', '--use-openssl-ca',
'--use-bundled-ca', '--use-openssl-ca', '--use-system-ca',
'--enable-fips', '--force-fips' ] },
{ compileConstant: NODE_HAVE_I18N_SUPPORT,
flags: [ '--icu-data-dir=...', 'NODE_ICU_DATA' ] },

View file

@ -0,0 +1,47 @@
// Flags: --use-system-ca
import * as common from '../common/index.mjs';
import assert from 'node:assert/strict';
import https from 'node:https';
import fixtures from '../common/fixtures.js';
import { it, beforeEach, afterEach, describe } from 'node:test';
import { once } from 'events';
const handleRequest = (req, res) => {
const path = req.url;
switch (path) {
case '/hello-world':
res.writeHead(200);
res.end('hello world\n');
break;
default:
assert(false, `Unexpected path: ${path}`);
}
};
describe('use-system-ca', { skip: !common.isMacOS }, function() {
let server;
beforeEach(async function() {
server = https.createServer({
key: fixtures.readKey('agent8-key.pem'),
cert: fixtures.readKey('agent8-cert.pem'),
}, handleRequest);
server.listen(0);
await once(server, 'listening');
});
it('can connect successfully with a trusted certificate', async function() {
// Requires trusting the CA certificate first (which needs an interactive GUI approval, e.g. TouchID):
// security add-trusted-cert \
// -k /Users/$USER/Library/Keychains/login.keychain-db test/fixtures/keys/fake-startcom-root-cert.pem
// To remove:
// security delete-certificate -c 'StartCom Certification Authority' \
// -t /Users/$USER/Library/Keychains/login.keychain-db
await fetch(`https://localhost:${server.address().port}/hello-world`);
});
afterEach(async function() {
server?.close();
});
});

View file

@ -61,6 +61,7 @@ const conditionalOpts = [
'--tls-cipher-list',
'--use-bundled-ca',
'--use-openssl-ca',
common.isMacOS ? '--use-system-ca' : '',
'--secure-heap',
'--secure-heap-min',
'--enable-fips',