diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ddaaca248fa..fc5d9d9df501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. [7.0.17]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.17 +### Changed + +- JWT/JWK auto-refresh and SEV-SNP endorsement fetches now verify outbound TLS connections against the node host's system trust store, which can be overridden with the `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables. The `set_ca_cert_bundle` and `remove_ca_cert_bundle` governance actions, `ca_cert_bundle_name` JWT issuer metadata, and `/gov/service/jwk` CA bundle reporting have been removed (#8027). + ### Fixed - JS registry tables and their configured namespace (`public:custom_endpoints.*` by default) are now read-only to JS endpoints. The governance-driven registry uses `public:ccf.gov.*` and leaves application namespaces unchanged. Apps requiring writes can opt out with `set_js_kv_namespace_restriction(restriction, false)`; platform permissions still apply (#8359). diff --git a/doc/audit/builtin_maps.rst b/doc/audit/builtin_maps.rst index a0c93c5c5571..2f2e2e10bb86 100644 --- a/doc/audit/builtin_maps.rst +++ b/doc/audit/builtin_maps.rst @@ -400,15 +400,6 @@ JavaScript endpoint definitions. .. doxygenenum:: ccf::endpoints::ForwardingRequired :project: CCF -``tls.ca_cert_bundles`` -~~~~~~~~~~~~~~~~~~~~~~~ - -CA cert bundle storage table, these bundles are used to authenticate connections to JWT issuers. - -**Key** Bundle name, represented as a string. - -**Value** Cert bundle, represented as a PEM-encoded string. - ``jwt.issuers`` ~~~~~~~~~~~~~~~ diff --git a/doc/build_apps/auth/jwt.rst b/doc/build_apps/auth/jwt.rst index 34d17058b92e..dd93feadfd70 100644 --- a/doc/build_apps/auth/jwt.rst +++ b/doc/build_apps/auth/jwt.rst @@ -66,29 +66,9 @@ In such cases, token issuers in CCF can be configured to automatically refresh t The following extra conditions must be true compared to setting up an issuer with manual key refresh: - The ``issuer`` must be an OpenID Connect issuer URL, for example ``https://login.microsoftonline.com/common/v2.0``. During auto-refresh, the keys are fetched from that URL by appending ``/.well-known/openid-configuration``. -- A CA certificate for the issuer URL must be stored so that the TLS connection to the IdP can be validated during key refresh. +- The certificate presented by the issuer URL must be trusted by the node host's system trust store so that the TLS connection to the IdP can be validated during key refresh. -The CA certificate is stored with a ``set_ca_cert_bundle`` proposal: - -.. code-block:: json - - { - "actions": [ - { - "name": "set_ca_cert_bundle", - "args": { - "name": "jwt_ms", - "cert_bundle": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" - } - } - ] - } - -.. note:: - - The ``cert_bundle`` in the example proposal above is a placeholder. The actual value should contain PEM-encoded certificates of all the root CAs for the given issuer, separated by newlines. For Microsoft Entra, the list of root CAs is `here `_. - -Now the issuer can be created with auto-refresh enabled: +The issuer can be created with auto-refresh enabled: .. code-block:: json @@ -98,7 +78,6 @@ Now the issuer can be created with auto-refresh enabled: "name": "set_jwt_issuer", "args": { "issuer": "https://login.microsoftonline.com/common/v2.0", - "ca_cert_bundle_name": "jwt_ms", "auto_refresh": true } } @@ -108,6 +87,9 @@ Now the issuer can be created with auto-refresh enabled: .. note:: The key refresh interval is set via the ``jwt.key_refresh_interval`` configuration entry, where the default is 30 min (1800 seconds). Failed refreshes are retried after the lesser of 5 seconds and the configured interval, with exponential backoff capped at the configured interval. The maximum response body size accepted when fetching OpenID metadata and JWKS is set via ``jwt.key_refresh_max_response_size``, where the default is 1 MB. + If auto-refresh fails with a TLS certificate verification error, make sure the IdP's issuing root CA is installed in the trust store of the host or container image running the CCF node. + Alternatively, point the node process at a PEM bundle with the ``SSL_CERT_FILE`` environment variable, or at a directory of hashed certificates with ``SSL_CERT_DIR``. These replace the system CA bundle and CA directory respectively, so they must also contain any public roots the node needs. + The trust store is read again for each fetch, so updates apply to subsequent refreshes without restarting the node. Removing a token issuer ----------------------- diff --git a/include/ccf/service/tables/cert_bundles.h b/include/ccf/service/tables/cert_bundles.h deleted file mode 100644 index 60ed6fec2e1b..000000000000 --- a/include/ccf/service/tables/cert_bundles.h +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "ccf/service/map.h" - -namespace ccf -{ - using CACertBundlePEMs = ServiceMap; - namespace Tables - { - static constexpr auto CA_CERT_BUNDLE_PEMS = - "public:ccf.gov.tls.ca_cert_bundles"; - } -} diff --git a/include/ccf/service/tables/jwt.h b/include/ccf/service/tables/jwt.h index aa3998b5e4e1..39c66d76880a 100644 --- a/include/ccf/service/tables/jwt.h +++ b/include/ccf/service/tables/jwt.h @@ -21,7 +21,8 @@ namespace ccf struct JwtIssuerMetadata { - /// Optional CA bundle name used for authentication when auto-refreshing + /// Legacy field accepted when reading existing issuer metadata, but ignored + /// for new outbound TLS verification. std::optional ca_cert_bundle_name; /// Whether to auto-refresh keys from the issuer bool auto_refresh = false; diff --git a/samples/constitutions/default/actions.js b/samples/constitutions/default/actions.js index 239f0f52f350..50aab479f537 100644 --- a/samples/constitutions/default/actions.js +++ b/samples/constitutions/default/actions.js @@ -107,46 +107,6 @@ function base64UrlByteLength(value, field) { return Math.floor(value.length / 4) * 3 + [0, 0, 1, 2][value.length % 4]; } -function splitX509CertBundle(value) { - // Match complete PEM certificates with both BEGIN and END markers. - // This ensures we only extract valid PEM blocks and reject malformed input. - const pemPattern = - /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; - const certs = value.match(pemPattern); - - if (!certs || certs.length === 0) { - throw new Error("No valid PEM certificates found in bundle"); - } - - // Verify the input contains only certificates and whitespace. - // Use a single-pass approach: replace all matched certificates with empty string - // using the global regex, then check if only whitespace remains. - const remaining = value.replace(pemPattern, ""); - - if (remaining.trim() !== "") { - throw new Error( - "Certificate bundle contains invalid content between certificates", - ); - } - - return certs; -} - -function checkX509CACertBundle(value, field) { - checkX509CertBundle(value, field); - // isValidX509RootCACert(pem) is backed by a C++ function that checks both - // X509_check_ca (CA:TRUE or self-signed x509v1) and EXFLAG_SS (self-signed). - // Every certificate in the bundle must be a root (self-signed) CA; intermediate - // CAs are rejected even when their signing root is also present in the bundle. - for (const [i, cert] of splitX509CertBundle(value).entries()) { - if (!ccf.crypto.isValidX509RootCACert(cert)) { - throw new Error( - `${field}[${i}] must be a self-signed (root) CA certificate`, - ); - } - } -} - function checkRsaPublicKey(jwk, field) { checkType(jwk.n, "string", `${field}.n`); checkType(jwk.e, "string", `${field}.e`); @@ -1149,42 +1109,12 @@ const actions = new Map([ }, ), ], - [ - "set_ca_cert_bundle", - new Action( - function (args) { - checkType(args.name, "string", "name"); - checkX509CACertBundle(args.cert_bundle, "cert_bundle"); - }, - function (args) { - const name = args.name; - const bundle = args.cert_bundle; - const nameBuf = ccf.strToBuf(name); - const bundleBuf = ccf.jsonCompatibleToBuf(bundle); - ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].set(nameBuf, bundleBuf); - }, - ), - ], - [ - "remove_ca_cert_bundle", - new Action( - function (args) { - checkType(args.name, "string", "name"); - }, - function (args) { - const name = args.name; - const nameBuf = ccf.strToBuf(name); - ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].delete(nameBuf); - }, - ), - ], [ "set_jwt_issuer", new Action( function (args) { checkType(args.issuer, "string", "issuer"); checkType(args.auto_refresh, "boolean?", "auto_refresh"); - checkType(args.ca_cert_bundle_name, "string?", "ca_cert_bundle_name"); checkType(args.jwks, "object?", "jwks"); if (args.jwks) { checkJwks(args.jwks, "jwks"); @@ -1201,31 +1131,12 @@ const actions = new Map([ if (url.query || url.fragment) { throw new Error("issuer must be a URL without query/fragment"); } - if (args.auto_refresh) { - if (!args.ca_cert_bundle_name) { - throw new Error( - "ca_cert_bundle_name is missing but required if auto_refresh is true", - ); - } - } }, function (args) { - if (args.auto_refresh) { - const caCertBundleName = args.ca_cert_bundle_name; - const caCertBundleNameBuf = ccf.strToBuf(args.ca_cert_bundle_name); - if ( - !ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].has( - caCertBundleNameBuf, - ) - ) { - throw new Error( - `No CA cert bundle found with name '${caCertBundleName}'`, - ); - } - } const issuer = args.issuer; const jwks = args.jwks; delete args.jwks; + delete args.ca_cert_bundle_name; const metadata = args; if (jwks) { ccf.setJwtPublicSigningKeys(issuer, metadata, jwks); diff --git a/samples/minimal_ccf/app/actions.js b/samples/minimal_ccf/app/actions.js index 458663e3bda1..ec24a8132e7e 100644 --- a/samples/minimal_ccf/app/actions.js +++ b/samples/minimal_ccf/app/actions.js @@ -107,46 +107,6 @@ function base64UrlByteLength(value, field) { return Math.floor(value.length / 4) * 3 + [0, 0, 1, 2][value.length % 4]; } -function splitX509CertBundle(value) { - // Match complete PEM certificates with both BEGIN and END markers. - // This ensures we only extract valid PEM blocks and reject malformed input. - const pemPattern = - /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; - const certs = value.match(pemPattern); - - if (!certs || certs.length === 0) { - throw new Error("No valid PEM certificates found in bundle"); - } - - // Verify the input contains only certificates and whitespace. - // Use a single-pass approach: replace all matched certificates with empty string - // using the global regex, then check if only whitespace remains. - const remaining = value.replace(pemPattern, ""); - - if (remaining.trim() !== "") { - throw new Error( - "Certificate bundle contains invalid content between certificates", - ); - } - - return certs; -} - -function checkX509CACertBundle(value, field) { - checkX509CertBundle(value, field); - // isValidX509RootCACert(pem) is backed by a C++ function that checks both - // X509_check_ca (CA:TRUE or self-signed x509v1) and EXFLAG_SS (self-signed). - // Every certificate in the bundle must be a root (self-signed) CA; intermediate - // CAs are rejected even when their signing root is also present in the bundle. - for (const [i, cert] of splitX509CertBundle(value).entries()) { - if (!ccf.crypto.isValidX509RootCACert(cert)) { - throw new Error( - `${field}[${i}] must be a self-signed (root) CA certificate`, - ); - } - } -} - function checkRsaPublicKey(jwk, field) { checkType(jwk.n, "string", `${field}.n`); checkType(jwk.e, "string", `${field}.e`); @@ -1128,42 +1088,12 @@ const actions = new Map([ }, ), ], - [ - "set_ca_cert_bundle", - new Action( - function (args) { - checkType(args.name, "string", "name"); - checkX509CACertBundle(args.cert_bundle, "cert_bundle"); - }, - function (args) { - const name = args.name; - const bundle = args.cert_bundle; - const nameBuf = ccf.strToBuf(name); - const bundleBuf = ccf.jsonCompatibleToBuf(bundle); - ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].set(nameBuf, bundleBuf); - }, - ), - ], - [ - "remove_ca_cert_bundle", - new Action( - function (args) { - checkType(args.name, "string", "name"); - }, - function (args) { - const name = args.name; - const nameBuf = ccf.strToBuf(name); - ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].delete(nameBuf); - }, - ), - ], [ "set_jwt_issuer", new Action( function (args) { checkType(args.issuer, "string", "issuer"); checkType(args.auto_refresh, "boolean?", "auto_refresh"); - checkType(args.ca_cert_bundle_name, "string?", "ca_cert_bundle_name"); checkType(args.jwks, "object?", "jwks"); if (args.jwks) { checkJwks(args.jwks, "jwks"); @@ -1180,31 +1110,12 @@ const actions = new Map([ if (url.query || url.fragment) { throw new Error("issuer must be a URL without query/fragment"); } - if (args.auto_refresh) { - if (!args.ca_cert_bundle_name) { - throw new Error( - "ca_cert_bundle_name is missing but required if auto_refresh is true", - ); - } - } }, function (args) { - if (args.auto_refresh) { - const caCertBundleName = args.ca_cert_bundle_name; - const caCertBundleNameBuf = ccf.strToBuf(args.ca_cert_bundle_name); - if ( - !ccf.kv["public:ccf.gov.tls.ca_cert_bundles"].has( - caCertBundleNameBuf, - ) - ) { - throw new Error( - `No CA cert bundle found with name '${caCertBundleName}'`, - ); - } - } const issuer = args.issuer; const jwks = args.jwks; delete args.jwks; + delete args.ca_cert_bundle_name; const metadata = args; if (jwks) { ccf.setJwtPublicSigningKeys(issuer, metadata, jwks); diff --git a/src/http_client/curl.h b/src/http_client/curl.h index 22b742e68ffc..1fb69b21f11c 100644 --- a/src/http_client/curl.h +++ b/src/http_client/curl.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -168,6 +169,31 @@ namespace ccf::http_client { CHECK_CURL_EASY_SETOPT(p.get(), option, value); } + + // Verify peer certificates against the system trust store. libcurl uses + // the CA bundle compiled into it by default, but ignores the OpenSSL + // environment variables, so SSL_CERT_FILE and SSL_CERT_DIR are applied + // here to override the CA bundle file and CA directory respectively. + // The trust store is re-read for each request rather than cached, so + // that updates to it apply without restarting the process. + void use_system_trust_store() + { + // NOLINTNEXTLINE(concurrency-mt-unsafe) + const char* cert_file = std::getenv("SSL_CERT_FILE"); + if (cert_file != nullptr && cert_file[0] != '\0') + { + set_opt(CURLOPT_CAINFO, cert_file); + } + + // NOLINTNEXTLINE(concurrency-mt-unsafe) + const char* cert_dir = std::getenv("SSL_CERT_DIR"); + if (cert_dir != nullptr && cert_dir[0] != '\0') + { + set_opt(CURLOPT_CAPATH, cert_dir); + } + + set_opt(CURLOPT_CA_CACHE_TIMEOUT, 0L); + } }; class UniqueCURLM diff --git a/src/node/gov/handlers/service_state.h b/src/node/gov/handlers/service_state.h index a595fb524603..c798569b71e0 100644 --- a/src/node/gov/handlers/service_state.h +++ b/src/node/gov/handlers/service_state.h @@ -886,14 +886,17 @@ namespace ccf::gov::endpoints [&response_body]( const ccf::JwtIssuer& issuer_id, const ccf::JwtIssuerMetadata& metadata) { + // Legacy ca_cert_bundle_name metadata is intentionally not + // reported: CA bundles are no longer managed by governance. response_body.issuers.emplace( - issuer_id, - api::JwtIssuer{ - metadata.auto_refresh, metadata.ca_cert_bundle_name}); + issuer_id, api::JwtIssuer{metadata.auto_refresh}); return true; }); } + // caCertBundles is retained in the response for schema + // compatibility, but is always empty. + // Populate keys field { auto jwt_keys_handle = @@ -920,19 +923,6 @@ namespace ccf::gov::endpoints }); } - // Populate caCertBundles field - { - auto cert_bundles_handle = - ctx.tx.template ro( - ccf::Tables::CA_CERT_BUNDLE_PEMS); - cert_bundles_handle->foreach([&response_body]( - const std::string& bundle_name, - const std::string& bundle_value) { - response_body.ca_cert_bundles.emplace(bundle_name, bundle_value); - return true; - }); - } - ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK); return; } diff --git a/src/node/jwt_key_auto_refresh.h b/src/node/jwt_key_auto_refresh.h index 0a9bbd2a044d..c7ca8d9e62af 100644 --- a/src/node/jwt_key_auto_refresh.h +++ b/src/node/jwt_key_auto_refresh.h @@ -5,7 +5,6 @@ #include "ccf/ds/json.h" #include "ccf/ds/locking.h" #include "ccf/ds/nonstd.h" -#include "ccf/service/tables/cert_bundles.h" #include "ccf/service/tables/jwt.h" #include "http/http_builder.h" #include "http_client/curl.h" @@ -170,9 +169,11 @@ namespace ccf ccf::tasks::add_delayed_task(retry_task, std::chrono::seconds(delay_s)); } - void send_curl_get( + protected: + // Issues an outbound HTTPS GET. Virtual so that unit tests can substitute + // the transport, without a live libcurl/libuv context. + virtual void send_curl_get( const std::string& url, - const std::string& ca_bundle_pem, ccf::http_client::CurlRequest::ResponseCallback callback) { ccf::http_client::UniqueCURL curl_handle; @@ -187,11 +188,7 @@ namespace ccf // https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYHOST.html curl_handle.set_opt(CURLOPT_SSL_VERIFYHOST, 2L); curl_handle.set_opt(CURLOPT_PROTOCOLS_STR, "https"); - curl_handle.set_blob_opt( - CURLOPT_CAINFO_BLOB, - reinterpret_cast(ca_bundle_pem.data()), - ca_bundle_pem.size()); - curl_handle.set_opt(CURLOPT_CAPATH, nullptr); + curl_handle.use_system_trust_store(); ccf::http_client::UniqueSlist headers; @@ -228,7 +225,7 @@ namespace ccf max_response_size(max_response_size) {} - ~JwtKeyAutoRefresh() + virtual ~JwtKeyAutoRefresh() { stop(); } @@ -397,7 +394,6 @@ namespace ccf void handle_jwt_metadata_response( const std::string& issuer, - std::string ca_bundle_pem, ccf::http_status status, std::vector&& data) { @@ -520,13 +516,11 @@ namespace ccf })); }; - send_curl_get(jwks_url_str, ca_bundle_pem, std::move(response_callback)); + send_curl_get(jwks_url_str, std::move(response_callback)); } void refresh_issuer_jwt_keys( - const JwtIssuer& issuer, - const JwtIssuerMetadata& metadata, - ccf::CACertBundlePEMs::ReadOnlyHandle* ca_cert_bundles) + const JwtIssuer& issuer, const JwtIssuerMetadata& metadata) { if (!metadata.auto_refresh) { @@ -543,39 +537,15 @@ namespace ccf LOG_DEBUG_FMT( "JWT key auto-refresh: Refreshing keys for issuer '{}'", issuer); - if (!metadata.ca_cert_bundle_name.has_value()) - { - LOG_INFO_FMT( - "JWT key auto-refresh: Issuer '{}' has auto-refresh enabled but no " - "CA cert bundle name", - issuer); - send_refresh_jwt_keys_error(issuer); - return; - } - const auto& ca_cert_bundle_name = metadata.ca_cert_bundle_name.value(); - auto ca_cert_bundle_pem = ca_cert_bundles->get(ca_cert_bundle_name); - if (!ca_cert_bundle_pem.has_value()) - { - LOG_INFO_FMT( - "JWT key auto-refresh: CA cert bundle with name '{}' for issuer " - "'{}' not " - "found", - ca_cert_bundle_name, - issuer); - send_refresh_jwt_keys_error(issuer); - return; - } auto metadata_url = issuer + "/.well-known/openid-configuration"; LOG_DEBUG_FMT( "JWT key auto-refresh: Requesting OpenID metadata at {}", metadata_url); - auto ca_bundle_pem = ca_cert_bundle_pem.value(); - const auto self = weak_from_this(); auto response_callback = - [self, issuer, ca_bundle_pem]( + [self, issuer]( std::unique_ptr&& request, CURLcode curl_response, long status_code) { @@ -584,13 +554,8 @@ namespace ccf request->get_response_body() != nullptr ? std::move(request->get_response_body()->buffer) : std::vector{}); - ccf::tasks::add_task( - ccf::tasks::make_basic_task([self, - issuer, - ca_bundle_pem, - curl_response, - http_status, - response_body_sp]() { + ccf::tasks::add_task(ccf::tasks::make_basic_task( + [self, issuer, curl_response, http_status, response_body_sp]() { const auto self_sp = self.lock(); if (self_sp == nullptr || self_sp->stopped.load()) { @@ -609,14 +574,11 @@ namespace ccf return; } self_sp->handle_jwt_metadata_response( - issuer, - ca_bundle_pem, - http_status, - std::move(*response_body_sp)); + issuer, http_status, std::move(*response_body_sp)); })); }; - send_curl_get(metadata_url, ca_bundle_pem, std::move(response_callback)); + send_curl_get(metadata_url, std::move(response_callback)); } void refresh_jwt_keys() @@ -628,16 +590,14 @@ namespace ccf auto tx = network.tables->create_read_only_tx(); auto* jwt_issuers = tx.ro(network.jwt_issuers); - auto* ca_cert_bundles = tx.ro(network.ca_cert_bundles); jwt_issuers->foreach( - [this, &ca_cert_bundles]( - const JwtIssuer& issuer, const JwtIssuerMetadata& metadata) { + [this](const JwtIssuer& issuer, const JwtIssuerMetadata& metadata) { if (stopped.load()) { return false; } - refresh_issuer_jwt_keys(issuer, metadata, ca_cert_bundles); + refresh_issuer_jwt_keys(issuer, metadata); return true; }); } @@ -662,8 +622,7 @@ namespace ccf return; } - refresh_issuer_jwt_keys( - issuer, metadata.value(), tx.ro(network.ca_cert_bundles)); + refresh_issuer_jwt_keys(issuer, metadata.value()); } // Returns a copy of the current attempts diff --git a/src/node/quote_endorsements_client.h b/src/node/quote_endorsements_client.h index f6ebf8e73a4c..5d8e99eaccc2 100644 --- a/src/node/quote_endorsements_client.h +++ b/src/node/quote_endorsements_client.h @@ -182,6 +182,13 @@ namespace ccf curl_easy_strerror(curl_response), curl_response, status_code); + if (curl_response == CURLE_PEER_FAILED_VERIFICATION) + { + LOG_INFO_FMT( + "TLS verification failed while fetching endorsements. Check that " + "the endorsement server certificate is trusted by the system trust " + "store, or by the trust store pointed to by SSL_CERT_FILE."); + } if ( self->server_retries_count >= @@ -291,13 +298,7 @@ namespace ccf if (endpoint.tls) { - // Note: server CA is not checked here as this client is not sending - // private data. If the server was malicious and the certificate chain - // was bogus, the verification of the endorsement of the quote would - // fail anyway. - curl_handle.set_opt(CURLOPT_SSL_VERIFYHOST, 0L); - curl_handle.set_opt(CURLOPT_SSL_VERIFYPEER, 0L); - curl_handle.set_opt(CURLOPT_SSL_VERIFYSTATUS, 0L); + curl_handle.use_system_trust_store(); } auto headers = ccf::http_client::UniqueSlist(); diff --git a/src/node/test/jwt_key_auto_refresh.cpp b/src/node/test/jwt_key_auto_refresh.cpp index 319b9b52f3d0..c5a02d9aeb5e 100644 --- a/src/node/test/jwt_key_auto_refresh.cpp +++ b/src/node/test/jwt_key_auto_refresh.cpp @@ -43,6 +43,27 @@ namespace } }; + // Fails every outbound request synchronously, so that each refresh attempt + // fails without wall-clock waits or external HTTP servers. + class FailingTransportRefresh : public ccf::JwtKeyAutoRefresh + { + public: + using ccf::JwtKeyAutoRefresh::JwtKeyAutoRefresh; + + protected: + void send_curl_get( + const std::string& url, + ccf::http_client::CurlRequest::ResponseCallback) override + { + // These tests only ever reach the OpenID metadata request, whose URL is + // the issuer followed by the well-known suffix. + static constexpr std::string_view suffix = + "/.well-known/openid-configuration"; + REQUIRE(url.ends_with(suffix)); + send_refresh_jwt_keys_error(url.substr(0, url.size() - suffix.size())); + } + }; + struct Fixture { ccf::NetworkState network; @@ -60,7 +81,7 @@ namespace consensus->force_become_primary(); auto rpc_map = std::make_shared(); rpc_map->register_frontend(endpoint); - refresh = std::make_shared( + refresh = std::make_shared( refresh_interval_s, network, consensus, @@ -80,8 +101,6 @@ namespace void set_issuer(const ccf::JwtIssuer& name, bool auto_refresh = true) { auto tx = network.tables->create_tx(); - // A missing CA bundle makes each attempt fail synchronously, without - // wall-clock waits or external HTTP servers. tx.rw(network.jwt_issuers) ->put(name, ccf::JwtIssuerMetadata{std::nullopt, auto_refresh}); REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); @@ -184,11 +203,11 @@ TEST_CASE("JWT response failures schedule an initial retry") SUBCASE("Metadata HTTP error") { f.refresh->handle_jwt_metadata_response( - f.issuer, "", HTTP_STATUS_SERVICE_UNAVAILABLE, {}); + f.issuer, HTTP_STATUS_SERVICE_UNAVAILABLE, {}); } SUBCASE("Malformed metadata") { - f.refresh->handle_jwt_metadata_response(f.issuer, "", HTTP_STATUS_OK, {}); + f.refresh->handle_jwt_metadata_response(f.issuer, HTTP_STATUS_OK, {}); } SUBCASE("JWKS HTTP error") { diff --git a/src/service/network_tables.h b/src/service/network_tables.h index 46e585e4ac83..95c1a582d4f5 100644 --- a/src/service/network_tables.h +++ b/src/service/network_tables.h @@ -4,7 +4,6 @@ #include "ccf/endpoint.h" #include "ccf/service/signed_req.h" -#include "ccf/service/tables/cert_bundles.h" #include "ccf/service/tables/code_id.h" #include "ccf/service/tables/constitution.h" #include "ccf/service/tables/gov.h" @@ -161,15 +160,13 @@ namespace ccf // // JWT tables // - const CACertBundlePEMs ca_cert_bundles = {Tables::CA_CERT_BUNDLE_PEMS}; const JwtIssuers jwt_issuers = {Tables::JWT_ISSUERS}; const JwtPublicSigningKeysMetadata jwt_public_signing_keys_metadata = { Tables::JWT_PUBLIC_SIGNING_KEYS_METADATA}; [[nodiscard]] auto get_all_jwt_tables() const { - return std::make_tuple( - ca_cert_bundles, jwt_issuers, jwt_public_signing_keys_metadata); + return std::make_tuple(jwt_issuers, jwt_public_signing_keys_metadata); } // diff --git a/tests/ca_certs.py b/tests/ca_certs.py deleted file mode 100644 index 26e71982a199..000000000000 --- a/tests/ca_certs.py +++ /dev/null @@ -1,121 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the Apache 2.0 License. -import json -import tempfile - -import infra.e2e_args -import infra.net -import infra.network -import infra.path -import infra.proc -import suite.test_requirements as reqs -from loguru import logger as LOG - - -@reqs.description("Add and remove CA certs") -def test_cert_store(network, args): - primary, _ = network.find_nodes() - - cert_name = "mycert" - raw_cert_name = cert_name.encode() - - LOG.info("Member builds a ca cert update proposal with malformed cert") - with tempfile.NamedTemporaryFile("w") as f: - f.write("foo") - f.flush() - try: - network.consortium.set_ca_cert_bundle( - primary, cert_name, f.name, skip_checks=False - ) - except ValueError: - pass - else: - assert False, "set_ca_cert_bundle should have raised an error" - - LOG.info("Member makes a ca cert update proposal with malformed cert") - with tempfile.NamedTemporaryFile("w") as f: - f.write("foo") - f.flush() - try: - network.consortium.set_ca_cert_bundle( - primary, cert_name, f.name, skip_checks=True - ) - except (infra.proposal.ProposalNotAccepted, infra.proposal.ProposalNotCreated): - pass - else: - assert False, "Proposal should not have been accepted" - - LOG.info("Member makes a ca cert update proposal with a non-CA cert") - key_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048) - non_ca_cert_pem = infra.crypto.generate_cert(key_priv_pem) - with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as cert_pem_fp: - cert_pem_fp.write(non_ca_cert_pem) - cert_pem_fp.flush() - try: - network.consortium.set_ca_cert_bundle(primary, cert_name, cert_pem_fp.name) - except (infra.proposal.ProposalNotAccepted, infra.proposal.ProposalNotCreated): - pass - else: - assert False, "Proposal should not have accepted a non-CA certificate" - - LOG.info( - "Member makes a ca cert update proposal with an intermediate CA " - "signed by a root CA also in the bundle -- must be rejected" - ) - root_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048) - root_cert_pem = infra.crypto.generate_cert(root_priv_pem, cn="root", ca=True) - intermediate_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048) - intermediate_cert_pem = infra.crypto.generate_cert( - intermediate_priv_pem, - cn="intermediate", - issuer_priv_key_pem=root_priv_pem, - issuer_cn="root", - ca=True, - ) - with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as cert_pem_fp: - cert_pem_fp.write(intermediate_cert_pem) - cert_pem_fp.write(root_cert_pem) - cert_pem_fp.flush() - try: - network.consortium.set_ca_cert_bundle(primary, cert_name, cert_pem_fp.name) - except (infra.proposal.ProposalNotAccepted, infra.proposal.ProposalNotCreated): - pass - else: - assert ( - False - ), "Proposal should not have accepted an intermediate CA certificate" - - LOG.info("Member makes a ca cert update proposal with valid certs") - key_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048) - cert_pem = infra.crypto.generate_cert(key_priv_pem, ca=True) - key2_priv_pem, _ = infra.crypto.generate_rsa_keypair(2048) - cert2_pem = infra.crypto.generate_cert(key2_priv_pem, ca=True) - with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as cert_pem_fp: - cert_pem_fp.write(cert_pem) - cert_pem_fp.write(cert2_pem) - cert_pem_fp.flush() - set_proposal = network.consortium.set_ca_cert_bundle( - primary, cert_name, cert_pem_fp.name - ) - - stored_cert = json.loads( - network.get_ledger_public_state_at( - set_proposal.completed_seqno, timeout=10 - )["public:ccf.gov.tls.ca_cert_bundles"][raw_cert_name] - ) - cert_ref = cert_pem + cert2_pem - assert ( - cert_ref == stored_cert - ), f"input certs not equal to stored cert: {cert_ref} != {stored_cert}" - - LOG.info("Member removes a ca cert") - remove_proposal = network.consortium.remove_ca_cert_bundle(primary, cert_name) - - assert ( - network.get_ledger_public_state_at(remove_proposal.completed_seqno, timeout=10)[ - "public:ccf.gov.tls.ca_cert_bundles" - ][raw_cert_name] - is None - ), "CA bundle was not removed" - - return network diff --git a/tests/infra/consortium.py b/tests/infra/consortium.py index b4ef3549179a..00d48d9457fb 100644 --- a/tests/infra/consortium.py +++ b/tests/infra/consortium.py @@ -10,8 +10,6 @@ import shutil import tempfile -import cryptography.hazmat.backends as crypto_backends -from cryptography import x509 from loguru import logger as LOG import infra.checker @@ -664,6 +662,8 @@ def set_jwt_issuer(self, remote_node, json_path): # lts_compatibility tests. "key_filter": "all", "issuer": obj["issuer"], + # Only required by releases up to and including 7.0.16, and ignored + # by later constitutions. "ca_cert_bundle_name": obj.get("ca_cert_bundle_name"), "auto_refresh": obj.get("auto_refresh", False), "jwks": obj.get("jwks"), @@ -687,23 +687,12 @@ def set_jwt_public_signing_keys(self, remote_node, issuer, jwks_path): proposal = self.get_any_active_member().propose(remote_node, proposal_body) return self.vote_using_majority(remote_node, proposal, careful_vote) - def set_ca_cert_bundle( - self, remote_node, cert_name, cert_bundle_path, skip_checks=False - ): - if not skip_checks: - cert_bundle_pem = slurp_file(cert_bundle_path) - delim = "-----END CERTIFICATE-----" - for cert_pem in cert_bundle_pem.split(delim): - if not cert_pem.strip(): - continue - cert_pem += delim - try: - x509.load_pem_x509_certificate( - cert_pem.encode(), crypto_backends.default_backend() - ) - except Exception as exc: - raise ValueError("Cannot parse PEM certificate") from exc - + def set_ca_cert_bundle(self, remote_node, cert_name, cert_bundle_path): + """ + Only supported by releases up to and including 7.0.16, whose + constitutions still define the set_ca_cert_bundle action. Used by + lts_compatibility tests to let those releases auto-refresh JWT keys. + """ proposal_body, careful_vote = self.make_proposal( "set_ca_cert_bundle", name=cert_name, @@ -712,13 +701,6 @@ def set_ca_cert_bundle( proposal = self.get_any_active_member().propose(remote_node, proposal_body) return self.vote_using_majority(remote_node, proposal, careful_vote) - def remove_ca_cert_bundle(self, remote_node, cert_name): - proposal_body, careful_vote = self.make_proposal( - "remove_ca_cert_bundle", name=cert_name - ) - proposal = self.get_any_active_member().propose(remote_node, proposal_body) - return self.vote_using_majority(remote_node, proposal, careful_vote) - def transition_service_to_open(self, remote_node, previous_service_identity=None): """ Assuming a network in state OPENING, this functions creates a new diff --git a/tests/infra/jwt_issuer.py b/tests/infra/jwt_issuer.py index 494ae56f83d9..47d32ba65bfb 100644 --- a/tests/infra/jwt_issuer.py +++ b/tests/infra/jwt_issuer.py @@ -2,6 +2,8 @@ # Licensed under the Apache 2.0 License. import base64 import json +import os +import shutil import ssl import tempfile import threading @@ -159,9 +161,58 @@ def to_b64(number: int, size=None): return base64.urlsafe_b64encode(as_bytes).rstrip(b"=").decode("ascii") +def system_ca_bundle(): + paths = ssl.get_default_verify_paths() + for candidate in (paths.cafile, paths.openssl_cafile): + if candidate and os.path.isfile(candidate): + return candidate + raise RuntimeError("Could not locate the system CA bundle") + + +class NodeTrustStore(AbstractContextManager): + """ + PEM bundle passed to nodes via SSL_CERT_FILE, so that their outbound TLS + connections trust the self-signed certificates of test OpenID servers. + It starts from the system roots, since SSL_CERT_FILE replaces the system + CA bundle, and issuers are appended as they are created. + """ + + def __init__(self): + self._lock = threading.Lock() + self._file = tempfile.NamedTemporaryFile( # noqa: SIM115 - closed in __exit__ + prefix="ccf_trust_store_", mode="w+" + ) + with open(system_ca_bundle(), encoding="utf-8") as system_roots: + shutil.copyfileobj(system_roots, self._file) + self._file.write("\n") + self._file.flush() + + def __exit__(self, *exc_info): + self._file.close() + + @property + def path(self): + return self._file.name + + @property + def env(self): + return {"SSL_CERT_FILE": self.path} + + def trust(self, issuer): + with self._lock: + self._file.write(issuer.tls_cert) + self._file.write("\n") + self._file.flush() + + class JwtIssuer: TEST_JWT_ISSUER_NAME = "https://example.issuer" TEST_CA_BUNDLE_NAME = "test_ca_bundle_name" + # Releases up to and including this one only auto-refresh keys over TLS + # connections verified against a governance-managed CA bundle, which their + # constitutions require set_jwt_issuer to name. Later versions verify + # against the node's trust store instead (see NodeTrustStore). + LAST_RELEASE_WITH_GOVERNANCE_CA_BUNDLES = "ccf-7.0.16" def _generate_auth_data(self, cn=None): if self._alg == JwtAlg.RS256: @@ -272,24 +323,25 @@ def create_jwks_for_kids(self, kids): jwks["keys"].append(self._create_jwks(kid)) return jwks - def register(self, network, kid=None, ca_bundle_name=TEST_CA_BUNDLE_NAME): + def register(self, network, kid=None): kid_ = kid or self.default_kid primary, _ = network.find_primary() - if self.auto_refresh: + issuer = {"issuer": self.issuer_url, "auto_refresh": self.auto_refresh} + if self.auto_refresh and not primary.version_after( + self.LAST_RELEASE_WITH_GOVERNANCE_CA_BUNDLES + ): with tempfile.NamedTemporaryFile( prefix="ccf", mode="w+" ) as ca_cert_bundle_fp: ca_cert_bundle_fp.write(self.tls_cert) ca_cert_bundle_fp.flush() network.consortium.set_ca_cert_bundle( - primary, ca_bundle_name, ca_cert_bundle_fp.name + primary, self.TEST_CA_BUNDLE_NAME, ca_cert_bundle_fp.name ) + issuer["ca_cert_bundle_name"] = self.TEST_CA_BUNDLE_NAME with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp: - issuer = {"issuer": self.issuer_url, "auto_refresh": self.auto_refresh} - if self.auto_refresh: - issuer.update({"ca_cert_bundle_name": ca_bundle_name}) json.dump(issuer, metadata_fp) metadata_fp.flush() network.consortium.set_jwt_issuer(primary, metadata_fp.name) diff --git a/tests/infra/remote.py b/tests/infra/remote.py index 6aaaacfbd5a2..5c17178a6b64 100644 --- a/tests/infra/remote.py +++ b/tests/infra/remote.py @@ -212,6 +212,11 @@ def start(self): ] for key, value in self.env.items(): launch_cmd.extend(["--env", f"{key}={value}"]) + trust_store = self.env.get("SSL_CERT_FILE") + if trust_store: + # The node trust store lives outside the node directory, and + # may be appended to after the container starts. + launch_cmd.extend(["--volume", f"{trust_store}:{trust_store}:ro"]) launch_cmd.extend([self.node_container_image, *self.cmd]) launch_env = os.environ.copy() self.proc = subprocess.Popen( diff --git a/tests/jwt_test.py b/tests/jwt_test.py index 9c9b3622057d..f9a2dce95829 100644 --- a/tests/jwt_test.py +++ b/tests/jwt_test.py @@ -7,7 +7,6 @@ import time from contextlib import contextmanager -import ca_certs import infra.clients import infra.crypto import infra.e2e_args @@ -20,6 +19,7 @@ import suite.test_requirements as reqs from ccf.tx_id import TxID from infra.jwt_issuer import ( + NodeTrustStore, OpenIDProviderServer, get_jwt_issuers, get_jwt_keys, @@ -418,20 +418,17 @@ def reserve_unlistened_local_port(): yield s -def add_auto_refresh_jwt_issuer(network, primary, issuer, ca_cert_bundle_name): - with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as ca_cert_bundle_fp: - ca_cert_bundle_fp.write(issuer.tls_cert) - ca_cert_bundle_fp.flush() - network.consortium.set_ca_cert_bundle( - primary, ca_cert_bundle_name, ca_cert_bundle_fp.name - ) +def trust_jwt_issuer(args, issuer): + args.node_trust_store.trust(issuer) + +def add_auto_refresh_jwt_issuer(network, args, primary, issuer): + trust_jwt_issuer(args, issuer) with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp: json.dump( { "issuer": issuer.name, "auto_refresh": True, - "ca_cert_bundle_name": ca_cert_bundle_name, }, metadata_fp, ) @@ -462,7 +459,7 @@ def test_jwt_key_auto_refresh_connection_failure(network, args): issuer = infra.jwt_issuer.JwtIssuer( f"https://{issuer_host}:{issuer_port}", cn=issuer_host ) - add_auto_refresh_jwt_issuer(network, primary, issuer, "jwt_connection_failure") + add_auto_refresh_jwt_issuer(network, args, primary, issuer) try: with_timeout( lambda: check_refresh_failures_increased(primary, failures_before), @@ -489,20 +486,10 @@ def test_jwt_key_auto_refresh_tls_failure(network, args): remove_all_jwt_issuers(network, args, primary) failures_before = get_jwt_refresh_endpoint_metrics(primary)["failures"] issuer = infra.jwt_issuer.JwtIssuer("https://127.0.0.1", cn="127.0.0.1") - wrong_ca_issuer = infra.jwt_issuer.JwtIssuer( - "https://127.0.0.1-wrong-ca", cn="127.0.0.1" - ) - ca_cert_bundle_name = "jwt_tls_failure" - - LOG.info("Add wrong CA cert for JWT issuer") - with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as ca_cert_bundle_fp: - ca_cert_bundle_fp.write(wrong_ca_issuer.tls_cert) - ca_cert_bundle_fp.flush() - network.consortium.set_ca_cert_bundle( - primary, ca_cert_bundle_name, ca_cert_bundle_fp.name - ) - LOG.info("Start OpenID endpoint server with a certificate signed by another CA") + # Do not add this issuer to args.node_trust_store. This verifies that + # nodes use the configured SSL_CERT_FILE trust store for JWT auto-refresh. + LOG.info("Start OpenID endpoint server with a certificate not in the system store") with issuer.start_openid_server(0) as server: issuer_name = f"https://127.0.0.1:{server.bind_port}" with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp: @@ -510,7 +497,6 @@ def test_jwt_key_auto_refresh_tls_failure(network, args): { "issuer": issuer_name, "auto_refresh": True, - "ca_cert_bundle_name": ca_cert_bundle_name, }, metadata_fp, ) @@ -531,15 +517,7 @@ def test_jwt_key_auto_refresh_invalid_metadata_issuer(network, args): remove_all_jwt_issuers(network, args, primary) failures_before = get_jwt_refresh_endpoint_metrics(primary)["failures"] issuer = infra.jwt_issuer.JwtIssuer("https://127.0.0.1", cn="127.0.0.1") - ca_cert_bundle_name = "jwt_invalid_metadata_issuer" - - LOG.info("Add CA cert for JWT issuer") - with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as ca_cert_bundle_fp: - ca_cert_bundle_fp.write(issuer.tls_cert) - ca_cert_bundle_fp.flush() - network.consortium.set_ca_cert_bundle( - primary, ca_cert_bundle_name, ca_cert_bundle_fp.name - ) + trust_jwt_issuer(args, issuer) LOG.info("Start OpenID endpoint server with a non-string issuer metadata field") with issuer.start_openid_server(0) as server: @@ -551,7 +529,6 @@ def test_jwt_key_auto_refresh_invalid_metadata_issuer(network, args): { "issuer": issuer_name, "auto_refresh": True, - "ca_cert_bundle_name": ca_cert_bundle_name, }, metadata_fp, ) @@ -572,17 +549,9 @@ def test_jwt_key_auto_refresh_cross_authority_jwks_uri(network, args): remove_all_jwt_issuers(network, args, primary) issuer_host = "127.0.0.1" issuer = infra.jwt_issuer.JwtIssuer(f"https://{issuer_host}", cn=issuer_host) - ca_cert_bundle_name = "jwt_cross_authority_jwks_uri" + trust_jwt_issuer(args, issuer) kid = "cross_authority_jwks_uri" - LOG.info("Add CA cert for JWT issuer") - with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as ca_cert_bundle_fp: - ca_cert_bundle_fp.write(issuer.tls_cert) - ca_cert_bundle_fp.flush() - network.consortium.set_ca_cert_bundle( - primary, ca_cert_bundle_name, ca_cert_bundle_fp.name - ) - LOG.info("Start OpenID endpoint server with cross-authority JWKS URI") with issuer.start_openid_server(0, kid) as server, OpenIDProviderServer( 0, issuer.tls_priv, issuer.tls_cert, issuer.create_jwks(kid) @@ -598,7 +567,6 @@ def test_jwt_key_auto_refresh_cross_authority_jwks_uri(network, args): { "issuer": issuer_name, "auto_refresh": True, - "ca_cert_bundle_name": ca_cert_bundle_name, }, metadata_fp, ) @@ -621,14 +589,13 @@ def test_jwt_key_auto_refresh_response_size_limit(network, args): remove_all_jwt_issuers(network, args, primary) failures_before = get_jwt_refresh_endpoint_metrics(primary)["failures"] issuer = infra.jwt_issuer.JwtIssuer("https://127.0.0.1", cn="127.0.0.1") - ca_cert_bundle_name = "jwt_response_size_limit" kid = "response_size_limit" LOG.info("Start OpenID endpoint server with oversized metadata") with issuer.start_openid_server(0, kid) as server: issuer.name = f"https://127.0.0.1:{server.bind_port}" server.metadata["oversized_response"] = "x" * 4096 - add_auto_refresh_jwt_issuer(network, primary, issuer, ca_cert_bundle_name) + add_auto_refresh_jwt_issuer(network, args, primary, issuer) try: with_timeout( @@ -639,7 +606,7 @@ def test_jwt_key_auto_refresh_response_size_limit(network, args): LOG.info("Restore OpenID metadata and re-add JWT issuer") del server.metadata["oversized_response"] network.consortium.remove_jwt_issuer(primary, issuer.name) - add_auto_refresh_jwt_issuer(network, primary, issuer, ca_cert_bundle_name) + add_auto_refresh_jwt_issuer(network, args, primary, issuer) with_timeout( lambda: check_kv_jwt_key_matches( @@ -655,7 +622,6 @@ def test_jwt_key_auto_refresh_response_size_limit(network, args): def test_jwt_key_auto_refresh(network, args): primary, _ = network.find_nodes() - ca_cert_bundle_name = "jwt" kid = "the_kid" issuer_host = "127.0.0.1" issuer_port = args.issuer_port @@ -664,13 +630,7 @@ def test_jwt_key_auto_refresh(network, args): f"https://{issuer_host}:{issuer_port}", cn=issuer_host ) - LOG.info("Add CA cert for JWT issuer") - with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as ca_cert_bundle_fp: - ca_cert_bundle_fp.write(issuer.tls_cert) - ca_cert_bundle_fp.flush() - network.consortium.set_ca_cert_bundle( - primary, ca_cert_bundle_name, ca_cert_bundle_fp.name - ) + trust_jwt_issuer(args, issuer) LOG.info("Start OpenID endpoint server") # Capture baseline metrics before the server starts: any connection failures @@ -689,7 +649,6 @@ def test_jwt_key_auto_refresh(network, args): { "issuer": issuer.name, "auto_refresh": True, - "ca_cert_bundle_name": ca_cert_bundle_name, }, metadata_fp, ) @@ -761,7 +720,6 @@ def check_has_failures(): def test_jwt_key_auto_refresh_entries(network, args): primary, _ = network.find_nodes() - ca_cert_bundle_name = "jwt" kid = "the_kid_no_duplicates" issuer_host = "127.0.0.1" issuer_port = args.issuer_port @@ -770,13 +728,7 @@ def test_jwt_key_auto_refresh_entries(network, args): f"https://{issuer_host}:{issuer_port}", cn=issuer_host ) - LOG.info("Add CA cert for JWT issuer") - with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as ca_cert_bundle_fp: - ca_cert_bundle_fp.write(issuer.tls_cert) - ca_cert_bundle_fp.flush() - network.consortium.set_ca_cert_bundle( - primary, ca_cert_bundle_name, ca_cert_bundle_fp.name - ) + trust_jwt_issuer(args, issuer) LOG.info("Start OpenID endpoint server") with issuer.start_openid_server(issuer_port, kid): @@ -786,7 +738,6 @@ def test_jwt_key_auto_refresh_entries(network, args): { "issuer": issuer.name, "auto_refresh": True, - "ca_cert_bundle_name": ca_cert_bundle_name, }, metadata_fp, ) @@ -849,7 +800,6 @@ def check_refresh_progressed(): def test_jwt_key_initial_refresh(network, args, timeout_s=15): primary, _ = network.find_nodes() - ca_cert_bundle_name = "jwt" kid = f"my_kid_autorefresh_{primary.local_node_id}" issuer_host = "127.0.0.1" issuer_port = args.issuer_port @@ -858,13 +808,7 @@ def test_jwt_key_initial_refresh(network, args, timeout_s=15): f"https://{issuer_host}:{issuer_port}", cn=issuer_host ) - LOG.info("Add CA cert for JWT issuer") - with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as ca_cert_bundle_fp: - ca_cert_bundle_fp.write(issuer.tls_cert) - ca_cert_bundle_fp.flush() - network.consortium.set_ca_cert_bundle( - primary, ca_cert_bundle_name, ca_cert_bundle_fp.name - ) + trust_jwt_issuer(args, issuer) LOG.info("Start OpenID endpoint server") with issuer.start_openid_server(issuer_port, kid): @@ -874,7 +818,6 @@ def test_jwt_key_initial_refresh(network, args, timeout_s=15): { "issuer": issuer.name, "auto_refresh": True, - "ca_cert_bundle_name": ca_cert_bundle_name, }, metadata_fp, ) @@ -909,9 +852,7 @@ def test_jwt_key_initial_refresh(network, args, timeout_s=15): "JWT initial refresh: key not yet present, " "re-triggering one-off refresh" ) - add_auto_refresh_jwt_issuer( - network, primary, issuer, ca_cert_bundle_name - ) + add_auto_refresh_jwt_issuer(network, args, primary, issuer) continue break @@ -922,79 +863,16 @@ def test_jwt_key_initial_refresh(network, args, timeout_s=15): return network -# Root CA for login.microsoftonline.com:443 -# Used as root of trust by CCF (after being set via set_ca_cert_bundle) -# for the purpose of fetching JWK list and JWKs -DIGICERT_GLOBAL_ROOT_CA = """-----BEGIN CERTIFICATE----- -MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh -MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 -d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD -QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT -MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j -b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG -9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB -CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 -nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt -43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P -T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 -gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO -BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR -TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw -DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr -hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg -06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF -PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls -YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk -CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= ------END CERTIFICATE-----""" - -DIGICERT_GLOBAL_ROOT_G2_CA = """-----BEGIN CERTIFICATE----- -MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh -MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 -d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH -MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT -MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j -b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG -9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI -2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx -1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ -q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz -tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ -vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP -BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV -5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY -1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4 -NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG -Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91 -8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe -pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl -MrY= ------END CERTIFICATE-----""" - - -def test_jwt_key_refresh_aad(network, args, ascending=True): +def test_jwt_key_refresh_aad(network, args): primary, _ = network.find_nodes() - LOG.info("Add CA cert for Entra JWT issuer") - with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as ca_cert_bundle_fp: - if ascending: - ca_cert_bundle_fp.write(DIGICERT_GLOBAL_ROOT_CA) - ca_cert_bundle_fp.write("\n") - ca_cert_bundle_fp.write(DIGICERT_GLOBAL_ROOT_G2_CA) - else: - ca_cert_bundle_fp.write(DIGICERT_GLOBAL_ROOT_G2_CA) - ca_cert_bundle_fp.write("\n") - ca_cert_bundle_fp.write(DIGICERT_GLOBAL_ROOT_CA) - ca_cert_bundle_fp.flush() - network.consortium.set_ca_cert_bundle(primary, "aad", ca_cert_bundle_fp.name) - + LOG.info("Check JWT auto-refresh against Entra using the system trust store") issuer = "https://login.microsoftonline.com/common/v2.0/" with tempfile.NamedTemporaryFile(prefix="ccf", mode="w+") as metadata_fp: json.dump( { "issuer": issuer, "auto_refresh": True, - "ca_cert_bundle_name": "aad", }, metadata_fp, ) @@ -1070,10 +948,11 @@ def with_timeout(fn, timeout): def run_auto(args): - with infra.network.network( + with NodeTrustStore() as trust_store, infra.network.network( args.nodes, args.binary_dir, args.debug_nodes, pdb=args.pdb ) as network: - network.start_and_open(args) + args.node_trust_store = trust_store + network.start_and_open(args, env=trust_store.env) test_jwt_issuer_and_jwks_validation(network, args) test_jwt_mulitple_issuers_same_kids_different_pem(network, args) test_jwt_mulitple_issuers_same_kids_same_pem(network, args) @@ -1089,18 +968,18 @@ def run_auto(args): network.wait_for_new_primary(primary) test_jwt_key_auto_refresh(network, args) # Check that we can refresh keys for Entra endpoint - test_jwt_key_refresh_aad(network, args, ascending=True) - test_jwt_key_refresh_aad(network, args, ascending=False) + test_jwt_key_refresh_aad(network, args) test_jwt_key_auto_refresh_entries(network, args) test_malformed_tokens(network, args) def run_manual(args): - with infra.network.network( + with NodeTrustStore() as trust_store, infra.network.network( args.nodes, args.binary_dir, args.debug_nodes, pdb=args.pdb ) as network: - network.start_and_open(args) + args.node_trust_store = trust_store + network.start_and_open(args, env=trust_store.env) test_jwt_key_initial_refresh(network, args) # Check that initial refresh also works on backups @@ -1113,11 +992,3 @@ def run_manual(args): test_jwt_key_auto_refresh_invalid_metadata_issuer(network, args) test_jwt_key_auto_refresh_cross_authority_jwks_uri(network, args) test_jwt_key_auto_refresh_response_size_limit(network, args) - - -def run_ca_cert(args): - with infra.network.network( - args.nodes, args.binary_dir, args.debug_nodes, pdb=args.pdb - ) as network: - network.start_and_open(args) - ca_certs.test_cert_store(network, args) diff --git a/tests/lts_compatibility.py b/tests/lts_compatibility.py index efc9b5162136..5134aaf1587f 100644 --- a/tests/lts_compatibility.py +++ b/tests/lts_compatibility.py @@ -373,7 +373,10 @@ def run_code_upgrade_from( fv_skip_verify_chunking = infra.node.CCFVersion(from_version) < ccf621 tv_skip_verify_chunking = infra.node.CCFVersion(to_version) < ccf621 - with jwt_issuer.start_openid_server(): + with jwt_issuer.start_openid_server(), infra.jwt_issuer.NodeTrustStore() as trust_store: + # Nodes newer than 7.0.16 verify the OpenID server's certificate against + # their trust store rather than a governance-managed CA bundle. + trust_store.trust(jwt_issuer) txs = app.LoggingTxs(jwt_issuer=jwt_issuer) with infra.network.network( args.nodes, @@ -386,7 +389,7 @@ def run_code_upgrade_from( skip_verify_chunking=fv_skip_verify_chunking or tv_skip_verify_chunking, ) as network: disable_openapi_validation(network) - kwargs = {} + kwargs = {"env": trust_store.env} if not infra.node.CCFVersion(from_version) > infra.node.CCFVersion( "ccf-4.0.0-rc1" ): @@ -442,7 +445,7 @@ def run_code_upgrade_from( new_nodes = [] fetch_recent_snapshot = True for _ in range(len(old_nodes)): - kwargs = {} + kwargs = {"env": trust_store.env} kwargs["fetch_recent_snapshot"] = fetch_recent_snapshot if not fetch_recent_snapshot: kwargs["copy_ledger"] = True @@ -695,7 +698,10 @@ def run_ledger_compatibility_since_first( jwt_issuer = infra.jwt_issuer.JwtIssuer( "https://localhost", refresh_interval=args.jwt_key_refresh_interval_s ) - with jwt_issuer.start_openid_server(): + with jwt_issuer.start_openid_server(), infra.jwt_issuer.NodeTrustStore() as trust_store: + # Nodes newer than 7.0.16 verify the OpenID server's certificate against + # their trust store rather than a governance-managed CA bundle. + trust_store.trust(jwt_issuer) txs = app.LoggingTxs(jwt_issuer=jwt_issuer) for idx, (_, lts_release) in enumerate(lts_releases.items()): if lts_release: @@ -724,7 +730,7 @@ def run_ledger_compatibility_since_first( "version": version, "skip_verify_chunking": True, # Old ledger files will have incorrect chunking } - kwargs = {} + kwargs = {"env": trust_store.env} if not infra.node.CCFVersion(version) > infra.node.CCFVersion( "ccf-4.0.0-rc1" ): diff --git a/tests/programmability.py b/tests/programmability.py index e4b1f92ca9f8..4e59cdb74fd6 100644 --- a/tests/programmability.py +++ b/tests/programmability.py @@ -903,12 +903,4 @@ def run(args): forwarding_timeout_ms=jwt_forwarding_timeout_ms, ) - cr.add( - "ca_cert", - jwt_test.run_ca_cert, - package="samples/apps/logging/logging", - nodes=infra.e2e_args.max_nodes(cr.args, f=0), - forwarding_timeout_ms=jwt_forwarding_timeout_ms, - ) - cr.run()