Repository: nodejs/node
Pull request: crypto: support --use-system-ca on non-Windows and non-macOS

crypto: support --use-system-ca on non-Windows and non-macOS

On other platforms, load from the OpenSSL default certificate
file and diretory.
This is different from --use-openssl-ca in that it caches
the certificates on first load, instead of always reading
from disk every time a new root store is needed.

When used together with the statically-linked OpenSSL, the
default configuration usually leads to this behavior:

- If SSL_CERT_FILE is used, load from SSL_CERT_FILE. Otherwise
  load from /etc/ssl/cert.pem
- If SSL_CERT_DIR is used, load from all the files under
  SSL_CERT_DIR. Otherwise, load from all the files under
  /etc/ssl/certs

I've only checked Ubuntu and RHEL so far. It may be worth checking whether this works on other popular distributions - from what I can tell, though, it seems the hard-coded configurations always leads to default locations under `/etc/ssl/`, which seems to be a location that most Linux distros would either use or link to anyway - for example, Ubuntu symlinks all the managed certificates to files under `/etc/ssl/certs`, while RHEL 9 just bundles all managed certificates to `/etc/ssl/certs/ca-bundle.crt`, so the current approach would usually just work.

If this somehow turns out to be sufficient, we can come back adding specific fallbacks for other systems similar to [what go does](https://go.dev/src/crypto/x509/root_linux.go) which is basically listing all the directories that are known to be used for this purpose - but from what I can tell this can be slow and lead to duplicates since they might get symlinked to each other, so ideally if `X509_get_default_cert_dir()` and `X509_get_default_cert_file()` works on a platform, it's better not to look further to avoid unnecessary costs.

There is also the question of whether we should use NSS shared DB, like what Chrome does, but since most command line tools rely on the directory/file-based system-wide storage instead (like go listed above, and e.g. every rust tool using the rustls-native-certs crate), and it seems to be losing the point if we statically link NSS, I didn't go with this route. In any case it seems various distros already consolidates the NSS shared DB somehow into these default locations when they manage their certificate stores, or at least NSS DB can be loaded via environment variable overrides, so the current approach looks good enough.

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: doc/api/cli.md
patch: @@ -2868,12 +2868,15 @@ The following values are valid for `mode`:
 ### `--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.
+the `--use-bundled-ca` option and the `NODE_EXTRA_CA_CERTS` environment variable.
+On platforms other than Windows and macOS, this loads certificates from the directory
+and file trusted by OpenSSL, similar to `--use-openssl-ca`, with the difference being
+that it caches the certificates after first load.
 
-This option is only supported on Windows and macOS, and the certificate trust policy
-is planned to follow [Chromium's policy for locally trusted certificates][]:
+On Windows and macOS, the certificate trust policy is planned to follow
+[Chromium's policy for locally trusted certificates][]:
 
-On macOS, the following certifcates are trusted:
+On macOS, the following settings are respected:
 
 * Default and System Keychains
   * Trust:
@@ -2883,8 +2886,8 @@ On macOS, the following certifcates are trusted:
     * Any certificate where the “When using this certificate” flag is set to “Never Trust” or
     * Any certificate where the “Secure Sockets Layer (SSL)” flag is set to “Never Trust.”
 
-On Windows, the following certificates are currently trusted (unlike
-Chromium's policy, distrust is not currently supported):
+On Windows, the following settings are respected (unlike Chromium's policy, distrust
+and intermediate CA are not currently supported):
 
 * Local Machine (accessed via `certlm.msc`)
   * Trust:
@@ -2899,8 +2902,19 @@ Chromium's policy, distrust is not currently supported):
     * Trusted Root Certification Authorities
     * Enterprise Trust -> Group Policy -> Trusted Root Certification Authorities
 
-On any supported system, Node.js would check that the certificate's key usage and extended key
-usage are consistent with TLS use cases before using it for server authentication.
+On Windows and macOS, Node.js would check that the user settings for the certificates
+do not forbid them for TLS server authentication before using them.
+
+On other systems, Node.js loads certificates from the default certificate file
+(typically `/etc/ssl/cert.pem`) and default certificate directory (typically
+`/etc/ssl/certs`) that the version of OpenSSL that Node.js links to respects.
+This typically works with the convention on major Linux distributions and other
+Unix-like systems. If the overriding OpenSSL environment variables
+(typically `SSL_CERT_FILE` and `SSL_CERT_DIR`, depending on the configuration
+of the OpenSSL that Node.js links to) are set, the specified paths will be used to load
+certificates instead. These environment variables can be used as workarounds
+if the conventional paths used by the version of OpenSSL Node.js links to are
+not consistent with the system configuration that the users have for some reason.
 
 ### `--v8-options`
 
@@ -3541,7 +3555,8 @@ variable is ignored.
 added: v7.7.0
 -->
 
-If `--use-openssl-ca` is enabled, this overrides and sets OpenSSL's directory
+If `--use-openssl-ca` is enabled, or if `--use-system-ca` is enabled on
+platforms other than macOS and Windows, this overrides and sets OpenSSL's directory
 containing trusted certificates.
 
 Be aware that unless the child environment is explicitly set, this environment
@@ -3554,7 +3569,8 @@ may cause them to trust the same CAs as node.
 added: v7.7.0
 -->
 
-If `--use-openssl-ca` is enabled, this overrides and sets OpenSSL's file
+If `--use-openssl-ca` is enabled, or if `--use-system-ca` is enabled on
+platforms other than macOS and Windows, this overrides and sets OpenSSL's file
 containing trusted certificates.
 
 Be aware that unless the child environment is explicitly set, this environment

filename: src/crypto/crypto_context.cc
patch: @@ -223,7 +223,7 @@ int SSL_CTX_use_certificate_chain(SSL_CTX* ctx,
                                        issuer);
 }
 
-unsigned long LoadCertsFromFile(  // NOLINT(runtime/int)
+static unsigned long LoadCertsFromFile(  // NOLINT(runtime/int)
     std::vector<X509*>* certs,
     const char* file) {
   MarkPopErrorOnReturn mark_pop_error_on_return;
@@ -645,6 +645,74 @@ void ReadWindowsCertificates(
 }
 #endif
 
+static void LoadCertsFromDir(std::vector<X509*>* certs,
+                             std::string_view cert_dir) {
+  uv_fs_t dir_req;
+  auto cleanup = OnScopeLeave([&dir_req]() { uv_fs_req_cleanup(&dir_req); });
+  int err = uv_fs_scandir(nullptr, &dir_req, cert_dir.data(), 0, nullptr);
+  if (err < 0) {
+    fprintf(stderr,
+            "Cannot open directory %s to load OpenSSL certificates.\n",
+            cert_dir.data());
+    return;
+  }
+
+  uv_fs_t stats_req;
+  auto cleanup_stats =
+      OnScopeLeave([&stats_req]() { uv_fs_req_cleanup(&stats_req); });
+  for (;;) {
+    uv_dirent_t ent;
+
+    int r = uv_fs_scandir_next(&dir_req, &ent);
+    if (r == UV_EOF) {
+      break;
+    }
+    if (r < 0) {
+      char message[64];
+      uv_strerror_r(r, message, sizeof(message));
+      fprintf(stderr,
+              "Cannot scan directory %s to load OpenSSL certificates.\n",
+              cert_dir.data());
+      return;
+    }
+
+    std::string file_path = std::string(cert_dir) + "/" + ent.name;
+    int stats_r = uv_fs_stat(nullptr, &stats_req, file_path.c_str(), nullptr);
+    if (stats_r == 0 &&
+        (static_cast<uv_stat_t*>(stats_req.ptr)->st_mode & S_IFREG)) {
+      LoadCertsFromFile(certs, file_path.c_str());
+    }
+  }
+}
+
+// Loads CA certificates from the default certificate paths respected by
+// OpenSSL.
+void GetOpenSSLSystemCertificates(std::vector<X509*>* system_store_certs) {
+  std::string cert_file;
+  // While configurable when OpenSSL is built, this is usually SSL_CERT_FILE.
+  if (!credentials::SafeGetenv(X509_get_default_cert_file_env(), &cert_file)) {
+    // This is usually /etc/ssl/cert.pem if we are using the OpenSSL statically
+    // linked and built with default configurations.
+    cert_file = X509_get_default_cert_file();
+  }
+
+  std::string cert_dir;
+  // While configurable when OpenSSL is built, this is usually SSL_CERT_DIR.
+  if (!credentials::SafeGetenv(X509_get_default_cert_dir_env(), &cert_dir)) {
+    // This is usually /etc/ssl/certs if we are using the OpenSSL statically
+    // linked and built with default configurations.
+    cert_dir = X509_get_default_cert_dir();
+  }
+
+  if (!cert_file.empty()) {
+    LoadCertsFromFile(system_store_certs, cert_file.c_str());
+  }
+
+  if (!cert_dir.empty()) {
+    LoadCertsFromDir(system_store_certs, cert_dir.c_str());
+  }
+}
+
 static std::vector<X509*> InitializeBundledRootCertificates() {
   // Read the bundled certificates in node_root_certs.h into
   // bundled_root_certs_vector.
@@ -685,6 +753,9 @@ static std::vector<X509*> InitializeSystemStoreCertificates() {
 #endif
 #ifdef _WIN32
   ReadWindowsCertificates(&system_store_certs);
+#endif
+#if !defined(__APPLE__) && !defined(_WIN32)
+  GetOpenSSLSystemCertificates(&system_store_certs);
 #endif
   return system_store_certs;
 }

filename: test/parallel/test-native-certs.mjs
patch: @@ -7,10 +7,6 @@ import fixtures from '../common/fixtures.js';
 import { it, beforeEach, afterEach, describe } from 'node:test';
 import { once } from 'events';
 
-if (!common.isMacOS && !common.isWindows) {
-  common.skip('--use-system-ca is only supported on macOS and Windows');
-}
-
 if (!common.hasCrypto) {
   common.skip('requires crypto');
 }
@@ -34,6 +30,19 @@ if (!common.hasCrypto) {
 //    $  $thumbprint = (Get-ChildItem -Path Cert:\CurrentUser\Root | \
 //          Where-Object { $_.Subject -match "StartCom Certification Authority" }).Thumbprint
 //    $  Remove-Item -Path "Cert:\CurrentUser\Root\$thumbprint"
+//
+// On Debian/Ubuntu:
+//   1. To add the certificate:
+//     $ sudo cp test/fixtures/keys/fake-startcom-root-cert.pem \
+//       /usr/local/share/ca-certificates/fake-startcom-root-cert.crt
+//     $ sudo update-ca-certificates
+//   2. To remove the certificate
+//     $ sudo rm /usr/local/share/ca-certificates/fake-startcom-root-cert.crt
+//     $ sudo update-ca-certificates --fresh
+//
+// For other Unix-like systems, consult their manuals, there are usually
+// file-based processes similar to the Debian/Ubuntu one but with different
+// file locations and update commands.
 const handleRequest = (req, res) => {
   const path = req.url;
   switch (path) {