diff --git a/CMakeLists.txt b/CMakeLists.txt index a000112..04a7e1d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,12 @@ find_package(MySQL REQUIRED) find_package(ZLIB REQUIRED) find_package(AWSSDK 1.11.774 EXACT REQUIRED COMPONENTS s3-crt) +# minimysql_server needs OpenSSL::SSL for boost::asio::ssl (server-side TLS +# listener). OpenSSL::Crypto is used by several other targets as well and +# was previously picked up transitively via AWS SDK; make the dependency +# explicit now that we also need the SSL half of OpenSSL. +find_package(OpenSSL REQUIRED) + # various utility files set(util_source_files src/util/bnf_parser_helpers.hpp @@ -615,9 +621,11 @@ set(minimysql_source_files src/minimysql/connection_context.cpp src/minimysql/network_io_operations_fwd.hpp src/minimysql/network_io_operations.hpp - src/minimysql/network_io_operations.cpp src/minimysql/network_service.hpp src/minimysql/network_service.cpp + src/minimysql/ssl_acceptor_context_fwd.hpp + src/minimysql/ssl_acceptor_context.hpp + src/minimysql/ssl_acceptor_context.cpp src/minimysql/sample_event_collection.hpp src/minimysql/sample_event_collection.cpp @@ -631,6 +639,7 @@ target_link_libraries(minimysql_server PRIVATE binlog_server_compiler_flags Boost::headers Boost::asio + OpenSSL::SSL OpenSSL::Crypto ) diff --git a/src/minimysql/caching_sha2_password_authenticator.cpp b/src/minimysql/caching_sha2_password_authenticator.cpp index bc7ae81..bd06c92 100644 --- a/src/minimysql/caching_sha2_password_authenticator.cpp +++ b/src/minimysql/caching_sha2_password_authenticator.cpp @@ -19,15 +19,63 @@ #include #include #include +#include +#include #include +#include #include +#include #include #include #include +#include +#include +#include #include +#include +#include #include +#include "minimysql/network_io_operations_fwd.hpp" + +// clang-format off +// caching_sha2_password authentication flow (minimysql mock): +// +// Client Server (this authenticator) +// | | +// |--- Handshake (plugin, scramble) ------->| +// | | +// |<-- AuthSwitch (plugin mismatch only) ---| needs_auth_method_switch() +// |--- Auth response (scramble) ----------->| begin_authentication() +// | | +// | [fast path — always tried first; no SHA2 digest cache; behaves as +// | permanent cache hit via verify_greeting_scramble(), even first conn] +// | | +// |<-- AuthMoreData 0x01|0x03 --------------| scramble matches password +// |<-- OK ----------------------------------| +// | | +// | [full auth — when scramble does not match; real server: cache miss] +// | | +// |<-- AuthMoreData 0x01|0x04 --------------| perform full authentication +// | | +// | Client chooses password encoding (server accepts per transport): +// | | +// | (A) secure transport [TLS stub; connection_is_secure() false today] +// |--- cleartext password (0-terminated) ->| verify_cleartext_password() +// |<-- OK / Access denied ------------------| +// | | +// | (B) plain TCP — client opts in to RSA (server expects ciphertext) +// |--- 0x02 Request public key (optional) ->| --get-server-public-key +// |<-- AuthMoreData 0x01|PEM ---------------| enqueue_public_key() +// | | (skip 0x02 via +// | | --server-public-key-path) +// |--- RSA-OAEP encrypted password -------->| verify_encrypted_password() +// |<-- OK / Access denied ------------------| +// | | +// | (C) plain TCP, no RSA flags — client fails locally before sending +// | ("Authentication requires secure connection.") +// clang-format on namespace { enum class digest_code_type : std::uint8_t { @@ -36,7 +84,6 @@ enum class digest_code_type : std::uint8_t { class digest_context { public: - // no std::string_view for 'type' as we need it to be nul-terminated explicit digest_context(digest_code_type digest_code) : impl_{EVP_MD_CTX_new(), digest_context_deleter{}} { if (!impl_) { @@ -118,29 +165,358 @@ std::string calculate_digest(digest_code_type digest_code, return ctx.finalize(); } +void xor_with_pattern(std::span data, std::string_view pattern) { + if (std::empty(pattern)) { + return; + } + + for (std::size_t index{0U}; index < std::size(data); ++index) { + data[index] = static_cast( + static_cast(data[index]) ^ + static_cast(pattern[index % std::size(pattern)])); + } +} + +constexpr std::uint8_t request_public_key{0x02U}; +constexpr std::uint8_t perform_full_authentication{0x04U}; + +// Embedded server RSA keys used when no server_rsa_*_key_path is supplied. +// Temporary for standalone minimysql; binlog server integration should pass +// paths from config via network_service. +constexpr std::string_view default_rsa_public_key_pem{ + "-----BEGIN PUBLIC KEY-----\n" + "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvV2VNbsQPG0Bh0KC8F4z\n" + "CGXvMNcSicCiLXxeLWrJsmKZl0ggf2ydymYUUewq+dVxDdh85sdSvxEmtIWvKSRK\n" + "+RRCAURztq2Succd+24SF5IZYjlIJE/U0AYUxHzUcOsannfzui60IaTHpcBFHTJK\n" + "6myxGx9MORZmhfv580mfvz4yvgLjS5yGOIS6rlxD9YV1Y04Rx3SXQQBnC7rDBL91\n" + "ktNWvbclsonfytY19N9p+Gprms30yRT+BmPFB7TqpReeZa3ivg15g/z3BLNyvj3Y\n" + "KiQM3cd7ENJC2x2LRxL5pG684cFNStSjT4FvA+oh45UnU45aOSEjrxNkBG8ci0e+\n" + "VKX539rK+nDzTE/MHpnvfHp4DB+kSYBPuKHY2Eaw31NwPpfLWwEJPiDrktJJmRZq\n" + "ENMHLXksdiqGhvYmI33wZaZAfjbDZFMfPF5yBMBGDZ3aeNz5Le7uqS6g6XMOoiz/\n" + "d2S5RzRrCol1yqCBPtODjfFPC4K8GGYVkWZgSCf/PRt/DgDnZOfZSSYIQNeyr21e\n" + "mqgqQ+yhXEGKVjcDTKcbSLiWAdA+GkAzLAXXhafM8mrhpnGKdO4Or6ySz7G1vk2J\n" + "t2ZSdP740oVSJi59P9NEgXcbd3c4FzjXSOOsxfhPQfobUk3ikt55lN3fBX3mBvUd\n" + "uxNhAcQ02ZD5zXrX6+loiV8CAwEAAQ==\n" + "-----END PUBLIC KEY-----\n"}; + +constexpr std::string_view default_rsa_private_key_pem{ + "-----BEGIN RSA PRIVATE KEY-----\n" + "MIIJKQIBAAKCAgEAvV2VNbsQPG0Bh0KC8F4zCGXvMNcSicCiLXxeLWrJsmKZl0gg\n" + "f2ydymYUUewq+dVxDdh85sdSvxEmtIWvKSRK+RRCAURztq2Succd+24SF5IZYjlI\n" + "JE/U0AYUxHzUcOsannfzui60IaTHpcBFHTJK6myxGx9MORZmhfv580mfvz4yvgLj\n" + "S5yGOIS6rlxD9YV1Y04Rx3SXQQBnC7rDBL91ktNWvbclsonfytY19N9p+Gprms30\n" + "yRT+BmPFB7TqpReeZa3ivg15g/z3BLNyvj3YKiQM3cd7ENJC2x2LRxL5pG684cFN\n" + "StSjT4FvA+oh45UnU45aOSEjrxNkBG8ci0e+VKX539rK+nDzTE/MHpnvfHp4DB+k\n" + "SYBPuKHY2Eaw31NwPpfLWwEJPiDrktJJmRZqENMHLXksdiqGhvYmI33wZaZAfjbD\n" + "ZFMfPF5yBMBGDZ3aeNz5Le7uqS6g6XMOoiz/d2S5RzRrCol1yqCBPtODjfFPC4K8\n" + "GGYVkWZgSCf/PRt/DgDnZOfZSSYIQNeyr21emqgqQ+yhXEGKVjcDTKcbSLiWAdA+\n" + "GkAzLAXXhafM8mrhpnGKdO4Or6ySz7G1vk2Jt2ZSdP740oVSJi59P9NEgXcbd3c4\n" + "FzjXSOOsxfhPQfobUk3ikt55lN3fBX3mBvUduxNhAcQ02ZD5zXrX6+loiV8CAwEA\n" + "AQKCAgAfFO45zIOEt4uprOQbGgscVMbm6FZVn/W+q4w1vjJvAjodl6wl3ikkII8z\n" + "RyViroMI98DAjHTrgaAtv0eZ5CgeLBINbTPlByZvMdyc+Vsk3UknUymhNC1FG8pq\n" + "2eZwxlYvLpcltya/4vEWJrHxceDUC5UiU4fKUv/u/AXxxeLfnBDuGUE/luh8/GQ7\n" + "3E8XTJmQ/C5045E0DSHczgHWlKpyuBejuh0I6hJ+k5x1nfoh2S3iUe3c14I+gD/F\n" + "3Q8qm+7W16zA7ytD29Cbx+yMh1Ak0pf+CxELGMf6eSX0O4wYTkjYcUcDglVv5lnX\n" + "daWsWj4DO/lZKTRXN0KSa75uqg72Q1FjK//UNEigO99HYMsOWHBtaRzAwkklY5Da\n" + "5WHn3sxmfotlFDiyT30R/T0dpAjvgH18A235KOpgLnM7Kaxc3kjMmorIJrkD25oG\n" + "OmRRTvdZ5rQ+IuBzaGUOD4ZwTwQ9HMieMjjLCcmkhhzzIZni1eNMva7MJyws4qcH\n" + "tjOPQvtb8m8ZXzT77nnkKirbJLVk+FqzL93/w1Kp/BRgVVChrXhdDFW2KSI8sx7Z\n" + "T7J8Dir4Oz2JFgpuBLKTz2Bnu6EDNEdGmomP79DO2IGoPNwhhBRDNM2oYR2nPTME\n" + "0f9moTJBghsi6rutgxkf1KDY6z2oysJKoJowegEYaUh0J0aHqQKCAQEA8hEL2y5C\n" + "iq2fzLRulXEVLG4di6ZZ0ZcyuV6rwQRWrhqv//+csagNmvguz6mFF9iNciv8FT2Z\n" + "crIgJUPefslKXuqqm/zEhhafDBXypMHsk4yReIdlxQDkmnamoGJZRd3CSsNFm68a\n" + "52hkl3gniMprMp8wWyr2UNeahD9cgtooyua/hyaXewh57L9pJGHlLayvqEn6Rs0V\n" + "0lpSzMTJWqFrDPuSc+ufsd3sk1MfvdnDw5oh7cHjZhlHJVtPSrjneCTbEnNpXIr/\n" + "yGL+qamZD+a8a318KMz72y3RwA0VMkhhkAYFYV+S5qYrlbFxjacVOS0Zi0LOklrl\n" + "jGMj6RzcD2W35QKCAQEAyEP27OgVTkaEr3bmNHYMBqYZ2snYMUgJF5GOitfLGSGM\n" + "55Io++BO6NMDbcNyCtWu2RYbHfdF1qjlTxPHjqsy6z4+tpxjpnPQEbO5eN1PG3iZ\n" + "+YO6z1yXLMwglkK4Acv1YWkMZ6l2V55MyntdiCWG/UYOlVw1kxqxlhgzmyq1ZMj5\n" + "4IOGqjsjPsMs2ZVANE54y/SriocnM/2Z08440SElOtheu5G/PfTF2j3ZZRBvuggu\n" + "MVnl2+5c0PpT1DGS74327WhRWDixmgEPEgLTd9hSpCWN/5nj67zskHKv6pmOLS+I\n" + "jd+rpzrnqDallDmTm/DqcLLDuaxsxEV/788pRllf8wKCAQEAoxcfENZTGNIv9yCd\n" + "3OvqoxuxplQ28cJX95K0T4BX0kfCyszySrP6Lq4GA/2n4VASxJij57+v8hnXFKRs\n" + "dKm0BM1Ak4Yy9lCpaeAjsiPB/AtaO4Wl6JxYaUWFsEty8GKfs/VqoaDRlJW+KFtY\n" + "743JubqNPu9sMz2AKpfyAWtwznu3ERzMNKWaWAsCkPOwEBzn4I+vIyKsECSw4qu3\n" + "KevVj1Kz8owO9SybZws7OJNOlSv0rhbS2ggv6hhiDOsVcNoMC5tconA4M0+XWsIc\n" + "kR0ZV6adD3REQADX7/ggjtc7fGjCGT/mXqYYeWurIRAweWxMaIpjWTIKtJJbMIU0\n" + "Mt+KjQKCAQAbtzw/QUdhk+TdG8l0TToQ2YAOhYzEFUIc3uopUQAstDX5/oJpiXui\n" + "QUHiOQBZe4U9Sg/qr8QclzdVIFmn5w2e/PhU8YPhD3omWQc8MPS3ypMUsyRxelD5\n" + "xC5mXUl2BjIpjw5Gcm+MZL4f777cDsWF2+I8zYwklbcqHKNXwCtmjWH3rnw+pvyT\n" + "vRNB8aP3GT0ijPQIsfe8/EYDyDCY0MuEP1ms/9jFzFBtic3CbOnphyRNdDGZpH13\n" + "9o0PeuTo/m7EIIHRgdcihy78wSNfHLMjQIdMbpHamETtINIz15iTrFZrvB7XgBF7\n" + "eESmJOnG1Sq8+iCYW8KZzzyLhdIiiE/9AoIBAQDGZG7/r8feIMKUWGJmm+uWDAEi\n" + "FRn0gZap3HZRDkmgYE6Xwr6CwUBp1YWvjQGQdln9BSrc6kXazOQrX+wpaNmW5x90\n" + "EMinO3Ekg+c5ivYgw1IxN26bbOnlDUpeUDH2mp4OV9MhMmPB6EfRWbztflK7545j\n" + "SJ0sOADajDCq5WeR3IyXT9Pq99wZ1BI4qw/MD7HUzx38n7G3qa/BOQcdyETN1L1l\n" + "BZgRlbpzktD2AjX71p8FaVfeRA2R4/BWPAzBEhGdLgitXL1UVZDC/TzZBKwQcwpG\n" + "JvKExITQBoOQmIOPbEYoLZ7UAiiOmCi/QlOjswP94gTKW4YHEqu6dqMHaaw+\n" + "-----END RSA PRIVATE KEY-----\n"}; + +struct evp_pkey_deleter { + void operator()(EVP_PKEY *key) const noexcept { EVP_PKEY_free(key); } +}; + +using evp_pkey_ptr = std::unique_ptr; + +[[nodiscard]] evp_pkey_ptr load_private_key(std::string_view pem) { + BIO *bio{BIO_new_mem_buf(std::data(pem), static_cast(std::size(pem)))}; + if (bio == nullptr) { + throw std::runtime_error{"failed to allocate OpenSSL BIO for private key"}; + } + + evp_pkey_ptr key{PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr), + evp_pkey_deleter{}}; + BIO_free(bio); + + if (!key) { + throw std::runtime_error{"failed to parse RSA private key"}; + } + + return key; +} + +[[nodiscard]] std::string read_pem_file(std::string_view path) { + // Avoid istreambuf_iterator: GCC 14 -Wnull-dereference false positive under + // -O2. + std::ifstream file{std::string{path}, std::ios::binary}; + if (!file) { + throw std::runtime_error{"failed to open PEM file: " + std::string{path}}; + } + if (!file.seekg(0, std::ios_base::end)) { + throw std::runtime_error{"failed to seek PEM file: " + std::string{path}}; + } + const auto end_offset{static_cast(file.tellg())}; + if (end_offset < 0) { + throw std::runtime_error{"failed to size PEM file: " + std::string{path}}; + } + if (!file.seekg(0, std::ios_base::beg)) { + throw std::runtime_error{"failed to rewind PEM file: " + std::string{path}}; + } + + std::string contents(static_cast(end_offset), '\0'); + if (end_offset != 0 && !file.read(std::data(contents), end_offset)) { + throw std::runtime_error{"failed to read PEM file: " + std::string{path}}; + } + return contents; +} + } // anonymous namespace namespace minimysql { +struct caching_sha2_password_authenticator::rsa_key_pair { + rsa_key_pair(std::string public_key_pem, std::string_view private_key_pem) + : public_key_pem_{std::move(public_key_pem)}, + private_key_{load_private_key(private_key_pem)}, + cipher_length_{ + static_cast(EVP_PKEY_get_size(private_key_.get()))} {} + + static std::unique_ptr + from_paths(std::string_view server_rsa_public_key_path, + std::string_view server_rsa_private_key_path) { + return std::make_unique( + read_pem_file(server_rsa_public_key_path), + read_pem_file(server_rsa_private_key_path)); + } + + static std::unique_ptr embedded_default() { + return std::make_unique( + std::string{default_rsa_public_key_pem}, + std::string{default_rsa_private_key_pem}); + } + + [[nodiscard]] std::string_view public_key_pem() const noexcept { + return public_key_pem_; + } + + [[nodiscard]] std::size_t cipher_length() const noexcept { + return cipher_length_; + } + + [[nodiscard]] std::string + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) + decrypt_password(std::string_view encrypted_password, + std::string_view salt) const { + if (std::size(encrypted_password) != cipher_length_) { + throw std::runtime_error{"encrypted password has unexpected length"}; + } + + std::string plain_text(cipher_length_ + 1U, '\0'); + std::size_t plain_text_length{cipher_length_}; + + EVP_PKEY_CTX *key_ctx{EVP_PKEY_CTX_new(private_key_.get(), nullptr)}; + if (key_ctx == nullptr) { + throw std::runtime_error{"failed to create RSA decrypt context"}; + } + + if (EVP_PKEY_decrypt_init(key_ctx) <= 0 || + EVP_PKEY_CTX_set_rsa_padding(key_ctx, RSA_PKCS1_OAEP_PADDING) <= 0 || + EVP_PKEY_decrypt( + key_ctx, + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + reinterpret_cast(std::data(plain_text)), + &plain_text_length, + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + reinterpret_cast( + std::data(encrypted_password)), + std::size(encrypted_password)) <= 0) { + EVP_PKEY_CTX_free(key_ctx); + throw std::runtime_error{"failed to decrypt RSA password"}; + } + EVP_PKEY_CTX_free(key_ctx); + + xor_with_pattern(std::span{std::data(plain_text), cipher_length_ + 1U}, + salt); + + const auto password_end{plain_text.find('\0')}; + if (password_end == std::string::npos) { + throw std::runtime_error{"decrypted password is missing a terminator"}; + } + + return plain_text.substr(0, password_end); + } + +private: + std::string public_key_pem_; + evp_pkey_ptr private_key_; + std::size_t cipher_length_; +}; + +caching_sha2_password_authenticator::caching_sha2_password_authenticator( + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) + std::string_view password, std::string_view server_rsa_public_key_path, + std::string_view server_rsa_private_key_path) + : password_{password} { + const bool has_public{!std::empty(server_rsa_public_key_path)}; + const bool has_private{!std::empty(server_rsa_private_key_path)}; + if (has_public != has_private) { + throw std::runtime_error{ + "server_rsa_public_key_path and server_rsa_private_key_path must both " + "be set or both be empty"}; + } + if (has_public) { + rsa_keys_ = rsa_key_pair::from_paths(server_rsa_public_key_path, + server_rsa_private_key_path); + } else { + rsa_keys_ = rsa_key_pair::embedded_default(); + } +} + +caching_sha2_password_authenticator::~caching_sha2_password_authenticator() = + default; + +// AuthSwitch only when the client plugin does not match. A cache miss on an +// already-matching plugin is handled by sending 0x04, not by restarting auth. +bool caching_sha2_password_authenticator::needs_auth_method_switch( + std::string_view client_plugin) noexcept { + return client_plugin != plugin_name; +} + +std::string +caching_sha2_password_authenticator::generate_auth_switch_plugin_data( + std::string_view salt) { + return std::string{salt} + '\0'; +} + +void caching_sha2_password_authenticator::begin_authentication( + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) + std::string_view expected_username, std::string_view client_username, + std::string_view client_auth_data, std::string_view salt, + bool secure_transport, auth_packet_encoder &encoder) { + expected_username_ = expected_username; + client_username_ = client_username; + salt_ = salt; + secure_transport_ = secure_transport; + outbound_frames_.clear(); + phase_ = phase::idle; + // Minimysql has no SHA2 digest cache. We always behave as if fast auth had a + // cache hit: verify the handshake scramble against the configured password + // directly, even on the first connection. A real server would send 0x04 here + // on cache miss and require RSA (plain TCP) or cleartext over SSL for full + // auth (see verify_cleartext_password(); not reached until TLS exists). + // Alternative: skip verify_greeting_scramble() and always enqueue full + // authentication (0x04) to mirror first-login / cache-miss behavior. + + // NOTE: Disable the below greeting scramble verification to enable full + // authentication with public key exchange. + if (/* false && */ verify_greeting_scramble( + expected_username_, client_username_, client_auth_data, salt_)) { + enqueue_fast_auth_success(encoder); + phase_ = phase::succeeded; + return; + } + + enqueue_perform_full_authentication(encoder); + phase_ = phase::awaiting_full_auth_response; +} + +authentication_state +caching_sha2_password_authenticator::state() const noexcept { + switch (phase_) { + case phase::succeeded: + return authentication_state::succeeded; + case phase::failed: + return authentication_state::failed; + default: + return authentication_state::in_progress; + } +} + +// True while the server must read another client AuthMoreData frame: +// - awaiting_full_auth_response: client replies to 0x04 with either 0x02 +// (request PEM) or RSA ciphertext when it already has the public key, or with +// a cleartext password when secure_transport_ is true (SSL/TLS stub). +// - awaiting_encrypted_password: client sends ciphertext after receiving PEM. +bool caching_sha2_password_authenticator::expects_client_input() + const noexcept { + return phase_ == phase::awaiting_full_auth_response || + phase_ == phase::awaiting_encrypted_password; +} + +std::vector +caching_sha2_password_authenticator::take_outbound_frames() { + return std::exchange(outbound_frames_, {}); +} + +authentication_state caching_sha2_password_authenticator::submit_client_frame( + const network_buffer_type &frame, auth_packet_encoder &encoder) { + encoder.validate_incoming_sequence(frame); + const std::string_view payload{encoder.frame_payload(frame)}; + + if (phase_ == phase::awaiting_full_auth_response) { + if (secure_transport_) { + return verify_cleartext_password(payload); + } + + // After 0x04 the client may send 0x02 to fetch PEM + // (--get-server-public-key) or send RSA ciphertext immediately when it + // already loaded the key from disk + // (--server-public-key-path). + if (check_public_key_request(payload)) { + enqueue_public_key(encoder); + phase_ = phase::awaiting_encrypted_password; + return authentication_state::in_progress; + } + + return verify_encrypted_password(payload); + } + + if (phase_ == phase::awaiting_encrypted_password) { + return verify_encrypted_password(payload); + } + + phase_ = phase::failed; + return authentication_state::failed; +} + std::string caching_sha2_password_authenticator::scramble( // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) std::string_view password, std::string_view salt) { - // this is how client calculates client_auth_data for caching_sha2_password - // plugin: SHA256(password) XOR SHA256(SHA256(SHA256(password)), - // server_auth_data) - - // server, provided that it knows original password and server_auth_data - // (salt), can verify client_auth_data by calculating the same way and - // comparing the result with client_auth_data const auto digest_code{digest_code_type::sha256}; - // calculating hashed password auto result{calculate_digest(digest_code, password)}; - - // calculating double-hashed password const auto double_hashed_password{calculate_digest(digest_code, result)}; - // calculating salted triple-hashed password digest_context ctx(digest_code); ctx.update(double_hashed_password); ctx.update(salt); @@ -152,4 +528,132 @@ std::string caching_sha2_password_authenticator::scramble( return result; } +std::string_view +caching_sha2_password_authenticator::get_rsa_public_key_pem() const noexcept { + return rsa_keys_->public_key_pem(); +} + +std::size_t +caching_sha2_password_authenticator::get_rsa_cipher_length() const noexcept { + return rsa_keys_->cipher_length(); +} + +bool caching_sha2_password_authenticator::check_public_key_request( + std::string_view payload) noexcept { + return std::size(payload) == 1U && + static_cast(payload.front()) == request_public_key; +} + +std::string caching_sha2_password_authenticator::decrypt_rsa_password( + std::string_view encrypted_password, std::string_view salt) const { + return rsa_keys_->decrypt_password(encrypted_password, salt); +} + +// Outbound caching_sha2_password plugin packets after the handshake must be +// framed as AuthMoreData on the wire: +// +// MySQL frame payload = 0x01 || +// +// where is one of: +// - 0x03 fast auth success +// - 0x04 perform full authentication +// - PEM server RSA public key after a client 0x02 request +// +// On a real MySQL / Percona Server, the auth plugin does NOT prepend 0x01 +// itself. It calls MYSQL_PLUGIN_VIO::write_packet() with the raw plugin bytes +// (e.g. a single 0x04, or the PEM string). The server mpvio layer then wraps +// that payload via wrap_plguin_data_into_proper_command() / +// net_write_command(..., command=1, ...), which is AuthMoreData. See +// sql/auth/sql_authentication.cc (server_mpvio_write_packet) and +// sql/auth/sha2_password.cc (write_packet of perform_full_authentication / +// public key PEM). +// +// Minimysql has no mpvio / plugin VIO. We write classic-protocol frames +// directly, so encode_auth_method_data() must supply the AuthMoreData 0x01 +// status byte that the real server would have added for us. +// +// On the client side, client_mpvio_read_packet() (sql-common/client.cc) strips +// a leading 0x01 when present before handing data to the auth plugin. That is +// why the plugin logic checks for a 1-byte 0x03 / 0x04, and why PEM_read sees +// a clean "-----BEGIN PUBLIC KEY-----" buffer rather than a 0x01-prefixed PEM. +// +// Sending raw 0x04 or raw PEM without the 0x01 prefix can still interoperate +// with some clients (the strip is conditional), but it diverges from the +// server protocol and from the fast-auth path, which already used AuthMoreData. +// Always use encode_auth_method_data() for these continuations. +void caching_sha2_password_authenticator::enqueue_perform_full_authentication( + auth_packet_encoder &encoder) { + const char full_auth_code{static_cast(perform_full_authentication)}; + outbound_frames_.emplace_back( + encoder.encode_auth_method_data(std::string_view{&full_auth_code, 1U})); +} + +void caching_sha2_password_authenticator::enqueue_public_key( + auth_packet_encoder &encoder) { + outbound_frames_.emplace_back( + encoder.encode_auth_method_data(get_rsa_public_key_pem())); +} + +void caching_sha2_password_authenticator::enqueue_fast_auth_success( + auth_packet_encoder &encoder) { + static constexpr std::string_view fast_auth_code{"\x03"}; + outbound_frames_.emplace_back( + encoder.encode_auth_method_data(fast_auth_code)); +} + +authentication_state +caching_sha2_password_authenticator::verify_encrypted_password( + std::string_view encrypted_password) { + if (std::size(encrypted_password) != get_rsa_cipher_length()) { + phase_ = phase::failed; + return authentication_state::failed; + } + + try { + const auto decrypted_password{ + decrypt_rsa_password(encrypted_password, salt_)}; + if (expected_username_ == client_username_ && + decrypted_password == password_) { + phase_ = phase::succeeded; + return authentication_state::succeeded; + } + } catch (const std::exception &) { + phase_ = phase::failed; + return authentication_state::failed; + } + + phase_ = phase::failed; + return authentication_state::failed; +} + +authentication_state +caching_sha2_password_authenticator::verify_cleartext_password( + std::string_view password_payload) { + // Full auth over a secure transport: client sends a 0-terminated password + // without RSA. Minimysql has no TLS yet; connection_is_secure() is always + // false, so this remains a placeholder until SSL is wired up. + if (std::empty(password_payload) || password_payload.back() != '\0') { + phase_ = phase::failed; + return authentication_state::failed; + } + + const std::string_view password{ + password_payload.substr(0, std::size(password_payload) - 1U)}; + if (expected_username_ == client_username_ && password == password_) { + phase_ = phase::succeeded; + return authentication_state::succeeded; + } + + phase_ = phase::failed; + return authentication_state::failed; +} + +bool caching_sha2_password_authenticator::verify_greeting_scramble( + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) + std::string_view expected_username, std::string_view client_username, + std::string_view client_auth_data, std::string_view salt) const { + return expected_username == client_username && + client_auth_data == scramble(password_, salt); +} + } // namespace minimysql diff --git a/src/minimysql/caching_sha2_password_authenticator.hpp b/src/minimysql/caching_sha2_password_authenticator.hpp index b9f69bb..aaff957 100644 --- a/src/minimysql/caching_sha2_password_authenticator.hpp +++ b/src/minimysql/caching_sha2_password_authenticator.hpp @@ -16,16 +16,127 @@ #ifndef MINIMYSQL_CACHING_SHA2_PASSWORD_AUTHENTICATOR_HPP #define MINIMYSQL_CACHING_SHA2_PASSWORD_AUTHENTICATOR_HPP +#include +#include +#include #include #include +#include + +#include "minimysql/network_io_operations_fwd.hpp" namespace minimysql { +class auth_packet_encoder { +public: + auth_packet_encoder() = default; + virtual ~auth_packet_encoder() = default; + + auth_packet_encoder(const auth_packet_encoder &) = delete; + auth_packet_encoder(auth_packet_encoder &&) = delete; + auth_packet_encoder &operator=(const auth_packet_encoder &) = delete; + auth_packet_encoder &operator=(auth_packet_encoder &&) = delete; + + [[nodiscard]] virtual network_buffer_type + encode_single_byte(std::uint8_t payload_byte) = 0; + [[nodiscard]] virtual network_buffer_type + encode_raw(std::string_view payload) = 0; + [[nodiscard]] virtual network_buffer_type + encode_auth_method_data(std::string_view payload) = 0; + virtual void + validate_incoming_sequence(const network_buffer_type &payload) = 0; + [[nodiscard]] virtual std::string_view + frame_payload(const network_buffer_type &payload) const = 0; +}; + +enum class authentication_state : std::uint8_t { + in_progress, + succeeded, + failed, +}; + class caching_sha2_password_authenticator { public: static constexpr std::string_view plugin_name{"caching_sha2_password"}; - static std::string scramble(std::string_view password, std::string_view salt); + explicit caching_sha2_password_authenticator( + std::string_view password, + std::string_view server_rsa_public_key_path = {}, + std::string_view server_rsa_private_key_path = {}); + ~caching_sha2_password_authenticator(); + + caching_sha2_password_authenticator( + const caching_sha2_password_authenticator &) = delete; + caching_sha2_password_authenticator & + operator=(const caching_sha2_password_authenticator &) = delete; + caching_sha2_password_authenticator( + caching_sha2_password_authenticator &&) noexcept = default; + caching_sha2_password_authenticator & + operator=(caching_sha2_password_authenticator &&) noexcept = default; + + [[nodiscard]] static bool + needs_auth_method_switch(std::string_view client_plugin) noexcept; + + [[nodiscard]] static std::string + generate_auth_switch_plugin_data(std::string_view salt); + + void begin_authentication(std::string_view expected_username, + std::string_view client_username, + std::string_view client_auth_data, + std::string_view salt, bool secure_transport, + auth_packet_encoder &encoder); + + [[nodiscard]] authentication_state state() const noexcept; + + [[nodiscard]] bool expects_client_input() const noexcept; + + [[nodiscard]] std::vector take_outbound_frames(); + + authentication_state submit_client_frame(const network_buffer_type &frame, + auth_packet_encoder &encoder); + + [[nodiscard]] static std::string scramble(std::string_view password, + std::string_view salt); + +private: + struct rsa_key_pair; + + [[nodiscard]] std::string_view get_rsa_public_key_pem() const noexcept; + [[nodiscard]] std::size_t get_rsa_cipher_length() const noexcept; + [[nodiscard]] static bool + check_public_key_request(std::string_view payload) noexcept; + [[nodiscard]] std::string + decrypt_rsa_password(std::string_view encrypted_password, + std::string_view salt) const; + + void enqueue_perform_full_authentication(auth_packet_encoder &encoder); + void enqueue_public_key(auth_packet_encoder &encoder); + void enqueue_fast_auth_success(auth_packet_encoder &encoder); + [[nodiscard]] authentication_state + verify_encrypted_password(std::string_view encrypted_password); + [[nodiscard]] authentication_state + verify_cleartext_password(std::string_view password_payload); + + [[nodiscard]] bool verify_greeting_scramble( + std::string_view expected_username, std::string_view client_username, + std::string_view client_auth_data, std::string_view salt) const; + + std::string password_; + std::string expected_username_; + std::string client_username_; + std::string salt_; + bool secure_transport_{false}; + std::unique_ptr rsa_keys_; + + enum class phase : std::uint8_t { + idle, + awaiting_full_auth_response, + awaiting_encrypted_password, + succeeded, + failed, + }; + phase phase_{phase::idle}; + std::vector outbound_frames_; }; } // namespace minimysql diff --git a/src/minimysql/connection_context.cpp b/src/minimysql/connection_context.cpp index f859bac..5b2fb5f 100644 --- a/src/minimysql/connection_context.cpp +++ b/src/minimysql/connection_context.cpp @@ -19,14 +19,18 @@ #include #include #include +#include #include +#include #include #include #include #include #include +#include #include +#include #include @@ -58,6 +62,8 @@ namespace minimysql { namespace { +constexpr std::size_t server_auth_method_data_length{20U}; + template classic_protocol::frame::Frame decode_client_command_frame(const network_buffer_type &payload, @@ -78,20 +84,118 @@ decode_client_command_frame(const network_buffer_type &payload, connection_context::connection_context( // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) - std::string_view server_username, std::string_view server_password) + std::string_view server_username, std::string_view server_password, + std::string_view server_rsa_public_key_path, + std::string_view server_rsa_private_key_path, bool ssl_capability_enabled) : server_username_(server_username), server_password_(server_password), - connection_id_(next_connection_id_++) { + connection_id_(next_connection_id_++), + authenticator_{server_password, server_rsa_public_key_path, + server_rsa_private_key_path}, + ssl_capability_enabled_{ssl_capability_enabled} { static_assert(std::is_same_v, "capability_bitset MUST be the same type as " "classic_protocol::capabilities::value_type"); } -[[nodiscard]] bool connection_context::check_client_authentication() const { - return get_client_username() == get_server_username() && - get_client_auth_method_data() == - caching_sha2_password_authenticator::scramble( - get_server_password(), get_server_auth_method_data()); +class connection_context::auth_packet_encoder_impl + : public auth_packet_encoder { +public: + explicit auth_packet_encoder_impl(connection_context &context) + : context_{context} {} + + [[nodiscard]] network_buffer_type + encode_single_byte(std::uint8_t payload_byte) override { + return context_.encode_single_byte_payload(payload_byte); + } + + [[nodiscard]] network_buffer_type + encode_raw(std::string_view payload) override { + return context_.encode_raw_payload(payload); + } + + [[nodiscard]] network_buffer_type + encode_auth_method_data(std::string_view payload) override { + return context_.encode_auth_method_data_payload(payload); + } + + void validate_incoming_sequence(const network_buffer_type &payload) override { + context_.validate_and_update_sequence_number_from_frame(payload); + } + + [[nodiscard]] std::string_view + frame_payload(const network_buffer_type &payload) const override { + return connection_context::get_frame_payload(payload); + } + +private: + // NOLINTNEXTLINE(cppcoreguidelines-avoid-const-or-ref-data-members) + connection_context &context_; +}; + +[[nodiscard]] auth_packet_encoder & +connection_context::get_auth_packet_encoder() { + if (!auth_packet_encoder_) { + auth_packet_encoder_ = std::make_unique(*this); + } + return *auth_packet_encoder_; +} + +[[nodiscard]] bool +connection_context::needs_auth_method_switch() const noexcept { + return caching_sha2_password_authenticator::needs_auth_method_switch( + get_client_auth_method()); +} + +[[nodiscard]] bool connection_context::connection_is_secure() const noexcept { + // Percona Server accepts cleartext password after 0x04 only when the + // transport is secure (SSL/TLS, unix socket, etc.). The network layer flips + // transport_is_secure_ once the TLS handshake succeeds. + return transport_is_secure_; +} + +void connection_context::mark_transport_secure() noexcept { + transport_is_secure_ = true; +} + +[[nodiscard]] bool connection_context::client_requested_ssl() const { + // Not noexcept because std::bitset<>::test() is not noexcept. + return get_client_capabilities().test( + classic_protocol::capabilities::pos::ssl); +} + +[[nodiscard]] bool connection_context::is_sslrequest_greeting() const { + // Not noexcept because std::bitset<>::test() is not noexcept. + return get_shared_capabilities().test( + classic_protocol::capabilities::pos::ssl) && + client_username_.empty(); +} + +void connection_context::begin_authentication() { + authenticator_.begin_authentication( + get_server_username(), get_client_username(), + get_client_auth_method_data(), get_server_auth_method_data(), + connection_is_secure(), get_auth_packet_encoder()); +} + +[[nodiscard]] enum authentication_state +connection_context::authentication_state() const noexcept { + return authenticator_.state(); +} + +[[nodiscard]] bool +connection_context::expects_authentication_input() const noexcept { + return authenticator_.expects_client_input(); +} + +[[nodiscard]] std::vector +connection_context::take_authentication_outbound_frames() { + return authenticator_.take_outbound_frames(); +} + +enum authentication_state connection_context::submit_authentication_frame( + const network_buffer_type &payload) { + return authenticator_.submit_client_frame(payload, get_auth_packet_encoder()); } [[nodiscard]] bool @@ -125,6 +229,9 @@ connection_context::generate_encoded_server_greeting() { std::string result_buffer{}; server_capabilities_ = get_default_server_capabilities(); + if (ssl_capability_enabled_) { + server_capabilities_ |= classic_protocol::capabilities::ssl; + } server_auth_method_ = std::string{default_server_auth_method}; // for historical reasons sever auth data must include a trailing '\0' byte @@ -158,8 +265,19 @@ void connection_context::parse_client_greeting( auto buffer{boost::asio::buffer(payload)}; using client_greeting_frame = classic_protocol::frame::Frame< classic_protocol::message::client::Greeting>; - auto decode_result{classic_protocol::decode( - buffer, get_server_capabilities())}; + // Decode with SSL forced into the codec's caps mask so the classic_protocol + // parser will accept the truncated Protocol::SSLRequest form even when the + // server did *not* advertise CLIENT_SSL. This lets the network layer parse + // any well-formed client greeting first and apply policy afterwards (e.g. + // "client wants SSL against a plaintext-only server" → log + close, like + // Percona Server does). The SSL bit here only gates acceptance of the + // short form; it does not change the shape of a full greeting decode and + // has no effect on the actual capability negotiation exposed through + // get_shared_capabilities(). + const auto decoder_caps{get_server_capabilities() | + classic_protocol::capabilities::ssl}; + auto decode_result{ + classic_protocol::decode(buffer, decoder_caps)}; if (!decode_result) { throw boost::system::system_error{decode_result.error()}; } @@ -179,16 +297,96 @@ void connection_context::parse_client_greeting( } [[nodiscard]] network_buffer_type -connection_context::generate_encoded_fast_auth() { +connection_context::generate_encoded_auth_method_switch() { + std::string result_buffer{}; + + const classic_protocol::message::server::AuthMethodSwitch auth_method_switch{ + get_server_auth_method(), + caching_sha2_password_authenticator::generate_auth_switch_plugin_data( + get_server_auth_method_data())}; + using auth_method_switch_frame = classic_protocol::frame::Frame< + classic_protocol::message::server::AuthMethodSwitch>; + auto encode_result{classic_protocol::encode( + {generate_sequence_number(), auth_method_switch}, + get_shared_capabilities(), boost::asio::dynamic_buffer(result_buffer))}; + + if (!encode_result) { + throw boost::system::system_error{encode_result.error()}; + } + return result_buffer; +} + +void connection_context::parse_client_auth_method_data( + const network_buffer_type &payload) { + auto buffer{boost::asio::buffer(payload)}; + using auth_method_data_frame = classic_protocol::frame::Frame< + classic_protocol::message::client::AuthMethodData>; + auto decode_result{classic_protocol::decode( + buffer, get_shared_capabilities())}; + if (!decode_result) { + throw boost::system::system_error{decode_result.error()}; + } + + validate_and_update_sequence_number(decode_result.value().second.seq_id()); + + // after the auth method switch the client uses the server's auth method + client_auth_method_ = server_auth_method_; + client_auth_method_data_ = + decode_result.value().second.payload().auth_method_data(); +} + +void connection_context::validate_and_update_sequence_number_from_frame( + const network_buffer_type &payload) { + auto buffer{boost::asio::buffer(payload)}; + auto decode_result{ + classic_protocol::decode(buffer, {})}; + if (!decode_result) { + throw boost::system::system_error{decode_result.error()}; + } + + validate_and_update_sequence_number(decode_result.value().second.seq_id()); +} + +[[nodiscard]] network_buffer_type +connection_context::encode_single_byte_payload(std::uint8_t payload_byte) { + std::string result_buffer{}; + result_buffer.reserve(get_frame_header_length() + 1U); + + auto encode_result{classic_protocol::encode( + {1U, generate_sequence_number()}, get_shared_capabilities(), + boost::asio::dynamic_buffer(result_buffer))}; + if (!encode_result) { + throw boost::system::system_error{encode_result.error()}; + } + + result_buffer.push_back(static_cast(payload_byte)); + return result_buffer; +} + +[[nodiscard]] network_buffer_type +connection_context::encode_raw_payload(std::string_view payload) { + std::string result_buffer{}; + result_buffer.reserve(get_frame_header_length() + std::size(payload)); + + auto encode_result{classic_protocol::encode( + {std::size(payload), generate_sequence_number()}, + get_shared_capabilities(), boost::asio::dynamic_buffer(result_buffer))}; + if (!encode_result) { + throw boost::system::system_error{encode_result.error()}; + } + + result_buffer.append(payload); + return result_buffer; +} + +[[nodiscard]] network_buffer_type +connection_context::encode_auth_method_data_payload(std::string_view payload) { std::string result_buffer{}; - static constexpr std::string_view fast_auth_code{ - "\x03"}; // 0x03 means "fast auth success" in caching_sha2_password - // protocol using auth_method_data_frame = classic_protocol::frame::Frame< classic_protocol::message::server::AuthMethodData>; auto encode_res = classic_protocol::encode( - {generate_sequence_number(), {std::string{fast_auth_code}}}, + {generate_sequence_number(), {std::string{payload}}}, get_shared_capabilities(), boost::asio::dynamic_buffer(result_buffer)); if (!encode_res) { @@ -423,12 +621,28 @@ connection_context::get_default_server_capabilities() noexcept { [[nodiscard]] const std::string & connection_context::generate_server_auth_method_data() { - // TODO: generate random auth method data (for caching_sha2_password, it must - // be 20 random bytes) - server_auth_method_data_ = "01234567890123456789"; + server_auth_method_data_.assign(server_auth_method_data_length, '\0'); + if (RAND_bytes( + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + reinterpret_cast( + std::data(server_auth_method_data_)), + static_cast(std::size(server_auth_method_data_))) != 1) { + throw std::runtime_error{"failed to generate server auth method data"}; + } return server_auth_method_data_; } +[[nodiscard]] std::string_view connection_context::get_frame_payload( + const network_buffer_type &payload) noexcept { + if (std::size(payload) <= get_frame_header_length()) { + return {}; + } + + // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) + return {std::data(payload) + get_frame_header_length(), + std::size(payload) - get_frame_header_length()}; +} + [[nodiscard]] std::uint8_t connection_context::generate_sequence_number() { return sequence_number_++; } diff --git a/src/minimysql/connection_context.hpp b/src/minimysql/connection_context.hpp index a2c69d7..a1e6573 100644 --- a/src/minimysql/connection_context.hpp +++ b/src/minimysql/connection_context.hpp @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -27,6 +28,8 @@ #include "minimysql/network_io_operations_fwd.hpp" +#include "minimysql/caching_sha2_password_authenticator.hpp" + namespace minimysql { class connection_context { @@ -39,10 +42,19 @@ class connection_context { static constexpr std::uint16_t default_server_status_flags{0U}; static constexpr std::uint8_t default_server_collation{0U}; static constexpr std::string_view default_server_auth_method{ - "caching_sha2_password"}; - + caching_sha2_password_authenticator::plugin_name}; + + // When `ssl_capability_enabled` is true, the server greeting generated by + // this context advertises CLIENT_SSL so a TLS-capable client can respond + // with an SSLRequest. The value is fixed at construction — a + // connection_context is created per session, and whether the listener has + // an SSL acceptor context is a per-listener property known before the + // session starts. connection_context(std::string_view server_username, - std::string_view server_password); + std::string_view server_password, + std::string_view server_rsa_public_key_path = {}, + std::string_view server_rsa_private_key_path = {}, + bool ssl_capability_enabled = false); [[nodiscard]] const std::string &get_server_username() const noexcept { return server_username_; @@ -50,7 +62,39 @@ class connection_context { [[nodiscard]] const std::string &get_server_password() const noexcept { return server_password_; } - [[nodiscard]] bool check_client_authentication() const; + [[nodiscard]] bool check_shared_plugin_auth_supported() const; + [[nodiscard]] bool + check_shared_text_result_with_session_tracking_supported() const; + + [[nodiscard]] bool needs_auth_method_switch() const noexcept; + [[nodiscard]] bool connection_is_secure() const noexcept; + // Flipped by the network layer once the underlying transport has been + // upgraded to TLS. Enables the caching_sha2_password cleartext-after-0x04 + // fast path in begin_authentication(), matching Percona Server behaviour. + void mark_transport_secure() noexcept; + + // True iff the last parsed client greeting has CLIENT_SSL set in its own + // capability flags — irrespective of whether the server advertised SSL. + // Callers combine this with knowledge of the listener's SSL configuration + // to detect a "client wants SSL against a plaintext-only server" case and + // handle it explicitly, matching Percona Server's behaviour. + // Not noexcept because std::bitset<>::test() is not noexcept. + [[nodiscard]] bool client_requested_ssl() const; + + // True iff the last parsed client greeting has the SSLRequest shape: + // shared caps include CLIENT_SSL and the username is empty (the classic + // protocol codec truncates the packet after the 23-byte filler in that + // case, leaving all subsequent fields empty). + // Not noexcept because std::bitset<>::test() is not noexcept. + [[nodiscard]] bool is_sslrequest_greeting() const; + + void begin_authentication(); + [[nodiscard]] enum authentication_state authentication_state() const noexcept; + [[nodiscard]] bool expects_authentication_input() const noexcept; + [[nodiscard]] std::vector + take_authentication_outbound_frames(); + enum authentication_state + submit_authentication_frame(const network_buffer_type &payload); [[nodiscard]] std::uint32_t get_connection_id() const noexcept { return connection_id_; @@ -70,9 +114,6 @@ class connection_context { [[nodiscard]] capability_bitset get_shared_capabilities() const noexcept { return client_capabilities_ & server_capabilities_; } - [[nodiscard]] bool check_shared_plugin_auth_supported() const; - [[nodiscard]] bool - check_shared_text_result_with_session_tracking_supported() const; [[nodiscard]] const std::string &get_server_auth_method() const noexcept { return server_auth_method_; @@ -130,7 +171,9 @@ class connection_context { [[nodiscard]] network_buffer_type generate_encoded_server_greeting(); void parse_client_greeting(const network_buffer_type &payload); - [[nodiscard]] network_buffer_type generate_encoded_fast_auth(); + [[nodiscard]] network_buffer_type generate_encoded_auth_method_switch(); + void parse_client_auth_method_data(const network_buffer_type &payload); + [[nodiscard]] network_buffer_type generate_encoded_ok(); [[nodiscard]] network_buffer_type generate_encoded_eof(); [[nodiscard]] network_buffer_type @@ -225,12 +268,33 @@ class connection_context { std::string binlog_filename_{}; std::uint64_t binlog_position_{}; + caching_sha2_password_authenticator authenticator_; + + bool transport_is_secure_{false}; + bool ssl_capability_enabled_; + [[nodiscard]] static capability_bitset get_default_server_capabilities() noexcept; [[nodiscard]] const std::string &generate_server_auth_method_data(); [[nodiscard]] std::uint8_t generate_sequence_number(); void validate_and_update_sequence_number(std::uint8_t sequence_number); + [[nodiscard]] network_buffer_type + encode_single_byte_payload(std::uint8_t payload_byte); + [[nodiscard]] network_buffer_type + encode_raw_payload(std::string_view payload); + [[nodiscard]] network_buffer_type + encode_auth_method_data_payload(std::string_view payload); + [[nodiscard]] static std::string_view + get_frame_payload(const network_buffer_type &payload) noexcept; + + class auth_packet_encoder_impl; + [[nodiscard]] auth_packet_encoder &get_auth_packet_encoder(); + + mutable std::unique_ptr auth_packet_encoder_; + void validate_and_update_sequence_number_from_frame( + const network_buffer_type &payload); + void encode_resultset_number_of_columns_internal( network_buffer_container &result_buffers, std::size_t number_of_columns); diff --git a/src/minimysql/network_io_operations.cpp b/src/minimysql/network_io_operations.cpp deleted file mode 100644 index a1d6191..0000000 --- a/src/minimysql/network_io_operations.cpp +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) 2023-2026 Percona and/or its affiliates. -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License, version 2.0, -// as published by the Free Software Foundation. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License, version 2.0, for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - -#include "minimysql/network_io_operations.hpp" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wnull-dereference" - -#include - -#pragma GCC diagnostic pop - -#include -#include - -#include - -#include - -#include - -#include "minimysql/connection_context_fwd.hpp" - -namespace minimysql { - -// as this coroutine is always used with co_await, it is absolutely safe to -// pass arguments by reference here -boost::asio::awaitable async_read_mysql_frame( - // a helper coroutine for reading MySQL frame with a timeout - returns a - // tuple of (error_code, bytes_transferred) - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - boost::asio::ip::tcp::socket &socket, - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - network_buffer_type &payload, std::chrono::steady_clock::duration timeout) { - using namespace boost::asio::experimental::awaitable_operators; - - network_buffer_type local_payload{}; - auto payload_buffer{boost::asio::dynamic_buffer(local_payload)}; - - boost::asio::steady_timer read_timer{socket.get_executor(), timeout}; - // timed_read_result is a variant of 2 results (one form async read, one from - // timer) - auto timed_read_result{ - co_await (boost::asio::async_read( - socket, payload_buffer, - boost::asio::transfer_exactly(get_frame_header_length()), - boost::asio::as_tuple(boost::asio::use_awaitable)) || - read_timer.async_wait( - boost::asio::as_tuple(boost::asio::use_awaitable)))}; - - // if timer finished first, we consider it a timeout error - if (timed_read_result.index() != 0UZ) { - throw boost::system::system_error{boost::asio::error::timed_out, - "frame header read timeout"}; - } - - // extracting the result of async_read for header - const auto &header_read_result{std::get<0UZ>(timed_read_result)}; - - // extracting the error code from the header_read_result and throwing if there - // was an error - const auto header_read_error_code{std::get<0UZ>(header_read_result)}; - if (header_read_error_code) { - throw boost::system::system_error{header_read_error_code, - "frame header read error"}; - } - - assert(std::size(local_payload) == get_frame_header_length()); - assert(std::get<1UZ>(header_read_result) == get_frame_header_length()); - - // checking the payload size from the header and throwing if it is larger than - // our maximum allowed size - auto payload_size{parse_frame_header(local_payload)}; - if (payload_size >= max_payload_size) { - throw boost::system::system_error{ - boost::asio::error::message_size, - "frame payload size too large to receive"}; - } - - // it is ok to reuse the same timer for reading the payload - calling - // expires_after() cancels any previously set timeout - read_timer.expires_after(timeout); - // reusing timed_read result for reading the payload - timed_read_result = co_await ( - boost::asio::async_read( - socket, payload_buffer, boost::asio::transfer_exactly(payload_size), - boost::asio::as_tuple(boost::asio::use_awaitable)) || - read_timer.async_wait(boost::asio::as_tuple(boost::asio::use_awaitable))); - - // if timer finished first, we consider it a timeout error - if (timed_read_result.index() != 0UZ) { - throw boost::system::system_error{boost::asio::error::timed_out, - "frame payload read timeout"}; - } - - // extracting the result of async_read for payload - const auto &payload_read_result{std::get<0UZ>(timed_read_result)}; - // extracting the error code from payload_read_result and throwing if there - // was an error - const auto payload_read_error_code{std::get<0UZ>(payload_read_result)}; - if (payload_read_error_code) { - throw boost::system::system_error{payload_read_error_code, - "frame payload read error"}; - } - assert(std::size(local_payload) == get_frame_header_length() + payload_size); - assert(std::get<1UZ>(payload_read_result) == payload_size); - - payload.swap(local_payload); -} - -// a helper coroutine for writing with a timeout - -// throws on error -boost::asio::awaitable async_write_mysql_frame( - // as this coroutine is always used with co_await, it is absolutely safe to - // pass arguments by reference here - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - boost::asio::ip::tcp::socket &socket, - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - const network_buffer_type &payload, - std::chrono::steady_clock::duration timeout) { - if (std::size(payload) >= max_payload_size) { - throw boost::system::system_error{boost::asio::error::message_size, - "frame payload size too large to send"}; - } - - using namespace boost::asio::experimental::awaitable_operators; - - boost::asio::steady_timer write_timer{socket.get_executor(), timeout}; - // timed_write_result is a variant of 2 results (one form async write, one - // from timer) - auto timed_write_result{ - co_await (boost::asio::async_write( - socket, boost::asio::buffer(payload), - boost::asio::as_tuple(boost::asio::use_awaitable)) || - write_timer.async_wait( - boost::asio::as_tuple(boost::asio::use_awaitable)))}; - - // if timer finished first, we consider it a timeout error - if (timed_write_result.index() != 0UZ) { - throw boost::system::system_error{boost::asio::error::timed_out, - "frame write timeout"}; - } - - // extracting the result of async_write - const auto &write_result{std::get<0UZ>(timed_write_result)}; - // extracting the error code from async_write result and throwing if there was - // an error - const auto write_error_code{std::get<0UZ>(write_result)}; - if (write_error_code) { - throw boost::system::system_error{write_error_code, "frame write error"}; - } - assert(std::get<1UZ>(write_result) == std::size(payload)); -} - -// a helper coroutine for writing a collection of frames with a timeout - -// throws on error -boost::asio::awaitable async_write_mysql_frames( - // as this coroutine is always used with co_await, it is absolutely safe to - // pass arguments by reference here - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - boost::asio::ip::tcp::socket &socket, - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - const network_buffer_container &payloads, - std::chrono::steady_clock::duration timeout) { - for (const auto &payload : payloads) { - co_await async_write_mysql_frame(socket, payload, timeout); - } -} - -} // namespace minimysql diff --git a/src/minimysql/network_io_operations.hpp b/src/minimysql/network_io_operations.hpp index bbfd608..fdbb7ef 100644 --- a/src/minimysql/network_io_operations.hpp +++ b/src/minimysql/network_io_operations.hpp @@ -18,47 +18,159 @@ #include "minimysql/network_io_operations_fwd.hpp" // IWYU pragma: export -#include +#include #include -#include +#include +#include #include +#include +#include +#include +#include -#include +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wnull-dereference" + +#include + +#pragma GCC diagnostic pop + +#include +#include + +#include + +#include + +#include "minimysql/connection_context_fwd.hpp" namespace minimysql { -// a helper coroutine for reading MySQL frame with a timeout - returns a tuple -// of (error_code, bytes_transferred) -boost::asio::awaitable async_read_mysql_frame( - // as this coroutine is always used with co_await, it is absolutely safe to - // pass arguments by reference here - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - boost::asio::basic_stream_socket &socket, - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - network_buffer_type &payload, std::chrono::steady_clock::duration timeout); - -// as this coroutine is always used with co_await, it is absolutely safe to -// pass arguments by reference here -boost::asio::awaitable async_write_mysql_frame( - // a helper coroutine for writing with a timeout - - // throws on error - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - boost::asio::basic_stream_socket &socket, - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - const network_buffer_type &payload, - std::chrono::steady_clock::duration timeout); - -// as this coroutine is always used with co_await, it is absolutely safe to -// pass arguments by reference here -boost::asio::awaitable async_write_mysql_frames( - // a helper coroutine for writing a collection of frames with a timeout - - // throws on error - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - boost::asio::basic_stream_socket &socket, - // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - const network_buffer_container &payloads, - std::chrono::steady_clock::duration timeout); +// Reads exactly one MySQL frame (header + payload) from `socket` with a +// combined timeout for header and payload. +// +// `Socket` may be any Boost.Asio AsyncReadStream — used with +// `boost::asio::ip::tcp::socket` for plaintext and +// `boost::asio::ssl::stream<...>` for TLS-upgraded connections. As this +// coroutine is always used with `co_await`, it is safe to pass arguments by +// reference here. +// NOLINTBEGIN(cppcoreguidelines-avoid-reference-coroutine-parameters) +template +boost::asio::awaitable +async_read_mysql_frame(Socket &socket, network_buffer_type &payload, + std::chrono::steady_clock::duration timeout) { + // NOLINTEND(cppcoreguidelines-avoid-reference-coroutine-parameters) + using namespace boost::asio::experimental::awaitable_operators; + + network_buffer_type local_payload{}; + auto payload_buffer{boost::asio::dynamic_buffer(local_payload)}; + + boost::asio::steady_timer read_timer{socket.get_executor(), timeout}; + auto timed_read_result{ + co_await (boost::asio::async_read( + socket, payload_buffer, + boost::asio::transfer_exactly(get_frame_header_length()), + boost::asio::as_tuple(boost::asio::use_awaitable)) || + read_timer.async_wait( + boost::asio::as_tuple(boost::asio::use_awaitable)))}; + + if (timed_read_result.index() != 0UZ) { + throw boost::system::system_error{boost::asio::error::timed_out, + "frame header read timeout"}; + } + + const auto &header_read_result{std::get<0UZ>(timed_read_result)}; + + const auto header_read_error_code{std::get<0UZ>(header_read_result)}; + if (header_read_error_code) { + throw boost::system::system_error{header_read_error_code, + "frame header read error"}; + } + + assert(std::size(local_payload) == get_frame_header_length()); + assert(std::get<1UZ>(header_read_result) == get_frame_header_length()); + + auto payload_size{parse_frame_header(local_payload)}; + if (payload_size >= max_payload_size) { + throw boost::system::system_error{ + boost::asio::error::message_size, + "frame payload size too large to receive"}; + } + + read_timer.expires_after(timeout); + timed_read_result = co_await ( + boost::asio::async_read( + socket, payload_buffer, boost::asio::transfer_exactly(payload_size), + boost::asio::as_tuple(boost::asio::use_awaitable)) || + read_timer.async_wait(boost::asio::as_tuple(boost::asio::use_awaitable))); + + if (timed_read_result.index() != 0UZ) { + throw boost::system::system_error{boost::asio::error::timed_out, + "frame payload read timeout"}; + } + + const auto &payload_read_result{std::get<0UZ>(timed_read_result)}; + const auto payload_read_error_code{std::get<0UZ>(payload_read_result)}; + if (payload_read_error_code) { + throw boost::system::system_error{payload_read_error_code, + "frame payload read error"}; + } + assert(std::size(local_payload) == get_frame_header_length() + payload_size); + assert(std::get<1UZ>(payload_read_result) == payload_size); + + payload.swap(local_payload); +} + +// Writes one MySQL frame (a pre-encoded header + payload buffer) to `socket` +// with a timeout, throwing on error. +// NOLINTBEGIN(cppcoreguidelines-avoid-reference-coroutine-parameters) +template +boost::asio::awaitable +async_write_mysql_frame(Socket &socket, const network_buffer_type &payload, + std::chrono::steady_clock::duration timeout) { + // NOLINTEND(cppcoreguidelines-avoid-reference-coroutine-parameters) + if (std::size(payload) >= max_payload_size) { + throw boost::system::system_error{boost::asio::error::message_size, + "frame payload size too large to send"}; + } + + using namespace boost::asio::experimental::awaitable_operators; + + boost::asio::steady_timer write_timer{socket.get_executor(), timeout}; + auto timed_write_result{ + co_await (boost::asio::async_write( + socket, boost::asio::buffer(payload), + boost::asio::as_tuple(boost::asio::use_awaitable)) || + write_timer.async_wait( + boost::asio::as_tuple(boost::asio::use_awaitable)))}; + + if (timed_write_result.index() != 0UZ) { + throw boost::system::system_error{boost::asio::error::timed_out, + "frame write timeout"}; + } + + const auto &write_result{std::get<0UZ>(timed_write_result)}; + const auto write_error_code{std::get<0UZ>(write_result)}; + if (write_error_code) { + throw boost::system::system_error{write_error_code, "frame write error"}; + } + assert(std::get<1UZ>(write_result) == std::size(payload)); +} + +// Writes each frame in `payloads` sequentially with the same timeout budget +// applied to every frame. +// NOLINTBEGIN(cppcoreguidelines-avoid-reference-coroutine-parameters) +template +boost::asio::awaitable +async_write_mysql_frames(Socket &socket, + const network_buffer_container &payloads, + std::chrono::steady_clock::duration timeout) { + // NOLINTEND(cppcoreguidelines-avoid-reference-coroutine-parameters) + for (const auto &payload : payloads) { + co_await async_write_mysql_frame(socket, payload, timeout); + } +} } // namespace minimysql diff --git a/src/minimysql/network_service.cpp b/src/minimysql/network_service.cpp index 412472f..0023807 100644 --- a/src/minimysql/network_service.cpp +++ b/src/minimysql/network_service.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,7 @@ // of the 'asio' headers ('boost/asio/impl/co_spawn.hpp') and should not be // included directly, but the 'boost/asio/co_spawn.hpp' header is a public // one that includes the 'impl' header +#include #include // IWYU pragma: keep #include #include @@ -48,6 +50,7 @@ #pragma GCC diagnostic ignored "-Wnull-dereference" #include +#include #pragma GCC diagnostic pop @@ -56,13 +59,20 @@ #include +#include + +#include +#include + #include #include +#include "minimysql/caching_sha2_password_authenticator.hpp" #include "minimysql/connection_context.hpp" #include "minimysql/network_io_operations.hpp" #include "minimysql/sample_event_collection.hpp" +#include "minimysql/ssl_acceptor_context.hpp" namespace minimysql { @@ -219,14 +229,317 @@ void handle_exception(std::string_view context) { #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wmismatched-new-delete" -// MySQL session handling coroutine - writes server greeting, then receives and -// parses client greeting +// Runs the post-greeting authentication exchange on `socket`. Returns true +// iff the client authenticated successfully and the server "OK after auth" +// frame has been written. On any failure path (plugin auth unsupported, bad +// credentials) the caller has nothing more to do: this function has already +// written an "access denied" error frame and returned false, so the caller +// should tear the connection down. +template +[[nodiscard]] boost::asio::awaitable perform_authentication( + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + Socket &socket, + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + minimysql::connection_context &context, + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + const boost::asio::ip::tcp::endpoint &remote_endpoint, + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + minimysql::network_buffer_type &data) { + if (!context.check_shared_plugin_auth_supported()) { + std::cout << "client does not support plugin authentication\n"; + const auto access_denied{context.generate_encoded_access_denied()}; + print_error(remote_endpoint, context, "plugin auth required"); + co_await minimysql::async_write_mysql_frame( + socket, access_denied, network_service::session_authentication_timeout); + std::cout << "sent server access denied (" << std::size(access_denied) + << " bytes to " << remote_endpoint << ")\n"; + co_return false; + } + + if (context.needs_auth_method_switch()) { + const auto auth_method_switch{ + context.generate_encoded_auth_method_switch()}; + print_generic(remote_endpoint, context, "auth method switch"); + co_await minimysql::async_write_mysql_frame( + socket, auth_method_switch, + network_service::session_authentication_timeout); + std::cout << "sent server auth method switch (" + << std::size(auth_method_switch) << " bytes to " + << remote_endpoint << ")\n"; + + co_await minimysql::async_read_mysql_frame( + socket, data, network_service::session_authentication_timeout); + std::cout << "received client auth method switch response (" + << std::size(data) << " bytes from " << remote_endpoint << ")\n"; + context.parse_client_auth_method_data(data); + std::cout << "client auth method after switch: " + << context.get_client_auth_method() << '\n' + << " auth_method_data: " + << std::size(context.get_client_auth_method_data()) + << " byte(s)\n"; + } + + context.begin_authentication(); + + for (;;) { + // An authenticator may produce several outbound AuthMoreData frames + // before it needs client input (for example fast-auth success plus a + // follow-up, or a multi-step RSA exchange). The inner loop sends every + // frame queued by begin_authentication() or submit_authentication_frame() + // in order; only then does the outer loop read the next client packet. + for (const auto &outbound_frame : + context.take_authentication_outbound_frames()) { + print_generic(remote_endpoint, context, "auth method data"); + co_await minimysql::async_write_mysql_frame( + socket, outbound_frame, + network_service::session_authentication_timeout); + std::cout << "sent server authentication packet (" + << std::size(outbound_frame) << " bytes to " << remote_endpoint + << ")\n"; + } + + if (context.authentication_state() != + minimysql::authentication_state::in_progress) { + break; + } + + if (!context.expects_authentication_input()) { + break; + } + + co_await minimysql::async_read_mysql_frame( + socket, data, network_service::session_authentication_timeout); + std::cout << "received client authentication packet (" << std::size(data) + << " bytes from " << remote_endpoint << ")\n"; + context.submit_authentication_frame(data); + } + + if (context.authentication_state() != + minimysql::authentication_state::succeeded) { + std::cout << "client authentication failed for " + << context.get_client_username() << '\n'; + const auto access_denied{context.generate_encoded_access_denied()}; + print_error(remote_endpoint, context, "auth failure"); + co_await minimysql::async_write_mysql_frame( + socket, access_denied, network_service::session_authentication_timeout); + std::cout << "sent server access denied (" << std::size(access_denied) + << " bytes to " << remote_endpoint << ")\n"; + co_return false; + } + + std::cout << "client authentication succeeded for " + << context.get_client_username() << '\n'; + + // sending server ok after successful authentication + const auto auth_ok{context.generate_encoded_ok()}; + print_generic(remote_endpoint, context, "ok (auth)"); + co_await minimysql::async_write_mysql_frame( + socket, auth_ok, network_service::session_authentication_timeout); + std::cout << "sent server ok after authentication (" << std::size(auth_ok) + << " bytes to " << remote_endpoint << ")\n"; + + co_return true; +} + +// Post-greeting session body. Templated on the socket type so it runs on +// either a raw boost::asio::ip::tcp::socket (plaintext) or an +// ssl::stream (after a successful TLS upgrade). Once the +// server greeting and the client greeting (SSLRequest or full) have been +// exchanged on the ORIGINAL socket, control transfers here on the socket +// the rest of the session should use. +template +boost::asio::awaitable session_body( + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + Socket &socket, + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + minimysql::connection_context &context, + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + const boost::asio::ip::tcp::endpoint &remote_endpoint, + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + minimysql::network_buffer_type &data) { + if (!co_await perform_authentication(socket, context, remote_endpoint, + data)) { + co_return; + } + + // defining known queries container + using query_handler_type = std::function; + using query_container = std::unordered_map; + + const auto set_checksum_query_handler = + [](minimysql::connection_context &ctx) { + minimysql::network_buffer_container resultset; + resultset.emplace_back(ctx.generate_encoded_ok()); + return resultset; + }; + query_container known_queries{ + {"select * from tbl", + [](minimysql::connection_context &ctx) { + using row_type = + std::tuple, + std::string, std::optional>; + using row_collection_type = std::vector; + const row_collection_type rows{{1, 100, "Alice", "Cooper"}, + {2, {}, "Bob", {}}}; + const std::array column_names{ + minimysql::column_name_pair{"id", "id"}, + minimysql::column_name_pair{"optional_id", "optional_id"}, + minimysql::column_name_pair{"name", "name"}, + minimysql::column_name_pair{"optional_name", "optional_name"}}; + return ctx.encode_resultset(rows, column_names); + }}, + {"select @@version_comment limit 1", + [](minimysql::connection_context &ctx) { + using version_comment_record = std::tuple; + using version_comment_record_collection = + std::vector; + const version_comment_record_collection records{ + {"Percona Binlog Server - GPL"}}; + const std::array column_names{ + minimysql::column_name_pair{"@@version_comment", ""}}; + return ctx.encode_resultset(records, column_names); + }}, + {"SELECT VERSION()", + [](minimysql::connection_context &ctx) { + using version_record = std::tuple; + using version_record_collection = std::vector; + const version_record_collection records{{"9.7.0"}}; + const std::array column_names{ + minimysql::column_name_pair{"VERSION()", ""}}; + return ctx.encode_resultset(records, column_names); + }}, + {"SET @source_binlog_checksum = 'NONE', @master_binlog_checksum = " + "'NONE'", + set_checksum_query_handler}, + {"SET @master_binlog_checksum = 'NONE', @source_binlog_checksum = " + "'NONE'", + set_checksum_query_handler}}; + + // starting command loop + bool terminated{false}; + while (!terminated) { + context.enter_command_loop_iteration(); + co_await minimysql::async_read_mysql_frame( + socket, data, network_service::session_command_timeout); + std::cout << "received client command (" << std::size(data) + << " bytes from " << remote_endpoint << ")\n"; + context.parse_client_command(data); + print_client_command(remote_endpoint, context); + + switch (context.get_client_mysql_command()) { + case minimysql::client_command_type::query: { + const auto known_query_it{ + known_queries.find(context.get_client_statement())}; + if (known_query_it != std::end(known_queries)) { + const auto resultset{known_query_it->second(context)}; + print_generic(remote_endpoint, context, "resultset"); + co_await minimysql::async_write_mysql_frames( + socket, resultset, network_service::session_command_timeout); + std::cout << "sent server resultset (" << std::size(resultset) + << " frames to " << remote_endpoint << ")\n"; + } else { + // return 'syntax error' for every other query + const auto syntax_error = context.generate_encoded_syntax_error(); + print_error(remote_endpoint, context, "syntax error"); + co_await minimysql::async_write_mysql_frame( + socket, syntax_error, network_service::session_command_timeout); + std::cout << "sent server syntax error (" << std::size(syntax_error) + << " bytes to " << remote_endpoint << ")\n"; + } + } break; + case minimysql::client_command_type::ping: { + const auto ok_after_ping{context.generate_encoded_ok()}; + print_generic(remote_endpoint, context, "ok (ping success)"); + co_await minimysql::async_write_mysql_frame( + socket, ok_after_ping, network_service::session_command_timeout); + std::cout << "sent server ok after ping (" << std::size(ok_after_ping) + << " bytes to " << remote_endpoint << ")\n"; + } break; + case minimysql::client_command_type::binlog_dump: { + const minimysql::sample_event_collection sample_events; + for (const auto &event_data : sample_events.get_events()) { + const auto event{context.generate_encoded_binlog_event(event_data)}; + print_generic(remote_endpoint, context, "binlog event"); + co_await minimysql::async_write_mysql_frame( + socket, event, network_service::session_command_timeout); + std::cout << "sent server binlog event (" << std::size(event) + << " bytes to " << remote_endpoint << ")\n"; + } + const auto eof = context.generate_encoded_eof(); + print_generic(remote_endpoint, context, "binlog eof"); + co_await minimysql::async_write_mysql_frame( + socket, eof, network_service::session_command_timeout); + std::cout << "sent server eof (" << std::size(eof) << " bytes to " + << remote_endpoint << ")\n"; + terminated = true; + } break; + case minimysql::client_command_type::quit: { + // TODO: read EOF from the socket to make sure the client has closed the + // connection instead of just closing it from our side + terminated = true; + } break; + default: { + const auto unknown_command_error = + context.generate_encoded_unknown_command(); + print_error(remote_endpoint, context, "unknown command"); + co_await minimysql::async_write_mysql_frame( + socket, unknown_command_error, + network_service::session_command_timeout); + std::cout << "sent server unknown command (" + << std::size(unknown_command_error) << " bytes to " + << remote_endpoint << ")\n"; + } + } + } +} + +// Perform a boost::asio::ssl::stream::async_handshake as server, bounded by +// the same timeout used for the rest of the authentication phase. On timeout +// or handshake error, throws a boost::system::system_error which the outer +// session catch handler logs. +boost::asio::awaitable perform_ssl_handshake( + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + boost::asio::ssl::stream &ssl_socket, + std::chrono::steady_clock::duration timeout) { + using namespace boost::asio::experimental::awaitable_operators; + + boost::asio::steady_timer handshake_timer{ssl_socket.get_executor(), timeout}; + auto timed_handshake_result{ + co_await (ssl_socket.async_handshake( + boost::asio::ssl::stream_base::server, + boost::asio::as_tuple(boost::asio::use_awaitable)) || + handshake_timer.async_wait( + boost::asio::as_tuple(boost::asio::use_awaitable)))}; + + if (timed_handshake_result.index() != 0UZ) { + throw boost::system::system_error{boost::asio::error::timed_out, + "TLS handshake timeout"}; + } + + const auto &handshake_result{std::get<0UZ>(timed_handshake_result)}; + const auto handshake_error_code{std::get<0UZ>(handshake_result)}; + if (handshake_error_code) { + throw boost::system::system_error{handshake_error_code, + "TLS handshake error"}; + } +} + +// MySQL session handling coroutine - writes server greeting, then receives +// and parses client greeting. On a Protocol::SSLRequest, upgrades the socket +// to TLS and re-reads the full HandshakeResponse from the encrypted stream +// before delegating to the templated post-greeting body. [[nodiscard]] boost::asio::awaitable session( boost::asio::ip::tcp::socket socket, // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) const std::string &username, // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - const std::string &password) { + const std::string &password, + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + const std::string &server_rsa_public_key_path, + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + const std::string &server_rsa_private_key_path, + minimysql::ssl_acceptor_context *ssl_ctx) { boost::system::error_code session_ec; const auto remote_endpoint{socket.remote_endpoint(session_ec)}; @@ -237,17 +550,10 @@ void handle_exception(std::string_view context) { minimysql::network_buffer_type data; data.reserve(network_service::expected_packet_size); - minimysql::connection_context context{username, password}; - - // creating and sending server greeting packet: - // protocol_version: 10 - // server_version: "9.7.0-pbs", - // connection_id: (maintained by connection_context, starts with - // 1 and is incremented for each new connection) auth_method_data: 20 - // random bytes generated by connection_context server_capabilities: - // collation: 0 (not set explicitly, client will assume the default one, - // most probably 255 utf8mb4_0900_ai_ci) status_flags: 0 auth_method: - // "caching_sha2_password" + minimysql::connection_context context{ + username, password, server_rsa_public_key_path, + server_rsa_private_key_path, + /* ssl_capability_enabled = */ ssl_ctx != nullptr}; const auto server_greeting{context.generate_encoded_server_greeting()}; print_server_greeting(remote_endpoint, context); @@ -257,16 +563,6 @@ void handle_exception(std::string_view context) { std::cout << "sent server greeting (" << std::size(server_greeting) << " bytes to " << remote_endpoint << ")\n"; - // receiving and parsing client greeting packet: - // capabilities - // max_packet_size - // collation - // username - // auth_method_data - // schema - // auth_method_name - // attributes - co_await minimysql::async_read_mysql_frame( socket, data, network_service::session_authentication_timeout); std::cout << "received client greeting (" << std::size(data) @@ -274,195 +570,47 @@ void handle_exception(std::string_view context) { context.parse_client_greeting(data); print_client_greeting(remote_endpoint, context); - if (!context.check_shared_plugin_auth_supported()) { - std::cout << "client does not support plugin authentication\n"; - const auto access_denied{context.generate_encoded_access_denied()}; - print_error(remote_endpoint, context, "plugin auth required"); - co_await minimysql::async_write_mysql_frame( - socket, access_denied, - network_service::session_authentication_timeout); - std::cout << "sent server access denied (" << std::size(access_denied) - << " bytes to " << remote_endpoint << ")\n"; + // Reject an SSL-requesting client the same way Percona Server does when + // its own SSL acceptor context is missing (see + // sql/auth/sql_authentication.cc: `if (!context.have_ssl()) return + // packet_error;`): drop the connection without sending an error frame, + // and log the reason for the operator. parse_client_greeting() is + // lenient enough to decode both the full form and the truncated + // SSLRequest form regardless of what the server advertised, so this + // decision is made after we have a fully populated context to inspect. + if (ssl_ctx == nullptr && context.client_requested_ssl()) { + std::cout << "client " << remote_endpoint + << " requested SSL (CLIENT_SSL capability bit set) but the " + "server has no SSL context configured; start " + "minimysql_server with --ssl-cert= --ssl-key= " + "to enable TLS. Closing connection (matches Percona " + "Server behaviour: no error frame is sent mid-handshake).\n"; co_return; } - if (context.get_client_auth_method() != context.get_server_auth_method()) { - std::cout << "client requested " << context.get_client_auth_method() - << " authentication that does not match the one associated " - "with the user account (" - << context.get_server_auth_method() << ")\n"; - // TODO: send SwitchAuthentication packet - co_return; - } - if (!context.check_client_authentication()) { - std::cout << "client authentication failed for " - << context.get_client_username() << '\n'; - const auto access_denied{context.generate_encoded_access_denied()}; - print_error(remote_endpoint, context, "auth failure"); - co_await minimysql::async_write_mysql_frame( - socket, access_denied, - network_service::session_authentication_timeout); - std::cout << "sent server access denied (" << std::size(access_denied) - << " bytes to " << remote_endpoint << ")\n"; - co_return; - } + if (ssl_ctx != nullptr && context.is_sslrequest_greeting()) { + std::cout << "client requested TLS upgrade (SSLRequest) from " + << remote_endpoint << '\n'; - std::cout << "client authentication succeeded for " - << context.get_client_username() << '\n'; + boost::asio::ssl::stream ssl_socket{ + std::move(socket), ssl_ctx->native()}; - // sending fast auth success - const auto fast_auth_success{context.generate_encoded_fast_auth()}; - print_generic(remote_endpoint, context, "auth method data (fast auth)"); - co_await minimysql::async_write_mysql_frame( - socket, fast_auth_success, - network_service::session_authentication_timeout); - std::cout << "sent server fast auth success (" - << std::size(fast_auth_success) << " bytes to " << remote_endpoint - << ")\n"; + co_await perform_ssl_handshake( + ssl_socket, network_service::session_authentication_timeout); - // sending server ok after successful authentication - const auto auth_ok{context.generate_encoded_ok()}; - print_generic(remote_endpoint, context, "ok (auth)"); - co_await minimysql::async_write_mysql_frame( - socket, auth_ok, network_service::session_authentication_timeout); - std::cout << "sent server ok after authentication (" << std::size(auth_ok) - << " bytes to " << remote_endpoint << ")\n"; + context.mark_transport_secure(); + std::cout << "TLS handshake completed with " << remote_endpoint << '\n'; - // defining known queries container - using query_handler_type = - std::function; - using query_container = std::unordered_map; - - const auto set_checksum_query_handler = - [](minimysql::connection_context &ctx) { - minimysql::network_buffer_container resultset; - resultset.emplace_back(ctx.generate_encoded_ok()); - return resultset; - }; - query_container known_queries{ - {"select * from tbl", - [](minimysql::connection_context &ctx) { - using row_type = - std::tuple, - std::string, std::optional>; - using row_collection_type = std::vector; - const row_collection_type rows{{1, 100, "Alice", "Cooper"}, - {2, {}, "Bob", {}}}; - const std::array column_names{ - minimysql::column_name_pair{"id", "id"}, - minimysql::column_name_pair{"optional_id", "optional_id"}, - minimysql::column_name_pair{"name", "name"}, - minimysql::column_name_pair{"optional_name", "optional_name"}}; - return ctx.encode_resultset(rows, column_names); - }}, - {"select @@version_comment limit 1", - [](minimysql::connection_context &ctx) { - using version_comment_record = std::tuple; - using version_comment_record_collection = - std::vector; - const version_comment_record_collection records{ - {"Percona Binlog Server - GPL"}}; - const std::array column_names{ - minimysql::column_name_pair{"@@version_comment", ""}}; - return ctx.encode_resultset(records, column_names); - }}, - {"SELECT VERSION()", - [](minimysql::connection_context &ctx) { - using version_record = std::tuple; - using version_record_collection = std::vector; - const version_record_collection records{{"9.7.0"}}; - const std::array column_names{ - minimysql::column_name_pair{"VERSION()", ""}}; - return ctx.encode_resultset(records, column_names); - }}, - {"SET @source_binlog_checksum = 'NONE', @master_binlog_checksum = " - "'NONE'", - set_checksum_query_handler}, - {"SET @master_binlog_checksum = 'NONE', @source_binlog_checksum = " - "'NONE'", - set_checksum_query_handler}}; - - // starting command loop - bool terminated{false}; - while (!terminated) { - context.enter_command_loop_iteration(); co_await minimysql::async_read_mysql_frame( - socket, data, network_service::session_command_timeout); - std::cout << "received client command (" << std::size(data) + ssl_socket, data, network_service::session_authentication_timeout); + std::cout << "received encrypted client greeting (" << std::size(data) << " bytes from " << remote_endpoint << ")\n"; - context.parse_client_command(data); - print_client_command(remote_endpoint, context); - - switch (context.get_client_mysql_command()) { - case minimysql::client_command_type::query: { - const auto known_query_it{ - known_queries.find(context.get_client_statement())}; - if (known_query_it != std::end(known_queries)) { - const auto resultset{known_query_it->second(context)}; - print_generic(remote_endpoint, context, "resultset"); - co_await minimysql::async_write_mysql_frames( - socket, resultset, network_service::session_command_timeout); - std::cout << "sent server resultset (" << std::size(resultset) - << " frames to " << remote_endpoint << ")\n"; - } else { - // return 'syntax error' for every other query - const auto syntax_error = context.generate_encoded_syntax_error(); - print_error(remote_endpoint, context, "syntax error"); - co_await minimysql::async_write_mysql_frame( - socket, syntax_error, network_service::session_command_timeout); - std::cout << "sent server syntax error (" << std::size(syntax_error) - << " bytes to " << remote_endpoint << ")\n"; - } - } break; - case minimysql::client_command_type::ping: { - const auto ok_after_ping{context.generate_encoded_ok()}; - print_generic(remote_endpoint, context, "ok (ping success)"); - co_await minimysql::async_write_mysql_frame( - socket, ok_after_ping, network_service::session_command_timeout); - std::cout << "sent server ok after ping (" << std::size(ok_after_ping) - << " bytes to " << remote_endpoint << ")\n"; - } break; - case minimysql::client_command_type::binlog_dump: { - const minimysql::sample_event_collection sample_events; - for (const auto &event_data : sample_events.get_events()) { - const auto event{context.generate_encoded_binlog_event(event_data)}; - print_generic(remote_endpoint, context, "binlog event"); - co_await minimysql::async_write_mysql_frame( - socket, event, network_service::session_command_timeout); - std::cout << "sent server binlog event (" << std::size(event) - << " bytes to " << remote_endpoint << ")\n"; - // co_await minimysql::async_read_mysql_frame(socket, data, - // network_service::session_command_timeout); std::cout << "received - // binlog event reply command (" << std::size(data) << " bytes from " - // << remote_endpoint - // << ")\n"; - } - const auto eof = context.generate_encoded_eof(); - print_generic(remote_endpoint, context, "binlog eof"); - co_await minimysql::async_write_mysql_frame( - socket, eof, network_service::session_command_timeout); - std::cout << "sent server eof (" << std::size(eof) << " bytes to " - << remote_endpoint << ")\n"; - terminated = true; - } break; - case minimysql::client_command_type::quit: { - // TODO: read EOF from the socket to make sure the client has closed the - // connection instead of just closing it from our side - terminated = true; - } break; - default: { - const auto unknown_command_error = - context.generate_encoded_unknown_command(); - print_error(remote_endpoint, context, "unknown command"); - co_await minimysql::async_write_mysql_frame( - socket, unknown_command_error, - network_service::session_command_timeout); - std::cout << "sent server unknown command (" - << std::size(unknown_command_error) << " bytes to " - << remote_endpoint << ")\n"; - } - } + context.parse_client_greeting(data); + print_client_greeting(remote_endpoint, context); + + co_await session_body(ssl_socket, context, remote_endpoint, data); + } else { + co_await session_body(socket, context, remote_endpoint, data); } } catch (...) { const std::string context{ @@ -480,7 +628,12 @@ void handle_exception(std::string_view context) { // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) const std::string &username, // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) - const std::string &password) { + const std::string &password, + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + const std::string &server_rsa_public_key_path, + // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) + const std::string &server_rsa_private_key_path, + minimysql::ssl_acceptor_context *ssl_ctx) { const scope_tracer tracer("listener"); auto executor = acceptor.get_executor(); @@ -504,7 +657,9 @@ void handle_exception(std::string_view context) { // NOLINTNEXTLINE(misc-include-cleaner) boost::asio::co_spawn(executor, - session(std::move(socket), username, password), + session(std::move(socket), username, password, + server_rsa_public_key_path, + server_rsa_private_key_path, ssl_ctx), boost::asio::detached); } } catch (...) { @@ -517,13 +672,22 @@ void handle_exception(std::string_view context) { network_service::network_service( boost::asio::io_context &context, std::uint16_t listening_port, // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) - std::string_view username, std::string_view password) - : username_(username), password_(password), context_{&context}, + std::string_view username, std::string_view password, + std::string_view server_rsa_public_key_path, + std::string_view server_rsa_private_key_path, + std::unique_ptr ssl_ctx) + : username_(username), password_(password), + server_rsa_public_key_path_{server_rsa_public_key_path}, + server_rsa_private_key_path_{server_rsa_private_key_path}, + context_{&context}, ssl_ctx_{std::move(ssl_ctx)}, acceptor_{std::make_unique( context, boost::asio::ip::tcp::endpoint{boost::asio::ip::tcp::v4(), listening_port})} { // NOLINTNEXTLINE(misc-include-cleaner) - boost::asio::co_spawn(*context_, listener(*acceptor_, username_, password_), + boost::asio::co_spawn(*context_, + listener(*acceptor_, username_, password_, + server_rsa_public_key_path_, + server_rsa_private_key_path_, ssl_ctx_.get()), boost::asio::detached); } diff --git a/src/minimysql/network_service.hpp b/src/minimysql/network_service.hpp index 7e341fe..8056596 100644 --- a/src/minimysql/network_service.hpp +++ b/src/minimysql/network_service.hpp @@ -17,10 +17,14 @@ #define MINIMYSQL_NETWORK_SERVICE_HPP #include +#include +#include #include #include +#include "minimysql/ssl_acceptor_context_fwd.hpp" + namespace minimysql { class network_service { @@ -29,9 +33,26 @@ class network_service { static constexpr std::chrono::seconds session_authentication_timeout{10}; static constexpr std::chrono::seconds session_command_timeout{120}; + // `ssl_ctx` is an optional owning handle. When non-empty, the server + // advertises CLIENT_SSL in its greeting and upgrades the transport to TLS + // on receipt of a Protocol::SSLRequest. When empty, the listener behaves + // exactly like the plaintext-only version (no SSL capability advertised, + // no upgrade path). Construction of the ssl_acceptor_context must happen + // in the caller — a failure there (bad cert/key path, mismatched pair) + // surfaces before network_service is instantiated instead of throwing + // from this constructor. Ownership is transferred by move; the caller + // does not retain a handle. + // + // No default argument for `ssl_ctx` because libc++'s `unique_ptr` requires + // the complete type at the point where the default-argument destructor is + // instantiated. Callers wanting the plaintext-only listener pass + // `nullptr` (or an empty unique_ptr) explicitly. network_service(boost::asio::io_context &context, std::uint16_t listening_port, std::string_view username, - std::string_view password); + std::string_view password, + std::string_view server_rsa_public_key_path, + std::string_view server_rsa_private_key_path, + std::unique_ptr ssl_ctx); network_service(const network_service &) = delete; network_service &operator=(const network_service &) = delete; @@ -43,8 +64,12 @@ class network_service { private: std::string username_; std::string password_; + std::string server_rsa_public_key_path_; + std::string server_rsa_private_key_path_; boost::asio::io_context *context_; + // Owned SSL acceptor state. Empty when the listener is plaintext-only. + std::unique_ptr ssl_ctx_; using acceptor_type = boost::asio::basic_socket_acceptor; using acceptor_ptr = std::unique_ptr; diff --git a/src/minimysql/ssl_acceptor_context.cpp b/src/minimysql/ssl_acceptor_context.cpp new file mode 100644 index 0000000..501e1f5 --- /dev/null +++ b/src/minimysql/ssl_acceptor_context.cpp @@ -0,0 +1,125 @@ +// Copyright (c) 2023-2026 Percona and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +#include "minimysql/ssl_acceptor_context.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace minimysql { + +namespace { + +// Drain the OpenSSL error queue into a single human-readable string. Used to +// annotate exceptions with the actual TLS failure reason (unknown file, +// invalid PEM, etc.) instead of the generic Boost.Asio message. +// +// Uses ERR_error_string_n() with a local stack buffer rather than +// ERR_error_string(code, nullptr): the latter writes to a shared static +// buffer that is not thread-safe, so concurrent SSL failures on multiple +// io_context threads (a common scaling pattern we may adopt later) could +// clobber each other's messages. +std::string drain_openssl_error_queue() { + // OpenSSL documents 256 bytes as sufficient for any error string. + constexpr std::size_t error_string_buffer_size{256UZ}; + std::array error_string_buffer{}; + + std::string result; + unsigned long error_code{0U}; + while ((error_code = ERR_get_error()) != 0U) { + if (!result.empty()) { + result.append("; "); + } + ERR_error_string_n(error_code, std::data(error_string_buffer), + std::size(error_string_buffer)); + result.append(std::data(error_string_buffer)); + } + return result; +} + +} // namespace + +ssl_acceptor_context::ssl_acceptor_context( + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) + std::string_view certificate_path, std::string_view private_key_path) + : certificate_path_{certificate_path}, private_key_path_{private_key_path}, + // Version-agnostic TLS method (SSLv23_server_method in OpenSSL terms) so + // both TLSv1.2 and TLSv1.3 clients can negotiate. tlsv12_server would + // pin the server to TLSv1.2 and reject TLSv1.3 handshakes. + context_{boost::asio::ssl::context::tls_server} { + // Mirror Percona Server 8.0's ssl_ctx_options: keep only TLSv1.2 and + // TLSv1.3 on the wire. + context_.set_options(boost::asio::ssl::context::no_sslv2 | + boost::asio::ssl::context::no_sslv3 | + boost::asio::ssl::context::no_tlsv1 | + boost::asio::ssl::context::no_tlsv1_1); + + context_.set_verify_mode(boost::asio::ssl::verify_none); + + // Prefer Boost.Asio's SSL context wrappers over direct OpenSSL calls where + // an equivalent exists. Use the error_code overloads so we can wrap the + // resulting message with the offending path — richer than the generic + // "certificate load failure" text a throwing overload would produce. + // + // Note: Boost's wrappers pop the top OpenSSL error into the error_code + // themselves, so drain_openssl_error_queue() would return an empty string + // here. Rely on error_code::message() for the underlying reason (bad PEM, + // no such file, key/cert mismatch, …) — for the boost::asio SSL error + // category, message() stringifies the OpenSSL reason. + // clang-tidy's misc-include-cleaner wants a private + // boost/system/detail/error_code.hpp include for error_code, which Boost + // convention forbids; the type comes transitively via the ssl/context + // header included above. + // NOLINTNEXTLINE(misc-include-cleaner) + boost::system::error_code error_code; + + context_.use_certificate_chain_file(certificate_path_, error_code); + if (error_code) { + throw std::runtime_error{"failed to load SSL certificate chain from '" + + certificate_path_ + "': " + error_code.message()}; + } + + context_.use_private_key_file(private_key_path_, + boost::asio::ssl::context::pem, error_code); + if (error_code) { + throw std::runtime_error{"failed to load SSL private key from '" + + private_key_path_ + "': " + error_code.message()}; + } + + // Boost.Asio's ssl::context has no wrapper for SSL_CTX_check_private_key, + // so we call it directly on the underlying handle. Clear the OpenSSL error + // queue first: a successful use_certificate_chain_file / use_private_key_file + // above may have left residual entries (advisory warnings, etc.) that would + // otherwise be prepended to the mismatch reason we drain below. + ERR_clear_error(); + if (SSL_CTX_check_private_key(context_.native_handle()) != 1) { + throw std::runtime_error{"SSL private key '" + private_key_path_ + + "' does not match certificate '" + + certificate_path_ + + "': " + drain_openssl_error_queue()}; + } +} + +} // namespace minimysql diff --git a/src/minimysql/ssl_acceptor_context.hpp b/src/minimysql/ssl_acceptor_context.hpp new file mode 100644 index 0000000..150b49e --- /dev/null +++ b/src/minimysql/ssl_acceptor_context.hpp @@ -0,0 +1,71 @@ +// Copyright (c) 2023-2026 Percona and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +#ifndef MINIMYSQL_SSL_ACCEPTOR_CONTEXT_HPP +#define MINIMYSQL_SSL_ACCEPTOR_CONTEXT_HPP + +#include "minimysql/ssl_acceptor_context_fwd.hpp" // IWYU pragma: export + +#include +#include + +#include + +namespace minimysql { + +// RAII wrapper around a single boost::asio::ssl::context configured as a +// MySQL-compatible TLS server acceptor. Owns the cert/key material for the +// lifetime of the listener and is shared (by reference) across all sessions. +// +// Configuration matches Percona Server 8.0 defaults: +// - Base method: TLSv1.2 server (SSLv2/v3/TLSv1.0/v1.1 explicitly disabled), +// - Certificate chain loaded from PEM, +// - Private key loaded from PEM and matched against the cert +// (SSL_CTX_check_private_key), +// - Peer verification: SSL_VERIFY_NONE (server does not request a client +// certificate). +class ssl_acceptor_context { +public: + ssl_acceptor_context(std::string_view certificate_path, + std::string_view private_key_path); + + ssl_acceptor_context(const ssl_acceptor_context &) = delete; + ssl_acceptor_context &operator=(const ssl_acceptor_context &) = delete; + ssl_acceptor_context(ssl_acceptor_context &&) = delete; + ssl_acceptor_context &operator=(ssl_acceptor_context &&) = delete; + + ~ssl_acceptor_context() = default; + + [[nodiscard]] boost::asio::ssl::context &native() noexcept { + return context_; + } + + [[nodiscard]] const std::string &get_certificate_path() const noexcept { + return certificate_path_; + } + + [[nodiscard]] const std::string &get_private_key_path() const noexcept { + return private_key_path_; + } + +private: + std::string certificate_path_; + std::string private_key_path_; + boost::asio::ssl::context context_; +}; + +} // namespace minimysql + +#endif // MINIMYSQL_SSL_ACCEPTOR_CONTEXT_HPP diff --git a/src/minimysql/ssl_acceptor_context_fwd.hpp b/src/minimysql/ssl_acceptor_context_fwd.hpp new file mode 100644 index 0000000..707cbd5 --- /dev/null +++ b/src/minimysql/ssl_acceptor_context_fwd.hpp @@ -0,0 +1,25 @@ +// Copyright (c) 2023-2026 Percona and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +#ifndef MINIMYSQL_SSL_ACCEPTOR_CONTEXT_FWD_HPP +#define MINIMYSQL_SSL_ACCEPTOR_CONTEXT_FWD_HPP + +namespace minimysql { + +class ssl_acceptor_context; + +} // namespace minimysql + +#endif // MINIMYSQL_SSL_ACCEPTOR_CONTEXT_FWD_HPP diff --git a/src/minimysql_app.cpp b/src/minimysql_app.cpp index c12f2df..4603513 100644 --- a/src/minimysql_app.cpp +++ b/src/minimysql_app.cpp @@ -1,4 +1,4 @@ -// Copyright (c) 2023-20264 Percona and/or its affiliates. +// Copyright (c) 2023-2026 Percona and/or its affiliates. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License, version 2.0, @@ -18,7 +18,12 @@ #include #include #include +#include +#include +#include +#include #include +#include #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wnull-dereference" @@ -30,19 +35,118 @@ #include #include "minimysql/network_service.hpp" +#include "minimysql/ssl_acceptor_context.hpp" -int main(int /* argc */, char * /* argv */[]) { +namespace { + +struct parsed_cli_options { + std::optional ssl_cert; + std::optional ssl_key; +}; + +std::optional +parse_command_line(std::span args) { + // Not noexcept because std::string_view::substr() may throw + // std::out_of_range on invalid inputs. We know it cannot in practice here + // (the passed index is always valid). + const auto executable_basename{[](std::string_view path) -> std::string_view { + const auto slash{path.find_last_of('/')}; + return slash == std::string_view::npos ? path : path.substr(slash + 1); + }}; + const std::string_view executable_name{ + args.empty() ? std::string_view{"minimysql_server"} + : executable_basename(args.front())}; + + const auto print_usage{[executable_name](std::ostream &stream) { + stream + << "usage: " << executable_name + << " [--ssl-cert=] [--ssl-key=]\n" + << " --ssl-cert / --ssl-key must be provided together to enable TLS;\n" + << " when both are omitted the server accepts plaintext connections\n" + << " only.\n"; + }}; + + parsed_cli_options options{}; + + for (std::size_t i{1UZ}; i < args.size(); ++i) { + const std::string_view arg{args[i]}; + + static constexpr std::string_view ssl_cert_prefix{"--ssl-cert="}; + static constexpr std::string_view ssl_key_prefix{"--ssl-key="}; + + if (arg.starts_with(ssl_cert_prefix)) { + options.ssl_cert = std::string{arg.substr(std::size(ssl_cert_prefix))}; + } else if (arg.starts_with(ssl_key_prefix)) { + options.ssl_key = std::string{arg.substr(std::size(ssl_key_prefix))}; + } else if (arg == "--help" || arg == "-h") { + print_usage(std::cout); + return std::nullopt; + } else { + std::cerr << executable_name << ": unrecognised argument '" << arg + << "'\n"; + print_usage(std::cerr); + return std::nullopt; + } + } + + if (options.ssl_cert.has_value() != options.ssl_key.has_value() || + (options.ssl_cert.has_value() && options.ssl_cert->empty()) || + (options.ssl_key.has_value() && options.ssl_key->empty())) { + std::cerr << executable_name + << ": --ssl-cert and --ssl-key must be provided together and " + "must be non-empty\n"; + print_usage(std::cerr); + return std::nullopt; + } + + return options; +} + +} // namespace + +// NOLINTNEXTLINE(bugprone-exception-escape) +int main(int argc, char *argv[]) { static constexpr std::uint16_t listening_port{3307}; static constexpr std::string_view default_username{"rpl"}; static constexpr std::string_view default_password{"password"}; + // Optional server RSA key paths for caching_sha2_password full auth; empty + // uses embedded defaults until wired from binlog server config. + static constexpr std::string_view default_server_rsa_public_key_path{}; + static constexpr std::string_view default_server_rsa_private_key_path{}; + + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-const-cast) + const std::span args(const_cast(argv), + static_cast(argc)); + + const auto options{parse_command_line(args)}; + if (!options.has_value()) { + return EXIT_FAILURE; + } int res{EXIT_FAILURE}; try { - std::cout << "starting mini-mysql-server" << '\n'; + // Build the SSL acceptor context in main (may throw on bad cert / key / + // mismatched pair) and then move it into network_service, which takes + // ownership. Any construction failure surfaces here — before we ever + // touch the network layer — instead of from network_service's + // constructor. + std::unique_ptr ssl_ctx; + if (options->ssl_cert.has_value() && options->ssl_key.has_value()) { + ssl_ctx = std::make_unique( + *options->ssl_cert, *options->ssl_key); + std::cout << "SSL enabled (cert=" << *options->ssl_cert + << ", key=" << *options->ssl_key << ")\n"; + } else { + std::cout << "SSL disabled (no --ssl-cert/--ssl-key provided)\n"; + } + + std::cout << "starting mini-mysql-server\n"; boost::asio::io_context ctx; const minimysql::network_service service( - ctx, listening_port, default_username, default_password); + ctx, listening_port, default_username, default_password, + default_server_rsa_public_key_path, default_server_rsa_private_key_path, + std::move(ssl_ctx)); boost::asio::signal_set signals(ctx, SIGINT, SIGTERM); signals.async_wait([&](auto, auto) { ctx.stop(); }); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4e014e7..c2135c3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -95,6 +95,75 @@ set_target_properties(event_test PROPERTIES CXX_EXTENSIONS NO ) +add_executable(caching_sha2_password_authenticator_test + caching_sha2_password_authenticator_test.cpp + "${PROJECT_SOURCE_DIR}/src/minimysql/caching_sha2_password_authenticator.cpp" +) +target_include_directories(caching_sha2_password_authenticator_test + PRIVATE + "${PROJECT_SOURCE_DIR}/src" +) +target_link_libraries(caching_sha2_password_authenticator_test + PRIVATE + binlog_server_compiler_flags + Boost::unit_test_framework + OpenSSL::Crypto +) +set_target_properties(caching_sha2_password_authenticator_test PROPERTIES + CXX_STANDARD_REQUIRED YES + CXX_EXTENSIONS NO +) + +add_executable(connection_context_ssl_test + connection_context_ssl_test.cpp + "${PROJECT_SOURCE_DIR}/src/minimysql/connection_context.cpp" + "${PROJECT_SOURCE_DIR}/src/minimysql/caching_sha2_password_authenticator.cpp" +) +target_include_directories(connection_context_ssl_test + PRIVATE + "${PROJECT_SOURCE_DIR}/src" + "${PROJECT_SOURCE_DIR}/extra/mysql_protocol" +) +target_link_libraries(connection_context_ssl_test + PRIVATE + binlog_server_compiler_flags + Boost::unit_test_framework + Boost::headers + MySQL::client + OpenSSL::Crypto +) +set_target_properties(connection_context_ssl_test PROPERTIES + CXX_STANDARD_REQUIRED YES + CXX_EXTENSIONS NO +) + +add_executable(ssl_acceptor_context_test + ssl_acceptor_context_test.cpp + "${PROJECT_SOURCE_DIR}/src/minimysql/ssl_acceptor_context.cpp" +) +# Match minimysql_server's Boost.Asio ABI so the ssl::context error_code +# overloads return void (BOOST_ASIO_SYNC_OP_VOID = void) rather than +# boost::system::error_code. Without this the same source compiles against a +# subtly different Boost.Asio API in the test executable and clang-tidy +# flags the nodiscard warnings on use_certificate_chain_file etc. +target_compile_definitions(ssl_acceptor_context_test PRIVATE BOOST_ASIO_NO_DEPRECATED) +target_include_directories(ssl_acceptor_context_test + PRIVATE + "${PROJECT_SOURCE_DIR}/src" +) +target_link_libraries(ssl_acceptor_context_test + PRIVATE + binlog_server_compiler_flags + Boost::unit_test_framework + Boost::headers + OpenSSL::SSL + OpenSSL::Crypto +) +set_target_properties(ssl_acceptor_context_test PROPERTIES + CXX_STANDARD_REQUIRED YES + CXX_EXTENSIONS NO +) + set(test_run_options --no_color_output) add_test(NAME byte_span_encoding_test COMMAND byte_span_encoding_test ${test_run_options}) @@ -103,3 +172,9 @@ add_test(NAME tag_test COMMAND tag_test ${test_run_options}) add_test(NAME gtid_test COMMAND gtid_test ${test_run_options}) add_test(NAME gtid_set_test COMMAND gtid_set_test ${test_run_options}) add_test(NAME event_test COMMAND event_test ${test_run_options}) +add_test(NAME caching_sha2_password_authenticator_test + COMMAND caching_sha2_password_authenticator_test ${test_run_options}) +add_test(NAME connection_context_ssl_test + COMMAND connection_context_ssl_test ${test_run_options}) +add_test(NAME ssl_acceptor_context_test + COMMAND ssl_acceptor_context_test ${test_run_options}) diff --git a/tests/caching_sha2_password_authenticator_test.cpp b/tests/caching_sha2_password_authenticator_test.cpp new file mode 100644 index 0000000..2eb6594 --- /dev/null +++ b/tests/caching_sha2_password_authenticator_test.cpp @@ -0,0 +1,262 @@ +// Copyright (c) 2023-2026 Percona and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +#include +#include +#include +#include +#include +#include +#include +#include + +#define BOOST_TEST_MODULE CachingSha2PasswordAuthenticatorTests +// this include is needed as it provides the 'main()' function +// NOLINTNEXTLINE(misc-include-cleaner) +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include "minimysql/caching_sha2_password_authenticator.hpp" +#include "minimysql/network_io_operations_fwd.hpp" + +namespace { + +class recording_encoder final : public minimysql::auth_packet_encoder { +public: + [[nodiscard]] minimysql::network_buffer_type + encode_single_byte(std::uint8_t payload_byte) override { + minimysql::network_buffer_type frame; + frame.push_back(static_cast(payload_byte)); + return frame; + } + + [[nodiscard]] minimysql::network_buffer_type + encode_raw(std::string_view payload) override { + return minimysql::network_buffer_type{payload}; + } + + [[nodiscard]] minimysql::network_buffer_type + encode_auth_method_data(std::string_view payload) override { + minimysql::network_buffer_type frame; + frame.push_back('\x01'); + frame.append(payload); + return frame; + } + + void validate_incoming_sequence( + const minimysql::network_buffer_type & /*payload*/) override {} + + [[nodiscard]] std::string_view + frame_payload(const minimysql::network_buffer_type &payload) const override { + return payload; + } +}; + +[[nodiscard]] std::string_view auth_more_data_payload(std::string_view frame) { + BOOST_REQUIRE_GE(std::size(frame), 1U); + BOOST_REQUIRE_EQUAL(static_cast(frame.front()), 0x01U); + return frame.substr(1U); +} + +[[nodiscard]] std::string rsa_encrypt_password(std::string_view public_key_pem, + std::string_view password, + std::string_view salt) { + BIO *bio{BIO_new_mem_buf(std::data(public_key_pem), + static_cast(std::size(public_key_pem)))}; + BOOST_REQUIRE(bio != nullptr); + + EVP_PKEY *key{PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr)}; + BIO_free(bio); + BOOST_REQUIRE(key != nullptr); + + const std::size_t cipher_length{ + static_cast(EVP_PKEY_get_size(key))}; + std::string plain(std::size(password) + 1U, '\0'); + plain.replace(0, std::size(password), password); + + for (std::size_t index{0U}; index < std::size(plain); ++index) { + plain[index] = static_cast( + static_cast(plain[index]) ^ + static_cast(salt[index % std::size(salt)])); + } + + std::string cipher(cipher_length, '\0'); + std::size_t out_length{cipher_length}; + + EVP_PKEY_CTX *ctx{EVP_PKEY_CTX_new(key, nullptr)}; + BOOST_REQUIRE(ctx != nullptr); + BOOST_REQUIRE(EVP_PKEY_encrypt_init(ctx) > 0); + BOOST_REQUIRE(EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING) > 0); + BOOST_REQUIRE( + EVP_PKEY_encrypt( + ctx, + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + reinterpret_cast(std::data(cipher)), &out_length, + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + reinterpret_cast(std::data(plain)), + std::size(plain)) > 0); + + EVP_PKEY_CTX_free(ctx); + EVP_PKEY_free(key); + cipher.resize(out_length); + return cipher; +} + +void write_temp_file(const std::string &path, std::string_view contents) { + std::ofstream out{path, std::ios::binary | std::ios::trunc}; + BOOST_REQUIRE(out); + out.write(std::data(contents), + static_cast(std::size(contents))); + BOOST_REQUIRE(out); +} + +} // namespace + +BOOST_AUTO_TEST_CASE(OneSidedServerRsaPathsThrow) { + BOOST_CHECK_THROW(minimysql::caching_sha2_password_authenticator( + "password", "/only/pub.pem", ""), + std::runtime_error); + BOOST_CHECK_THROW(minimysql::caching_sha2_password_authenticator( + "password", "", "/only/priv.pem"), + std::runtime_error); +} + +BOOST_AUTO_TEST_CASE(EmptyServerRsaPathsUseEmbeddedDefaults) { + BOOST_CHECK_NO_THROW( + minimysql::caching_sha2_password_authenticator("password")); +} + +BOOST_AUTO_TEST_CASE(FastAuthPathSucceedsWithMatchingScramble) { + static constexpr std::string_view password{"password"}; + static constexpr std::string_view username{"rpl"}; + static constexpr std::string_view salt{"01234567890123456789"}; + + minimysql::caching_sha2_password_authenticator authenticator{password}; + recording_encoder encoder; + + const auto scramble{ + minimysql::caching_sha2_password_authenticator::scramble(password, salt)}; + authenticator.begin_authentication(username, username, scramble, salt, false, + encoder); + + const auto outbound{authenticator.take_outbound_frames()}; + BOOST_REQUIRE_EQUAL(std::size(outbound), 1U); + BOOST_CHECK_EQUAL(auth_more_data_payload(outbound.front()), + std::string_view{"\x03"}); + BOOST_CHECK(authenticator.state() == + minimysql::authentication_state::succeeded); + BOOST_CHECK(!authenticator.expects_client_input()); +} + +BOOST_AUTO_TEST_CASE(FullAuthRsaPathSucceedsViaPublicKeyRequest) { + static constexpr std::string_view password{"password"}; + static constexpr std::string_view username{"rpl"}; + static constexpr std::string_view salt{"01234567890123456789"}; + + minimysql::caching_sha2_password_authenticator authenticator{password}; + recording_encoder encoder; + + authenticator.begin_authentication(username, username, "bad-scramble", salt, + false, encoder); + + auto outbound{authenticator.take_outbound_frames()}; + BOOST_REQUIRE_EQUAL(std::size(outbound), 1U); + BOOST_CHECK_EQUAL(auth_more_data_payload(outbound.front()), + std::string_view{"\x04"}); + BOOST_CHECK(authenticator.state() == + minimysql::authentication_state::in_progress); + + const minimysql::network_buffer_type public_key_request{"\x02"}; + BOOST_CHECK(authenticator.submit_client_frame(public_key_request, encoder) == + minimysql::authentication_state::in_progress); + + outbound = authenticator.take_outbound_frames(); + BOOST_REQUIRE_EQUAL(std::size(outbound), 1U); + const auto public_key_pem{auth_more_data_payload(outbound.front())}; + BOOST_CHECK(public_key_pem.starts_with("-----BEGIN PUBLIC KEY-----")); + + const auto ciphertext{rsa_encrypt_password(public_key_pem, password, salt)}; + BOOST_CHECK(authenticator.submit_client_frame(ciphertext, encoder) == + minimysql::authentication_state::succeeded); + BOOST_CHECK(authenticator.state() == + minimysql::authentication_state::succeeded); +} + +BOOST_AUTO_TEST_CASE(FullAuthCleartextPathOnSecureTransport) { + static constexpr std::string_view password{"password"}; + static constexpr std::string_view username{"rpl"}; + static constexpr std::string_view salt{"01234567890123456789"}; + + minimysql::caching_sha2_password_authenticator authenticator{password}; + recording_encoder encoder; + + authenticator.begin_authentication(username, username, "bad-scramble", salt, + true, encoder); + (void)authenticator.take_outbound_frames(); + + minimysql::network_buffer_type cleartext{password}; + cleartext.push_back('\0'); + BOOST_CHECK(authenticator.submit_client_frame(cleartext, encoder) == + minimysql::authentication_state::succeeded); +} + +BOOST_AUTO_TEST_CASE(BothServerRsaPathsLoadSuccessfully) { + const std::string pub_path{"/tmp/minimysql_test_server_rsa_public.pem"}; + const std::string priv_path{"/tmp/minimysql_test_server_rsa_private.pem"}; + + EVP_PKEY_CTX *ctx{EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr)}; + BOOST_REQUIRE(ctx != nullptr); + BOOST_REQUIRE(EVP_PKEY_keygen_init(ctx) > 0); + BOOST_REQUIRE(EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, 2048) > 0); + EVP_PKEY *key{nullptr}; + BOOST_REQUIRE(EVP_PKEY_keygen(ctx, &key) > 0); + EVP_PKEY_CTX_free(ctx); + + BIO *pub_bio{BIO_new(BIO_s_mem())}; + BIO *priv_bio{BIO_new(BIO_s_mem())}; + BOOST_REQUIRE(pub_bio != nullptr); + BOOST_REQUIRE(priv_bio != nullptr); + BOOST_REQUIRE(PEM_write_bio_PUBKEY(pub_bio, key) == 1); + BOOST_REQUIRE(PEM_write_bio_PrivateKey(priv_bio, key, nullptr, nullptr, 0, + nullptr, nullptr) == 1); + + char *pub_data{nullptr}; + char *priv_data{nullptr}; + const long pub_len{BIO_get_mem_data(pub_bio, &pub_data)}; + const long priv_len{BIO_get_mem_data(priv_bio, &priv_data)}; + BOOST_REQUIRE(pub_len > 0); + BOOST_REQUIRE(priv_len > 0); + + write_temp_file( + pub_path, std::string_view{pub_data, static_cast(pub_len)}); + write_temp_file( + priv_path, + std::string_view{priv_data, static_cast(priv_len)}); + + BIO_free(pub_bio); + BIO_free(priv_bio); + EVP_PKEY_free(key); + + BOOST_CHECK_NO_THROW(minimysql::caching_sha2_password_authenticator( + "password", pub_path, priv_path)); +} diff --git a/tests/connection_context_ssl_test.cpp b/tests/connection_context_ssl_test.cpp new file mode 100644 index 0000000..75fe653 --- /dev/null +++ b/tests/connection_context_ssl_test.cpp @@ -0,0 +1,206 @@ +// Copyright (c) 2023-2026 Percona and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +#include +#include +#include +#include + +#define BOOST_TEST_MODULE ConnectionContextSslTests +// this include is needed as it provides the 'main()' function +// NOLINTNEXTLINE(misc-include-cleaner) +#include + +#include +#include + +#include + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wsign-conversion" +#pragma GCC diagnostic ignored "-Wconversion" + +#include "mysqlrouter/classic_protocol_codec_base.h" +#include "mysqlrouter/classic_protocol_codec_frame.h" // IWYU pragma: keep +#include "mysqlrouter/classic_protocol_codec_message.h" // IWYU pragma: keep +#include "mysqlrouter/classic_protocol_constants.h" +#include "mysqlrouter/classic_protocol_frame.h" +#include "mysqlrouter/classic_protocol_message.h" + +#pragma GCC diagnostic pop + +#include "minimysql/connection_context.hpp" +#include "minimysql/network_io_operations_fwd.hpp" + +namespace { + +constexpr std::string_view test_username{"rpl"}; +constexpr std::string_view test_password{"password"}; + +// Bit position of CLIENT_SSL in the MySQL capability flags word (24-bit region +// visible in the server Greeting; the low 16 bits are followed by 3 fixed +// bytes and the high 8 bits). We test at the classic_protocol level rather +// than by counting bytes, so we do not need to know the exact byte offset. +constexpr std::size_t client_ssl_bit{classic_protocol::capabilities::pos::ssl}; + +// Encode a fabricated Protocol::SSLRequest frame using the classic_protocol +// codec. This is the same shape the mysql CLI sends when +// --ssl-mode>=PREFERRED and the server advertised CLIENT_SSL: the greeting +// carries the ssl capability bit but everything from username onward is +// empty, and the codec truncates the packet accordingly. +// NOLINTBEGIN(bugprone-easily-swappable-parameters) +minimysql::network_buffer_type +encode_sslrequest_frame(classic_protocol::capabilities::value_type server_caps, + std::uint8_t sequence_number, + std::uint32_t max_packet_size = 16UL * 1024UL * 1024UL, + std::uint8_t collation = 255U) { + // NOLINTEND(bugprone-easily-swappable-parameters) + const classic_protocol::capabilities::value_type client_caps = + server_caps | classic_protocol::capabilities::ssl; + + const classic_protocol::message::client::Greeting sslrequest{ + client_caps, + max_packet_size, + collation, + // username / auth-method-data / schema / auth-method-name / attributes + // all empty — this is the SSLRequest shape. + {}, + {}, + {}, + {}, + {}}; + + using ssl_request_frame = classic_protocol::frame::Frame< + classic_protocol::message::client::Greeting>; + + minimysql::network_buffer_type buffer{}; + auto encode_result = classic_protocol::encode( + {sequence_number, sslrequest}, server_caps, + boost::asio::dynamic_buffer(buffer)); + if (!encode_result) { + throw std::runtime_error{"encoding SSLRequest failed"}; + } + return buffer; +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(connection_context_ssl_tests) + +BOOST_AUTO_TEST_CASE(mark_transport_secure_flips_connection_is_secure) { + minimysql::connection_context context{test_username, test_password}; + BOOST_CHECK(!context.connection_is_secure()); + context.mark_transport_secure(); + BOOST_CHECK(context.connection_is_secure()); +} + +BOOST_AUTO_TEST_CASE(default_greeting_does_not_advertise_ssl) { + minimysql::connection_context context{test_username, test_password}; + [[maybe_unused]] const auto greeting = + context.generate_encoded_server_greeting(); + BOOST_CHECK(!context.get_server_capabilities().test(client_ssl_bit)); +} + +BOOST_AUTO_TEST_CASE( + enabling_ssl_capability_only_changes_ssl_bit_in_server_capabilities) { + minimysql::connection_context baseline{test_username, test_password}; + [[maybe_unused]] const auto baseline_greeting = + baseline.generate_encoded_server_greeting(); + const auto baseline_caps = baseline.get_server_capabilities(); + + minimysql::connection_context ssl_enabled{ + test_username, + test_password, + {}, + {}, + /* ssl_capability_enabled = */ true}; + [[maybe_unused]] const auto ssl_greeting = + ssl_enabled.generate_encoded_server_greeting(); + const auto ssl_caps = ssl_enabled.get_server_capabilities(); + + BOOST_CHECK(!baseline_caps.test(client_ssl_bit)); + BOOST_CHECK(ssl_caps.test(client_ssl_bit)); + + // Only the SSL bit differs. + const auto xor_bits = baseline_caps ^ ssl_caps; + BOOST_CHECK_EQUAL(xor_bits.count(), 1U); + BOOST_CHECK(xor_bits.test(client_ssl_bit)); +} + +BOOST_AUTO_TEST_CASE(sslrequest_recognised_as_short_greeting) { + minimysql::connection_context context{test_username, + test_password, + {}, + {}, + /* ssl_capability_enabled = */ true}; + + // The server must have generated the greeting first so that server_caps and + // sequence-number progression are initialised the same way as in a real + // session. + [[maybe_unused]] const auto server_greeting = + context.generate_encoded_server_greeting(); + + // Sequence number of the SSLRequest is 1 (server used 0 for its greeting). + const auto sslrequest_frame = + encode_sslrequest_frame(context.get_server_capabilities(), 1U); + + context.parse_client_greeting(sslrequest_frame); + + BOOST_CHECK(context.is_sslrequest_greeting()); + BOOST_CHECK(context.get_client_username().empty()); + BOOST_CHECK(context.get_shared_capabilities().test(client_ssl_bit)); +} + +BOOST_AUTO_TEST_CASE(non_ssl_greeting_is_not_flagged_as_sslrequest) { + minimysql::connection_context context{test_username, + test_password, + {}, + {}, + /* ssl_capability_enabled = */ true}; + + [[maybe_unused]] const auto server_greeting = + context.generate_encoded_server_greeting(); + + // Encode a normal (non-SSL) client greeting with a real username. The + // client did not set CLIENT_SSL. + const classic_protocol::capabilities::value_type client_caps = + context.get_server_capabilities() & ~classic_protocol::capabilities::ssl; + + const classic_protocol::message::client::Greeting normal_greeting{ + client_caps, + 16UL * 1024UL * 1024UL, + 255U, + std::string{"rpl"}, + {}, + {}, + std::string{"caching_sha2_password"}, + {}}; + + using client_greeting_frame = classic_protocol::frame::Frame< + classic_protocol::message::client::Greeting>; + + minimysql::network_buffer_type buffer{}; + const auto encode_result = classic_protocol::encode( + {1U, normal_greeting}, context.get_server_capabilities(), + boost::asio::dynamic_buffer(buffer)); + BOOST_REQUIRE(encode_result); + + context.parse_client_greeting(buffer); + + BOOST_CHECK(!context.is_sslrequest_greeting()); + BOOST_CHECK_EQUAL(context.get_client_username(), "rpl"); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/ssl_acceptor_context_test.cpp b/tests/ssl_acceptor_context_test.cpp new file mode 100644 index 0000000..5015267 --- /dev/null +++ b/tests/ssl_acceptor_context_test.cpp @@ -0,0 +1,192 @@ +// Copyright (c) 2023-2026 Percona and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +#include +#include +#include +#include +#include +#include + +#define BOOST_TEST_MODULE SslAcceptorContextTests +// NOLINTNEXTLINE(misc-include-cleaner) +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "minimysql/ssl_acceptor_context.hpp" + +namespace { + +void write_temp_file(const std::string &path, std::string_view content) { + std::ofstream stream{path, std::ios::binary | std::ios::trunc}; + BOOST_REQUIRE(stream.is_open()); + stream.write(std::data(content), + static_cast(std::size(content))); + BOOST_REQUIRE(stream.good()); +} + +// Generate a fresh 2048-bit RSA key and return an owning EVP_PKEY handle. +EVP_PKEY *generate_rsa_keypair() { + EVP_PKEY_CTX *ctx{EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr)}; + BOOST_REQUIRE(ctx != nullptr); + BOOST_REQUIRE(EVP_PKEY_keygen_init(ctx) > 0); + BOOST_REQUIRE(EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, 2048) > 0); + EVP_PKEY *key{nullptr}; + BOOST_REQUIRE(EVP_PKEY_keygen(ctx, &key) > 0); + EVP_PKEY_CTX_free(ctx); + return key; +} + +// Build a minimal self-signed X.509 certificate signed by `key`. +X509 *build_self_signed_certificate(EVP_PKEY *key, const char *common_name) { + constexpr long seconds_per_minute{60L}; + constexpr long minutes_per_hour{60L}; + constexpr long hours_per_day{24L}; + constexpr long validity_days{30L}; + constexpr long validity_seconds{seconds_per_minute * minutes_per_hour * + hours_per_day * validity_days}; + + X509 *cert{X509_new()}; + BOOST_REQUIRE(cert != nullptr); + BOOST_REQUIRE(X509_set_version(cert, 2) == 1); // X509v3 + BOOST_REQUIRE(ASN1_INTEGER_set(X509_get_serialNumber(cert), 1) == 1); + X509_gmtime_adj(X509_get_notBefore(cert), 0); + X509_gmtime_adj(X509_get_notAfter(cert), validity_seconds); + BOOST_REQUIRE(X509_set_pubkey(cert, key) == 1); + + X509_NAME *name{X509_get_subject_name(cert)}; + BOOST_REQUIRE(name != nullptr); + BOOST_REQUIRE( + X509_NAME_add_entry_by_txt( + name, "CN", MBSTRING_ASC, + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + reinterpret_cast(common_name), -1, -1, + 0) == 1); + BOOST_REQUIRE(X509_set_issuer_name(cert, name) == 1); + + BOOST_REQUIRE(X509_sign(cert, key, EVP_sha256()) > 0); + return cert; +} + +std::string pem_encode_private_key(EVP_PKEY *key) { + BIO *bio{BIO_new(BIO_s_mem())}; + BOOST_REQUIRE(bio != nullptr); + BOOST_REQUIRE(PEM_write_bio_PrivateKey(bio, key, nullptr, nullptr, 0, nullptr, + nullptr) == 1); + char *data{nullptr}; + const long length{BIO_get_mem_data(bio, &data)}; + BOOST_REQUIRE(length > 0); + std::string result{data, static_cast(length)}; + BIO_free(bio); + return result; +} + +std::string pem_encode_certificate(X509 *cert) { + BIO *bio{BIO_new(BIO_s_mem())}; + BOOST_REQUIRE(bio != nullptr); + BOOST_REQUIRE(PEM_write_bio_X509(bio, cert) == 1); + char *data{nullptr}; + const long length{BIO_get_mem_data(bio, &data)}; + BOOST_REQUIRE(length > 0); + std::string result{data, static_cast(length)}; + BIO_free(bio); + return result; +} + +struct pem_pair { + std::string cert_path; + std::string key_path; +}; + +pem_pair make_valid_pem_pair(const std::string &path_prefix) { + EVP_PKEY *key{generate_rsa_keypair()}; + X509 *cert{build_self_signed_certificate(key, "minimysql-test")}; + + const std::string cert_path{path_prefix + "_cert.pem"}; + const std::string key_path{path_prefix + "_key.pem"}; + + write_temp_file(cert_path, pem_encode_certificate(cert)); + write_temp_file(key_path, pem_encode_private_key(key)); + + X509_free(cert); + EVP_PKEY_free(key); + + return {.cert_path = cert_path, .key_path = key_path}; +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(ssl_acceptor_context_tests) + +BOOST_AUTO_TEST_CASE(constructs_with_valid_cert_and_key) { + const auto files{make_valid_pem_pair("/tmp/minimysql_ssl_valid")}; + + BOOST_CHECK_NO_THROW( + minimysql::ssl_acceptor_context(files.cert_path, files.key_path)); + + minimysql::ssl_acceptor_context ssl_ctx{files.cert_path, files.key_path}; + BOOST_CHECK(ssl_ctx.native().native_handle() != nullptr); + BOOST_CHECK_EQUAL(ssl_ctx.get_certificate_path(), files.cert_path); + BOOST_CHECK_EQUAL(ssl_ctx.get_private_key_path(), files.key_path); +} + +BOOST_AUTO_TEST_CASE(throws_on_missing_cert_file) { + const auto files{make_valid_pem_pair("/tmp/minimysql_ssl_missing_cert")}; + + const std::string bogus_cert{"/tmp/minimysql_ssl_does_not_exist.pem"}; + BOOST_CHECK_EXCEPTION( + minimysql::ssl_acceptor_context(bogus_cert, files.key_path), + std::runtime_error, [&bogus_cert](const std::runtime_error &exc) { + return std::string{exc.what()}.find(bogus_cert) != std::string::npos; + }); +} + +BOOST_AUTO_TEST_CASE(throws_on_mismatched_key) { + const auto files_a{make_valid_pem_pair("/tmp/minimysql_ssl_pair_a")}; + const auto files_b{make_valid_pem_pair("/tmp/minimysql_ssl_pair_b")}; + + try { + // cert from pair A + key from pair B — private key does not match cert. + const minimysql::ssl_acceptor_context ssl_ctx{files_a.cert_path, + files_b.key_path}; + BOOST_FAIL("expected exception was not thrown"); + } catch (const std::runtime_error &exc) { + const std::string what{exc.what()}; + // Depending on the OpenSSL version, the mismatch may surface either from + // SSL_CTX_use_PrivateKey_file (which internally checks the key against + // any already-loaded cert on modern OpenSSL) or from our explicit + // SSL_CTX_check_private_key call. Either failure references the key path + // and reports a key-values/cert mismatch — assert on both. + BOOST_CHECK(what.find(files_b.key_path) != std::string::npos); + const bool mentions_mismatch = + what.find("does not match") != std::string::npos || + what.find("key values mismatch") != std::string::npos || + what.find("KEY_VALUES_MISMATCH") != std::string::npos; + BOOST_CHECK_MESSAGE(mentions_mismatch, + "unexpected mismatch message: " + what); + } +} + +BOOST_AUTO_TEST_SUITE_END()