diff --git a/src/iocore/net/CMakeLists.txt b/src/iocore/net/CMakeLists.txt index d1d5875ab46..9dcfa22dc73 100644 --- a/src/iocore/net/CMakeLists.txt +++ b/src/iocore/net/CMakeLists.txt @@ -149,7 +149,7 @@ if(BUILD_TESTING) unit_tests/benchmark_TLSCertCompression.cc ) if(SSLLIB_IS_OPENSSL3) - target_sources(test_net PRIVATE unit_tests/test_SSLDHParams.cc) + target_sources(test_net PRIVATE unit_tests/test_SSLDHParams.cc unit_tests/MockHardwareProvider.cc) endif() # Use link groups to solve circular dependency set(LINK_GROUP_LIBS diff --git a/src/iocore/net/P_SSLUtils.h b/src/iocore/net/P_SSLUtils.h index af5728e4869..498c6e50dae 100644 --- a/src/iocore/net/P_SSLUtils.h +++ b/src/iocore/net/P_SSLUtils.h @@ -28,6 +28,8 @@ #ifdef OPENSSL_IS_OPENSSL3 #include #include +#include +#include #endif #define OPENSSL_THREAD_DEFINES #if __has_include() @@ -130,6 +132,38 @@ namespace detail OSSL_DECODER_CTX_free(dctx); } }; + + struct StoreCTXDeleter { + void + operator()(OSSL_STORE_CTX *sctx) + { + OSSL_STORE_close(sctx); + } + }; + + struct StoreInfoDeleter { + void + operator()(OSSL_STORE_INFO *info) + { + OSSL_STORE_INFO_free(info); + } + }; + + struct PKEYDeleter { + void + operator()(EVP_PKEY *pkey) + { + EVP_PKEY_free(pkey); + } + }; + + struct UIMethodDeleter { + void + operator()(UI_METHOD *ui) + { + UI_destroy_method(ui); + } + }; #endif } // namespace detail @@ -159,4 +193,8 @@ using scoped_BIO = std::unique_ptr; #ifdef OPENSSL_IS_OPENSSL3 using scoped_PKEY_CTX = std::unique_ptr; using scoped_Decoder_CTX = std::unique_ptr; +using scoped_Store_CTX = std::unique_ptr; +using scoped_Store_Info = std::unique_ptr; +using scoped_PKEY = std::unique_ptr; +using scoped_UI_Method = std::unique_ptr; #endif diff --git a/src/iocore/net/SSLKeyUtils.cc b/src/iocore/net/SSLKeyUtils.cc index 4bf14c5f473..6d5f40eb5c7 100644 --- a/src/iocore/net/SSLKeyUtils.cc +++ b/src/iocore/net/SSLKeyUtils.cc @@ -23,22 +23,26 @@ #include "P_SSLUtils.h" #include -#ifdef OPENSSL_IS_OPENSSL3 #include -#else #include + +#include +#include +#include +#include +#include + +#if HAVE_ENGINE_GET_DEFAULT_RSA && HAVE_ENGINE_LOAD_PRIVATE_KEY +#include #endif #ifdef OPENSSL_IS_OPENSSL3 -#include #include #include -#include +#include +#include #else -#include #include -#include -#include #endif #ifdef OPENSSL_IS_OPENSSL3 @@ -188,3 +192,113 @@ set_ctx_dh(SSL_CTX *ctx, dh_key_t *pkey) } #endif // OPENSSL_IS_OPENSSL3 + +// A hardware-backed key is named rather than stored: the name resolves through +// the device and there is no file holding the key. Both versions below therefore +// ask the hardware before falling back to reading keyPath as a file, which is +// also the order ATS used when this was ENGINE-only, so that a configured device +// keeps precedence over any same-named file on disk. + +#ifdef OPENSSL_IS_OPENSSL3 + +bool +use_pkey_from_file(SSL_CTX *ctx, const char *keyPath) +{ + ink_assert(keyPath && keyPath[0] != '\0'); + + // A store prompts for a PIN or passphrase through a UI_METHOD rather than the + // pem_password_cb an SSL_CTX carries, so wrap the configured callback. The + // wrapper invokes the callback unconditionally once prompted, so leave the + // UI_METHOD null when there is nothing to wrap rather than hand it a null + // callback to call. + pem_password_cb *password_cb = SSL_CTX_get_default_passwd_cb(ctx); + scoped_UI_Method ui{password_cb ? UI_UTIL_wrap_read_pem_callback(password_cb, 0) : nullptr}; + + // OpenSSL 3 reaches a hardware key store through a provider, which exposes + // its keys as an OSSL_STORE under its own URI scheme -- "pkcs11:" for a + // PKCS#11 provider fronting an HSM, for instance. + scoped_Store_CTX store{OSSL_STORE_open(keyPath, ui.get(), SSL_CTX_get_default_passwd_cb_userdata(ctx), nullptr, nullptr)}; + + if (store) { + // Announcing the one type wanted lets a loader retrieve it directly rather + // than enumerate the whole store. It does not make a single load sufficient: + // a loader that ignores the hint still yields other objects first, which the + // generic layer drops by returning nullptr without reaching EOF. + OSSL_STORE_expect(store.get(), OSSL_STORE_INFO_PKEY); + } + + // A store URI may name a whole collection -- a token's worth of objects, of + // which only some are keys -- so scan until a private key turns up. + scoped_PKEY pkey; + while (store && !pkey && !OSSL_STORE_eof(store.get())) { + scoped_Store_Info info{OSSL_STORE_load(store.get())}; + if (info && OSSL_STORE_INFO_get_type(info.get()) == OSSL_STORE_INFO_PKEY) { + pkey.reset(OSSL_STORE_INFO_get1_PKEY(info.get())); + } + // A single object failing to load is not fatal; OSSL_STORE_eof reports true + // once the store itself gives up, so the loop terminates either way. + } + + if (pkey && SSL_CTX_use_PrivateKey(ctx, pkey.get())) { + return true; + } + + // OSSL_STORE also handles plain paths, but only for keys it can decode + // itself, so getting here says nothing about whether keyPath names a loadable + // file. Not finding the key in hardware is the ordinary case for a file-based + // configuration, so leave no errors behind for the caller to misread. + ERR_clear_error(); + + return 1 == SSL_CTX_use_PrivateKey_file(ctx, keyPath, SSL_FILETYPE_PEM); +} + +#else + +bool +use_pkey_from_file(SSL_CTX *ctx, const char *keyPath) +{ + ink_assert(keyPath && keyPath[0] != '\0'); + +#if HAVE_ENGINE_GET_DEFAULT_RSA && HAVE_ENGINE_LOAD_PRIVATE_KEY + // Before providers, a hardware key store was reached through an ENGINE. + // Absent a configured engine there is nothing to ask. + if (ENGINE *e = ENGINE_get_default_RSA(); e != nullptr) { + EVP_PKEY *pkey = ENGINE_load_private_key(e, keyPath, nullptr, nullptr); + + if (pkey != nullptr) { + bool const result{1 == SSL_CTX_use_PrivateKey(ctx, pkey)}; + + EVP_PKEY_free(pkey); + if (result) { + return true; + } + } + // Not finding the key in hardware is the ordinary case for a file-based + // configuration, so leave no errors behind for the caller to misread. + ERR_clear_error(); + } +#endif + + return 1 == SSL_CTX_use_PrivateKey_file(ctx, keyPath, SSL_FILETYPE_PEM); +} + +#endif // OPENSSL_IS_OPENSSL3 + +bool +use_pkey_from_secret_data(SSL_CTX *ctx, const char *secret_data, int secret_data_len) +{ + scoped_BIO bio(BIO_new_mem_buf(secret_data, secret_data_len)); + + pem_password_cb *password_cb = SSL_CTX_get_default_passwd_cb(ctx); + void *u = SSL_CTX_get_default_passwd_cb_userdata(ctx); + EVP_PKEY *pkey = PEM_read_bio_PrivateKey(bio.get(), nullptr, password_cb, u); + if (nullptr == pkey) { + return false; + } + if (!SSL_CTX_use_PrivateKey(ctx, pkey)) { + EVP_PKEY_free(pkey); + return false; + } + EVP_PKEY_free(pkey); + return true; +} diff --git a/src/iocore/net/SSLKeyUtils.h b/src/iocore/net/SSLKeyUtils.h index 64bea23e9ea..b207ef10892 100644 --- a/src/iocore/net/SSLKeyUtils.h +++ b/src/iocore/net/SSLKeyUtils.h @@ -40,3 +40,6 @@ dh_key_t *load_dhparams_file(char const *dhparams_file); // Takes ownership of pkey. bool set_ctx_dh(SSL_CTX *ctx, dh_key_t *pkey); + +bool use_pkey_from_file(SSL_CTX *ctx, const char *keyPath); +bool use_pkey_from_secret_data(SSL_CTX *ctx, const char *secret_data, int secret_data_len); diff --git a/src/iocore/net/SSLUtils.cc b/src/iocore/net/SSLUtils.cc index 9c865bbbf80..3313a1e029f 100644 --- a/src/iocore/net/SSLUtils.cc +++ b/src/iocore/net/SSLUtils.cc @@ -856,43 +856,24 @@ SSLMultiCertConfigLoader::default_server_ssl_ctx() static bool SSLPrivateKeyHandler(SSL_CTX *ctx, const char *keyPath, const char *secret_data, int secret_data_len) { - EVP_PKEY *pkey = nullptr; -#if HAVE_ENGINE_GET_DEFAULT_RSA && HAVE_ENGINE_LOAD_PRIVATE_KEY - ENGINE *e = ENGINE_get_default_RSA(); - if (e != nullptr) { - pkey = ENGINE_load_private_key(e, keyPath, nullptr, nullptr); - if (pkey) { - if (!SSL_CTX_use_PrivateKey(ctx, pkey)) { - Dbg(dbg_ctl_ssl_load, "failed to load server private key from engine"); - EVP_PKEY_free(pkey); - return false; - } - } + bool result{false}; + if (keyPath && keyPath[0] != '\0') { + result = use_pkey_from_file(ctx, keyPath); } -#else - void *e = nullptr; -#endif - if (pkey == nullptr) { - scoped_BIO bio(BIO_new_mem_buf(secret_data, secret_data_len)); - - pem_password_cb *password_cb = SSL_CTX_get_default_passwd_cb(ctx); - void *u = SSL_CTX_get_default_passwd_cb_userdata(ctx); - pkey = PEM_read_bio_PrivateKey(bio.get(), nullptr, password_cb, u); - if (nullptr == pkey) { - Dbg(dbg_ctl_ssl_load, "failed to load server private key (%.*s) from %s", secret_data_len < 50 ? secret_data_len : 50, - secret_data, (!keyPath || keyPath[0] == '\0') ? "[empty key path]" : keyPath); - return false; - } - if (!SSL_CTX_use_PrivateKey(ctx, pkey)) { - Dbg(dbg_ctl_ssl_load, "failed to attach server private key loaded from %s", - (!keyPath || keyPath[0] == '\0') ? "[empty key path]" : keyPath); - EVP_PKEY_free(pkey); - return false; - } - if (e == nullptr && !SSL_CTX_check_private_key(ctx)) { - Dbg(dbg_ctl_ssl_load, "server private key does not match the certificate public key"); - return false; - } + + if (!result) { + result = use_pkey_from_secret_data(ctx, secret_data, secret_data_len); + } + + if (!result) { + Dbg(dbg_ctl_ssl_load, "failed to load server private key (%.*s) from %s", secret_data_len < 50 ? secret_data_len : 50, + secret_data, (!keyPath || keyPath[0] == '\0') ? "[empty key path]" : keyPath); + return false; + } + + if (!SSL_CTX_check_private_key(ctx)) { + Dbg(dbg_ctl_ssl_load, "server private key does not match the certificate public key"); + return false; } return true; diff --git a/src/iocore/net/unit_tests/MockHardwareProvider.cc b/src/iocore/net/unit_tests/MockHardwareProvider.cc new file mode 100644 index 00000000000..397210d0578 --- /dev/null +++ b/src/iocore/net/unit_tests/MockHardwareProvider.cc @@ -0,0 +1,184 @@ +/** @file + + A minimal OpenSSL 3 provider for unit tests, standing in for an HSM or other + hardware-backed key store. + + @section license License + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#include "MockHardwareProvider.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace MockHardwareProvider +{ + +char const SCHEME[]{"hwtest"}; +char const URI[]{"hwtest:server-key"}; + +namespace +{ + + // The key material a load of URI yields, PEM-encoded. Owned by ScopedProvider, + // which is not copyable and is intended for use by one test at a time. + std::string key_pem; + + struct LoaderCtx { + bool exhausted{false}; + }; + + void * + store_open(void * /* provctx */, char const *uri) + { + if (std::strncmp(uri, SCHEME, sizeof(SCHEME) - 1) != 0) { + return nullptr; + } + return new LoaderCtx{}; + } + + int + store_load(void *loaderctx, OSSL_CALLBACK *object_cb, void *object_cbarg, OSSL_PASSPHRASE_CALLBACK * /* pw_cb */, + void * /* pw_cbarg */) + { + auto *ctx = static_cast(loaderctx); + + if (ctx->exhausted) { + return 0; + } + ctx->exhausted = true; + + // Report the key as an unparsed PEM blob and let the default provider's + // decoders turn it into an EVP_PKEY, which is how a store loader with no + // opinion about encoding hands back an object. + int object_type{OSSL_OBJECT_PKEY}; + OSSL_PARAM params[3]; + + params[0] = OSSL_PARAM_construct_int(OSSL_OBJECT_PARAM_TYPE, &object_type); + params[1] = OSSL_PARAM_construct_octet_string(OSSL_OBJECT_PARAM_DATA, key_pem.data(), key_pem.size()); + params[2] = OSSL_PARAM_construct_end(); + return object_cb(params, object_cbarg); + } + + int + store_eof(void *loaderctx) + { + return static_cast(loaderctx)->exhausted ? 1 : 0; + } + + int + store_close(void *loaderctx) + { + delete static_cast(loaderctx); + return 1; + } + + // OSSL_STORE sets OSSL_STORE_PARAM_EXPECT when the caller wants one specific + // object type. This loader only ever yields a private key, so the hint is + // accepted and ignored. + OSSL_PARAM const * + store_settable_ctx_params(void * /* provctx */) + { + static OSSL_PARAM const known[]{ + OSSL_PARAM_int(OSSL_STORE_PARAM_EXPECT, nullptr), + OSSL_PARAM_END, + }; + return known; + } + + int + store_set_ctx_params(void * /* loaderctx */, OSSL_PARAM const /* params */[]) + { + return 1; + } + + OSSL_DISPATCH const store_functions[]{ + {OSSL_FUNC_STORE_OPEN, reinterpret_cast(store_open) }, + {OSSL_FUNC_STORE_LOAD, reinterpret_cast(store_load) }, + {OSSL_FUNC_STORE_EOF, reinterpret_cast(store_eof) }, + {OSSL_FUNC_STORE_CLOSE, reinterpret_cast(store_close) }, + {OSSL_FUNC_STORE_SETTABLE_CTX_PARAMS, reinterpret_cast(store_settable_ctx_params)}, + {OSSL_FUNC_STORE_SET_CTX_PARAMS, reinterpret_cast(store_set_ctx_params) }, + {0, nullptr }, + }; + + OSSL_ALGORITHM const store_algorithms[]{ + {SCHEME, "provider=hwtest", store_functions, "mock hardware key store"}, + {nullptr, nullptr, nullptr, nullptr }, + }; + + OSSL_ALGORITHM const * + query_operation(void * /* provctx */, int operation_id, int *no_cache) + { + *no_cache = 0; + return operation_id == OSSL_OP_STORE ? store_algorithms : nullptr; + } + + OSSL_DISPATCH const provider_functions[]{ + {OSSL_FUNC_PROVIDER_QUERY_OPERATION, reinterpret_cast(query_operation)}, + {0, nullptr }, + }; + + int + provider_init(OSSL_CORE_HANDLE const *handle, OSSL_DISPATCH const * /* in */, OSSL_DISPATCH const **out, void **provctx) + { + *provctx = const_cast(handle); + *out = provider_functions; + return 1; + } + +} // namespace + +ScopedProvider::ScopedProvider(std::string const &pem) +{ + key_pem = pem; + if (1 != OSSL_PROVIDER_add_builtin(nullptr, SCHEME, provider_init)) { + return; + } + // Activating any provider replaces the implicit default, and the decoders + // that turn this provider's PEM blob into an EVP_PKEY live in the default + // provider, so load it explicitly alongside. + this->default_provider = OSSL_PROVIDER_load(nullptr, "default"); + this->hw_provider = OSSL_PROVIDER_load(nullptr, SCHEME); +} + +ScopedProvider::~ScopedProvider() +{ + if (this->hw_provider != nullptr) { + OSSL_PROVIDER_unload(this->hw_provider); + } + if (this->default_provider != nullptr) { + OSSL_PROVIDER_unload(this->default_provider); + } + key_pem.clear(); +} + +bool +ScopedProvider::is_loaded() const +{ + return this->default_provider != nullptr && this->hw_provider != nullptr; +} + +} // namespace MockHardwareProvider diff --git a/src/iocore/net/unit_tests/MockHardwareProvider.h b/src/iocore/net/unit_tests/MockHardwareProvider.h new file mode 100644 index 00000000000..c8cd46a99cd --- /dev/null +++ b/src/iocore/net/unit_tests/MockHardwareProvider.h @@ -0,0 +1,73 @@ +/** @file + + A minimal OpenSSL 3 provider for unit tests, standing in for an HSM or other + hardware-backed key store. + + @section license License + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#pragma once + +#include + +#include + +namespace MockHardwareProvider +{ + +// The provider name, which doubles as the URI scheme its store loader claims. +extern char const SCHEME[]; + +// A key URI this provider resolves, in the shape a hardware module's +// configuration would use. +extern char const URI[]; + +// Registers and activates the mock provider for the lifetime of the instance, +// and publishes the key material it should yield. +// +// The provider is registered with OSSL_PROVIDER_add_builtin, so everything stays +// in-process and no external module (pkcs11-provider, SoftHSM) is needed. +// +// Loading it takes the shape a real hardware module does: an OSSL_STORE loader +// for its own URI scheme. Real hardware keeps the key on the device and exposes +// only an operation handle; this mock returns ordinary key material instead, +// because what tests need from it is that the key is fetched through the +// provider at all, not that it is unextractable. +class ScopedProvider +{ +public: + // key_pem is the private key, PEM-encoded, that a load of URI will yield. + explicit ScopedProvider(std::string const &key_pem); + ScopedProvider(ScopedProvider const &) = delete; + ScopedProvider(ScopedProvider &&) = delete; + ScopedProvider &operator=(ScopedProvider const &) = delete; + ScopedProvider &operator=(ScopedProvider &&) = delete; + ~ScopedProvider(); + + // Whether registration and activation succeeded. Construction does not throw + // or assert so that callers can report the failure through their own test + // framework. + bool is_loaded() const; + +private: + OSSL_PROVIDER *default_provider{nullptr}; + OSSL_PROVIDER *hw_provider{nullptr}; +}; + +} // namespace MockHardwareProvider diff --git a/src/iocore/net/unit_tests/test_SSLDHParams.cc b/src/iocore/net/unit_tests/test_SSLDHParams.cc index 6a32a7458ba..93b68e59f75 100644 --- a/src/iocore/net/unit_tests/test_SSLDHParams.cc +++ b/src/iocore/net/unit_tests/test_SSLDHParams.cc @@ -1,9 +1,18 @@ /** @file - Catch based unit tests for the DH-parameter handling behavior of - SSLMultiCertConfigLoader::init_server_ssl_ctx, which is the inknet - public boundary that transitively invokes ssl_context_enable_dhe - and (when a file is configured) load_dhparams_file. + Catch based unit tests for two pieces of inknet SSL_CTX setup, each + exercised through its public SSLMultiCertConfigLoader boundary: + + * The DH-parameter handling of init_server_ssl_ctx, which transitively + invokes ssl_context_enable_dhe and (when a file is configured) + load_dhparams_file. + + * The private key handling of load_certs, which transitively invokes the + file-static SSLPrivateKeyHandler. + + * The loading of a private key held by an OpenSSL 3 provider, standing in + for an HSM or other hardware-backed key store. A mock provider is + registered in-process so the test needs no external module. @section license License @@ -28,6 +37,7 @@ #include "../P_SSLCertLookup.h" #include "../P_SSLConfig.h" #include "../P_SSLUtils.h" +#include "MockHardwareProvider.h" #include #include @@ -38,14 +48,25 @@ #include #include #include +#include #include +#include #include +#include #include namespace { +std::string +bio_to_string(BIO *bio) +{ + BUF_MEM *bm = nullptr; + REQUIRE(1 == BIO_get_mem_ptr(bio, &bm)); + return std::string{bm->data, bm->length}; +} + std::string make_valid_dh_pem() { @@ -62,31 +83,76 @@ make_valid_dh_pem() REQUIRE(EVP_PKEY_generate(pctx, &pkey) > 0); BIO *bio = BIO_new(BIO_s_mem()); + REQUIRE(bio != nullptr); REQUIRE(PEM_write_bio_Parameters(bio, pkey) == 1); - BUF_MEM *bm = nullptr; - BIO_get_mem_ptr(bio, &bm); - std::string out{bm->data, bm->length}; + std::string const out{bio_to_string(bio)}; BIO_free(bio); EVP_PKEY_free(pkey); EVP_PKEY_CTX_free(pctx); return out; } +// PEM-encodes pkey as a private key, optionally encrypting it with the given +// cipher and passphrase (cipher==nullptr leaves it unencrypted). std::string -make_rsa_pem() +key_to_pem(EVP_PKEY *pkey, EVP_CIPHER const *cipher, char *pass) { - EVP_PKEY *pkey = EVP_RSA_gen(2048); - REQUIRE(pkey != nullptr); BIO *bio = BIO_new(BIO_s_mem()); - REQUIRE(PEM_write_bio_PrivateKey(bio, pkey, nullptr, nullptr, 0, nullptr, nullptr) == 1); - BUF_MEM *bm = nullptr; - BIO_get_mem_ptr(bio, &bm); - std::string out{bm->data, bm->length}; + REQUIRE(bio != nullptr); + int passlen{pass ? static_cast(std::strlen(pass)) : 0}; + REQUIRE(PEM_write_bio_PrivateKey(bio, pkey, cipher, reinterpret_cast(pass), passlen, nullptr, nullptr) == 1); + std::string out{bio_to_string(bio)}; BIO_free(bio); + return out; +} + +std::string +make_rsa_pem() +{ + EVP_PKEY *pkey = EVP_RSA_gen(2048); + std::string const out{key_to_pem(pkey, nullptr, nullptr)}; EVP_PKEY_free(pkey); return out; } +// A self-signed certificate paired with the matching 2048-bit RSA private key, +// both PEM-encoded. Each call produces a fresh, independent key pair. When a +// cipher is given the key PEM is encrypted under the passphrase. +struct CertAndKey { + std::string cert_pem; + std::string key_pem; +}; + +CertAndKey +make_cert_and_key(EVP_CIPHER const *cipher = nullptr, char *pass = nullptr) +{ + EVP_PKEY *pkey = EVP_RSA_gen(2048); + REQUIRE(pkey != nullptr); + + X509 *x509 = X509_new(); + REQUIRE(x509 != nullptr); + ASN1_INTEGER_set(X509_get_serialNumber(x509), 1); + X509_gmtime_adj(X509_getm_notBefore(x509), 0); + X509_gmtime_adj(X509_getm_notAfter(x509), 60L * 60L * 24L * 365L); + REQUIRE(X509_set_pubkey(x509, pkey) == 1); + + X509_NAME *name = X509_get_subject_name(x509); + X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, reinterpret_cast("ats-test"), -1, -1, 0); + REQUIRE(X509_set_issuer_name(x509, name) == 1); + REQUIRE(X509_sign(x509, pkey, EVP_sha256()) > 0); + + BIO *cert_bio = BIO_new(BIO_s_mem()); + REQUIRE(cert_bio != nullptr); + REQUIRE(PEM_write_bio_X509(cert_bio, x509) == 1); + std::string const cert_pem{bio_to_string(cert_bio)}; + BIO_free(cert_bio); + X509_free(x509); + + std::string const key_pem{key_to_pem(pkey, cipher, pass)}; + EVP_PKEY_free(pkey); + return {cert_pem, key_pem}; +} + class TempFile { public: @@ -140,6 +206,55 @@ init_with_dhparams(char const *dhparams_file) return ok; } +// A fixed-passphrase callback, matching how SSLPrivateKeyHandler consults the +// SSL_CTX default password callback to decrypt an encrypted private key. +char test_passphrase[]{"ats-secret-pass"}; + +int +fixed_passphrase_cb(char *buf, int size, int /* rwflag */, void * /* u */) +{ + int len{static_cast(std::strlen(test_passphrase))}; + if (len > size) { + len = size; + } + std::memcpy(buf, test_passphrase, len); + return len; +} + +// Drives SSLPrivateKeyHandler via the public static load_certs boundary, +// holding the certificate fixed and valid so the only variable under test is +// the private key material. The certificate and key are read from real files, +// exactly as a production ssl_multicert entry would be, so that the file-load +// path (load_rsa_pkey_from_file) is genuinely exercised. +// +// An empty key_path selects the "key bundled in the certificate file" branch, +// where the file load is skipped and the key is read from the certificate +// secret. A non-null passwd_cb is installed as the SSL_CTX default password +// callback, exactly as init_server_ssl_ctx's dialog setup would do for an +// encrypted key. +bool +load_key_via_load_certs(char const *cert_path, char const *key_path, pem_password_cb *passwd_cb = nullptr) +{ + SSLConfigParams params; + SSLMultiCertConfigParams settings; + settings.cert = ats_strdup(cert_path); + + SSLMultiCertConfigLoader::CertLoadData data; + data.cert_names_list.emplace_back(cert_path); + data.key_list.emplace_back(key_path); + + SSL_CTX *ctx = SSL_CTX_new(TLS_server_method()); + REQUIRE(ctx != nullptr); + if (passwd_cb != nullptr) { + SSL_CTX_set_default_passwd_cb(ctx, passwd_cb); + } + + bool ok = SSLMultiCertConfigLoader::load_certs(ctx, data.cert_names_list, data.key_list, data, ¶ms, &settings); + + SSL_CTX_free(ctx); + return ok; +} + } // namespace TEST_CASE("ssl_context_enable_dhe: nullptr dhparams file falls back to built-in DH parameters") @@ -184,3 +299,65 @@ TEST_CASE("ssl_context_enable_dhe: truncated DH PEM (missing END marker) is reje TempFile truncated{pem.substr(0, end)}; CHECK_FALSE(init_with_dhparams(truncated.get_path())); } + +TEST_CASE("SSLPrivateKeyHandler: a key file matching the certificate is loaded") +{ + CertAndKey ck = make_cert_and_key(); + TempFile cert{ck.cert_pem}; + TempFile key{ck.key_pem}; + CHECK(load_key_via_load_certs(cert.get_path(), key.get_path())); +} + +TEST_CASE("SSLPrivateKeyHandler: an empty key path loads the key bundled in the certificate file") +{ + CertAndKey ck = make_cert_and_key(); + TempFile cert{ck.cert_pem + ck.key_pem}; + CHECK(load_key_via_load_certs(cert.get_path(), "")); +} + +TEST_CASE("SSLPrivateKeyHandler: a valid key file not matching the certificate is rejected") +{ + TempFile cert{make_cert_and_key().cert_pem}; + TempFile key{make_cert_and_key().key_pem}; + CHECK_FALSE(load_key_via_load_certs(cert.get_path(), key.get_path())); +} + +TEST_CASE("SSLPrivateKeyHandler: an unparseable key file is rejected") +{ + TempFile cert{make_cert_and_key().cert_pem}; + TempFile key{"-----BEGIN PRIVATE KEY-----\nnot base64\n-----END PRIVATE KEY-----\n"}; + CHECK_FALSE(load_key_via_load_certs(cert.get_path(), key.get_path())); +} + +TEST_CASE("SSLPrivateKeyHandler: an encrypted key file is decrypted via the SSL_CTX password callback") +{ + CertAndKey ck = make_cert_and_key(EVP_aes_256_cbc(), test_passphrase); + TempFile cert{ck.cert_pem}; + TempFile key{ck.key_pem}; + CHECK(load_key_via_load_certs(cert.get_path(), key.get_path(), fixed_passphrase_cb)); +} + +TEST_CASE("SSLPrivateKeyHandler: an encrypted key file with the wrong passphrase is rejected") +{ + char wrong_pass[]{"the-wrong-passphrase"}; + CertAndKey ck = make_cert_and_key(EVP_aes_256_cbc(), wrong_pass); + TempFile cert{ck.cert_pem}; + TempFile key{ck.key_pem}; + CHECK_FALSE(load_key_via_load_certs(cert.get_path(), key.get_path(), fixed_passphrase_cb)); +} + +// A hardware-backed key is named by a provider URI rather than a path, and the +// key material never appears in the certificate secret. Loading one therefore +// has to go through the provider: there is no file to read, so the secret-data +// path has nothing to fall back to. +// +// This is the case an ENGINE-based ATS handled via ENGINE_load_private_key. +TEST_CASE("SSLPrivateKeyHandler: a key named by an OpenSSL provider URI is loaded from the provider") +{ + CertAndKey ck = make_cert_and_key(); + MockHardwareProvider::ScopedProvider provider{ck.key_pem}; + REQUIRE(provider.is_loaded()); + + TempFile cert{ck.cert_pem}; + CHECK(load_key_via_load_certs(cert.get_path(), MockHardwareProvider::URI)); +} diff --git a/src/iocore/net/unit_tests/unit_test_main.cc b/src/iocore/net/unit_tests/unit_test_main.cc index 25b355f0b64..41b96438d98 100644 --- a/src/iocore/net/unit_tests/unit_test_main.cc +++ b/src/iocore/net/unit_tests/unit_test_main.cc @@ -23,6 +23,7 @@ #include "iocore/eventsystem/EventSystem.h" #include "../P_SSLConfig.h" +#include "api/LifecycleAPIHooks.h" #include "records/RecordsConfig.h" #include "tscore/BaseLogFile.h" #include "tscore/Diags.h" @@ -55,6 +56,10 @@ class EventProcessorListener final : public Catch::EventListenerBase RecProcessInit(); LibRecordsConfigInit(); + // SSLSecret::loadSecret consults the global lifecycle hooks for the + // SSL_SECRET hook, so they must be allocated before any secret is loaded. + init_global_lifecycle_hooks(); + ink_event_system_init(EVENT_SYSTEM_MODULE_PUBLIC_VERSION); eventProcessor.start(test_threads);