diff --git a/CHANGELOG.md b/CHANGELOG.md index 30cbca2c3aa..71d8e1213d8 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.10]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.10 +### Added + +- TLS groups offered by node RPC interfaces can now be configured with the optional `tls.groups` node setting. Successful TLS handshakes report the negotiated group at debug level. The default group list remains `P-521`, `P-384`, and `P-256`. + ### Changed - `ccf::http::ParsedQuery` (in `include/ccf/http_query.h`), returned by `ccf::http::parse_query()`, is now a `std::multimap>` that owns its decoded keys and values, rather than a `std::multimap` pointing into the source query string. Owned storage is required because each key and value is now URL-decoded individually after splitting, which produces bytes not present in the original query. Application code that consumed the previous `std::string_view` keys/values may need to be updated (#8024). diff --git a/doc/host_config_schema/host_config.json b/doc/host_config_schema/host_config.json index ec7c776871c..76c1441f643 100644 --- a/doc/host_config_schema/host_config.json +++ b/doc/host_config_schema/host_config.json @@ -373,6 +373,32 @@ "description": "This section includes configuration of how the node should start (either start, join or recover) and associated information", "required": ["type"] }, + "tls": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "P-521", + "P-384", + "P-256", + "X25519", + "X448", + "X25519MLKEM768", + "SecP256r1MLKEM768", + "SecP384r1MLKEM1024" + ] + }, + "minItems": 1, + "default": ["P-521", "P-384", "P-256"], + "description": "Ordered list of OpenSSL TLS group names offered by this node. The first mutually supported group is preferred" + } + }, + "description": "Transport Layer Security configuration for RPC interfaces", + "additionalProperties": false + }, "node_certificate": { "type": "object", "properties": { diff --git a/doc/operations/configuration.rst b/doc/operations/configuration.rst index fb0b0d1bf00..b5198f47f46 100644 --- a/doc/operations/configuration.rst +++ b/doc/operations/configuration.rst @@ -20,6 +20,18 @@ The configuration for each CCF node must be contained in a single JSON configura .. include:: generated_config.rst +TLS groups +---------- + +The optional ``tls.groups`` setting contains an ordered list of OpenSSL TLS +group names offered by the node's RPC interfaces. The default remains +``["P-521", "P-384", "P-256"]``. A node fails to start if the list is empty or +contains a group that its OpenSSL providers do not support. Operators can use +this setting to deploy new groups without rebuilding CCF, while retaining +explicit control over compatibility and fallback order. CCF also rejects group +names outside its approved set, even if the active OpenSSL providers support +them. + IPv6 Addresses -------------- diff --git a/include/ccf/node/startup_config.h b/include/ccf/node/startup_config.h index 3d6cef41379..066f9008ae6 100644 --- a/include/ccf/node/startup_config.h +++ b/include/ccf/node/startup_config.h @@ -32,6 +32,14 @@ namespace ccf ccf::consensus::Configuration consensus = {}; ccf::NodeInfoNetwork network; + struct TLS + { + std::vector groups = {"P-521", "P-384", "P-256"}; + + bool operator==(const TLS&) const = default; + }; + TLS tls = {}; + struct NodeCertificateInfo { std::string subject_name = "CN=CCF Node"; diff --git a/src/common/configuration.h b/src/common/configuration.h index d11b775b26e..13b69393624 100644 --- a/src/common/configuration.h +++ b/src/common/configuration.h @@ -53,6 +53,10 @@ namespace ccf {LoggerLevel::FAIL, "Fail"}, {LoggerLevel::FATAL, "Fatal"}}); + DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(CCFConfig::TLS); + DECLARE_JSON_REQUIRED_FIELDS(CCFConfig::TLS); + DECLARE_JSON_OPTIONAL_FIELDS(CCFConfig::TLS, groups); + DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(CCFConfig::NodeCertificateInfo); DECLARE_JSON_REQUIRED_FIELDS(CCFConfig::NodeCertificateInfo); DECLARE_JSON_OPTIONAL_FIELDS( @@ -133,6 +137,7 @@ namespace ccf DECLARE_JSON_OPTIONAL_FIELDS( CCFConfig, worker_threads, + tls, node_certificate, consensus, ledger, diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index 5e864b7f9e0..f51e0234984 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -199,7 +199,8 @@ namespace ccf { start_type = start_type_; - rpcsessions->update_listening_interface_options(ccf_config_.network); + rpcsessions->update_listening_interface_options( + ccf_config_.network, ccf_config_.tls.groups); node->set_n2n_message_limit(ccf_config_.node_to_node_message_limit); diff --git a/src/enclave/rpc_sessions.h b/src/enclave/rpc_sessions.h index ec919cda804..304fc1bf5a5 100644 --- a/src/enclave/rpc_sessions.h +++ b/src/enclave/rpc_sessions.h @@ -63,6 +63,7 @@ namespace ccf nullptr; std::shared_ptr commit_callbacks_subsystem = nullptr; + std::vector tls_groups = {"P-521", "P-384", "P-256"}; ccf::pal::Mutex lock; std::unordered_map< @@ -194,10 +195,13 @@ namespace ccf } void update_listening_interface_options( - const ccf::NodeInfoNetwork& node_info) + const ccf::NodeInfoNetwork& node_info, + const std::vector& configured_tls_groups) { std::lock_guard guard(lock); + tls_groups = configured_tls_groups; + for (const auto& [name, interface] : node_info.rpc_interfaces) { auto& li = listening_interfaces[name]; @@ -361,7 +365,8 @@ namespace ccf listen_interface_id, per_listen_interface.max_open_sessions_soft); - auto ctx = std::make_unique<::tls::Server>(certs[listen_interface_id]); + auto ctx = std::make_unique<::tls::Server>( + certs[listen_interface_id], false, tls_groups); std::shared_ptr capped_session; if (per_listen_interface.app_protocol == "HTTP2") { @@ -442,7 +447,8 @@ namespace ccf { ctx = std::make_unique<::tls::Server>( certs[listen_interface_id], - per_listen_interface.app_protocol == "HTTP2"); + per_listen_interface.app_protocol == "HTTP2", + tls_groups); } auto session = make_server_session( diff --git a/src/host/run.cpp b/src/host/run.cpp index 15debb665bb..c6f07e8478f 100644 --- a/src/host/run.cpp +++ b/src/host/run.cpp @@ -41,6 +41,7 @@ #include "tcp.h" #include "ticker.h" #include "time_bound_logger.h" +#include "tls/context.h" #include "udp.h" #include @@ -974,6 +975,7 @@ namespace ccf } host::HostConfig config = config_json; + ccf::tls::validate_groups(config.tls.groups); if (config.logging.format == host::LogFormat::JSON) { diff --git a/src/node/node_state.h b/src/node/node_state.h index 2d7f2f84dee..f670212d150 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -59,6 +59,7 @@ #include "share_manager.h" #include "snapshots/fetch.h" #include "snapshots/filenames.h" +#include "tls/context.h" #include "uvm_endorsements.h" #include @@ -1132,6 +1133,8 @@ namespace ccf config.join.service_cert.data(), config.join.service_cert.size()); curl_handle.set_opt(CURLOPT_CAPATH, nullptr); + const auto tls_group_list = ccf::tls::groups_to_list(config.tls.groups); + curl_handle.set_opt(CURLOPT_SSL_EC_CURVES, tls_group_list.c_str()); // Bound each attempt so a stalled connection is eventually abandoned, // releasing the single-in-flight gate above so the periodic join timer diff --git a/src/tls/client.h b/src/tls/client.h index 4bbc5b65898..85a4e1deaa7 100644 --- a/src/tls/client.h +++ b/src/tls/client.h @@ -12,7 +12,11 @@ namespace tls std::shared_ptr cert; public: - Client(std::shared_ptr cert_) : Context(true), cert(std::move(cert_)) + Client( + std::shared_ptr cert_, + const std::vector& groups = {"P-521", "P-384", "P-256"}) : + Context(true, groups), + cert(std::move(cert_)) { cert->configure_ssl(ssl, cfg); } diff --git a/src/tls/context.h b/src/tls/context.h index 1c8a2a61440..ea9e43c62d2 100644 --- a/src/tls/context.h +++ b/src/tls/context.h @@ -7,12 +7,81 @@ #include "ds/internal_logger.h" #include "tls/tls.h" +#include +#include +#include #include #include #include +#include +#include +#include +#include +#include namespace ccf::tls { + inline constexpr std::array approved_groups = { + "P-521", + "P-384", + "P-256", + "X25519", + "X448", + "X25519MLKEM768", + "SecP256r1MLKEM768", + "SecP384r1MLKEM1024"}; + + inline std::string groups_to_list(const std::vector& groups) + { + if (groups.empty()) + { + throw std::invalid_argument("TLS groups must not be empty"); + } + + std::string group_list; + for (const auto& group : groups) + { + constexpr size_t max_group_name_size = 64; + const auto valid_character = [](const unsigned char c) { + return std::isalnum(c) != 0 || c == '-' || c == '_' || c == '.'; + }; + if ( + group.empty() || group.size() > max_group_name_size || + !std::all_of(group.begin(), group.end(), valid_character)) + { + throw std::invalid_argument( + "Invalid TLS group '" + group + + "': expected 1-64 alphanumeric, '-', '_' or '.' characters"); + } + if (std::find(approved_groups.begin(), approved_groups.end(), group) == + approved_groups.end()) + { + throw std::invalid_argument("TLS group '" + group + "' is not approved"); + } + + if (!group_list.empty()) + { + group_list += ':'; + } + group_list += group; + } + return group_list; + } + + inline void validate_groups(const std::vector& groups) + { + const auto group_list = groups_to_list(groups); + ccf::crypto::OpenSSL::Unique_SSL_CTX context(TLS_method()); + const auto rc = SSL_CTX_set1_groups_list(context, group_list.c_str()); + if (rc != 1) + { + const auto error_code = ERR_get_error(); + throw std::runtime_error( + "OpenSSL rejected TLS groups '" + group_list + "': " + + ccf::crypto::OpenSSL::error_string(error_code)); + } + } + class Context { protected: @@ -20,7 +89,9 @@ namespace ccf::tls ccf::crypto::OpenSSL::Unique_SSL ssl; public: - Context(bool client) : + Context( + bool client, + const std::vector& groups = {"P-521", "P-384", "P-256"}) : cfg(client ? TLS_client_method() : TLS_server_method()), ssl(cfg) { @@ -56,9 +127,13 @@ namespace ccf::tls SSL_CTX_set_ciphersuites(cfg, ciphersuites); SSL_set_ciphersuites(ssl, ciphersuites); - // Restrict the curves to approved ones - SSL_CTX_set1_curves_list(cfg, "P-521:P-384:P-256"); - SSL_set1_curves_list(ssl, "P-521:P-384:P-256"); + // Restrict the supported groups to those configured by the operator. + const auto group_list = groups_to_list(groups); + validate_groups(groups); + ccf::crypto::OpenSSL::CHECK1( + SSL_CTX_set1_groups_list(cfg, group_list.c_str())); + ccf::crypto::OpenSSL::CHECK1( + SSL_set1_groups_list(ssl, group_list.c_str())); // Allow buffer to be relocated between WANT_WRITE retries, and do partial // writes if possible @@ -82,6 +157,23 @@ namespace ccf::tls virtual ~Context() = default; + [[nodiscard]] std::optional get_negotiated_group() const + { + const auto group_id = SSL_get_negotiated_group(ssl); + if (group_id == 0) + { + return std::nullopt; + } + + const auto* group_name = SSL_group_to_name(ssl, group_id); + if (group_name == nullptr) + { + return std::nullopt; + } + + return group_name; + } + virtual void set_bio( void* cb_obj, BIO_callback_fn_ex send, BIO_callback_fn_ex recv) { @@ -110,7 +202,10 @@ namespace ccf::tls // Success in OpenSSL is 1, MBed is 0 if (rc > 0) { - LOG_TRACE_FMT("Context::handshake() : Success"); + const auto group = get_negotiated_group(); + LOG_DEBUG_FMT( + "Context::handshake() : Success, negotiated TLS group {}", + group.value_or("")); return 0; } diff --git a/src/tls/server.h b/src/tls/server.h index 3da1d877f2e..9bc5200112f 100644 --- a/src/tls/server.h +++ b/src/tls/server.h @@ -43,8 +43,11 @@ namespace tls std::shared_ptr cert; public: - Server(const std::shared_ptr& cert_, bool http2 = false) : - Context(false), + Server( + const std::shared_ptr& cert_, + bool http2 = false, + const std::vector& groups = {"P-521", "P-384", "P-256"}) : + Context(false, groups), cert(cert_) { cert->configure_ssl(ssl, cfg); diff --git a/src/tls/test/main.cpp b/src/tls/test/main.cpp index 2791ef16775..470c398ec9e 100644 --- a/src/tls/test/main.cpp +++ b/src/tls/test/main.cpp @@ -317,6 +317,18 @@ TEST_CASE("Cert configures TLS verification and own certificate") REQUIRE(SSL_get_certificate(ssl) != nullptr); } +TEST_CASE("Invalid TLS group configuration is rejected") +{ + REQUIRE_THROWS_AS( + ccf::tls::Context(true, std::vector{}), std::invalid_argument); + REQUIRE_THROWS_AS( + ccf::tls::Context(true, {"not-a-real-group"}), std::invalid_argument); + REQUIRE_THROWS_AS( + ccf::tls::Context(true, {"P-256:P-384"}), std::invalid_argument); + REQUIRE_THROWS_AS( + ccf::tls::Context(true, {"P-256\nforged-log-line"}), std::invalid_argument); +} + /// Helper to write past the maximum buffer (16k) int write_helper(ccf::tls::Context& handler, const uint8_t* buf, size_t len) { @@ -355,13 +367,15 @@ void run_test_case( const uint8_t* response, size_t response_length, unique_ptr<::tls::Cert> server_cert, - unique_ptr<::tls::Cert> client_cert) + unique_ptr<::tls::Cert> client_cert, + const std::vector& groups = {"P-521", "P-384", "P-256"}, + const std::optional& expected_group = std::nullopt) { std::vector buf(max(message_length, response_length) + 1); // Create a pair of client/server - tls::Server server(std::move(server_cert)); - tls::Client client(std::move(client_cert)); + tls::Server server(std::move(server_cert), false, groups); + tls::Client client(std::move(client_cert), groups); // Connect BIOs together TestPipe pipe; @@ -415,6 +429,17 @@ void run_test_case( throw *server_exception; } + const auto client_group = client.get_negotiated_group(); + const auto server_group = server.get_negotiated_group(); + REQUIRE(client_group.has_value()); + REQUIRE(server_group.has_value()); + INFO("Negotiated TLS group: ", client_group.value()); + REQUIRE(client_group == server_group); + if (expected_group.has_value()) + { + REQUIRE(client_group == expected_group); + } + // The rest of the communication is deterministic and easy to simulate // so we take them out of the thread, to guarantee there will be bytes // to read at the right time. @@ -466,6 +491,23 @@ void run_test_case( server.close(); } +TEST_CASE("configured TLS group") +{ + auto ca = get_ca(); + auto server_cert = get_dummy_cert(ca, "server", false); + auto client_cert = get_dummy_cert(ca, "client", false); + + run_test_case( + (const uint8_t*)"", + 0, + (const uint8_t*)"", + 0, + std::move(server_cert), + std::move(client_cert), + {"P-256"}, + "secp256r1"); +} + TEST_CASE("unverified handshake") { // Create a CA