Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added support for reading certificates from macOS system store #56599

Open
wants to merge 33 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f3c212c
feat: added support for reading certificates from macOS system store
timja Jan 13, 2025
884c300
Address review comments
timja Jan 15, 2025
57579c7
Use correct list when iterating
timja Jan 15, 2025
3a18890
Doc update
timja Jan 15, 2025
00e9da7
CoreFoundation include seems unneeded
timja Jan 15, 2025
1959175
Simplify code
timja Jan 15, 2025
7b0197f
Remove debugging function
timja Jan 15, 2025
438b6ed
Fix stray character
timja Jan 15, 2025
da9b740
Update doc/api/tls.md
timja Jan 15, 2025
8306360
Use LocalVector
timja Jan 15, 2025
42d41cf
Add CFRelease's, refactor domain searching
timja Jan 15, 2025
9cb41b0
Use X509View
timja Jan 16, 2025
69d176c
Remove unused function
timja Jan 16, 2025
19d30a0
More X509View
timja Jan 16, 2025
e525465
Much simpler comparison
timja Jan 17, 2025
13e8647
Add test
timja Jan 17, 2025
efc303b
Simplify
timja Jan 20, 2025
d4728a1
Format
timja Jan 20, 2025
4c1a3ef
Add comment
timja Jan 20, 2025
148187d
Use enum class
timja Jan 22, 2025
5bf95f6
Don't check trustRoot for non root certs
timja Jan 22, 2025
1dc93ca
Fmt
timja Jan 22, 2025
186378e
Fix test
timja Jan 22, 2025
aea63c4
Simplify code
timja Jan 22, 2025
9581c7d
Add license / doc
timja Jan 22, 2025
3ec1669
Fmt
timja Jan 22, 2025
a03500d
Remove unneeded new lines
timja Jan 22, 2025
382c7af
Check kSecPolicyAppleSSL
timja Jan 22, 2025
91869ce
Fix some casts
timja Jan 22, 2025
7da430c
Snakecase
timja Jan 22, 2025
8669766
Extract helper function
timja Jan 22, 2025
9e0041f
Improve test
timja Jan 22, 2025
80fe9e2
More casting
timja Jan 22, 2025
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
8 changes: 8 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -2809,6 +2809,13 @@ environment variables.

See `SSL_CERT_DIR` and `SSL_CERT_FILE`.

### `--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.

### `--use-largepages=mode`

<!-- YAML
Expand Down Expand Up @@ -3227,6 +3234,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`
Expand Down
3 changes: 3 additions & 0 deletions doc/api/tls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion node.gypi
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@
[ 'OS=="mac"', {
# linking Corefoundation is needed since certain macOS debugging tools
# like Instruments require it for some features
timja marked this conversation as resolved.
Show resolved Hide resolved
'libraries': [ '-framework CoreFoundation' ],
'libraries': [ '-framework CoreFoundation -framework Security' ],
'defines!': [
'NODE_PLATFORM="mac"',
],
Expand Down
278 changes: 273 additions & 5 deletions src/crypto/crypto_context.cc
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
#ifndef OPENSSL_NO_ENGINE
#include <openssl/engine.h>
#endif // !OPENSSL_NO_ENGINE
#ifdef __APPLE__
#include <Security/Security.h>
#endif


namespace node {

Expand All @@ -37,6 +41,7 @@ using v8::Integer;
using v8::Isolate;
using v8::JustVoid;
using v8::Local;
using v8::LocalVector;
using v8::Maybe;
using v8::Nothing;
using v8::Object;
Expand Down Expand Up @@ -222,6 +227,245 @@ unsigned long LoadCertsFromFile( // NOLINT(runtime/int)
}
}

enum TrustStatus { UNSPECIFIED, TRUSTED, DISTRUSTED };
timja marked this conversation as resolved.
Show resolved Hide resolved

char* bioToCString(BIOPointer* bio) {
jasnell marked this conversation as resolved.
Show resolved Hide resolved
const int len = BIO_pending(bio->get());
char* result = reinterpret_cast<char *>(calloc(len + 1, 1));
BIO_read(bio->get(), result, len);

return result;
}

char* getIssuer(ncrypto::X509View x509_view) {
jasnell marked this conversation as resolved.
Show resolved Hide resolved
auto bio = x509_view.getIssuer();

return bioToCString(&bio);
}

char* getSubject(ncrypto::X509View x509_view) {
auto bio = x509_view.getSubject();

return bioToCString(&bio);
}
timja marked this conversation as resolved.
Show resolved Hide resolved

bool IsSelfSigned(X509* cert) {
auto subject = X509_get_subject_name(cert);
auto issuer = X509_get_issuer_name(cert);

if (X509_NAME_cmp(subject, issuer) == 0) {
return true;
} else {
return false;
}
timja marked this conversation as resolved.
Show resolved Hide resolved
}

enum TrustStatus IsTrustDictionaryTrustedForPolicy(
timja marked this conversation as resolved.
Show resolved Hide resolved
CFDictionaryRef trust_dict
) {
// Trust settings may be scoped to a single application
// skip as this is not supported
if (CFDictionaryContainsKey(trust_dict, kSecTrustSettingsApplication)) {
return 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 UNSPECIFIED;
}

timja marked this conversation as resolved.
Show resolved Hide resolved
int trust_settings_result = kSecTrustSettingsResultTrustRoot;
if (CFDictionaryContainsKey(trust_dict, kSecTrustSettingsResult)) {
CFNumberRef trust_settings_result_ref = (CFNumberRef) CFDictionaryGetValue(
trust_dict, kSecTrustSettingsResult);

CFNumberGetValue(trust_settings_result_ref, kCFNumberIntType,
&trust_settings_result);

if (!trust_settings_result_ref) {
return UNSPECIFIED;
}

if (trust_settings_result == kSecTrustSettingsResultDeny) {
return DISTRUSTED;
}
return trust_settings_result == kSecTrustSettingsResultTrustRoot ||
timja marked this conversation as resolved.
Show resolved Hide resolved
trust_settings_result == kSecTrustSettingsResultTrustAsRoot ?
TRUSTED : UNSPECIFIED;
}

return UNSPECIFIED;
}

enum TrustStatus IsTrustSettingsTrustedForPolicy(CFArrayRef trustSettings,
timja marked this conversation as resolved.
Show resolved Hide resolved
bool isSelfIssued) {
// The trustSettings 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(trustSettings) == 0) {
if (isSelfIssued) {
return TRUSTED;
}
}

CFIndex trustSettingsCount = CFArrayGetCount(trustSettings);

for (CFIndex i = 0; i < trustSettingsCount ; ++i) {
CFDictionaryRef trustDict = (CFDictionaryRef) CFArrayGetValueAtIndex(
trustSettings, i);

enum TrustStatus trust = IsTrustDictionaryTrustedForPolicy(trustDict);

if (trust == DISTRUSTED) {
return trust;
} else if (trust == TRUSTED) {
return trust;
}
timja marked this conversation as resolved.
Show resolved Hide resolved
}
return UNSPECIFIED;
}

bool IsCertificateTrustValid(SecCertificateRef ref) {
SecTrustRef secTrust = nullptr;
CFMutableArrayRef subjCerts = CFArrayCreateMutable(
nullptr, 1, &kCFTypeArrayCallBacks);
CFArraySetValueAtIndex(subjCerts, 0, ref);

SecPolicyRef policy = SecPolicyCreateBasicX509();
OSStatus ortn = SecTrustCreateWithCertificates(subjCerts, policy, &secTrust);
bool result = false;
if (ortn) {
/* should never happen */
goto done;
}

result = SecTrustEvaluateWithError(secTrust, nullptr);
timja marked this conversation as resolved.
Show resolved Hide resolved
done:
if (policy) {
CFRelease(policy);
}
if (secTrust) {
CFRelease(secTrust);
}
if (subjCerts) {
CFRelease(subjCerts);
}
return result;
}

bool IsCertificateTrustedForPolicy(X509* cert, SecCertificateRef ref) {
OSStatus err;

bool trustEvaluated = false;
bool isSelfSigned = IsSelfSigned(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 trustSettings;
err = SecTrustSettingsCopyTrustSettings(ref, trust_domain, &trustSettings);

if (err == errSecSuccess && trustSettings != nullptr) {
TrustStatus result = IsTrustSettingsTrustedForPolicy(
trustSettings, isSelfSigned);
if (result != UNSPECIFIED) {
CFRelease(trustSettings);
return result == TRUSTED;
}
}

// An empty trust settings array isn’t the same as no trust settings,
// where the trustSettings parameter returns NULL.
// No trust-settings array means
// “this certificate must be verifiable using a known trusted certificate”.
if (trustSettings == nullptr && !trustEvaluated) {
Copy link
Member

@joyeecheung joyeecheung Jan 20, 2025

Choose a reason for hiding this comment

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

Isn't this going against what the docs says? The certificate is only to be trusted as root certificate if trustSettings is not null but points to an empty array? i.e. this should already be covered in IsTrustSettingsTrustedForPolicy?

Copy link
Author

Choose a reason for hiding this comment

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

No is trust settings won’t find it as there’s no trust settings. The validate is implementing the part of the statement must be validated by another certificate. This is for intermediate certificates that are not sent by the web server but are present in the OS keychain

Copy link
Member

Choose a reason for hiding this comment

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

In that case shouldn't this be removed from the global root certificate array? And verified using SecPolicyCreateSSL instead on a per-connection basis? IIUC this is taking all the intermediate certificates, doing a basic check with SecPolicyCreateBasicX509, and if it looks X509 compliant, add it to the global root certificate array, which seems unsafe..

Copy link
Author

Choose a reason for hiding this comment

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

SecTrustEvaluateWithError is validating that this is a trusted certificate.

Potentially, I suspect that’s what Chromium is doing although I didn’t find the code, intermediate certificates do work there

Copy link
Author

@timja timja Jan 22, 2025

Choose a reason for hiding this comment

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

As far as I can tell chromium stores intermediates here:
https://github.com/chromium/chromium/blob/main/net/cert/internal/trust_store_mac.cc#L823

The code is quite hard to follow.


When evaluating client certificates they do use SecTrustEvaluateWithError in order to get the full chain:
https://github.com/chromium/chromium/blob/98f89988c9774d0e138a0724aa64c46187203a77/net/ssl/client_cert_store_mac.cc#L83-L84

but I can't see the same for regular certificate validation.


I think this is compliant but let me know if you think there's a better way of doing it

bool result = IsCertificateTrustValid(ref);
if (result) {
return true;
}
// no point re-evaluating this in the admin domain
trustEvaluated = true;
} else {
if (trustSettings) {
CFRelease(trustSettings);
}
}
timja marked this conversation as resolved.
Show resolved Hide resolved
}
return false;
}

void ReadMacOSKeychainCertificates(
std::vector<std::string>* system_root_certificates) {
CFTypeRef searchKeys[] = { kSecClass, kSecMatchLimit, kSecReturnRef };
CFTypeRef searchValues[] = {
kSecClassCertificate, kSecMatchLimitAll, kCFBooleanTrue };
CFDictionaryRef search = CFDictionaryCreate(
kCFAllocatorDefault, searchKeys, searchValues, 3,
&kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);

CFArrayRef currAnchors = nullptr;
OSStatus ortn = SecItemCopyMatching(
search,
reinterpret_cast<CFTypeRef *>(&currAnchors));
CFRelease(search);

if (ortn) {
fprintf(stderr, "ERROR: SecItemCopyMatching failed %d\n", ortn);
jasnell marked this conversation as resolved.
Show resolved Hide resolved
}

CFIndex count = CFArrayGetCount(currAnchors);

std::vector<X509*> system_root_certificates_X509;
for (int i = 0; i < count ; ++i) {
SecCertificateRef certRef = (SecCertificateRef) CFArrayGetValueAtIndex(
currAnchors, i);
timja marked this conversation as resolved.
Show resolved Hide resolved

CFDataRef derData = SecCertificateCopyData(certRef);
if (!derData) {
fprintf(stderr, "ERROR: SecCertificateCopyData failed\n");
continue;
}
auto dataBufferPointer = CFDataGetBytePtr(derData);

X509* cert =
d2i_X509(nullptr, &dataBufferPointer, CFDataGetLength(derData));
CFRelease(derData);
bool isValid = IsCertificateTrustedForPolicy(cert, certRef);
if (isValid) {
system_root_certificates_X509.emplace_back(cert);
}
}
CFRelease(currAnchors);

for (size_t i = 0; i < system_root_certificates_X509.size(); i++) {
ncrypto::X509View x509_view(system_root_certificates_X509[i]);

auto pemBio = x509_view.toPEM();
if (!pemBio) {
fprintf(stderr, "Warning: converting to pem failed");
timja marked this conversation as resolved.
Show resolved Hide resolved
continue;
}

auto pemCString = bioToCString(&pemBio);
std::string certificate_string_pem = pemCString;
timja marked this conversation as resolved.
Show resolved Hide resolved

system_root_certificates->emplace_back(certificate_string_pem);
timja marked this conversation as resolved.
Show resolved Hide resolved
}
}

void ReadSystemStoreCertificates(
std::vector<std::string>* system_root_certificates) {
#ifdef __APPLE__
ReadMacOSKeychainCertificates(system_root_certificates);
#endif
}

X509_STORE* NewRootCertStore() {
static std::vector<X509*> root_certs_vector;
static bool root_certs_vector_loaded = false;
Expand All @@ -230,9 +474,21 @@ X509_STORE* NewRootCertStore() {

if (!root_certs_vector_loaded) {
if (per_process::cli_options->ssl_openssl_cert_store == false) {
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) {
timja marked this conversation as resolved.
Show resolved Hide resolved
ReadSystemStoreCertificates(&combined_root_certs);
}

for (size_t i = 0; i < combined_root_certs.size(); i++) {
X509* x509 = PEM_read_bio_X509(
NodeBIO::NewFixed(root_certs[i], strlen(root_certs[i])).get(),
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
Expand Down Expand Up @@ -282,19 +538,31 @@ X509_STORE* NewRootCertStore() {

void GetRootCertificates(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Local<Value> result[arraysize(root_certs)];
std::vector<std::string> combined_root_certs;
timja marked this conversation as resolved.
Show resolved Hide resolved

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

LocalVector<Value> result(env->isolate(), combined_root_certs.size());

for (size_t i = 0; i < combined_root_certs.size(); i++) {
if (!String::NewFromOneByte(
env->isolate(),
reinterpret_cast<const uint8_t*>(root_certs[i]))
.ToLocal(&result[i])) {
reinterpret_cast<const uint8_t*>(combined_root_certs[i].data()),
v8::NewStringType::kNormal,
combined_root_certs[i].size())
.ToLocal(&result[i])) {
return;
jasnell marked this conversation as resolved.
Show resolved Hide resolved
}
}

args.GetReturnValue().Set(
Array::New(env->isolate(), result, arraysize(root_certs)));
Array::New(env->isolate(), result.data(), combined_root_certs.size()));
}

bool SecureContext::HasInstance(Environment* env, const Local<Value>& value) {
Expand Down
4 changes: 4 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,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)
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,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;
Expand Down
3 changes: 3 additions & 0 deletions test/parallel/parallel.status
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading