From 172e54f824b24908f6018fa68a30fcc10d0984e8 Mon Sep 17 00:00:00 2001 From: Kamil Holubicki Date: Mon, 6 Jul 2026 11:27:38 +0200 Subject: [PATCH 1/3] PBS-32: handle auth method switch negotiation https://perconadev.atlassian.net/browse/PBS-32 Implement AuthSwitchRequest handling when the client replies with an authentication plugin that differs from the server account plugin. The server now sends the switch request, reads the client auth switch response, updates the connection context, and continues normal authentication. Format the auth switch plugin data through a helper so caching_sha2_password keeps the historical trailing NULL filler expected by MySQL clients, while leaving future plugins free to define their own payload shape. --- src/minimysql/connection_context.cpp | 49 ++++++++++++++++++++++++++++ src/minimysql/connection_context.hpp | 4 +++ src/minimysql/network_service.cpp | 24 ++++++++++++-- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/minimysql/connection_context.cpp b/src/minimysql/connection_context.cpp index f859bac..a459668 100644 --- a/src/minimysql/connection_context.cpp +++ b/src/minimysql/connection_context.cpp @@ -178,6 +178,43 @@ void connection_context::parse_client_greeting( client_attributes_ = client_greeting.attributes(); } +[[nodiscard]] network_buffer_type +connection_context::generate_encoded_auth_method_switch() { + std::string result_buffer{}; + + classic_protocol::message::server::AuthMethodSwitch auth_method_switch{ + get_server_auth_method(), generate_server_auth_method_switch_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(); +} + [[nodiscard]] network_buffer_type connection_context::generate_encoded_fast_auth() { std::string result_buffer{}; @@ -429,6 +466,18 @@ connection_context::generate_server_auth_method_data() { return server_auth_method_data_; } +[[nodiscard]] std::string +connection_context::generate_server_auth_method_switch_data() const { + if (get_server_auth_method() == + caching_sha2_password_authenticator::plugin_name) { + // caching_sha2_password follows the historical handshake seed shape: + // 20 bytes of challenge data plus a trailing NUL filler. + return get_server_auth_method_data() + '\0'; + } + + return get_server_auth_method_data(); +} + [[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..40d5875 100644 --- a/src/minimysql/connection_context.hpp +++ b/src/minimysql/connection_context.hpp @@ -130,6 +130,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_auth_method_switch(); + void parse_client_auth_method_data(const network_buffer_type &payload); + [[nodiscard]] network_buffer_type generate_encoded_fast_auth(); [[nodiscard]] network_buffer_type generate_encoded_ok(); [[nodiscard]] network_buffer_type generate_encoded_eof(); @@ -228,6 +231,7 @@ class connection_context { [[nodiscard]] static capability_bitset get_default_server_capabilities() noexcept; [[nodiscard]] const std::string &generate_server_auth_method_data(); + [[nodiscard]] std::string generate_server_auth_method_switch_data() const; [[nodiscard]] std::uint8_t generate_sequence_number(); void validate_and_update_sequence_number(std::uint8_t sequence_number); diff --git a/src/minimysql/network_service.cpp b/src/minimysql/network_service.cpp index 412472f..c977aba 100644 --- a/src/minimysql/network_service.cpp +++ b/src/minimysql/network_service.cpp @@ -291,8 +291,28 @@ void handle_exception(std::string_view context) { << " authentication that does not match the one associated " "with the user account (" << context.get_server_auth_method() << ")\n"; - // TODO: send SwitchAuthentication packet - co_return; + + 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"; } if (!context.check_client_authentication()) { std::cout << "client authentication failed for " From 38272065e62ad23b8b34db45cd65efdda5c0db77 Mon Sep 17 00:00:00 2001 From: Kamil Holubicki Date: Thu, 16 Jul 2026 12:43:30 +0200 Subject: [PATCH 2/3] PBS-33: support caching_sha2_password RSA full auth in minimysql https://perconadev.atlassian.net/browse/PBS-33 Added: Encapsulate caching_sha2_password in caching_sha2_password_authenticator (scramble verify, AuthSwitch data, 0x03/0x04, PEM, RSA decrypt, cleartext stub for future TLS). Auth loop in network_service stays auth-method agnostic: optional AuthSwitch on plugin mismatch, then begin_authentication / outbound frames / client frames until done. Fast auth when the handshake scramble matches the configured password; otherwise full auth (0x04) with RSA on plain TCP (0x02 + PEM or ciphertext via --server-public-key-path). Wire AuthMoreData (0x01 || plugin data) for 0x03, 0x04, and PEM, matching Percona Server/MySql mpvio wrapping. Thread server_rsa_public/private_key_path from minimysql_app through network_service -> connection_context -> authenticator (empty = embedded defaults; one-sided config fails hard). Random 20-byte greeting salt; unit tests for fast path, RSA path, and key path validation. How this differs from Percona Server/MySql: No SHA2 digest cache: every successful scramble is treated as a cache hit, including the first connection. PS does full auth (RSA on plain TCP / cleartext on SSL) on cache miss, then caches for later fast auth. No TLS yet: connection_is_secure() is always false; cleartext-after-0x04 is stubbed only. PS accepts cleartext password over SSL/socket after 0x04. Single configured user/password and optional key paths (or embedded MTR keys), not ACL / auto-generated server RSA keys from config. --- .../caching_sha2_password_authenticator.cpp | 528 +++++++++++++++++- .../caching_sha2_password_authenticator.hpp | 113 +++- src/minimysql/connection_context.cpp | 183 +++++- src/minimysql/connection_context.hpp | 46 +- src/minimysql/network_service.cpp | 91 ++- src/minimysql/network_service.hpp | 6 +- src/minimysql_app.cpp | 8 +- tests/CMakeLists.txt | 21 + ...ching_sha2_password_authenticator_test.cpp | 262 +++++++++ 9 files changed, 1186 insertions(+), 72 deletions(-) create mode 100644 tests/caching_sha2_password_authenticator_test.cpp 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 a459668..f0b9516 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,100 @@ 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) : 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} { 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()); +} + +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) +[[nodiscard]] bool connection_context::connection_is_secure() const noexcept { + // Stub until minimysql gains TLS: Percona Server accepts cleartext password + // after 0x04 only when the transport is secure (SSL/TLS, socket, etc.). + return false; +} + +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 @@ -182,8 +268,10 @@ void connection_context::parse_client_greeting( connection_context::generate_encoded_auth_method_switch() { std::string result_buffer{}; - classic_protocol::message::server::AuthMethodSwitch auth_method_switch{ - get_server_auth_method(), generate_server_auth_method_switch_data()}; + 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( @@ -215,17 +303,58 @@ void connection_context::parse_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::generate_encoded_fast_auth() { +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) { @@ -460,22 +589,26 @@ 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 -connection_context::generate_server_auth_method_switch_data() const { - if (get_server_auth_method() == - caching_sha2_password_authenticator::plugin_name) { - // caching_sha2_password follows the historical handshake seed shape: - // 20 bytes of challenge data plus a trailing NUL filler. - return get_server_auth_method_data() + '\0'; +[[nodiscard]] std::string_view connection_context::get_frame_payload( + const network_buffer_type &payload) noexcept { + if (std::size(payload) <= get_frame_header_length()) { + return {}; } - return get_server_auth_method_data(); + // 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() { diff --git a/src/minimysql/connection_context.hpp b/src/minimysql/connection_context.hpp index 40d5875..45221a7 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,12 @@ 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}; 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 = {}); [[nodiscard]] const std::string &get_server_username() const noexcept { return server_username_; @@ -50,7 +55,19 @@ 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; + 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 +87,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_; @@ -133,7 +147,6 @@ class connection_context { [[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_fast_auth(); [[nodiscard]] network_buffer_type generate_encoded_ok(); [[nodiscard]] network_buffer_type generate_encoded_eof(); [[nodiscard]] network_buffer_type @@ -228,13 +241,30 @@ class connection_context { std::string binlog_filename_{}; std::uint64_t binlog_position_{}; + caching_sha2_password_authenticator authenticator_; + [[nodiscard]] static capability_bitset get_default_server_capabilities() noexcept; [[nodiscard]] const std::string &generate_server_auth_method_data(); - [[nodiscard]] std::string generate_server_auth_method_switch_data() const; [[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_service.cpp b/src/minimysql/network_service.cpp index c977aba..57dc1f4 100644 --- a/src/minimysql/network_service.cpp +++ b/src/minimysql/network_service.cpp @@ -60,6 +60,7 @@ #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" @@ -226,7 +227,11 @@ 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) { boost::system::error_code session_ec; const auto remote_endpoint{socket.remote_endpoint(session_ec)}; @@ -237,7 +242,9 @@ 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}; + minimysql::connection_context context{username, password, + server_rsa_public_key_path, + server_rsa_private_key_path}; // creating and sending server greeting packet: // protocol_version: 10 @@ -286,12 +293,7 @@ void handle_exception(std::string_view context) { 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"; - + 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"); @@ -314,7 +316,44 @@ void handle_exception(std::string_view context) { << std::size(context.get_client_auth_method_data()) << " byte(s)\n"; } - if (!context.check_client_authentication()) { + + 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()}; @@ -330,16 +369,6 @@ void handle_exception(std::string_view context) { std::cout << "client authentication succeeded for " << context.get_client_username() << '\n'; - // 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"; - // sending server ok after successful authentication const auto auth_ok{context.generate_encoded_ok()}; print_generic(remote_endpoint, context, "ok (auth)"); @@ -500,7 +529,11 @@ 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) { const scope_tracer tracer("listener"); auto executor = acceptor.get_executor(); @@ -524,7 +557,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), boost::asio::detached); } } catch (...) { @@ -537,13 +572,21 @@ 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) + : 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}, 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_), boost::asio::detached); } diff --git a/src/minimysql/network_service.hpp b/src/minimysql/network_service.hpp index 7e341fe..f85a670 100644 --- a/src/minimysql/network_service.hpp +++ b/src/minimysql/network_service.hpp @@ -31,7 +31,9 @@ class network_service { 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 = {}); network_service(const network_service &) = delete; network_service &operator=(const network_service &) = delete; @@ -43,6 +45,8 @@ 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_; using acceptor_type = diff --git a/src/minimysql_app.cpp b/src/minimysql_app.cpp index c12f2df..0dd8977 100644 --- a/src/minimysql_app.cpp +++ b/src/minimysql_app.cpp @@ -36,13 +36,19 @@ int main(int /* argc */, char * /* argv */[]) { 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{}; int res{EXIT_FAILURE}; try { 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); 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..041071f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -95,6 +95,25 @@ 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 +) + set(test_run_options --no_color_output) add_test(NAME byte_span_encoding_test COMMAND byte_span_encoding_test ${test_run_options}) @@ -103,3 +122,5 @@ 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}) 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)); +} From dc6b8c4fd06e344b772fd9da2ea7dfefa9364722 Mon Sep 17 00:00:00 2001 From: Kamil Holubicki Date: Fri, 24 Jul 2026 18:28:05 +0200 Subject: [PATCH 3/3] PBS-31 Implement support for SSL/TLS for the listener https://perconadev.atlassian.net/browse/PBS-31 Problem: minimysql_server accepted only plaintext client connections. It did not advertise CLIENT_SSL, so mysql clients running with --ssl-mode=REQUIRED could not connect and every authentication ran in the clear. The "cleartext password after 0x04 is safe only on a secure transport" invariant of caching_sha2_password kept the fast path disabled unconditionally, diverging from Percona Server behaviour. Solution: Add optional per-listener TLS to minimysql_server, matching a TLS-configured Percona Server node with require_secure_transport=OFF. The listener is TLS-enabled when both --ssl-cert and --ssl-key are supplied. TLS configuration is captured in a shared acceptor context owned by the network layer; it advertises CLIENT_SSL in the greeting only when configured. TLSv1.2 and TLSv1.3 are the accepted protocol versions; older SSL/TLS versions are explicitly disabled. Client-certificate verification is disabled (server-cert only), matching the mysql CLI default. Per session, the transport starts on the raw TCP socket, and the first client greeting drives the branch. A Protocol::SSLRequest against a TLS-configured listener triggers a TLS handshake and switches all subsequent I/O to the encrypted stream; the same intent against a plaintext-only listener drops the connection with a diagnostic and no error frame, matching Percona Server's "if (!context.have_ssl()) return packet_error;" in sql/auth/sql_authentication.cc. Once TLS is established the connection is treated as secure, which unlocks the caching_sha2_password cleartext-after-0x04 fast path for TLS clients as in Percona Server. The MySQL frame I/O helpers and the post-greeting session are generic over the socket type so plaintext and TLS-upgraded sessions share one authentication and command-loop implementation. Build wiring adds explicit OpenSSL and links OpenSSL::SSL; Boost's asio ssl headers are already available. New unit tests cover the connection_context predicates and the TLS acceptor construction; handshake success over TLSv1.2 and TLSv1.3, and the Percona-style rejection of SSL-requesting clients against a plaintext listener, were verified end-to-end. --- CMakeLists.txt | 11 +- src/minimysql/connection_context.cpp | 48 +- src/minimysql/connection_context.hpp | 32 +- src/minimysql/network_io_operations.cpp | 193 ------- src/minimysql/network_io_operations.hpp | 180 ++++-- src/minimysql/network_service.cpp | 605 ++++++++++++--------- src/minimysql/network_service.hpp | 25 +- src/minimysql/ssl_acceptor_context.cpp | 125 +++++ src/minimysql/ssl_acceptor_context.hpp | 71 +++ src/minimysql/ssl_acceptor_context_fwd.hpp | 25 + src/minimysql_app.cpp | 108 +++- tests/CMakeLists.txt | 54 ++ tests/connection_context_ssl_test.cpp | 206 +++++++ tests/ssl_acceptor_context_test.cpp | 192 +++++++ 14 files changed, 1379 insertions(+), 496 deletions(-) delete mode 100644 src/minimysql/network_io_operations.cpp create mode 100644 src/minimysql/ssl_acceptor_context.cpp create mode 100644 src/minimysql/ssl_acceptor_context.hpp create mode 100644 src/minimysql/ssl_acceptor_context_fwd.hpp create mode 100644 tests/connection_context_ssl_test.cpp create mode 100644 tests/ssl_acceptor_context_test.cpp 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/connection_context.cpp b/src/minimysql/connection_context.cpp index f0b9516..5b2fb5f 100644 --- a/src/minimysql/connection_context.cpp +++ b/src/minimysql/connection_context.cpp @@ -86,11 +86,12 @@ connection_context::connection_context( // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) 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) + 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_++), authenticator_{server_password, server_rsa_public_key_path, - server_rsa_private_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 " @@ -146,11 +147,28 @@ connection_context::needs_auth_method_switch() const noexcept { get_client_auth_method()); } -// NOLINTNEXTLINE(readability-convert-member-functions-to-static) [[nodiscard]] bool connection_context::connection_is_secure() const noexcept { - // Stub until minimysql gains TLS: Percona Server accepts cleartext password - // after 0x04 only when the transport is secure (SSL/TLS, socket, etc.). - return false; + // 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() { @@ -211,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 @@ -244,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()}; } diff --git a/src/minimysql/connection_context.hpp b/src/minimysql/connection_context.hpp index 45221a7..a1e6573 100644 --- a/src/minimysql/connection_context.hpp +++ b/src/minimysql/connection_context.hpp @@ -44,10 +44,17 @@ class connection_context { static constexpr std::string_view default_server_auth_method{ 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_rsa_public_key_path = {}, - std::string_view server_rsa_private_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_; @@ -61,6 +68,26 @@ class connection_context { [[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; @@ -243,6 +270,9 @@ class connection_context { 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(); 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 57dc1f4..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,6 +59,11 @@ #include +#include + +#include +#include + #include #include @@ -64,6 +72,7 @@ #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 { @@ -220,8 +229,306 @@ 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) @@ -231,7 +538,8 @@ void handle_exception(std::string_view context) { // 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) { + 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)}; @@ -242,19 +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, - server_rsa_public_key_path, - server_rsa_private_key_path}; - - // 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); @@ -264,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) @@ -281,237 +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.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(); + if (ssl_ctx != nullptr && context.is_sslrequest_greeting()) { + std::cout << "client requested TLS upgrade (SSLRequest) from " + << remote_endpoint << '\n'; - 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"; - } + boost::asio::ssl::stream ssl_socket{ + std::move(socket), ssl_ctx->native()}; - if (context.authentication_state() != - minimysql::authentication_state::in_progress) { - break; - } + co_await perform_ssl_handshake( + ssl_socket, network_service::session_authentication_timeout); - if (!context.expects_authentication_input()) { - break; - } + context.mark_transport_secure(); + std::cout << "TLS handshake completed with " << remote_endpoint << '\n'; co_await minimysql::async_read_mysql_frame( - socket, data, network_service::session_authentication_timeout); - std::cout << "received client authentication packet (" << 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.submit_authentication_frame(data); - } + context.parse_client_greeting(data); + print_client_greeting(remote_endpoint, context); - 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; - } - - 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"; - - // 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"; - // 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"; - } - } + 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{ @@ -533,7 +632,8 @@ void handle_exception(std::string_view context) { // 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) { + const std::string &server_rsa_private_key_path, + minimysql::ssl_acceptor_context *ssl_ctx) { const scope_tracer tracer("listener"); auto executor = acceptor.get_executor(); @@ -559,7 +659,7 @@ void handle_exception(std::string_view context) { boost::asio::co_spawn(executor, session(std::move(socket), username, password, server_rsa_public_key_path, - server_rsa_private_key_path), + server_rsa_private_key_path, ssl_ctx), boost::asio::detached); } } catch (...) { @@ -574,11 +674,12 @@ network_service::network_service( // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) 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::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}, + 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})} { @@ -586,7 +687,7 @@ network_service::network_service( boost::asio::co_spawn(*context_, listener(*acceptor_, username_, password_, server_rsa_public_key_path_, - server_rsa_private_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 f85a670..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,11 +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 server_rsa_public_key_path = {}, - std::string_view server_rsa_private_key_path = {}); + 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; @@ -49,6 +68,8 @@ class network_service { 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 0dd8977..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,8 +35,77 @@ #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"}; @@ -41,14 +115,38 @@ int main(int /* argc */, char * /* argv */[]) { 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, - default_server_rsa_public_key_path, - default_server_rsa_private_key_path); + 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 041071f..c2135c3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -114,6 +114,56 @@ set_target_properties(caching_sha2_password_authenticator_test PROPERTIES 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}) @@ -124,3 +174,7 @@ 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/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()