Skip to content

PBS-31 Implement support for SSL/TLS for the listener - #161

Open
kamil-holubicki wants to merge 3 commits into
Percona-Lab:mainfrom
kamil-holubicki:PBS-31
Open

kamil-holubicki wants to merge 3 commits into
Percona-Lab:mainfrom
kamil-holubicki:PBS-31

Conversation

@kamil-holubicki

Copy link
Copy Markdown
Contributor

No description provided.

kamil-holubicki and others added 2 commits September 15, 2026 15:53
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=<pem>') 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<void, impl_deleter>' pimpl, 'native_helper' in the '.cpp',
'core_error' on failure, 'util::byte_span' IO, no '<openssl/*>' 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
'<openssl/*>' 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=<pem>', 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 <noreply@anthropic.com>
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<tcp::socket>' 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 <typename Socket> perform_authentication(Socket &socket,
    connection_context &, endpoint &, buffer &) -> awaitable<bool>' -
    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 <typename Socket> 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<tcp::socket>'
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 <noreply@anthropic.com>
@kamil-holubicki
kamil-holubicki force-pushed the PBS-31 branch 3 times, most recently from fa3d221 to ca26902 Compare September 15, 2026 18:17
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<tcp::socket>'; 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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant