diff --git a/src/iocore/net/unit_tests/test_SSLDHParams.cc b/src/iocore/net/unit_tests/test_SSLDHParams.cc index 6a32a7458ba..3e75f5fe097 100644 --- a/src/iocore/net/unit_tests/test_SSLDHParams.cc +++ b/src/iocore/net/unit_tests/test_SSLDHParams.cc @@ -1,9 +1,14 @@ /** @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. @section license License @@ -38,14 +43,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 +78,77 @@ 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 +key_to_pem(EVP_PKEY *pkey, EVP_CIPHER const *cipher, char *pass) +{ + BIO *bio = BIO_new(BIO_s_mem()); + 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); 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}; - BIO_free(bio); + 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 +202,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 +295,49 @@ 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)); +} 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);