From d0dfd5f5ff1336f6b60b3a406c24fb7c7864c1ca Mon Sep 17 00:00:00 2001 From: Kamil Holubicki Date: Thu, 16 Jul 2026 12:43:30 +0200 Subject: [PATCH 1/3] PBS-33: support caching_sha2_password RSA full auth https://perconadev.atlassian.net/browse/PBS-33 Before this change, the Binlog Server's built-in caching_sha2_password authentication could only issue a fast-auth success ('0x03') against the configured plaintext password. A client that ran 'mysql --get-server-public-key' (or '--server-public-key-path=') on a cache miss would trigger a '0x04' full-authentication challenge that PBS could neither issue nor complete: it had no RSA key pair, no PEM send, and no OAEP decrypt. Implement caching_sha2_password full authentication end to end. The new 'minimysql::caching_sha2_password_authenticator' encapsulates the whole plugin sub-protocol as a state machine: fresh 20-byte greeting salt (matching Percona Server's 'generate_user_salt()' in 'mysys/crypt_genhash_impl.cc'), AuthMethodSwitch data formatting, '0x03'/'0x04' selection, '0x02' public-key-request handling, PEM send, RSA-OAEP decrypt with the caching_sha2_password XOR-with-salt-repeated wrapping, and a cleartext-over-secure branch reserved for the day TLS lands (PBS-31). 'minimysql::connection_context' delegates every auth step to the authenticator through a small 'auth_packet_encoder' interface so the frame codec stays in one place. 'minimysql::network_service::session()' was reworked around an auth-method-agnostic loop. It sends the greeting, optionally emits an AuthMethodSwitch when 'needs_auth_method_switch()' says the client picked a different plugin (re-using the fresh-salt helper), then calls 'begin_authentication()' and cycles through 'take_authentication_outbound_frames()' / 'submit_authentication_frame()' until 'authentication_state()' is no longer 'in_progress'. The same loop drives fast-auth ('0x03'), full-auth-over-RSA ('0x04' -> PEM/'0x02' -> ciphertext), and future cleartext-over-TLS ('0x04' -> cleartext) without special casing. PBS has no SHA-2 digest cache, so every session is treated as a first-login / cache miss and 'begin_authentication()' always enqueues '0x04'. The RSA / PEM handshake is therefore the default and only path exercised in production. The fast-auth helpers ('verify_greeting_scramble()', 'enqueue_fast_auth_success()', and the 'scramble()' static) are deliberately kept in the authenticator with a documented pointer to where a future SHA-2 cache would re-enable the shortcut, gated on a cache lookup rather than a live plaintext compare. Added two OpenSSL RAII wrappers under 'src/opensslpp/', following the existing 'cipher_context' / 'crypto_rng' convention (opaque 'unique_ptr' pimpl, 'native_helper' in the '.cpp', 'core_error' on failure, 'util::byte_span' IO, no '' in headers): * 'opensslpp::digest_context' plus 'opensslpp::digest_code_type' - 'EVP_MD_CTX' wrapper (SHA-256 today) with a one-shot 'calculate()' helper; used by 'scramble()'. * 'opensslpp::rsa_private_key' - 'EVP_PKEY' loaded from an in-memory PEM buffer, exposing 'get_cipher_length_in_bytes()' and 'decrypt_oaep()' (PKCS#1 v2 OAEP with OpenSSL default SHA-1 MGF). After this change, no '.cpp' outside 'src/opensslpp/' includes an '' header directly; both 'connection_context.cpp' (random salt) and 'caching_sha2_password_authenticator.cpp' (RSA decrypt, SHA-256 digest) go through the wrappers. The authenticator unit test in 'tests/CMakeLists.txt' links 'binsrv::lib_opensslpp' instead of the raw 'OpenSSL::Crypto'. Server RSA key pair sourced from an optional 'pbs_listener' block in the JSON main_config ('binsrv::pbs_listener_config' with fields 'rsa_public_key_path' and 'rsa_private_key_path'). 'pull_operation' reads the block when present and forwards the two paths through 'minimysql::network_service' -> 'connection_context' -> authenticator as 'std::string_view'; when the block is absent it forwards empty views. The authenticator loads the pair from disk when both paths are non-empty, accepts both-empty at construction (no keys loaded, subsequent full-auth attempts fail per-session), and rejects one-sided configuration with a clear error. 'pbs_listener_config::validate()' additionally rejects one-sided configuration at the JSON-config layer with a matching message. There are no embedded default RSA keys - the operator provides them. Unit test ('tests/caching_sha2_password_authenticator_test.cpp') covers 'scramble()' correctness, the RSA full-auth success and failure paths (both via '0x02' PEM request and via a pre-loaded local PEM), cleartext-over-secure success, one-sided-path rejection, both-empty-path acceptance at construction, and locks in the always-full-auth policy by asserting that a matching greeting scramble still drives '0x04'. Tests that need real keys generate them on the fly with the new 'write_temp_rsa_key_pair()' helper and write them to '/tmp/pbs_test_server_rsa_*.pem' - no static test-only PEM constants live in-source. MTR test ('mtr/binlog_streaming/t/caching_sha2_full_auth.test' with the server RSA key pair shipped as static assets at 'mtr/binlog_streaming/std_data/caching_sha2_full_auth_{pubkey,privkey} .pem') covers three end-to-end scenarios inside one binsrv lifetime - correct password + '--get-server-public-key', correct password + '--server-public-key-path=', and wrong password + PEM fetch (asserted '--error 1'). The test feeds both key paths into the generated binsrv JSON config through the new '$binsrv_pbs_listener_rsa_public_key_path' / '$binsrv_pbs_listener_rsa_private_key_path' MTR vars, which 'generate_binsrv_config.inc' turns into the 'pbs_listener' block. After graceful SIGTERM the stdout log is grepped for 'client authentication succeeded for rpl' and 'client authentication failed for rpl' markers so each case is confirmed both by mysql client exit code and by server-side logging. 'mtr/binlog_streaming/t/auth_method_switch.test' (PBS-32) was updated to pass '--get-server-public-key' on both mysql invocations, because the always-full-auth policy requires clients to complete '0x04' on plain TCP. Once PBS-31 wires TLS these flags can be dropped in favour of the secure-transport cleartext branch. Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 12 + .../include/generate_binsrv_config.inc | 21 + .../include/set_up_binsrv_environment.inc | 2 + .../r/auth_method_switch.result | 10 +- .../r/caching_sha2_full_auth.result | 67 +++ .../caching_sha2_full_auth_privkey.pem | 51 ++ .../caching_sha2_full_auth_pubkey.pem | 14 + .../t/auth_method_switch.test | 20 +- .../t/caching_sha2_full_auth.test | 214 ++++++++ src/binsrv/main_config.cpp | 4 + src/binsrv/main_config.hpp | 20 +- src/binsrv/pbs_listener_config.cpp | 38 ++ src/binsrv/pbs_listener_config.hpp | 52 ++ src/binsrv/pbs_listener_config_fwd.hpp | 28 + .../caching_sha2_password_authenticator.cpp | 507 ++++++++++++++---- .../caching_sha2_password_authenticator.hpp | 113 +++- src/minimysql/connection_context.cpp | 162 +++++- src/minimysql/connection_context.hpp | 45 +- src/minimysql/network_service.cpp | 91 +++- src/minimysql/network_service.hpp | 6 +- src/opensslpp/digest_context.cpp | 151 ++++++ src/opensslpp/digest_context.hpp | 87 +++ src/opensslpp/digest_context_fwd.hpp | 29 + src/opensslpp/rsa_private_key.cpp | 159 ++++++ src/opensslpp/rsa_private_key.hpp | 79 +++ src/opensslpp/rsa_private_key_fwd.hpp | 25 + src/operations/pull_operation.cpp | 21 +- tests/CMakeLists.txt | 21 + ...ching_sha2_password_authenticator_test.cpp | 300 +++++++++++ 29 files changed, 2193 insertions(+), 156 deletions(-) create mode 100644 mtr/binlog_streaming/r/caching_sha2_full_auth.result create mode 100644 mtr/binlog_streaming/std_data/caching_sha2_full_auth_privkey.pem create mode 100644 mtr/binlog_streaming/std_data/caching_sha2_full_auth_pubkey.pem create mode 100644 mtr/binlog_streaming/t/caching_sha2_full_auth.test create mode 100644 src/binsrv/pbs_listener_config.cpp create mode 100644 src/binsrv/pbs_listener_config.hpp create mode 100644 src/binsrv/pbs_listener_config_fwd.hpp create mode 100644 src/opensslpp/digest_context.cpp create mode 100644 src/opensslpp/digest_context.hpp create mode 100644 src/opensslpp/digest_context_fwd.hpp create mode 100644 src/opensslpp/rsa_private_key.cpp create mode 100644 src/opensslpp/rsa_private_key.hpp create mode 100644 src/opensslpp/rsa_private_key_fwd.hpp create mode 100644 tests/caching_sha2_password_authenticator_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 09e69753..db073019 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -179,6 +179,14 @@ set(opensslpp_source_files src/opensslpp/crypto_rng.hpp src/opensslpp/crypto_rng.cpp + src/opensslpp/digest_context_fwd.hpp + src/opensslpp/digest_context.hpp + src/opensslpp/digest_context.cpp + + src/opensslpp/rsa_private_key_fwd.hpp + src/opensslpp/rsa_private_key.hpp + src/opensslpp/rsa_private_key.cpp + src/opensslpp/core_error_fwd.hpp src/opensslpp/core_error.hpp src/opensslpp/core_error.cpp @@ -591,6 +599,10 @@ set(binsrv_source_files src/binsrv/main_config.hpp src/binsrv/main_config.cpp + src/binsrv/pbs_listener_config_fwd.hpp + src/binsrv/pbs_listener_config.hpp + src/binsrv/pbs_listener_config.cpp + src/binsrv/replication_config_fwd.hpp src/binsrv/replication_config.hpp src/binsrv/replication_config.cpp diff --git a/mtr/binlog_streaming/include/generate_binsrv_config.inc b/mtr/binlog_streaming/include/generate_binsrv_config.inc index 8499d05b..d08b3b07 100644 --- a/mtr/binlog_streaming/include/generate_binsrv_config.inc +++ b/mtr/binlog_streaming/include/generate_binsrv_config.inc @@ -21,6 +21,8 @@ # --let $binsrv_encryption_cipher = AES-256-CTR (optional) # --let $binsrv_encryption_kek_id = alpha (optional) # --let $binsrv_keyring_data_file_path = $MYSQL_TMP_DIR/keyring_data.json (optional) +# --let $binsrv_pbs_listener_rsa_public_key_path = /path/to/pub.pem (optional) +# --let $binsrv_pbs_listener_rsa_private_key_path = /path/to/priv.pem (optional) # --source set_up_binsrv_environment.inc --echo @@ -170,6 +172,25 @@ if ($binsrv_encryption_enabled) ); } +# Optional 'pbs_listener' block (see binsrv/pbs_listener_config.hpp). Both +# rsa_public_key_path and rsa_private_key_path must be non-empty; the block +# is omitted when either input var is unset. Any operation that uses the +# minimysql listener (currently: 'pull') requires this block, so tests that +# spawn 'binlog_server pull' must set both. +if ($binsrv_pbs_listener_rsa_public_key_path != "") +{ + if ($binsrv_pbs_listener_rsa_private_key_path != "") + { + eval SET @binsrv_config_json = JSON_INSERT( + @binsrv_config_json, '$.pbs_listener', + JSON_OBJECT( + 'rsa_public_key_path', '$binsrv_pbs_listener_rsa_public_key_path', + 'rsa_private_key_path', '$binsrv_pbs_listener_rsa_private_key_path' + ) + ); + } +} + --let $binsrv_config_file_path = $MYSQL_TMP_DIR/binsrv_config.json --let $write_var = `SELECT @binsrv_config_json` --let $write_to_file = $binsrv_config_file_path diff --git a/mtr/binlog_streaming/include/set_up_binsrv_environment.inc b/mtr/binlog_streaming/include/set_up_binsrv_environment.inc index 3b2959bb..e52f5290 100644 --- a/mtr/binlog_streaming/include/set_up_binsrv_environment.inc +++ b/mtr/binlog_streaming/include/set_up_binsrv_environment.inc @@ -21,6 +21,8 @@ # --let $binsrv_encryption_cipher = AES-256-CTR (optional) # --let $binsrv_encryption_kek_id = alpha (optional) # --let $binsrv_keyring_data_file_path = $MYSQL_TMP_DIR/keyring_data.json (optional) +# --let $binsrv_pbs_listener_rsa_public_key_path = /path/to/pub.pem (optional) +# --let $binsrv_pbs_listener_rsa_private_key_path = /path/to/priv.pem (optional) # --source set_up_binsrv_environment.inc --source ../include/generate_binsrv_config.inc diff --git a/mtr/binlog_streaming/r/auth_method_switch.result b/mtr/binlog_streaming/r/auth_method_switch.result index 25705955..b8735fc5 100644 --- a/mtr/binlog_streaming/r/auth_method_switch.result +++ b/mtr/binlog_streaming/r/auth_method_switch.result @@ -26,16 +26,20 @@ include/read_file_to_var.inc *** Control: client picks the same plugin the server advertises *** (caching_sha2_password). No AuthMethodSwitch is expected on the *** wire; a zero exit code from mysql confirms the session got as -*** far as running the probe query. +*** far as running the probe query. --get-server-public-key is +*** required because PBS has no digest cache and drives every +*** login through the 0x04 full-authentication branch (see +*** caching_sha2_full_auth.test for the full-auth path itself). *** Trigger: client forces mysql_native_password in its handshake so *** the Binlog Server has to send an AuthMethodSwitch, and the client *** must recompute its response against a fresh caching_sha2_password *** scramble. If the switch encoding, the follow-up parse, or the -*** re-verified fast-auth path is broken, mysql returns non-zero and +*** post-switch authentication is broken, mysql returns non-zero and *** MTR fails the --exec. The log grep that follows the shutdown *** is what actually proves the switch happened - this line only -*** proves the session survived it. +*** proves the session survived it. --get-server-public-key covers +*** the 0x04 that follows the switch (always-full-auth in PBS). *** Sending SIGTERM to the Binlog Server Utility and waiting for the *** process to terminate (poll kill -0 until the pid is gone). diff --git a/mtr/binlog_streaming/r/caching_sha2_full_auth.result b/mtr/binlog_streaming/r/caching_sha2_full_auth.result new file mode 100644 index 00000000..8b38a908 --- /dev/null +++ b/mtr/binlog_streaming/r/caching_sha2_full_auth.result @@ -0,0 +1,67 @@ +*** Resetting replication at the very beginning of the test. + +*** Generating a configuration file in JSON format for the Binlog +*** Server utility. + +*** Determining binlog file directory from the server. + +*** Creating a temporary directory for storing +*** binlog files downloaded via the Binlog Server utility. + +*** Starting Binlog Server Utility in background in pull mode. +*** Because PBS has no digest cache, every login this binsrv +*** sees will be driven through the 0x04 full-auth branch. +*** stdout/stderr goes to a dedicated log so we can grep for auth +*** markers AFTER binsrv has exited. +include/read_file_to_var.inc + +*** Waiting for the Binlog Server listener to come up on +*** 127.0.0.1:3307. We probe with bash's /dev/tcp instead of the +*** mysql client because bash is not ASAN-instrumented and +*** /dev/tcp uses a plain connect(2), so each attempt is cheap and +*** measures exactly the "listening on the port" state we care +*** about. + +*** Scenario 1: correct password + --get-server-public-key. Server +*** sends 0x04 (PBS default), client requests the PEM +*** with 0x02, server enqueues the RSA public key, client OAEP- +*** encrypts the password and sends the ciphertext, server +*** rsa_private_key::decrypt_oaep recovers it and matches. A zero +*** exit code from mysql means the whole PEM + RSA round-trip +*** succeeded. + +*** Scenario 2: correct password + --server-public-key-path pointing +*** at the PEM we wrote above. Server sends 0x04, client skips the +*** 0x02 PEM-fetch step and OAEP-encrypts the password directly with +*** the local key, server decrypts and matches. Confirms +*** rsa_private_key::decrypt_oaep and the ciphertext-length check +*** in the wrapper agree with what mysql's client-side OAEP produces +*** for this key. + +*** Scenario 3: WRONG password + --get-server-public-key. Same full- +*** auth handshake as scenario 1, but the RSA-decrypted plaintext no +*** longer matches the configured password so +*** verify_encrypted_password() returns failed and the server issues +*** an access_denied. mysql exits non-zero, which we assert on to +*** confirm the rejection path is graceful (no crash, no hang). + +*** Sending SIGTERM to the Binlog Server Utility and waiting for the +*** process to terminate (poll kill -0 until the pid is gone). +*** Graceful exit flushes std::cout, so any log line binsrv wrote +*** during its lifetime is now safely on disk. + +*** Confirming end-to-end results by grepping the Binlog Server +*** stdout log (now complete and flushed): +*** - "client authentication succeeded for rpl" proves at least one +*** full-auth session round-tripped correctly (scenarios 1 & 2) +*** - "client authentication failed for rpl" proves the rejection +*** path fired for the wrong-password session (scenario 3) +include/wait_for_pattern_in_file.inc [client authentication succeeded for rpl] +include/wait_for_pattern_in_file.inc [client authentication failed for rpl] + +*** Removing the Binlog Server utility storage directory. + +*** Removing the Binlog Server utility log file. + +*** Removing the Binlog Server utility configuration file. +KILL CONNECTION ; diff --git a/mtr/binlog_streaming/std_data/caching_sha2_full_auth_privkey.pem b/mtr/binlog_streaming/std_data/caching_sha2_full_auth_privkey.pem new file mode 100644 index 00000000..2102690b --- /dev/null +++ b/mtr/binlog_streaming/std_data/caching_sha2_full_auth_privkey.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKQIBAAKCAgEAvV2VNbsQPG0Bh0KC8F4zCGXvMNcSicCiLXxeLWrJsmKZl0gg +f2ydymYUUewq+dVxDdh85sdSvxEmtIWvKSRK+RRCAURztq2Succd+24SF5IZYjlI +JE/U0AYUxHzUcOsannfzui60IaTHpcBFHTJK6myxGx9MORZmhfv580mfvz4yvgLj +S5yGOIS6rlxD9YV1Y04Rx3SXQQBnC7rDBL91ktNWvbclsonfytY19N9p+Gprms30 +yRT+BmPFB7TqpReeZa3ivg15g/z3BLNyvj3YKiQM3cd7ENJC2x2LRxL5pG684cFN +StSjT4FvA+oh45UnU45aOSEjrxNkBG8ci0e+VKX539rK+nDzTE/MHpnvfHp4DB+k +SYBPuKHY2Eaw31NwPpfLWwEJPiDrktJJmRZqENMHLXksdiqGhvYmI33wZaZAfjbD +ZFMfPF5yBMBGDZ3aeNz5Le7uqS6g6XMOoiz/d2S5RzRrCol1yqCBPtODjfFPC4K8 +GGYVkWZgSCf/PRt/DgDnZOfZSSYIQNeyr21emqgqQ+yhXEGKVjcDTKcbSLiWAdA+ +GkAzLAXXhafM8mrhpnGKdO4Or6ySz7G1vk2Jt2ZSdP740oVSJi59P9NEgXcbd3c4 +FzjXSOOsxfhPQfobUk3ikt55lN3fBX3mBvUduxNhAcQ02ZD5zXrX6+loiV8CAwEA +AQKCAgAfFO45zIOEt4uprOQbGgscVMbm6FZVn/W+q4w1vjJvAjodl6wl3ikkII8z +RyViroMI98DAjHTrgaAtv0eZ5CgeLBINbTPlByZvMdyc+Vsk3UknUymhNC1FG8pq +2eZwxlYvLpcltya/4vEWJrHxceDUC5UiU4fKUv/u/AXxxeLfnBDuGUE/luh8/GQ7 +3E8XTJmQ/C5045E0DSHczgHWlKpyuBejuh0I6hJ+k5x1nfoh2S3iUe3c14I+gD/F +3Q8qm+7W16zA7ytD29Cbx+yMh1Ak0pf+CxELGMf6eSX0O4wYTkjYcUcDglVv5lnX +daWsWj4DO/lZKTRXN0KSa75uqg72Q1FjK//UNEigO99HYMsOWHBtaRzAwkklY5Da +5WHn3sxmfotlFDiyT30R/T0dpAjvgH18A235KOpgLnM7Kaxc3kjMmorIJrkD25oG +OmRRTvdZ5rQ+IuBzaGUOD4ZwTwQ9HMieMjjLCcmkhhzzIZni1eNMva7MJyws4qcH +tjOPQvtb8m8ZXzT77nnkKirbJLVk+FqzL93/w1Kp/BRgVVChrXhdDFW2KSI8sx7Z +T7J8Dir4Oz2JFgpuBLKTz2Bnu6EDNEdGmomP79DO2IGoPNwhhBRDNM2oYR2nPTME +0f9moTJBghsi6rutgxkf1KDY6z2oysJKoJowegEYaUh0J0aHqQKCAQEA8hEL2y5C +iq2fzLRulXEVLG4di6ZZ0ZcyuV6rwQRWrhqv//+csagNmvguz6mFF9iNciv8FT2Z +crIgJUPefslKXuqqm/zEhhafDBXypMHsk4yReIdlxQDkmnamoGJZRd3CSsNFm68a +52hkl3gniMprMp8wWyr2UNeahD9cgtooyua/hyaXewh57L9pJGHlLayvqEn6Rs0V +0lpSzMTJWqFrDPuSc+ufsd3sk1MfvdnDw5oh7cHjZhlHJVtPSrjneCTbEnNpXIr/ +yGL+qamZD+a8a318KMz72y3RwA0VMkhhkAYFYV+S5qYrlbFxjacVOS0Zi0LOklrl +jGMj6RzcD2W35QKCAQEAyEP27OgVTkaEr3bmNHYMBqYZ2snYMUgJF5GOitfLGSGM +55Io++BO6NMDbcNyCtWu2RYbHfdF1qjlTxPHjqsy6z4+tpxjpnPQEbO5eN1PG3iZ ++YO6z1yXLMwglkK4Acv1YWkMZ6l2V55MyntdiCWG/UYOlVw1kxqxlhgzmyq1ZMj5 +4IOGqjsjPsMs2ZVANE54y/SriocnM/2Z08440SElOtheu5G/PfTF2j3ZZRBvuggu +MVnl2+5c0PpT1DGS74327WhRWDixmgEPEgLTd9hSpCWN/5nj67zskHKv6pmOLS+I +jd+rpzrnqDallDmTm/DqcLLDuaxsxEV/788pRllf8wKCAQEAoxcfENZTGNIv9yCd +3OvqoxuxplQ28cJX95K0T4BX0kfCyszySrP6Lq4GA/2n4VASxJij57+v8hnXFKRs +dKm0BM1Ak4Yy9lCpaeAjsiPB/AtaO4Wl6JxYaUWFsEty8GKfs/VqoaDRlJW+KFtY +743JubqNPu9sMz2AKpfyAWtwznu3ERzMNKWaWAsCkPOwEBzn4I+vIyKsECSw4qu3 +KevVj1Kz8owO9SybZws7OJNOlSv0rhbS2ggv6hhiDOsVcNoMC5tconA4M0+XWsIc +kR0ZV6adD3REQADX7/ggjtc7fGjCGT/mXqYYeWurIRAweWxMaIpjWTIKtJJbMIU0 +Mt+KjQKCAQAbtzw/QUdhk+TdG8l0TToQ2YAOhYzEFUIc3uopUQAstDX5/oJpiXui +QUHiOQBZe4U9Sg/qr8QclzdVIFmn5w2e/PhU8YPhD3omWQc8MPS3ypMUsyRxelD5 +xC5mXUl2BjIpjw5Gcm+MZL4f777cDsWF2+I8zYwklbcqHKNXwCtmjWH3rnw+pvyT +vRNB8aP3GT0ijPQIsfe8/EYDyDCY0MuEP1ms/9jFzFBtic3CbOnphyRNdDGZpH13 +9o0PeuTo/m7EIIHRgdcihy78wSNfHLMjQIdMbpHamETtINIz15iTrFZrvB7XgBF7 +eESmJOnG1Sq8+iCYW8KZzzyLhdIiiE/9AoIBAQDGZG7/r8feIMKUWGJmm+uWDAEi +FRn0gZap3HZRDkmgYE6Xwr6CwUBp1YWvjQGQdln9BSrc6kXazOQrX+wpaNmW5x90 +EMinO3Ekg+c5ivYgw1IxN26bbOnlDUpeUDH2mp4OV9MhMmPB6EfRWbztflK7545j +SJ0sOADajDCq5WeR3IyXT9Pq99wZ1BI4qw/MD7HUzx38n7G3qa/BOQcdyETN1L1l +BZgRlbpzktD2AjX71p8FaVfeRA2R4/BWPAzBEhGdLgitXL1UVZDC/TzZBKwQcwpG +JvKExITQBoOQmIOPbEYoLZ7UAiiOmCi/QlOjswP94gTKW4YHEqu6dqMHaaw+ +-----END RSA PRIVATE KEY----- diff --git a/mtr/binlog_streaming/std_data/caching_sha2_full_auth_pubkey.pem b/mtr/binlog_streaming/std_data/caching_sha2_full_auth_pubkey.pem new file mode 100644 index 00000000..62936dce --- /dev/null +++ b/mtr/binlog_streaming/std_data/caching_sha2_full_auth_pubkey.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvV2VNbsQPG0Bh0KC8F4z +CGXvMNcSicCiLXxeLWrJsmKZl0ggf2ydymYUUewq+dVxDdh85sdSvxEmtIWvKSRK ++RRCAURztq2Succd+24SF5IZYjlIJE/U0AYUxHzUcOsannfzui60IaTHpcBFHTJK +6myxGx9MORZmhfv580mfvz4yvgLjS5yGOIS6rlxD9YV1Y04Rx3SXQQBnC7rDBL91 +ktNWvbclsonfytY19N9p+Gprms30yRT+BmPFB7TqpReeZa3ivg15g/z3BLNyvj3Y +KiQM3cd7ENJC2x2LRxL5pG684cFNStSjT4FvA+oh45UnU45aOSEjrxNkBG8ci0e+ +VKX539rK+nDzTE/MHpnvfHp4DB+kSYBPuKHY2Eaw31NwPpfLWwEJPiDrktJJmRZq +ENMHLXksdiqGhvYmI33wZaZAfjbDZFMfPF5yBMBGDZ3aeNz5Le7uqS6g6XMOoiz/ +d2S5RzRrCol1yqCBPtODjfFPC4K8GGYVkWZgSCf/PRt/DgDnZOfZSSYIQNeyr21e +mqgqQ+yhXEGKVjcDTKcbSLiWAdA+GkAzLAXXhafM8mrhpnGKdO4Or6ySz7G1vk2J +t2ZSdP740oVSJi59P9NEgXcbd3c4FzjXSOOsxfhPQfobUk3ikt55lN3fBX3mBvUd +uxNhAcQ02ZD5zXrX6+loiV8CAwEAAQ== +-----END PUBLIC KEY----- diff --git a/mtr/binlog_streaming/t/auth_method_switch.test b/mtr/binlog_streaming/t/auth_method_switch.test index 81727316..93b40ea6 100644 --- a/mtr/binlog_streaming/t/auth_method_switch.test +++ b/mtr/binlog_streaming/t/auth_method_switch.test @@ -24,6 +24,12 @@ eval $stmt_reset_binary_logs_and_gtids; --let $binsrv_idle_time = 1 --let $binsrv_verify_checksum = TRUE --let $binsrv_replication_mode = position +# Both client invocations below pass --get-server-public-key, so the Binlog +# Server needs a real RSA key pair to serve the '0x02' PEM request that +# follows '0x04'. Point at the pair shipped in this suite's std_data/ (same +# assets caching_sha2_full_auth.test uses). +--let $binsrv_pbs_listener_rsa_public_key_path = $MYSQL_TEST_DIR/suite/binlog_streaming/std_data/caching_sha2_full_auth_pubkey.pem +--let $binsrv_pbs_listener_rsa_private_key_path = $MYSQL_TEST_DIR/suite/binlog_streaming/std_data/caching_sha2_full_auth_privkey.pem --source ../include/set_up_binsrv_environment.inc --echo @@ -98,19 +104,23 @@ if (!$port_open) --echo *** Control: client picks the same plugin the server advertises --echo *** (caching_sha2_password). No AuthMethodSwitch is expected on the --echo *** wire; a zero exit code from mysql confirms the session got as ---echo *** far as running the probe query. ---exec $MYSQL --protocol=TCP --host=127.0.0.1 --port=3307 --user=rpl --password=password --default-auth=caching_sha2_password --skip-column-names -e "$probe_query" >/dev/null 2>&1 +--echo *** far as running the probe query. --get-server-public-key is +--echo *** required because PBS has no digest cache and drives every +--echo *** login through the 0x04 full-authentication branch (see +--echo *** caching_sha2_full_auth.test for the full-auth path itself). +--exec $MYSQL --protocol=TCP --host=127.0.0.1 --port=3307 --user=rpl --password=password --default-auth=caching_sha2_password --get-server-public-key --skip-column-names -e "$probe_query" >/dev/null 2>&1 --echo --echo *** Trigger: client forces mysql_native_password in its handshake so --echo *** the Binlog Server has to send an AuthMethodSwitch, and the client --echo *** must recompute its response against a fresh caching_sha2_password --echo *** scramble. If the switch encoding, the follow-up parse, or the ---echo *** re-verified fast-auth path is broken, mysql returns non-zero and +--echo *** post-switch authentication is broken, mysql returns non-zero and --echo *** MTR fails the --exec. The log grep that follows the shutdown --echo *** is what actually proves the switch happened - this line only ---echo *** proves the session survived it. ---exec $MYSQL --protocol=TCP --host=127.0.0.1 --port=3307 --user=rpl --password=password --default-auth=mysql_native_password --skip-column-names -e "$probe_query" >/dev/null 2>&1 +--echo *** proves the session survived it. --get-server-public-key covers +--echo *** the 0x04 that follows the switch (always-full-auth in PBS). +--exec $MYSQL --protocol=TCP --host=127.0.0.1 --port=3307 --user=rpl --password=password --default-auth=mysql_native_password --get-server-public-key --skip-column-names -e "$probe_query" >/dev/null 2>&1 --echo --echo *** Sending SIGTERM to the Binlog Server Utility and waiting for the diff --git a/mtr/binlog_streaming/t/caching_sha2_full_auth.test b/mtr/binlog_streaming/t/caching_sha2_full_auth.test new file mode 100644 index 00000000..6dfd1661 --- /dev/null +++ b/mtr/binlog_streaming/t/caching_sha2_full_auth.test @@ -0,0 +1,214 @@ +# Exercises the caching_sha2_password full-authentication path (0x04) in the +# Binlog Server: RSA-OAEP over plain TCP, via both --get-server-public-key +# (client fetches the server PEM) and --server-public-key-path (client +# encrypts with a pre-loaded local PEM), plus one rejection case with a +# wrong password. +# +# PBS has no SHA-2 digest cache, so its authenticator treats every session +# as a cache miss and drives 0x04. +# That means the full-auth handshake this test verifies is the default +# path in binsrv. + +# The Binlog Server listens on a hard-coded TCP port, so this test can not +# run in parallel with other tests using the same port. +--source include/not_parallel.inc + +--source ../include/have_binsrv.inc + +--source ../include/v80_v84_compatibility_defines.inc + +--source include/count_sessions.inc + +# in case of --repeat=N, we need to start from a fresh binary log to make +# this test deterministic +--echo *** Resetting replication at the very beginning of the test. +--disable_query_log +eval $stmt_reset_binary_logs_and_gtids; +--enable_query_log + +# identifying backend storage type ('file' or 's3') +--source ../include/identify_storage_backend.inc + +# creating data directory, configuration file, etc. +--let $binsrv_connect_timeout = 10 +--let $binsrv_read_timeout = 10 +--let $binsrv_idle_time = 1 +--let $binsrv_verify_checksum = TRUE +--let $binsrv_replication_mode = position + +# Server-side RSA key pair for the caching_sha2_password authenticator, fed +# to binsrv through the 'pbs_listener' config block that +# generate_binsrv_config.inc emits from these two vars. Both are required +# for 'pull'. The matching +# public key ships alongside as caching_sha2_full_auth_pubkey.pem so that +# scenario 2 below can also point mysql's --server-public-key-path at it. +--let $binsrv_pubkey_file = $MYSQL_TEST_DIR/suite/binlog_streaming/std_data/caching_sha2_full_auth_pubkey.pem +--let $binsrv_privkey_file = $MYSQL_TEST_DIR/suite/binlog_streaming/std_data/caching_sha2_full_auth_privkey.pem +--let $binsrv_pbs_listener_rsa_public_key_path = $binsrv_pubkey_file +--let $binsrv_pbs_listener_rsa_private_key_path = $binsrv_privkey_file +--source ../include/set_up_binsrv_environment.inc + +--echo +--echo *** Starting Binlog Server Utility in background in pull mode. +--echo *** Because PBS has no digest cache, every login this binsrv +--echo *** sees will be driven through the 0x04 full-auth branch. +--echo *** stdout/stderr goes to a dedicated log so we can grep for auth +--echo *** markers AFTER binsrv has exited. +--let $binsrv_pid_file = $MYSQL_TMP_DIR/caching_sha2_full_auth_binsrv.pid +--let $binsrv_stdout_log = $MYSQL_TMP_DIR/caching_sha2_full_auth_binsrv_stdout.log +--let $binsrv_spawn_cmd_line = $BINSRV pull $binsrv_config_file_path > $binsrv_stdout_log 2>&1 & echo \$! > $binsrv_pid_file + +# We route the spawn through Perl's system() rather than --exec because +# mysqltest strips the shell's `$!` before it can be expanded, leaving the +# PID file empty. Passing the command as an env var and executing it from +# Perl leaves the shell metacharacters intact. +--let EXPORTED_BINSRV_SPAWN_CMD_LINE = $binsrv_spawn_cmd_line +--perl + use strict; + use warnings; + my $cmd = $ENV{'EXPORTED_BINSRV_SPAWN_CMD_LINE'}; + system("$cmd"); +EOF + +--let $read_from_file = $binsrv_pid_file +--source include/read_file_to_var.inc +--let $binsrv_pid = $result + +# SELECT VERSION() is one of the queries the Binlog Server's command loop +# recognises (see network_service.cpp). Only the mysql client exit code is +# used as pass/fail; the row itself is discarded so the test stays stable +# across changes to the Binlog Server's hard-coded version string. +--let $probe_query = SELECT VERSION() + +--echo +--echo *** Waiting for the Binlog Server listener to come up on +--echo *** 127.0.0.1:3307. We probe with bash's /dev/tcp instead of the +--echo *** mysql client because bash is not ASAN-instrumented and +--echo *** /dev/tcp uses a plain connect(2), so each attempt is cheap and +--echo *** measures exactly the "listening on the port" state we care +--echo *** about. +--let $max_wait = 300 +--let $iteration = 0 +--let $port_open = 0 +while ($iteration < $max_wait) +{ + if (!$port_open) + { + --error 0, 1 + --exec bash -c "echo > /dev/tcp/127.0.0.1/3307" 2>/dev/null + --let $port_status = $__error + if ($port_status == 0) + { + --let $port_open = 1 + --let $iteration = $max_wait + } + if ($port_status != 0) + { + --sleep 1 + --inc $iteration + } + } +} +if (!$port_open) +{ + --die The Binlog Server listener did not become reachable on 3307 within 300 seconds +} + +--echo +--echo *** Scenario 1: correct password + --get-server-public-key. Server +--echo *** sends 0x04 (PBS default), client requests the PEM +--echo *** with 0x02, server enqueues the RSA public key, client OAEP- +--echo *** encrypts the password and sends the ciphertext, server +--echo *** rsa_private_key::decrypt_oaep recovers it and matches. A zero +--echo *** exit code from mysql means the whole PEM + RSA round-trip +--echo *** succeeded. +--exec $MYSQL --protocol=TCP --host=127.0.0.1 --port=3307 --user=rpl --password=password --default-auth=caching_sha2_password --get-server-public-key --skip-column-names -e "$probe_query" >/dev/null 2>&1 + +--echo +--echo *** Scenario 2: correct password + --server-public-key-path pointing +--echo *** at the PEM we wrote above. Server sends 0x04, client skips the +--echo *** 0x02 PEM-fetch step and OAEP-encrypts the password directly with +--echo *** the local key, server decrypts and matches. Confirms +--echo *** rsa_private_key::decrypt_oaep and the ciphertext-length check +--echo *** in the wrapper agree with what mysql's client-side OAEP produces +--echo *** for this key. +--exec $MYSQL --protocol=TCP --host=127.0.0.1 --port=3307 --user=rpl --password=password --default-auth=caching_sha2_password --server-public-key-path=$binsrv_pubkey_file --skip-column-names -e "$probe_query" >/dev/null 2>&1 + +--echo +--echo *** Scenario 3: WRONG password + --get-server-public-key. Same full- +--echo *** auth handshake as scenario 1, but the RSA-decrypted plaintext no +--echo *** longer matches the configured password so +--echo *** verify_encrypted_password() returns failed and the server issues +--echo *** an access_denied. mysql exits non-zero, which we assert on to +--echo *** confirm the rejection path is graceful (no crash, no hang). +--error 1 +--exec $MYSQL --protocol=TCP --host=127.0.0.1 --port=3307 --user=rpl --password=wrong-password --default-auth=caching_sha2_password --get-server-public-key --skip-column-names -e "$probe_query" >/dev/null 2>&1 + +--echo +--echo *** Sending SIGTERM to the Binlog Server Utility and waiting for the +--echo *** process to terminate (poll kill -0 until the pid is gone). +--echo *** Graceful exit flushes std::cout, so any log line binsrv wrote +--echo *** during its lifetime is now safely on disk. +--replace_result $binsrv_pid +--exec kill -s TERM $binsrv_pid + +--let $max_exit_wait = 30 +--let $iteration = 0 +--let $binsrv_running = 1 +while ($iteration < $max_exit_wait) +{ + if ($binsrv_running) + { + --error 0, 1 + --exec kill -0 $binsrv_pid 2>/dev/null + --let $kill_status = $__error + if ($kill_status != 0) + { + --let $binsrv_running = 0 + --let $iteration = $max_exit_wait + } + if ($kill_status == 0) + { + --sleep 1 + --inc $iteration + } + } +} +if ($binsrv_running) +{ + --die Binlog Server Utility did not terminate within 30 seconds +} + +--echo +--echo *** Confirming end-to-end results by grepping the Binlog Server +--echo *** stdout log (now complete and flushed): +--echo *** - "client authentication succeeded for rpl" proves at least one +--echo *** full-auth session round-tripped correctly (scenarios 1 & 2) +--echo *** - "client authentication failed for rpl" proves the rejection +--echo *** path fired for the wrong-password session (scenario 3) +--let $grep_file = $binsrv_stdout_log +--let $grep_pattern = client authentication succeeded for rpl +--let $wait_timeout = 30 +--source include/wait_for_pattern_in_file.inc + +--let $grep_file = $binsrv_stdout_log +--let $grep_pattern = client authentication failed for rpl +--let $wait_timeout = 30 +--source include/wait_for_pattern_in_file.inc + +--remove_file $binsrv_pid_file +--remove_file $binsrv_stdout_log + +# cleaning up +--source ../include/tear_down_binsrv_environment.inc + +# As the Binlog Server Utility interrupts the connection upon timeout, here we +# need to close it on the MySQL server side as well in order to make sure that +# MTR 'check-test' before and after the test produces the same output. +--let $binlog_dump_connection_id = `SELECT ID FROM performance_schema.processlist WHERE COMMAND = 'Binlog Dump'` +--replace_result $binlog_dump_connection_id +eval KILL CONNECTION $binlog_dump_connection_id; + +# Also, we use 'count_sessions' include files to make sure that 'Binlog Dump' +# connection is indeed closed. +--source include/wait_until_count_sessions.inc diff --git a/src/binsrv/main_config.cpp b/src/binsrv/main_config.cpp index 95e5682f..7fd98a85 100644 --- a/src/binsrv/main_config.cpp +++ b/src/binsrv/main_config.cpp @@ -60,6 +60,10 @@ void main_config::validate() const { root().get<"connection">().validate(); root().get<"storage">().validate(); root().get<"replication">().validate(); + const auto &optional_listener{root().get<"pbs_listener">()}; + if (optional_listener.has_value()) { + optional_listener->validate(); + } } } // namespace binsrv diff --git a/src/binsrv/main_config.hpp b/src/binsrv/main_config.hpp index bdd05727..0d8a3cdc 100644 --- a/src/binsrv/main_config.hpp +++ b/src/binsrv/main_config.hpp @@ -18,10 +18,11 @@ #include "binsrv/main_config_fwd.hpp" // IWYU pragma: export -#include "binsrv/keyring_config.hpp" // IWYU pragma: export -#include "binsrv/logger_config.hpp" // IWYU pragma: export -#include "binsrv/replication_config.hpp" // IWYU pragma: export -#include "binsrv/storage_config.hpp" // IWYU pragma: export +#include "binsrv/keyring_config.hpp" // IWYU pragma: export +#include "binsrv/logger_config.hpp" // IWYU pragma: export +#include "binsrv/pbs_listener_config.hpp" // IWYU pragma: export +#include "binsrv/replication_config.hpp" // IWYU pragma: export +#include "binsrv/storage_config.hpp" // IWYU pragma: export #include "easymysql/connection_config.hpp" // IWYU pragma: export @@ -33,11 +34,12 @@ class [[nodiscard]] main_config { private: using impl_type = util::nv_tuple< // clang-format off - util::nv<"logger" , logger_config>, - util::nv<"connection" , easymysql::connection_config>, - util::nv<"replication", binsrv::replication_config>, - util::nv<"keyring" , optional_keyring_config>, - util::nv<"storage" , storage_config> + util::nv<"logger" , logger_config>, + util::nv<"connection" , easymysql::connection_config>, + util::nv<"replication" , binsrv::replication_config>, + util::nv<"keyring" , optional_keyring_config>, + util::nv<"storage" , storage_config>, + util::nv<"pbs_listener", optional_pbs_listener_config> // clang-format on >; diff --git a/src/binsrv/pbs_listener_config.cpp b/src/binsrv/pbs_listener_config.cpp new file mode 100644 index 00000000..2a3f2cfe --- /dev/null +++ b/src/binsrv/pbs_listener_config.cpp @@ -0,0 +1,38 @@ +// Copyright (c) 2023-2024 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 "binsrv/pbs_listener_config.hpp" + +#include + +#include "util/exception_location_helpers.hpp" + +namespace binsrv { + +void pbs_listener_config::validate() const { + // When the block is present at all, both paths must be non-empty; the + // authenticator loads them together and a one-sided configuration would + // fail deep inside opensslpp with a less actionable error. + const auto &public_key{get<"rsa_public_key_path">()}; + const auto &private_key{get<"rsa_private_key_path">()}; + if (public_key.empty() || private_key.empty()) { + util::exception_location().raise( + "error validating pbs_listener config: " + "rsa_public_key_path and rsa_private_key_path must both be " + "non-empty when 'pbs_listener' is set"); + } +} + +} // namespace binsrv diff --git a/src/binsrv/pbs_listener_config.hpp b/src/binsrv/pbs_listener_config.hpp new file mode 100644 index 00000000..ead04d63 --- /dev/null +++ b/src/binsrv/pbs_listener_config.hpp @@ -0,0 +1,52 @@ +// Copyright (c) 2023-2024 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 BINSRV_PBS_LISTENER_CONFIG_HPP +#define BINSRV_PBS_LISTENER_CONFIG_HPP + +#include "binsrv/pbs_listener_config_fwd.hpp" // IWYU pragma: export + +#include + +#include "util/nv_tuple.hpp" + +namespace binsrv { + +// Settings for the MySQL-protocol listener the Binlog Server exposes to +// downstream clients when running in the 'pull' operation (the source-side +// half of the replication graph). The whole block is optional in +// main_config; when omitted the listener has no server-side RSA key pair +// and any caching_sha2_password full-authentication attempt (0x04) will +// fail per-session - matching the authenticator's own "both empty is OK" +// acceptance at construction. +// +// When the block IS present, both 'rsa_public_key_path' and +// 'rsa_private_key_path' must be non-empty and readable PEM files - the +// authenticator loads them to serve --get-server-public-key and to +// RSA-OAEP-decrypt password ciphertext (see PBS-33 and +// minimysql::caching_sha2_password_authenticator). +struct [[nodiscard]] pbs_listener_config + : util::nv_tuple< + // clang-format off + util::nv<"rsa_public_key_path" , std::string>, + util::nv<"rsa_private_key_path", std::string> + // clang-format on + > { + void validate() const; +}; + +} // namespace binsrv + +#endif // BINSRV_PBS_LISTENER_CONFIG_HPP diff --git a/src/binsrv/pbs_listener_config_fwd.hpp b/src/binsrv/pbs_listener_config_fwd.hpp new file mode 100644 index 00000000..36c62d3e --- /dev/null +++ b/src/binsrv/pbs_listener_config_fwd.hpp @@ -0,0 +1,28 @@ +// Copyright (c) 2023-2024 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 BINSRV_PBS_LISTENER_CONFIG_FWD_HPP +#define BINSRV_PBS_LISTENER_CONFIG_FWD_HPP + +#include + +namespace binsrv { + +struct pbs_listener_config; +using optional_pbs_listener_config = std::optional; + +} // namespace binsrv + +#endif // BINSRV_PBS_LISTENER_CONFIG_FWD_HPP diff --git a/src/minimysql/caching_sha2_password_authenticator.cpp b/src/minimysql/caching_sha2_password_authenticator.cpp index bc7ae819..852ed1d0 100644 --- a/src/minimysql/caching_sha2_password_authenticator.cpp +++ b/src/minimysql/caching_sha2_password_authenticator.cpp @@ -19,132 +19,327 @@ #include #include #include +#include +#include #include +#include #include +#include #include #include #include +#include +#include -#include -#include +#include "minimysql/network_io_operations_fwd.hpp" +#include "opensslpp/digest_context.hpp" +#include "opensslpp/rsa_private_key.hpp" + +#include "util/byte_span_fwd.hpp" + +// clang-format off +// caching_sha2_password authentication flow: +// +// 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 { - sha256, -}; +// Thin std::string_view adapters over the byte-span-based +// opensslpp::digest_context API so the caching_sha2_password scramble() +// implementation reads without conversion noise. The wrapper itself stays +// strictly typed on util::byte_span / util::const_byte_span (matching +// crypto_rng / cipher_context); these helpers live here because they are +// specific to how this authenticator hashes password / salt strings. +[[nodiscard]] util::const_byte_span as_const_bytes(std::string_view data) { + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + return {reinterpret_cast(std::data(data)), + std::size(data)}; +} -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_) { - throw std::runtime_error{"failed to create digest context"}; - } - if (EVP_DigestInit_ex(impl_.get(), get_md_by_digest_code(digest_code), - nullptr) == 0) { - throw std::runtime_error{"failed to initialize digest context"}; - } +[[nodiscard]] util::byte_span as_writable_bytes(std::string &data) { + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + return {reinterpret_cast(std::data(data)), std::size(data)}; +} + +[[nodiscard]] std::string sha256_of(std::string_view data) { + return opensslpp::digest_context::calculate( + opensslpp::digest_code_type::sha256, as_const_bytes(data)); +} + +void xor_with_pattern(std::span data, std::string_view pattern) { + if (std::empty(pattern)) { + return; } - ~digest_context() noexcept = default; + 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)])); + } +} - digest_context(const digest_context &obj) = delete; - digest_context(digest_context &&obj) noexcept = delete; +constexpr std::uint8_t request_public_key{0x02U}; +constexpr std::uint8_t perform_full_authentication{0x04U}; - digest_context &operator=(const digest_context &obj) = delete; - digest_context &operator=(digest_context &&obj) noexcept = delete; +[[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}}; + } - [[nodiscard]] std::size_t get_size_in_bytes() const noexcept { - assert(impl_); - auto native_result{EVP_MD_CTX_size(impl_.get())}; - assert(native_result != -1); - return static_cast(native_result); + 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; +} - void update(std::string_view data) { - assert(impl_); - if (EVP_DigestUpdate(impl_.get(), std::data(data), std::size(data)) == 0) { - throw std::runtime_error{"failed to update digest context"}; - } +} // 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_{private_key_pem} {} + + 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)); } - std::string finalize() { - assert(impl_); - std::string result(get_size_in_bytes(), '\0'); - - unsigned int result_size = 0; - if (EVP_DigestFinal_ex( - impl_.get(), - // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) - reinterpret_cast(std::data(result)), - &result_size) == 0) { - throw std::runtime_error{"cannot finalize digest context"}; - } - assert(result_size == std::size(result)); - impl_.reset(); - return result; + [[nodiscard]] std::string_view public_key_pem() const noexcept { + return public_key_pem_; } -private: - struct digest_context_deleter { - void operator()(EVP_MD_CTX *digest_context) const noexcept { - // null-ness is handled by EVP_MD_CTX_free - EVP_MD_CTX_free(digest_context); - } - }; - - using impl_ptr = std::unique_ptr; - impl_ptr impl_; - - [[nodiscard]] static const EVP_MD * - get_md_by_digest_code(digest_code_type digest_code) noexcept { - switch (digest_code) { - case digest_code_type::sha256: - return EVP_sha256(); - default: - // should never happen as we only construct digest_context with supported - // digest_code_type - return nullptr; + [[nodiscard]] std::size_t cipher_length() const noexcept { + return private_key_.get_cipher_length_in_bytes(); + } + + [[nodiscard]] std::string + // NOLINTNEXTLINE(bugprone-easily-swappable-parameters) + decrypt_password(std::string_view encrypted_password, std::string_view salt) { + // OAEP recovers exactly (password_length + 1) bytes: the client sent the + // trailing NUL of the C-string as part of the OAEP plaintext, XORed with + // the salt repeated to cover the whole plaintext. Undo the XOR and read + // back through the NUL terminator to isolate the password. + auto plain_text{ + private_key_.decrypt_oaep(as_const_bytes(encrypted_password))}; + + xor_with_pattern(std::span{std::data(plain_text), std::size(plain_text)}, + 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_; + opensslpp::rsa_private_key private_key_; }; -std::string calculate_digest(digest_code_type digest_code, - std::string_view data) { - digest_context ctx(digest_code); - ctx.update(data); - return ctx.finalize(); +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} { + // One-sided configuration is a mis-configuration and fails hard. Both + // paths empty is accepted at construction: the authenticator simply + // leaves 'rsa_keys_' null and any subsequent caching_sha2_password + // full-auth attempt (RSA / PEM handshake) will fail at that point. The + // "operator must configure the key pair before running 'pull'" invariant + // is enforced one level up, at the JSON-config layer, by + // pbs_listener_config::validate() and pull_operation. + 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); + } } -} // anonymous namespace +caching_sha2_password_authenticator::~caching_sha2_password_authenticator() = + default; -namespace minimysql { +// 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::scramble( +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 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) + std::string_view expected_username, std::string_view client_username, + [[maybe_unused]] 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; + // PBS has no SHA2 digest cache, so we cannot honestly claim a + // "cache hit" for the fast-auth (0x03) shortcut. Every session is treated + // as a first-login / cache-miss and driven through the 0x04 full-auth + // branch (RSA on plain TCP, cleartext over TLS once TLS is wired). Real + // Percona Server / MySQL takes the fast path only on cache hit: it caches + // the SHA-256 double-hash of the password on the first successful full + // auth and, on subsequent logins for the same user, verifies the greeting + // scramble against that cached hash and answers 0x03. + // + // The fast-path helpers (verify_greeting_scramble(), + // enqueue_fast_auth_success(), and the scramble() static used by both) + // are deliberately kept alive here: once PBS grows a SHA-2 digest + // cache, this branch should reappear, gated on a cache lookup for the + // (username, salt) pair rather than on a live compare against the + // plaintext password. See mtr/binlog_streaming/t/caching_sha2_full_auth + // .test for the full-auth handshake this always-miss policy exercises. + // + // Note: on plain TCP, mysql clients require --get-server-public-key or + // --server-public-key-path to satisfy 0x04; without either they refuse + // to send credentials in the clear ("Authentication requires secure + // connection.") - that is the client's protective behaviour, not a bug + // here. + enqueue_perform_full_authentication(encoder); + phase_ = phase::awaiting_full_auth_response; +} - // 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}; +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; +} - // calculating hashed password - auto result{calculate_digest(digest_code, password)}; +std::vector +caching_sha2_password_authenticator::take_outbound_frames() { + return std::exchange(outbound_frames_, {}); +} - // calculating double-hashed password - const auto double_hashed_password{calculate_digest(digest_code, result)}; +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)}; - // calculating salted triple-hashed password - digest_context ctx(digest_code); - ctx.update(double_hashed_password); - ctx.update(salt); - const auto salted_triple_hashed_password{ctx.finalize()}; + 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) { + auto result{sha256_of(password)}; + const auto double_hashed_password{sha256_of(result)}; + + opensslpp::digest_context ctx{opensslpp::digest_code_type::sha256}; + ctx.update(as_const_bytes(double_hashed_password)); + ctx.update(as_const_bytes(salt)); + + std::string salted_triple_hashed_password(ctx.get_digest_size_in_bytes(), + '\0'); + ctx.finalize(as_writable_bytes(salted_triple_hashed_password)); assert(std::size(result) == std::size(salted_triple_hashed_password)); std::ranges::transform(result, salted_triple_hashed_password, @@ -152,4 +347,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) { + 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). +// +// PBS 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. PBS has no TLS yet (PBS-31); 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 b9f69bbd..75f1f553 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); + + 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 e6104d5b..005ccfe6 100644 --- a/src/minimysql/connection_context.cpp +++ b/src/minimysql/connection_context.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,7 @@ #include #include #include +#include #include @@ -61,6 +63,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, @@ -90,20 +94,101 @@ std::string fix_server_auth_data(std::string_view data) { 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 PBS gains TLS (PBS-31): 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 @@ -232,17 +317,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::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::generate_encoded_fast_auth() { +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) { @@ -484,7 +610,6 @@ connection_context::generate_server_auth_method_data() { // a legal UTF-8, non-NUL character (the on-wire representation is // NUL-terminated, so an interior NUL would truncate the field on the // client side). - static constexpr std::size_t server_auth_method_data_length{20U}; static constexpr std::uint8_t seven_bit_mask{0x7FU}; server_auth_method_data_.assign(server_auth_method_data_length, '\0'); @@ -500,6 +625,17 @@ connection_context::generate_server_auth_method_data() { return server_auth_method_data_; } +[[nodiscard]] std::string_view connection_context::get_frame_payload( + const network_buffer_type &payload) noexcept { + if (std::size(payload) <= get_frame_header_length()) { + return {}; + } + + // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) + return {std::data(payload) + get_frame_header_length(), + std::size(payload) - get_frame_header_length()}; +} + [[nodiscard]] std::uint8_t connection_context::generate_sequence_number() { return sequence_number_++; } diff --git a/src/minimysql/connection_context.hpp b/src/minimysql/connection_context.hpp index cbe7e302..45221a79 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,12 +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::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 c977aba8..57dc1f48 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 7e341fe4..f85a670e 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/opensslpp/digest_context.cpp b/src/opensslpp/digest_context.cpp new file mode 100644 index 00000000..b2e8bacd --- /dev/null +++ b/src/opensslpp/digest_context.cpp @@ -0,0 +1,151 @@ +// Copyright (c) 2023-2024 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 "opensslpp/digest_context.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "opensslpp/core_error.hpp" + +#include "util/byte_span_fwd.hpp" +#include "util/exception_location_helpers.hpp" + +namespace opensslpp { + +struct digest_context::native_helper { + [[nodiscard]] static auto deimpl(auto &impl) noexcept { + using cast_type = std::conditional_t< + std::is_const_v>, + const EVP_MD_CTX, EVP_MD_CTX>; + return static_cast(impl.get()); + } + + [[nodiscard]] static const EVP_MD * + get_md_by_code_internal(digest_code_type code) noexcept { + switch (code) { + case digest_code_type::sha256: + return EVP_sha256(); + default: + return nullptr; + } + } + + [[nodiscard]] static const EVP_MD * + get_validated_md_by_code_internal(digest_code_type code) { + const auto *digest{get_md_by_code_internal(code)}; + if (digest == nullptr) { + util::exception_location().raise("unknown digest code"); + } + return digest; + } +}; + +void digest_context::impl_deleter::operator()(void *digest_ctx) const noexcept { + if (digest_ctx != nullptr) { + EVP_MD_CTX_free(static_cast(digest_ctx)); + } +} + +digest_context::digest_context(digest_code_type code) : code_{code} { + const auto *digest{native_helper::get_validated_md_by_code_internal(code)}; + + impl_ptr new_impl{EVP_MD_CTX_new(), impl_deleter{}}; + if (!new_impl) { + util::exception_location().raise( + "cannot allocate digest context"); + } + + if (EVP_DigestInit_ex(static_cast(new_impl.get()), digest, + nullptr) == 0) { + util::exception_location().raise( + "cannot initialize digest context"); + } + + impl_ = std::move(new_impl); +} + +std::size_t digest_context::get_digest_size_in_bytes() const noexcept { + assert(impl_); + const auto native_result{EVP_MD_CTX_size(native_helper::deimpl(impl_))}; + assert(native_result != -1); + return static_cast(native_result); +} + +std::size_t +digest_context::get_digest_size_in_bytes(digest_code_type code) noexcept { + const auto *digest{native_helper::get_md_by_code_internal(code)}; + if (digest == nullptr) { + return 0U; + } + return static_cast(EVP_MD_get_size(digest)); +} + +void digest_context::update(util::const_byte_span input) { + assert(impl_); + if (std::empty(input)) { + return; + } + if (EVP_DigestUpdate(native_helper::deimpl(impl_), std::data(input), + std::size(input)) == 0) { + util::exception_location().raise( + "cannot update digest context"); + } +} + +void digest_context::finalize(util::byte_span output) { + assert(impl_); + const auto expected_size{get_digest_size_in_bytes()}; + if (std::size(output) != expected_size) { + util::exception_location().raise( + "digest output buffer has unexpected size"); + } + + unsigned int native_size{0U}; + if (EVP_DigestFinal_ex( + native_helper::deimpl(impl_), + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + reinterpret_cast(std::data(output)), + &native_size) == 0) { + util::exception_location().raise( + "cannot finalize digest context"); + } + assert(static_cast(native_size) == expected_size); + + // discard the finalized context: any further update()/finalize() call must + // start with a fresh EVP_MD_CTX (users can re-initialize via move-assign + // from a newly constructed digest_context). + impl_.reset(); +} + +std::string digest_context::calculate(digest_code_type code, + util::const_byte_span input) { + digest_context ctx{code}; + ctx.update(input); + std::string result(ctx.get_digest_size_in_bytes(), '\0'); + ctx.finalize(util::byte_span{ + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + reinterpret_cast(std::data(result)), std::size(result)}); + return result; +} + +} // namespace opensslpp diff --git a/src/opensslpp/digest_context.hpp b/src/opensslpp/digest_context.hpp new file mode 100644 index 00000000..ab7bf229 --- /dev/null +++ b/src/opensslpp/digest_context.hpp @@ -0,0 +1,87 @@ +// Copyright (c) 2023-2024 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 OPENSSLPP_DIGEST_CONTEXT_HPP +#define OPENSSLPP_DIGEST_CONTEXT_HPP + +#include "opensslpp/digest_context_fwd.hpp" // IWYU pragma: export + +#include +#include +#include + +#include "util/byte_span_fwd.hpp" + +namespace opensslpp { + +// Thin RAII wrapper around OpenSSL's EVP_MD_CTX for message-digest +// computation. Feed input via update() and read out the fixed-length digest +// via finalize(); or use the one-shot calculate() helper when the whole input +// is already in memory. +class digest_context { +public: + digest_context() noexcept = default; + // * 'code' must be one of the supported digest_code_type values (currently + // only sha256). + explicit digest_context(digest_code_type code); + ~digest_context() noexcept = default; + + digest_context(const digest_context &obj) = delete; + digest_context(digest_context &&obj) noexcept = default; + + digest_context &operator=(const digest_context &obj) = delete; + digest_context &operator=(digest_context &&obj) noexcept = default; + + void swap(digest_context &obj) noexcept { + impl_.swap(obj.impl_); + std::swap(code_, obj.code_); + } + + [[nodiscard]] bool is_empty() const noexcept { return !impl_; } + + [[nodiscard]] digest_code_type get_code() const noexcept { return code_; } + [[nodiscard]] std::size_t get_digest_size_in_bytes() const noexcept; + + // static variant that maps a digest_code_type to its output length without + // needing a live context (mirrors cipher_context::get_key_size_in_bytes()) + [[nodiscard]] static std::size_t + get_digest_size_in_bytes(digest_code_type code) noexcept; + + void update(util::const_byte_span input); + + // 'output' must be exactly get_digest_size_in_bytes() bytes long. The + // context is reset after finalization so the object can be reused (a fresh + // update()/finalize() sequence starts a new digest with the same code). + void finalize(util::byte_span output); + + // One-shot helper that sizes and allocates the output string and returns + // the raw digest bytes. + [[nodiscard]] static std::string calculate(digest_code_type code, + util::const_byte_span input); + +private: + struct native_helper; + struct impl_deleter { + void operator()(void *digest_ctx) const noexcept; + }; + + using impl_ptr = std::unique_ptr; + impl_ptr impl_; + digest_code_type code_{digest_code_type::delimiter}; +}; + +} // namespace opensslpp + +#endif // OPENSSLPP_DIGEST_CONTEXT_HPP diff --git a/src/opensslpp/digest_context_fwd.hpp b/src/opensslpp/digest_context_fwd.hpp new file mode 100644 index 00000000..ca0147cb --- /dev/null +++ b/src/opensslpp/digest_context_fwd.hpp @@ -0,0 +1,29 @@ +// Copyright (c) 2023-2024 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 OPENSSLPP_DIGEST_CONTEXT_FWD_HPP +#define OPENSSLPP_DIGEST_CONTEXT_FWD_HPP + +#include + +namespace opensslpp { + +enum class digest_code_type : std::uint8_t { sha256, delimiter }; + +class digest_context; + +} // namespace opensslpp + +#endif // OPENSSLPP_DIGEST_CONTEXT_FWD_HPP diff --git a/src/opensslpp/rsa_private_key.cpp b/src/opensslpp/rsa_private_key.cpp new file mode 100644 index 00000000..63e61142 --- /dev/null +++ b/src/opensslpp/rsa_private_key.cpp @@ -0,0 +1,159 @@ +// Copyright (c) 2023-2024 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 "opensslpp/rsa_private_key.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "opensslpp/core_error.hpp" + +#include "util/byte_span_fwd.hpp" +#include "util/exception_location_helpers.hpp" + +namespace opensslpp { + +namespace { + +struct bio_deleter { + void operator()(BIO *bio) const noexcept { BIO_free(bio); } +}; + +using bio_ptr = std::unique_ptr; + +struct pkey_ctx_deleter { + void operator()(EVP_PKEY_CTX *ctx) const noexcept { EVP_PKEY_CTX_free(ctx); } +}; + +using pkey_ctx_ptr = std::unique_ptr; + +} // anonymous namespace + +struct rsa_private_key::native_helper { + [[nodiscard]] static auto deimpl(auto &impl) noexcept { + using cast_type = std::conditional_t< + std::is_const_v>, + const EVP_PKEY, EVP_PKEY>; + return static_cast(impl.get()); + } +}; + +void rsa_private_key::impl_deleter::operator()(void *pkey) const noexcept { + if (pkey != nullptr) { + EVP_PKEY_free(static_cast(pkey)); + } +} + +rsa_private_key::rsa_private_key(std::string_view pem) { + if (std::empty(pem)) { + util::exception_location().raise( + "empty PEM buffer for RSA private key"); + } + if (!std::in_range(std::size(pem))) { + util::exception_location().raise( + "PEM buffer size is out of range"); + } + + const bio_ptr bio{ + BIO_new_mem_buf(std::data(pem), static_cast(std::size(pem))), + bio_deleter{}}; + if (!bio) { + util::exception_location().raise( + "cannot allocate PEM memory BIO for RSA private key"); + } + + impl_ptr new_impl{ + PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr), + impl_deleter{}}; + if (!new_impl) { + util::exception_location().raise( + "cannot parse RSA private key from PEM"); + } + + impl_ = std::move(new_impl); +} + +std::size_t rsa_private_key::get_cipher_length_in_bytes() const noexcept { + assert(impl_); + const auto native_size{EVP_PKEY_get_size(native_helper::deimpl(impl_))}; + assert(native_size > 0); + return static_cast(native_size); +} + +std::string rsa_private_key::decrypt_oaep(util::const_byte_span ciphertext) { + assert(impl_); + + const auto cipher_length{get_cipher_length_in_bytes()}; + if (std::size(ciphertext) != cipher_length) { + util::exception_location().raise( + "RSA ciphertext has unexpected length"); + } + + // decrypt_oaep is intentionally non-const: OpenSSL's EVP_PKEY_CTX_new() + // takes a non-const EVP_PKEY *, so keeping the underlying handle mutable + // through the wrapper matches cipher_context::update() / finalize(), which + // are also non-const because their EVP_CIPHER_CTX_* C calls require it. + const pkey_ctx_ptr key_ctx{ + EVP_PKEY_CTX_new(native_helper::deimpl(impl_), nullptr), + pkey_ctx_deleter{}}; + if (!key_ctx) { + util::exception_location().raise( + "cannot allocate RSA decrypt context"); + } + + if (EVP_PKEY_decrypt_init(key_ctx.get()) <= 0) { + util::exception_location().raise( + "cannot initialize RSA decrypt context"); + } + if (EVP_PKEY_CTX_set_rsa_padding(key_ctx.get(), RSA_PKCS1_OAEP_PADDING) <= + 0) { + util::exception_location().raise( + "cannot select OAEP padding on RSA decrypt context"); + } + + // The plaintext is at most cipher_length bytes long; size the buffer to + // that upper bound and shrink to the exact length reported by + // EVP_PKEY_decrypt(). + std::string plain_text(cipher_length, '\0'); + std::size_t plain_text_length{cipher_length}; + + if (EVP_PKEY_decrypt( + key_ctx.get(), + // 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(ciphertext)), + std::size(ciphertext)) <= 0) { + util::exception_location().raise("cannot RSA-OAEP decrypt"); + } + + plain_text.resize(plain_text_length); + return plain_text; +} + +} // namespace opensslpp diff --git a/src/opensslpp/rsa_private_key.hpp b/src/opensslpp/rsa_private_key.hpp new file mode 100644 index 00000000..c0b0c511 --- /dev/null +++ b/src/opensslpp/rsa_private_key.hpp @@ -0,0 +1,79 @@ +// Copyright (c) 2023-2024 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 OPENSSLPP_RSA_PRIVATE_KEY_HPP +#define OPENSSLPP_RSA_PRIVATE_KEY_HPP + +#include "opensslpp/rsa_private_key_fwd.hpp" // IWYU pragma: export + +#include +#include +#include +#include + +#include "util/byte_span_fwd.hpp" + +namespace opensslpp { + +// Thin RAII wrapper around an OpenSSL EVP_PKEY holding an RSA private key +// loaded from a PEM-encoded buffer. Exposes the primitives needed by the +// caching_sha2_password full-authentication path: the cipher output length +// (== RSA modulus size in bytes) and RSA-OAEP decryption. +class rsa_private_key { +public: + rsa_private_key() noexcept = default; + // Loads a PEM-encoded RSA private key from an in-memory buffer. + // 'pem' must reference a complete, self-contained PEM block; anything + // OpenSSL's PEM_read_bio_PrivateKey() accepts is accepted here. + explicit rsa_private_key(std::string_view pem); + ~rsa_private_key() noexcept = default; + + rsa_private_key(const rsa_private_key &obj) = delete; + rsa_private_key(rsa_private_key &&obj) noexcept = default; + + rsa_private_key &operator=(const rsa_private_key &obj) = delete; + rsa_private_key &operator=(rsa_private_key &&obj) noexcept = default; + + void swap(rsa_private_key &obj) noexcept { impl_.swap(obj.impl_); } + + [[nodiscard]] bool is_empty() const noexcept { return !impl_; } + + // RSA cipher output length in bytes (equals the modulus size, i.e. the + // ciphertext length OAEP will accept and the maximum plaintext length it + // can return). + [[nodiscard]] std::size_t get_cipher_length_in_bytes() const noexcept; + + // Decrypts 'ciphertext' using PKCS#1 v2 OAEP padding (OpenSSL default MGF + // and hash parameters, i.e. SHA-1 for both). 'ciphertext' must be exactly + // get_cipher_length_in_bytes() bytes long. Returns the recovered plaintext. + // + // Not a const method: OpenSSL's EVP_PKEY_CTX_new() takes a non-const + // EVP_PKEY *, mirroring how cipher_context::update() / finalize() are + // non-const for the same reason. + [[nodiscard]] std::string decrypt_oaep(util::const_byte_span ciphertext); + +private: + struct native_helper; + struct impl_deleter { + void operator()(void *pkey) const noexcept; + }; + + using impl_ptr = std::unique_ptr; + impl_ptr impl_; +}; + +} // namespace opensslpp + +#endif // OPENSSLPP_RSA_PRIVATE_KEY_HPP diff --git a/src/opensslpp/rsa_private_key_fwd.hpp b/src/opensslpp/rsa_private_key_fwd.hpp new file mode 100644 index 00000000..a0f2723d --- /dev/null +++ b/src/opensslpp/rsa_private_key_fwd.hpp @@ -0,0 +1,25 @@ +// Copyright (c) 2023-2024 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 OPENSSLPP_RSA_PRIVATE_KEY_FWD_HPP +#define OPENSSLPP_RSA_PRIVATE_KEY_FWD_HPP + +namespace opensslpp { + +class rsa_private_key; + +} // namespace opensslpp + +#endif // OPENSSLPP_RSA_PRIVATE_KEY_FWD_HPP diff --git a/src/operations/pull_operation.cpp b/src/operations/pull_operation.cpp index 377afa39..d11db150 100644 --- a/src/operations/pull_operation.cpp +++ b/src/operations/pull_operation.cpp @@ -112,8 +112,27 @@ generic_operation::generic_operation( operations::collector_context collector_ctx{ easymysql::connection_replication_mode_type::blocking, config, logger}; + // The 'pbs_listener' JSON config block carries the server-side RSA key + // pair the caching_sha2_password authenticator needs for the 0x04 + // full-authentication branch (--get-server-public-key / --server-public + // -key-path clients). The block is optional at the main_config layer + // (see binsrv::pbs_listener_config); when absent we simply forward + // empty paths, mirroring the authenticator's "both empty is OK" + // acceptance at construction. Per-session RSA operations then fail if + // a client actually reaches the full-auth branch without keys. + std::string_view server_rsa_public_key_path{}; + std::string_view server_rsa_private_key_path{}; + const auto &optional_listener{config->root().get<"pbs_listener">()}; + if (optional_listener.has_value()) { + server_rsa_public_key_path = + optional_listener->get<"rsa_public_key_path">(); + server_rsa_private_key_path = + optional_listener->get<"rsa_private_key_path">(); + } + const minimysql::network_service service( - io_ctx, listening_port, default_username, default_password); + io_ctx, listening_port, default_username, default_password, + server_rsa_public_key_path, server_rsa_private_key_path); const auto idle_time_seconds{ config->root().get<"replication">().get<"idle_time">()}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 10b3ca61..56d20bbd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -121,6 +121,25 @@ set_target_properties(crypto_rnd_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 + binsrv::lib_opensslpp + Boost::unit_test_framework +) +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}) @@ -131,3 +150,5 @@ 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 cipher_context_test COMMAND cipher_context_test ${test_run_options}) add_test(NAME crypto_rnd_test COMMAND crypto_rnd_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 00000000..1485dff6 --- /dev/null +++ b/tests/caching_sha2_password_authenticator_test.cpp @@ -0,0 +1,300 @@ +// 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); +} + +// Generates a fresh 2048-bit RSA key pair on the fly, writes the SubjectPublic +// KeyInfo-format public key to 'pub_path' and the PKCS#8-format private key +// (unencrypted) to 'priv_path'. The authenticator no longer embeds default +// keys, so every test that needs one bring its own via this helper. +void write_temp_rsa_key_pair(const std::string &pub_path, + const std::string &priv_path) { + 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); +} + +constexpr std::string_view test_rsa_pub_path{ + "/tmp/pbs_test_server_rsa_public.pem"}; +constexpr std::string_view test_rsa_priv_path{ + "/tmp/pbs_test_server_rsa_private.pem"}; + +} // 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(EmptyServerRsaPathsAreAcceptedAtConstruction) { + // Empty paths on both sides are a valid ctor input: the authenticator + // simply builds without RSA key material and any subsequent full-auth + // attempt fails then. The "operator must configure the pair for 'pull'" + // invariant is enforced by binsrv::pbs_listener_config::validate() and + // operations/pull_operation, not by the authenticator itself. + BOOST_CHECK_NO_THROW( + minimysql::caching_sha2_password_authenticator("password")); +} + +BOOST_AUTO_TEST_CASE(MatchingGreetingScrambleStillDrivesFullAuth) { + // PBS has no SHA-2 digest cache, so begin_authentication always + // enqueues 0x04 (perform_full_authentication) and drives every login + // through the RSA / PEM handshake, even when the client's greeting + // scramble already matches the configured password. Once a digest cache + // is added, this case will flip back to 0x03 (fast auth success); the + // scramble() / verify_greeting_scramble() helpers are kept alive in the + // authenticator for that day. + static constexpr std::string_view password{"password"}; + static constexpr std::string_view username{"rpl"}; + static constexpr std::string_view salt{"01234567890123456789"}; + + write_temp_rsa_key_pair(std::string{test_rsa_pub_path}, + std::string{test_rsa_priv_path}); + minimysql::caching_sha2_password_authenticator authenticator{ + password, test_rsa_pub_path, test_rsa_priv_path}; + 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{"\x04"}); + BOOST_CHECK(authenticator.state() == + minimysql::authentication_state::in_progress); + 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"}; + + write_temp_rsa_key_pair(std::string{test_rsa_pub_path}, + std::string{test_rsa_priv_path}); + minimysql::caching_sha2_password_authenticator authenticator{ + password, test_rsa_pub_path, test_rsa_priv_path}; + recording_encoder encoder; + + // The greeting scramble is what a real client sends in the handshake + // response. Under always-full-auth its value is not consulted here (the + // authenticator will drive full auth regardless), but we pass a properly + // computed scramble to mirror what a real mysql client would send. + const auto greeting_scramble{ + minimysql::caching_sha2_password_authenticator::scramble(password, salt)}; + authenticator.begin_authentication(username, username, greeting_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"}; + + write_temp_rsa_key_pair(std::string{test_rsa_pub_path}, + std::string{test_rsa_priv_path}); + minimysql::caching_sha2_password_authenticator authenticator{ + password, test_rsa_pub_path, test_rsa_priv_path}; + 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) { + write_temp_rsa_key_pair(std::string{test_rsa_pub_path}, + std::string{test_rsa_priv_path}); + BOOST_CHECK_NO_THROW(minimysql::caching_sha2_password_authenticator( + "password", test_rsa_pub_path, test_rsa_priv_path)); +} From 1dd7d5d865744535c727dd109d21f32409dd7cf7 Mon Sep 17 00:00:00 2001 From: Kamil Holubicki Date: Tue, 15 Sep 2026 18:52:01 +0200 Subject: [PATCH 2/3] PBS-31 prep: refactor minimysql session for socket-generic templates https://perconadev.atlassian.net/browse/PBS-31 Pure refactor - no functional change. Prepares the minimysql session layer for the upcoming TLS-listener work by making the post-greeting session body and its authentication sub-loop generic over the boost::asio socket type, so the same code drives both a plain 'tcp::socket' session and (in the follow-up commit) a 'ssl::stream' session after a successful TLS handshake. Extracted two template functions from what used to be a monolithic 'session()' coroutine in 'src/minimysql/network_service.cpp': * 'template perform_authentication(Socket &socket, connection_context &, endpoint &, buffer &) -> awaitable' - runs the caching_sha2_password authentication loop (AuthMethodSwitch on plugin mismatch, then begin_authentication / take_outbound_frames / read_client_frame / submit_authentication_frame until 'in_progress' clears), and emits the final OK or access_denied. Returns true on success, false on rejection. * 'template session_body(Socket &socket, connection_context &, endpoint &, buffer &)' - runs perform_authentication and, on success, the command loop (query / ping / binlog_dump / quit dispatch). Everything that happens on the connection after the initial handshake exchange. 'session()' itself continues to take a 'tcp::socket' by value (no signature change) and delegates its post-greeting half to 'session_body(socket, ...)'. Its behaviour is unchanged; only the factoring changed. Made 'minimysql::network_io_operations' header-only for the same reason - the free functions 'async_read_mysql_frame' and 'async_write_mysql_frame' are now templates parametrized on the stream type, so a caller passing a 'tcp::socket' or a 'ssl::stream' picks up the same implementation without an extra virtual dispatch: * Moved the bodies from 'src/minimysql/network_io_operations.cpp' into 'src/minimysql/network_io_operations.hpp' as template definitions. * Deleted 'src/minimysql/network_io_operations.cpp'. * Removed the corresponding source entry from 'minimysql_source_files' in the top-level 'CMakeLists.txt'. Behaviour verification: 'binlog_server' compiles clean, all 9 ctest targets pass (byte_span_encoding_test, uuid_test, tag_test, gtid_test, gtid_set_test, event_test, cipher_context_test, crypto_rnd_test, caching_sha2_password_authenticator_test). Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 1 - src/minimysql/network_io_operations.cpp | 193 --------- src/minimysql/network_io_operations.hpp | 180 ++++++-- src/minimysql/network_service.cpp | 530 ++++++++++++------------ 4 files changed, 422 insertions(+), 482 deletions(-) delete mode 100644 src/minimysql/network_io_operations.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index db073019..7b819a57 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -687,7 +687,6 @@ 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/sample_event_collection.hpp diff --git a/src/minimysql/network_io_operations.cpp b/src/minimysql/network_io_operations.cpp deleted file mode 100644 index a1d61918..00000000 --- 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 bbfd608e..fdbb7ef6 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 57dc1f48..5d89b6ec 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 @@ -220,8 +222,279 @@ 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() + << (context.connection_is_secure() ? " (over TLS)" + : " (over plain TCP)") + << '\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"; + } + } + } +} + +// MySQL session handling coroutine - writes server greeting, receives and +// parses the client greeting, then delegates to the templated post-greeting +// body. The socket type is left as a plain tcp::socket for now; a future +// change adds an optional TLS-upgrade branch that hands session_body an +// ssl::stream instead (PBS-31). [[nodiscard]] boost::asio::awaitable session( boost::asio::ip::tcp::socket socket, // NOLINTNEXTLINE(cppcoreguidelines-avoid-reference-coroutine-parameters) @@ -246,16 +519,6 @@ void handle_exception(std::string_view context) { 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" - const auto server_greeting{context.generate_encoded_server_greeting()}; print_server_greeting(remote_endpoint, context); co_await minimysql::async_write_mysql_frame( @@ -264,16 +527,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,238 +534,7 @@ 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"; - 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(); - - 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; - } - - 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(socket, context, remote_endpoint, data); } catch (...) { const std::string context{ "session " + boost::lexical_cast(remote_endpoint)}; From c3fad80cbd1289a4cce167dd47a873fe7a6759c8 Mon Sep 17 00:00:00 2001 From: Kamil Holubicki Date: Tue, 15 Sep 2026 18:53:57 +0200 Subject: [PATCH 3/3] PBS-31: implement SSL/TLS listener on top of the socket-generic refactor https://perconadev.atlassian.net/browse/PBS-31 Sits on top of the preceding "PBS-31 prep: refactor minimysql session for socket-generic templates" commit, which turned the post-greeting session body and its authentication sub-loop into templates parametrized on the boost::asio socket type. This commit adds the actual TLS functionality on that foundation: an optional TLS-enabled listener whose sessions upgrade the transport in place and then reuse the same 'session_body' template that a plaintext session uses. Problem: Before this pair of commits the Binlog Server accepted only plaintext client connections on its MySQL-protocol listener. 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, matching a TLS-configured Percona Server node with 'require_secure_transport=OFF'. The listener is TLS-enabled when the 'pbs_listener.ssl_cert_path' and 'pbs_listener.ssl_key_path' fields of the main_config JSON are both non-empty (config validation rejects one-sided configuration). TLS configuration is captured in a shared 'minimysql::ssl_acceptor_context' owned by 'network_service'; the connection_context advertises CLIENT_SSL in its greeting only when the acceptor is present. 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 ('perform_ssl_handshake' with the same timeout used for the rest of authentication) and switches all subsequent I/O to the encrypted stream via 'session_body' called on a 'boost::asio::ssl::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 'context.mark_transport_secure()' flips 'connection_is_secure()' to true, which unlocks the caching_sha2_password cleartext-after-0x04 fast path for TLS clients as in Percona Server. Because the previous commit already made 'session_body' and 'perform_authentication' templates over the socket type, the TLS branch here reuses exactly the same authentication and command-loop implementation as the plaintext branch - the only per-branch code is the SSLRequest check, the handshake call, and the 'mark_transport_secure' notification. OpenSSL wrapper: 'opensslpp::verify_ssl_ctx_private_key_matches_certificate' runs the 'SSL_CTX_check_private_key()' check on the underlying SSL_CTX handle and throws 'opensslpp::core_error' with a caller-supplied prefix plus the drained OpenSSL error queue, keeping the raw OpenSSL calls out of 'minimysql::ssl_acceptor_context'. New unit tests: 'connection_context_ssl_test.cpp' covers the CLIENT_SSL advertisement predicate, the client_requested_ssl / is_sslrequest_greeting predicates, and the mark_transport_secure transition; 'ssl_acceptor_context_test.cpp' covers TLS-context construction (successful pair, one-sided, mismatched pair, missing files) using MySQL's test-suite std_data cert / key files. MTR test: 'mtr/binlog_streaming/t/ssl_listener.test' brings the whole thing up end to end - spawns 'binlog_server pull' with a 'pbs_listener' block pointing at std_data server-cert / server-key, runs one '--ssl-mode=REQUIRED' mysql client (TLS handshake + cleartext-over-0x04 auth) and one '--ssl-mode=DISABLED --get-server-public-key' client (plain-TCP RSA auth) against the same listener, then greps the stdout log for "TLS handshake completed", "(over TLS)" and "(over plain TCP)" markers. Handshake success over TLSv1.2 and TLSv1.3, and the Percona-style rejection of SSL-requesting clients against a plaintext-only listener, were verified end-to-end. Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 15 +- .../include/generate_binsrv_config.inc | 46 +++- .../include/set_up_binsrv_environment.inc | 2 + mtr/binlog_streaming/r/ssl_listener.result | 66 ++++++ mtr/binlog_streaming/t/ssl_listener.test | 222 ++++++++++++++++++ src/binsrv/pbs_listener_config.cpp | 29 ++- src/binsrv/pbs_listener_config.hpp | 31 ++- .../caching_sha2_password_authenticator.cpp | 15 +- src/minimysql/connection_context.cpp | 49 +++- src/minimysql/connection_context.hpp | 32 ++- src/minimysql/network_service.cpp | 114 +++++++-- src/minimysql/network_service.hpp | 25 +- src/minimysql/ssl_acceptor_context.cpp | 102 ++++++++ src/minimysql/ssl_acceptor_context.hpp | 71 ++++++ src/minimysql/ssl_acceptor_context_fwd.hpp | 25 ++ src/opensslpp/ssl_context_helpers.cpp | 44 ++++ src/opensslpp/ssl_context_helpers.hpp | 42 ++++ src/operations/pull_operation.cpp | 41 +++- tests/CMakeLists.txt | 54 +++++ tests/connection_context_ssl_test.cpp | 206 ++++++++++++++++ tests/ssl_acceptor_context_test.cpp | 192 +++++++++++++++ 21 files changed, 1350 insertions(+), 73 deletions(-) create mode 100644 mtr/binlog_streaming/r/ssl_listener.result create mode 100644 mtr/binlog_streaming/t/ssl_listener.test 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 src/opensslpp/ssl_context_helpers.cpp create mode 100644 src/opensslpp/ssl_context_helpers.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 7b819a57..4f534808 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 @@ -187,6 +193,9 @@ set(opensslpp_source_files src/opensslpp/rsa_private_key.hpp src/opensslpp/rsa_private_key.cpp + src/opensslpp/ssl_context_helpers.hpp + src/opensslpp/ssl_context_helpers.cpp + src/opensslpp/core_error_fwd.hpp src/opensslpp/core_error.hpp src/opensslpp/core_error.cpp @@ -195,7 +204,7 @@ add_library(lib_opensslpp STATIC ${opensslpp_source_files}) target_link_libraries(lib_opensslpp PRIVATE binlog_server_compiler_flags - Boost::headers OpenSSL::Crypto + Boost::headers OpenSSL::SSL OpenSSL::Crypto ) # it is not possible to propagate CXX_EXTENSIONS and CXX_STANDARD_REQUIRED # via interface library (binlog_server_compiler_flags) @@ -689,6 +698,9 @@ set(minimysql_source_files src/minimysql/network_io_operations.hpp 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 ) @@ -712,6 +724,7 @@ target_link_libraries(binlog_server binsrv::lib_opensslpp Boost::headers Boost::json Boost::url Boost::asio aws-cpp-sdk-s3-crt + OpenSSL::SSL OpenSSL::Crypto ) # it is not possible to propagate CXX_EXTENSIONS and CXX_STANDARD_REQUIRED diff --git a/mtr/binlog_streaming/include/generate_binsrv_config.inc b/mtr/binlog_streaming/include/generate_binsrv_config.inc index d08b3b07..5b3268ed 100644 --- a/mtr/binlog_streaming/include/generate_binsrv_config.inc +++ b/mtr/binlog_streaming/include/generate_binsrv_config.inc @@ -23,6 +23,8 @@ # --let $binsrv_keyring_data_file_path = $MYSQL_TMP_DIR/keyring_data.json (optional) # --let $binsrv_pbs_listener_rsa_public_key_path = /path/to/pub.pem (optional) # --let $binsrv_pbs_listener_rsa_private_key_path = /path/to/priv.pem (optional) +# --let $binsrv_pbs_listener_ssl_cert = /path/to/server-cert.pem (optional) +# --let $binsrv_pbs_listener_ssl_key = /path/to/server-key.pem (optional) # --source set_up_binsrv_environment.inc --echo @@ -172,24 +174,44 @@ if ($binsrv_encryption_enabled) ); } -# Optional 'pbs_listener' block (see binsrv/pbs_listener_config.hpp). Both -# rsa_public_key_path and rsa_private_key_path must be non-empty; the block -# is omitted when either input var is unset. Any operation that uses the -# minimysql listener (currently: 'pull') requires this block, so tests that -# spawn 'binlog_server pull' must set both. +# Optional 'pbs_listener' block (see binsrv/pbs_listener_config.hpp). +# The block carries two independent field pairs (RSA key pair for +# caching_sha2_password full auth, and TLS cert/key pair for the SSL +# listener); the block is emitted whenever at least one of the pairs is +# fully set, but ALL FOUR fields are always written - the pair that the +# caller did not set gets emitted as empty strings so nv_tuple_from_json +# always sees every declared field and pbs_listener_config::validate() +# sees the empty pair as "not configured" (which is fine per each pair's +# own "both-set-or-both-empty" invariant). Tests that don't set either +# pair get a plain plaintext-only, RSA-less listener config with no +# 'pbs_listener' block at all. +--let $binsrv_pbs_listener_needs_block = 0 if ($binsrv_pbs_listener_rsa_public_key_path != "") { if ($binsrv_pbs_listener_rsa_private_key_path != "") { - eval SET @binsrv_config_json = JSON_INSERT( - @binsrv_config_json, '$.pbs_listener', - JSON_OBJECT( - 'rsa_public_key_path', '$binsrv_pbs_listener_rsa_public_key_path', - 'rsa_private_key_path', '$binsrv_pbs_listener_rsa_private_key_path' - ) - ); + --let $binsrv_pbs_listener_needs_block = 1 } } +if ($binsrv_pbs_listener_ssl_cert != "") +{ + if ($binsrv_pbs_listener_ssl_key != "") + { + --let $binsrv_pbs_listener_needs_block = 1 + } +} +if ($binsrv_pbs_listener_needs_block) +{ + eval SET @binsrv_config_json = JSON_INSERT( + @binsrv_config_json, '$.pbs_listener', + JSON_OBJECT( + 'rsa_public_key_path', '$binsrv_pbs_listener_rsa_public_key_path', + 'rsa_private_key_path', '$binsrv_pbs_listener_rsa_private_key_path', + 'ssl_cert_path', '$binsrv_pbs_listener_ssl_cert', + 'ssl_key_path', '$binsrv_pbs_listener_ssl_key' + ) + ); +} --let $binsrv_config_file_path = $MYSQL_TMP_DIR/binsrv_config.json --let $write_var = `SELECT @binsrv_config_json` diff --git a/mtr/binlog_streaming/include/set_up_binsrv_environment.inc b/mtr/binlog_streaming/include/set_up_binsrv_environment.inc index e52f5290..80fba5ff 100644 --- a/mtr/binlog_streaming/include/set_up_binsrv_environment.inc +++ b/mtr/binlog_streaming/include/set_up_binsrv_environment.inc @@ -23,6 +23,8 @@ # --let $binsrv_keyring_data_file_path = $MYSQL_TMP_DIR/keyring_data.json (optional) # --let $binsrv_pbs_listener_rsa_public_key_path = /path/to/pub.pem (optional) # --let $binsrv_pbs_listener_rsa_private_key_path = /path/to/priv.pem (optional) +# --let $binsrv_pbs_listener_ssl_cert = /path/to/server-cert.pem (optional) +# --let $binsrv_pbs_listener_ssl_key = /path/to/server-key.pem (optional) # --source set_up_binsrv_environment.inc --source ../include/generate_binsrv_config.inc diff --git a/mtr/binlog_streaming/r/ssl_listener.result b/mtr/binlog_streaming/r/ssl_listener.result new file mode 100644 index 00000000..8c4c7113 --- /dev/null +++ b/mtr/binlog_streaming/r/ssl_listener.result @@ -0,0 +1,66 @@ +*** Resetting replication at the very beginning of the test. + +*** Generating a configuration file in JSON format for the Binlog +*** Server utility. + +*** Determining binlog file directory from the server. + +*** Creating a temporary directory for storing +*** binlog files downloaded via the Binlog Server utility. + +*** Starting Binlog Server Utility in background in pull mode with +*** TLS enabled on its MySQL-protocol listener (server cert / key +*** taken from MYSQL_TEST_DIR/std_data). +*** stdout/stderr goes to a dedicated log so we can grep for TLS and +*** auth markers AFTER binsrv has exited (see auth_method_switch +*** .test for why we defer log inspection until after graceful +*** shutdown). +include/read_file_to_var.inc + +*** Waiting for the Binlog Server listener to come up on +*** 127.0.0.1:3307. We probe with bash's /dev/tcp instead of the +*** mysql client because bash is not ASAN-instrumented and +*** /dev/tcp uses a plain connect(2), so each attempt is cheap and +*** measures exactly the "listening on the port" state we care +*** about. + +*** Scenario 1: TLS client. --ssl-mode=REQUIRED forces mysql to send +*** Protocol::SSLRequest first; the Binlog Server responds with a +*** TLS handshake, marks the transport secure, and then the +*** caching_sha2_password full-auth (0x04) branch takes the +*** cleartext-over-secure-transport path (verify_cleartext_password) +*** - no --get-server-public-key needed. A zero exit code from +*** mysql means the whole handshake + auth + probe round-trip +*** succeeded. + +*** Scenario 2: non-TLS client on the same TLS-enabled listener. +*** --ssl-mode=DISABLED tells mysql to skip the TLS upgrade; the +*** session stays on plain TCP and full-auth (0x04) therefore +*** requires the RSA / PEM handshake (--get-server-public-key). A +*** zero exit code confirms TLS is per-session opt-in and does not +*** break plain-TCP clients (matches Percona Server behaviour with +*** require_secure_transport=OFF). + +*** Sending SIGTERM to the Binlog Server Utility and waiting for the +*** process to terminate (poll kill -0 until the pid is gone). +*** Graceful exit flushes std::cout, so any log line binsrv wrote +*** during its lifetime is now safely on disk. + +*** Confirming end-to-end results by grepping the Binlog Server +*** stdout log (now complete and flushed): +*** - "TLS handshake completed with" proves scenario 1 negotiated +*** the TLS upgrade successfully +*** - "(over TLS)" proves scenario 1's auth ran on the encrypted +*** transport (cleartext-over-0x04 branch), not RSA +*** - "(over plain TCP)" proves scenario 2 kept using plain TCP +*** and completed via the RSA / PEM full-auth branch +include/wait_for_pattern_in_file.inc [TLS handshake completed with] +include/wait_for_pattern_in_file.inc [client authentication succeeded for rpl \(over TLS\)] +include/wait_for_pattern_in_file.inc [client authentication succeeded for rpl \(over plain TCP\)] + +*** Removing the Binlog Server utility storage directory. + +*** Removing the Binlog Server utility log file. + +*** Removing the Binlog Server utility configuration file. +KILL CONNECTION ; diff --git a/mtr/binlog_streaming/t/ssl_listener.test b/mtr/binlog_streaming/t/ssl_listener.test new file mode 100644 index 00000000..732db587 --- /dev/null +++ b/mtr/binlog_streaming/t/ssl_listener.test @@ -0,0 +1,222 @@ +# Exercises the optional TLS listener on binlog_server pull (PBS-31). With +# 'pbs_listener' set in the JSON config, the Binlog Server advertises +# CLIENT_SSL and upgrades a client's Protocol::SSLRequest to TLS; once the +# handshake completes, the caching_sha2_password full-auth branch takes the +# cleartext-over-0x04 path instead of RSA (matches Percona Server on a +# TLS-configured listener). TLS is per-session opt-in, so a non-TLS client +# still connects to the same listener over plain TCP and completes +# authentication via RSA (--get-server-public-key), matching Percona Server +# behaviour with require_secure_transport=OFF. +# +# Reuses the standard MTR TLS materials shipped under $MYSQL_TEST_DIR +# /std_data/ (the same server certificate ssl_connection.test uses when +# exercising the replica-side TLS handshake); nothing new is shipped from +# the Binlog Server suite for this test. + +# The Binlog Server listens on a hard-coded TCP port, so this test can not +# run in parallel with other tests using the same port. +--source include/not_parallel.inc + +--source ../include/have_binsrv.inc + +--source ../include/v80_v84_compatibility_defines.inc + +--source include/count_sessions.inc + +# in case of --repeat=N, we need to start from a fresh binary log to make +# this test deterministic +--echo *** Resetting replication at the very beginning of the test. +--disable_query_log +eval $stmt_reset_binary_logs_and_gtids; +--enable_query_log + +# identifying backend storage type ('file' or 's3') +--source ../include/identify_storage_backend.inc + +# creating data directory, configuration file, etc. The generated JSON gains +# a 'pbs_listener' block because both ssl_cert / ssl_key vars are non-empty; +# see mtr/binlog_streaming/include/generate_binsrv_config.inc. +# +# Scenario 2 below drives '--get-server-public-key' against the same +# listener, so we also point pbs_listener at the RSA key pair shipped in +# this suite's std_data - without it, the plain-TCP RSA branch of +# caching_sha2_password full auth has no server public key to hand out +# and the client's SELECT fails (see the memory +# 'pbs-listener-rsa-key-mtr-dependency': any test that uses +# --get-server-public-key or --server-public-key-path must configure the +# RSA pair too). +--let $binsrv_connect_timeout = 10 +--let $binsrv_read_timeout = 10 +--let $binsrv_idle_time = 1 +--let $binsrv_verify_checksum = TRUE +--let $binsrv_replication_mode = position +--let $binsrv_pbs_listener_rsa_public_key_path = $MYSQL_TEST_DIR/suite/binlog_streaming/std_data/caching_sha2_full_auth_pubkey.pem +--let $binsrv_pbs_listener_rsa_private_key_path = $MYSQL_TEST_DIR/suite/binlog_streaming/std_data/caching_sha2_full_auth_privkey.pem +--let $binsrv_pbs_listener_ssl_cert = $MYSQL_TEST_DIR/std_data/server-cert-verify-san.pem +--let $binsrv_pbs_listener_ssl_key = $MYSQL_TEST_DIR/std_data/server-key-verify-san.pem +--source ../include/set_up_binsrv_environment.inc + +--echo +--echo *** Starting Binlog Server Utility in background in pull mode with +--echo *** TLS enabled on its MySQL-protocol listener (server cert / key +--echo *** taken from MYSQL_TEST_DIR/std_data). +--echo *** stdout/stderr goes to a dedicated log so we can grep for TLS and +--echo *** auth markers AFTER binsrv has exited (see auth_method_switch +--echo *** .test for why we defer log inspection until after graceful +--echo *** shutdown). +--let $binsrv_pid_file = $MYSQL_TMP_DIR/ssl_listener_binsrv.pid +--let $binsrv_stdout_log = $MYSQL_TMP_DIR/ssl_listener_binsrv_stdout.log +--let $binsrv_spawn_cmd_line = $BINSRV pull $binsrv_config_file_path > $binsrv_stdout_log 2>&1 & echo \$! > $binsrv_pid_file + +# We route the spawn through Perl's system() rather than --exec because +# mysqltest strips the shell's `$!` before it can be expanded, leaving the +# PID file empty. Passing the command as an env var and executing it from +# Perl leaves the shell metacharacters intact. +--let EXPORTED_BINSRV_SPAWN_CMD_LINE = $binsrv_spawn_cmd_line +--perl + use strict; + use warnings; + my $cmd = $ENV{'EXPORTED_BINSRV_SPAWN_CMD_LINE'}; + system("$cmd"); +EOF + +--let $read_from_file = $binsrv_pid_file +--source include/read_file_to_var.inc +--let $binsrv_pid = $result + +# SELECT VERSION() is one of the queries the Binlog Server's command loop +# recognises (see network_service.cpp). Only the mysql client exit code is +# used as pass/fail; the row itself is discarded so the test stays stable +# across changes to the Binlog Server's hard-coded version string. +--let $probe_query = SELECT VERSION() + +--echo +--echo *** Waiting for the Binlog Server listener to come up on +--echo *** 127.0.0.1:3307. We probe with bash's /dev/tcp instead of the +--echo *** mysql client because bash is not ASAN-instrumented and +--echo *** /dev/tcp uses a plain connect(2), so each attempt is cheap and +--echo *** measures exactly the "listening on the port" state we care +--echo *** about. +--let $max_wait = 300 +--let $iteration = 0 +--let $port_open = 0 +while ($iteration < $max_wait) +{ + if (!$port_open) + { + --error 0, 1 + --exec bash -c "echo > /dev/tcp/127.0.0.1/3307" 2>/dev/null + --let $port_status = $__error + if ($port_status == 0) + { + --let $port_open = 1 + --let $iteration = $max_wait + } + if ($port_status != 0) + { + --sleep 1 + --inc $iteration + } + } +} +if (!$port_open) +{ + --die The Binlog Server listener did not become reachable on 3307 within 300 seconds +} + +--echo +--echo *** Scenario 1: TLS client. --ssl-mode=REQUIRED forces mysql to send +--echo *** Protocol::SSLRequest first; the Binlog Server responds with a +--echo *** TLS handshake, marks the transport secure, and then the +--echo *** caching_sha2_password full-auth (0x04) branch takes the +--echo *** cleartext-over-secure-transport path (verify_cleartext_password) +--echo *** - no --get-server-public-key needed. A zero exit code from +--echo *** mysql means the whole handshake + auth + probe round-trip +--echo *** succeeded. +--exec $MYSQL --protocol=TCP --host=127.0.0.1 --port=3307 --user=rpl --password=password --default-auth=caching_sha2_password --ssl-mode=REQUIRED --skip-column-names -e "$probe_query" >/dev/null 2>&1 + +--echo +--echo *** Scenario 2: non-TLS client on the same TLS-enabled listener. +--echo *** --ssl-mode=DISABLED tells mysql to skip the TLS upgrade; the +--echo *** session stays on plain TCP and full-auth (0x04) therefore +--echo *** requires the RSA / PEM handshake (--get-server-public-key). A +--echo *** zero exit code confirms TLS is per-session opt-in and does not +--echo *** break plain-TCP clients (matches Percona Server behaviour with +--echo *** require_secure_transport=OFF). +--exec $MYSQL --protocol=TCP --host=127.0.0.1 --port=3307 --user=rpl --password=password --default-auth=caching_sha2_password --ssl-mode=DISABLED --get-server-public-key --skip-column-names -e "$probe_query" >/dev/null 2>&1 + +--echo +--echo *** Sending SIGTERM to the Binlog Server Utility and waiting for the +--echo *** process to terminate (poll kill -0 until the pid is gone). +--echo *** Graceful exit flushes std::cout, so any log line binsrv wrote +--echo *** during its lifetime is now safely on disk. +--replace_result $binsrv_pid +--exec kill -s TERM $binsrv_pid + +--let $max_exit_wait = 30 +--let $iteration = 0 +--let $binsrv_running = 1 +while ($iteration < $max_exit_wait) +{ + if ($binsrv_running) + { + --error 0, 1 + --exec kill -0 $binsrv_pid 2>/dev/null + --let $kill_status = $__error + if ($kill_status != 0) + { + --let $binsrv_running = 0 + --let $iteration = $max_exit_wait + } + if ($kill_status == 0) + { + --sleep 1 + --inc $iteration + } + } +} +if ($binsrv_running) +{ + --die Binlog Server Utility did not terminate within 30 seconds +} + +--echo +--echo *** Confirming end-to-end results by grepping the Binlog Server +--echo *** stdout log (now complete and flushed): +--echo *** - "TLS handshake completed with" proves scenario 1 negotiated +--echo *** the TLS upgrade successfully +--echo *** - "(over TLS)" proves scenario 1's auth ran on the encrypted +--echo *** transport (cleartext-over-0x04 branch), not RSA +--echo *** - "(over plain TCP)" proves scenario 2 kept using plain TCP +--echo *** and completed via the RSA / PEM full-auth branch +--let $grep_file = $binsrv_stdout_log +--let $grep_pattern = TLS handshake completed with +--let $wait_timeout = 30 +--source include/wait_for_pattern_in_file.inc + +--let $grep_file = $binsrv_stdout_log +--let $grep_pattern = client authentication succeeded for rpl \(over TLS\) +--let $wait_timeout = 30 +--source include/wait_for_pattern_in_file.inc + +--let $grep_file = $binsrv_stdout_log +--let $grep_pattern = client authentication succeeded for rpl \(over plain TCP\) +--let $wait_timeout = 30 +--source include/wait_for_pattern_in_file.inc + +--remove_file $binsrv_pid_file +--remove_file $binsrv_stdout_log + +# cleaning up +--source ../include/tear_down_binsrv_environment.inc + +# As the Binlog Server Utility interrupts the connection upon timeout, here we +# need to close it on the MySQL server side as well in order to make sure that +# MTR 'check-test' before and after the test produces the same output. +--let $binlog_dump_connection_id = `SELECT ID FROM performance_schema.processlist WHERE COMMAND = 'Binlog Dump'` +--replace_result $binlog_dump_connection_id +eval KILL CONNECTION $binlog_dump_connection_id; + +# Also, we use 'count_sessions' include files to make sure that 'Binlog Dump' +# connection is indeed closed. +--source include/wait_until_count_sessions.inc diff --git a/src/binsrv/pbs_listener_config.cpp b/src/binsrv/pbs_listener_config.cpp index 2a3f2cfe..0a060f6e 100644 --- a/src/binsrv/pbs_listener_config.cpp +++ b/src/binsrv/pbs_listener_config.cpp @@ -22,16 +22,29 @@ namespace binsrv { void pbs_listener_config::validate() const { - // When the block is present at all, both paths must be non-empty; the - // authenticator loads them together and a one-sided configuration would - // fail deep inside opensslpp with a less actionable error. - const auto &public_key{get<"rsa_public_key_path">()}; - const auto &private_key{get<"rsa_private_key_path">()}; - if (public_key.empty() || private_key.empty()) { + // The two field pairs are validated independently. Each pair uses the + // "both set or both empty" invariant - a one-sided configuration is a + // mis-configuration that would fail deeper in the load path (opensslpp + // for RSA, boost::asio::ssl::context for TLS) with a less actionable + // error. Both-empty for a pair means "not configured", which is fine: + // the caching_sha2_password authenticator accepts empty RSA paths (any + // 0x04 full-auth attempt then fails per-session) and the network layer + // accepts empty SSL paths (listener stays plaintext-only). + const auto &rsa_public_key{get<"rsa_public_key_path">()}; + const auto &rsa_private_key{get<"rsa_private_key_path">()}; + if (rsa_public_key.empty() != rsa_private_key.empty()) { util::exception_location().raise( "error validating pbs_listener config: " - "rsa_public_key_path and rsa_private_key_path must both be " - "non-empty when 'pbs_listener' is set"); + "rsa_public_key_path and rsa_private_key_path must both be set " + "or both be empty"); + } + + const auto &ssl_cert{get<"ssl_cert_path">()}; + const auto &ssl_key{get<"ssl_key_path">()}; + if (ssl_cert.empty() != ssl_key.empty()) { + util::exception_location().raise( + "error validating pbs_listener config: " + "ssl_cert_path and ssl_key_path must both be set or both be empty"); } } diff --git a/src/binsrv/pbs_listener_config.hpp b/src/binsrv/pbs_listener_config.hpp index ead04d63..db338743 100644 --- a/src/binsrv/pbs_listener_config.hpp +++ b/src/binsrv/pbs_listener_config.hpp @@ -27,21 +27,32 @@ namespace binsrv { // Settings for the MySQL-protocol listener the Binlog Server exposes to // downstream clients when running in the 'pull' operation (the source-side // half of the replication graph). The whole block is optional in -// main_config; when omitted the listener has no server-side RSA key pair -// and any caching_sha2_password full-authentication attempt (0x04) will -// fail per-session - matching the authenticator's own "both empty is OK" -// acceptance at construction. +// main_config. // -// When the block IS present, both 'rsa_public_key_path' and -// 'rsa_private_key_path' must be non-empty and readable PEM files - the -// authenticator loads them to serve --get-server-public-key and to -// RSA-OAEP-decrypt password ciphertext (see PBS-33 and -// minimysql::caching_sha2_password_authenticator). +// The block carries two independent pairs of options, each validated on +// its own (both fields set together, or both left empty): +// +// * 'rsa_public_key_path' / 'rsa_private_key_path' - server-side RSA +// key pair used by the caching_sha2_password authenticator to serve +// --get-server-public-key and to RSA-OAEP-decrypt password ciphertext +// (PBS-33). When empty, any caching_sha2_password full-auth attempt +// (0x04) fails per-session - matching the authenticator's own "both +// empty is OK" acceptance at construction. See +// minimysql::caching_sha2_password_authenticator. +// +// * 'ssl_cert_path' / 'ssl_key_path' - server-side TLS cert / key pair +// for the optional TLS listener (PBS-31). When set, the listener +// advertises CLIENT_SSL in its greeting and honours +// Protocol::SSLRequest by upgrading the transport to TLS; when empty, +// the listener runs in plaintext-only mode. See +// minimysql::ssl_acceptor_context. struct [[nodiscard]] pbs_listener_config : util::nv_tuple< // clang-format off util::nv<"rsa_public_key_path" , std::string>, - util::nv<"rsa_private_key_path", std::string> + util::nv<"rsa_private_key_path", std::string>, + util::nv<"ssl_cert_path" , std::string>, + util::nv<"ssl_key_path" , std::string> // clang-format on > { void validate() const; diff --git a/src/minimysql/caching_sha2_password_authenticator.cpp b/src/minimysql/caching_sha2_password_authenticator.cpp index 852ed1d0..d1e2b14d 100644 --- a/src/minimysql/caching_sha2_password_authenticator.cpp +++ b/src/minimysql/caching_sha2_password_authenticator.cpp @@ -60,8 +60,8 @@ // | | // | 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() +// | (A) secure transport (TLS negotiated via PBS-31's SSLRequest branch) +// |--- cleartext password (0-terminated) -->| verify_cleartext_password() // |<-- OK / Access denied ------------------| // | | // | (B) plain TCP — client opts in to RSA (server expects ciphertext) @@ -282,8 +282,10 @@ caching_sha2_password_authenticator::state() const noexcept { // 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). +// (request PEM) or RSA ciphertext when it already has the public key, or +// with a cleartext password when secure_transport_ is true (i.e. the +// network layer negotiated TLS and called +// connection_context::mark_transport_secure() before begin_authentication). // - awaiting_encrypted_password: client sends ciphertext after receiving PEM. bool caching_sha2_password_authenticator::expects_client_input() const noexcept { @@ -449,8 +451,9 @@ 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. PBS has no TLS yet (PBS-31); connection_is_secure() is always - // false, so this remains a placeholder until SSL is wired up. + // without RSA. Reached when the network layer upgraded the transport to + // TLS (see PBS-31) and connection_context::mark_transport_secure() was + // called so begin_authentication() observed secure_transport_ set. if (std::empty(password_payload) || password_payload.back() != '\0') { phase_ = phase::failed; return authentication_state::failed; diff --git a/src/minimysql/connection_context.cpp b/src/minimysql/connection_context.cpp index 005ccfe6..2589caeb 100644 --- a/src/minimysql/connection_context.cpp +++ b/src/minimysql/connection_context.cpp @@ -96,11 +96,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 " @@ -156,12 +157,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 PBS gains TLS (PBS-31): 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() { @@ -222,6 +239,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}; const std::string fixed_server_auth_data{ @@ -254,8 +274,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 45221a79..a1e65733 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_service.cpp b/src/minimysql/network_service.cpp index 5d89b6ec..c302e019 100644 --- a/src/minimysql/network_service.cpp +++ b/src/minimysql/network_service.cpp @@ -50,6 +50,7 @@ #pragma GCC diagnostic ignored "-Wnull-dereference" #include +#include #pragma GCC diagnostic pop @@ -58,6 +59,11 @@ #include +#include + +#include +#include + #include #include @@ -66,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 { @@ -490,11 +497,41 @@ boost::asio::awaitable session_body( } } -// MySQL session handling coroutine - writes server greeting, receives and -// parses the client greeting, then delegates to the templated post-greeting -// body. The socket type is left as a plain tcp::socket for now; a future -// change adds an optional TLS-upgrade branch that hands session_body an -// ssl::stream instead (PBS-31). +// 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) @@ -504,7 +541,8 @@ boost::asio::awaitable session_body( // 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)}; @@ -515,9 +553,10 @@ boost::asio::awaitable session_body( 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}; + 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); @@ -534,7 +573,50 @@ boost::asio::awaitable session_body( context.parse_client_greeting(data); print_client_greeting(remote_endpoint, context); - co_await session_body(socket, context, remote_endpoint, data); + // 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; set " + "'pbs_listener.ssl_cert_path' and " + "'pbs_listener.ssl_key_path' in the binlog_server " + "config to enable TLS. Closing connection (matches " + "Percona Server behaviour: no error frame is sent " + "mid-handshake).\n"; + co_return; + } + + if (ssl_ctx != nullptr && context.is_sslrequest_greeting()) { + std::cout << "client requested TLS upgrade (SSLRequest) from " + << remote_endpoint << '\n'; + + boost::asio::ssl::stream ssl_socket{ + std::move(socket), ssl_ctx->native()}; + + co_await perform_ssl_handshake( + ssl_socket, network_service::session_authentication_timeout); + + context.mark_transport_secure(); + std::cout << "TLS handshake completed with " << remote_endpoint << '\n'; + + co_await minimysql::async_read_mysql_frame( + ssl_socket, data, network_service::session_authentication_timeout); + std::cout << "received encrypted client greeting (" << std::size(data) + << " bytes from " << remote_endpoint << ")\n"; + context.parse_client_greeting(data); + print_client_greeting(remote_endpoint, context); + + co_await session_body(ssl_socket, context, remote_endpoint, data); + } else { + co_await session_body(socket, context, remote_endpoint, data); + } } catch (...) { const std::string context{ "session " + boost::lexical_cast(remote_endpoint)}; @@ -555,7 +637,8 @@ boost::asio::awaitable session_body( // 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(); @@ -581,7 +664,7 @@ boost::asio::awaitable session_body( 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 (...) { @@ -596,11 +679,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})} { @@ -608,7 +692,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 f85a670e..8056596c 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 00000000..c3334839 --- /dev/null +++ b/src/minimysql/ssl_acceptor_context.cpp @@ -0,0 +1,102 @@ +// 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 "opensslpp/ssl_context_helpers.hpp" + +namespace minimysql { + +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. + // + // Boost.Asio inlines SSL_CTX_set_options / SSL_CTX_set_verify inside + // set_options / set_verify_mode; cert-err33-c flags the discarded OpenSSL + // return through the wrapper. On a freshly constructed context with these + // well-formed inputs there is nothing to check, so we silence the warning + // at the call site instead of consuming a return that the wrapper does not + // expose. + // NOLINTNEXTLINE(bugprone-unused-return-value,cert-err33-c) + 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); + + // NOLINTNEXTLINE(bugprone-unused-return-value,cert-err33-c) + 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; + + // Boost.Asio's error_code overloads also return the error_code (deprecated + // dual API); the immediate `if (error_code)` below already consumes it, so + // silence the cert-err33-c warning at the call rather than repeating the + // check on the return value. + // NOLINTNEXTLINE(bugprone-unused-return-value,cert-err33-c) + 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()}; + } + + // NOLINTNEXTLINE(bugprone-unused-return-value,cert-err33-c) + 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; + // opensslpp::verify_ssl_ctx_private_key_matches_certificate() runs the + // check on the underlying handle, clears any residual OpenSSL error queue + // entries left by the loads above, and throws opensslpp::core_error (a + // std::runtime_error) with the mismatch reason appended to the prefix. + opensslpp::verify_ssl_ctx_private_key_matches_certificate( + context_.native_handle(), "SSL private key '" + private_key_path_ + + "' does not match certificate '" + + certificate_path_ + "'"); +} + +} // namespace minimysql diff --git a/src/minimysql/ssl_acceptor_context.hpp b/src/minimysql/ssl_acceptor_context.hpp new file mode 100644 index 00000000..150b49ee --- /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 00000000..707cbd5c --- /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/opensslpp/ssl_context_helpers.cpp b/src/opensslpp/ssl_context_helpers.cpp new file mode 100644 index 00000000..7bb4ac32 --- /dev/null +++ b/src/opensslpp/ssl_context_helpers.cpp @@ -0,0 +1,44 @@ +// Copyright (c) 2023-2024 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 "opensslpp/ssl_context_helpers.hpp" + +#include +#include + +#include +#include +#include + +#include "opensslpp/core_error.hpp" + +#include "util/exception_location_helpers.hpp" + +namespace opensslpp { + +void verify_ssl_ctx_private_key_matches_certificate( + void *native_ssl_ctx, std::string_view error_prefix) { + assert(native_ssl_ctx != nullptr); + // A prior successful SSL_CTX_use_certificate_chain_file() or + // SSL_CTX_use_PrivateKey_file() can leave advisory entries on the OpenSSL + // error queue; drain them so the exception raised on mismatch reports the + // real SSL_CTX_check_private_key() reason instead of leftover noise. + ERR_clear_error(); + if (SSL_CTX_check_private_key(static_cast(native_ssl_ctx)) != 1) { + util::exception_location().raise(error_prefix); + } +} + +} // namespace opensslpp diff --git a/src/opensslpp/ssl_context_helpers.hpp b/src/opensslpp/ssl_context_helpers.hpp new file mode 100644 index 00000000..5ece05e1 --- /dev/null +++ b/src/opensslpp/ssl_context_helpers.hpp @@ -0,0 +1,42 @@ +// Copyright (c) 2023-2024 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 OPENSSLPP_SSL_CONTEXT_HELPERS_HPP +#define OPENSSLPP_SSL_CONTEXT_HELPERS_HPP + +#include + +namespace opensslpp { + +// Verifies that the private key currently loaded in the OpenSSL SSL_CTX +// pointed to by 'native_ssl_ctx' matches the certificate loaded in the same +// context (OpenSSL's SSL_CTX_check_private_key). The OpenSSL error queue is +// cleared before the call so residual entries from a prior successful load +// (SSL_CTX_use_certificate_chain_file / SSL_CTX_use_PrivateKey_file may leave +// advisory reasons behind) do not leak into the exception raised on +// mismatch. Throws opensslpp::core_error prefixed with 'error_prefix' on +// mismatch; the exception message reads as +// ": ::". +// +// 'native_ssl_ctx' must be a non-null pointer returned by SSL_CTX_new(); the +// void * type is deliberate so this header can stay free of any OpenSSL +// include (matches the rest of the opensslpp API). Callers using +// Boost.Asio's ssl::context pass its native_handle(). +void verify_ssl_ctx_private_key_matches_certificate( + void *native_ssl_ctx, std::string_view error_prefix = {}); + +} // namespace opensslpp + +#endif // OPENSSLPP_SSL_CONTEXT_HELPERS_HPP diff --git a/src/operations/pull_operation.cpp b/src/operations/pull_operation.cpp index d11db150..a3f924d9 100644 --- a/src/operations/pull_operation.cpp +++ b/src/operations/pull_operation.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wnull-dereference" @@ -41,6 +42,7 @@ #include "easymysql/connection_fwd.hpp" #include "minimysql/network_service.hpp" +#include "minimysql/ssl_acceptor_context.hpp" #include "operations/basic_operation.hpp" #include "operations/collector_context.hpp" @@ -112,27 +114,48 @@ generic_operation::generic_operation( operations::collector_context collector_ctx{ easymysql::connection_replication_mode_type::blocking, config, logger}; - // The 'pbs_listener' JSON config block carries the server-side RSA key - // pair the caching_sha2_password authenticator needs for the 0x04 - // full-authentication branch (--get-server-public-key / --server-public - // -key-path clients). The block is optional at the main_config layer - // (see binsrv::pbs_listener_config); when absent we simply forward - // empty paths, mirroring the authenticator's "both empty is OK" - // acceptance at construction. Per-session RSA operations then fail if - // a client actually reaches the full-auth branch without keys. + // The 'pbs_listener' JSON config block carries per-listener options: + // * RSA key pair for the caching_sha2_password full-authentication + // branch (PBS-33) - forwarded to network_service as-is. Empty + // paths mean the authenticator has no RSA key material and any + // 0x04 attempt fails per-session. + // * TLS cert / key pair for the optional TLS listener (PBS-31) - + // when both non-empty we build an ssl_acceptor_context here so + // any load failure (bad file, mismatched pair) surfaces before + // network_service is instantiated, matching the ownership- + // transfer contract on the network_service constructor. + // The block itself is optional; when absent we forward empty views + // for RSA and a null ssl_ctx for TLS. std::string_view server_rsa_public_key_path{}; std::string_view server_rsa_private_key_path{}; + std::unique_ptr ssl_ctx; const auto &optional_listener{config->root().get<"pbs_listener">()}; if (optional_listener.has_value()) { server_rsa_public_key_path = optional_listener->get<"rsa_public_key_path">(); server_rsa_private_key_path = optional_listener->get<"rsa_private_key_path">(); + + const auto &ssl_cert_path{optional_listener->get<"ssl_cert_path">()}; + const auto &ssl_key_path{optional_listener->get<"ssl_key_path">()}; + if (!ssl_cert_path.empty() && !ssl_key_path.empty()) { + ssl_ctx = std::make_unique( + ssl_cert_path, ssl_key_path); + logger->log(binsrv::log_severity::info, + "SSL enabled for the Binlog Server listener (cert='" + + ssl_cert_path + "', key='" + ssl_key_path + "')"); + } + } + if (!ssl_ctx) { + logger->log(binsrv::log_severity::info, + "SSL disabled for the Binlog Server listener (no " + "'pbs_listener.ssl_cert_path' / 'ssl_key_path' in config)"); } const minimysql::network_service service( io_ctx, listening_port, default_username, default_password, - server_rsa_public_key_path, server_rsa_private_key_path); + server_rsa_public_key_path, server_rsa_private_key_path, + std::move(ssl_ctx)); const auto idle_time_seconds{ config->root().get<"replication">().get<"idle_time">()}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 56d20bbd..c68501ed 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -140,6 +140,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 + binsrv::lib_opensslpp + Boost::unit_test_framework + Boost::headers + MySQL::client +) +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 + binsrv::lib_opensslpp + Boost::unit_test_framework + Boost::headers + OpenSSL::SSL +) +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}) @@ -152,3 +202,7 @@ add_test(NAME cipher_context_test COMMAND cipher_context_test ${test_run_options add_test(NAME crypto_rnd_test COMMAND crypto_rnd_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 00000000..75fe653e --- /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 00000000..5015267f --- /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()