From cd642d981845305c5f0f2670e2a2b7d34f61347b Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 17:09:22 +0000 Subject: [PATCH 01/18] feat(auth): add caching_sha2_password RSA key exchange --- doc/caching_sha2_password_rsa.md | 97 ++ doc/internal/passthrough_authentication.md | 9 +- include/MySQL_Caching_Sha2_RSA.h | 57 ++ include/MySQL_Passthrough_Auth_Cache.h | 7 +- include/MySQL_Protocol.h | 22 + include/MySQL_Thread.h | 27 +- include/mysql_connection.h | 1 + lib/Admin_FlushVariables.cpp | 6 +- lib/Makefile | 4 + lib/MySQL_Authentication.cpp | 69 +- lib/MySQL_Caching_Sha2_RSA.cpp | 879 ++++++++++++++++++ lib/MySQL_Passthrough_Auth_Cache.cpp | 22 +- lib/MySQL_Protocol.cpp | 306 ++++-- lib/MySQL_Session.cpp | 52 +- lib/MySQL_Thread.cpp | 128 ++- lib/mysql_connection.cpp | 14 +- lib/mysql_data_stream.cpp | 7 +- test/tap/groups/groups.json | 2 + .../reg_test_5988-caching_sha2_rsa-t.cpp | 302 ++++++ test/tap/tests/unit/Makefile | 4 + .../tests/unit/caching_sha2_rsa_unit-t.cpp | 544 +++++++++++ .../tap/tests/unit/mysql_variables_unit-t.cpp | 164 ++++ test/tap/tests/unit/protocol_unit-t.cpp | 133 ++- 23 files changed, 2697 insertions(+), 159 deletions(-) create mode 100644 doc/caching_sha2_password_rsa.md create mode 100644 include/MySQL_Caching_Sha2_RSA.h create mode 100644 lib/MySQL_Caching_Sha2_RSA.cpp create mode 100644 test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp create mode 100644 test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp diff --git a/doc/caching_sha2_password_rsa.md b/doc/caching_sha2_password_rsa.md new file mode 100644 index 0000000000..cc0a5ecfd3 --- /dev/null +++ b/doc/caching_sha2_password_rsa.md @@ -0,0 +1,97 @@ +# RSA key exchange for `caching_sha2_password` + +ProxySQL 3.1 can authenticate MySQL clients that use +`caching_sha2_password` over a non-TLS frontend connection. When full +authentication is required, the client can request ProxySQL's RSA public key, +encrypt its password, and send the ciphertext back to ProxySQL. + +TLS remains the recommended configuration. Requesting a public key over an +unauthenticated connection encrypts the password on the wire, but it does not +authenticate the ProxySQL server and is vulnerable to public-key substitution +by an active network attacker. Use TLS when server identity and transport +integrity are required. + +## Configuration + +The following MySQL variables are available in ProxySQL 3.1 and later: + +| Variable | Default | Description | +| --- | --- | --- | +| `mysql-caching_sha2_password_auto_generate_rsa_keys` | `true` | Generate a 2048-bit RSA pair when both configured files are absent. | +| `mysql-caching_sha2_password_private_key_path` | `proxysql-caching-sha2-private-key.pem` | Private-key path. A relative path is resolved below ProxySQL's data directory. | +| `mysql-caching_sha2_password_public_key_path` | `proxysql-caching-sha2-public-key.pem` | Public-key path. A relative path is resolved below ProxySQL's data directory. | + +Apply changes with: + +```sql +LOAD MYSQL VARIABLES TO RUNTIME; +``` + +The three variables form one configuration unit. ProxySQL validates or +generates the complete pair before publishing it to frontend sessions. If a +reload fails, all three runtime values and the previously loaded key snapshot +remain unchanged. + +Relative paths must stay beneath ProxySQL's data directory. Empty, `.` and +`..` components are rejected, and every parent directory is opened without +following symbolic links. Absolute paths are allowed when keys are managed in +another operator-controlled directory. + +## Key formats and permissions + +The private key must be an unencrypted PKCS#8 PEM RSA private key (the PEM +header is `BEGIN PRIVATE KEY`). Traditional PKCS#1 (`BEGIN RSA PRIVATE KEY`) +and encrypted private keys are rejected. The public key must be a PEM +SubjectPublicKeyInfo public key. The two files must contain a structurally +valid matching RSA pair of at least 2048 bits. + +The private file must be a regular file and must not grant any group or other +permissions. Generated files use these modes: + +- private key: `0600` +- public key: `0644` + +Encrypted private keys are not supported because ProxySQL has no runtime +passphrase input for this feature. + +If the compiled default pair is unusable during initial runtime loading and +cannot be regenerated safely, ProxySQL records an explicit TLS-only state +(automatic generation off and both paths empty). TLS authentication remains +available, while RSA public-key authentication stays disabled until a valid +pair is loaded. + +Automatic generation occurs only when both paths are absent. If exactly one +file exists, ProxySQL reports a configuration error and does not overwrite or +replace either path. Generation uses temporary files and no-overwrite +publication so concurrent ProxySQL processes cannot publish a mixed pair. + +## Reload and cluster behavior + +Each authentication exchange retains the same immutable key snapshot from the +public-key response through RSA decryption. A concurrent +`LOAD MYSQL VARIABLES TO RUNTIME` can therefore rotate keys without breaking +an exchange already in progress. + +Cluster synchronization transfers the variable values, not private-key +contents. Every ProxySQL node must be able to read its configured local pair, +or generate its own pair when automatic generation is enabled. Do not store +private-key contents in the ProxySQL configuration database. + +## Client behavior and failures + +The client must use `caching_sha2_password`, disable TLS only when intended, +and enable its server-public-key request option. For Oracle's MySQL CLI: + +```bash +mysql --default-auth=caching_sha2_password \ + --ssl-mode=DISABLED --get-server-public-key \ + --host=127.0.0.1 --port=6033 --user=app --password +``` + +ProxySQL implements the MySQL protocol's RSA OAEP exchange, including the +protocol-defined SHA-1 OAEP and MGF1 digests and password/scramble XOR step. +Malformed ciphertext, malformed plaintext, and an incorrect password all +produce the normal `1045` / `28000` access-denied response. If no valid RSA key +pair is available, the same error code and SQLSTATE are returned with a message +that identifies the unavailable RSA key exchange and suggests TLS or key +configuration. diff --git a/doc/internal/passthrough_authentication.md b/doc/internal/passthrough_authentication.md index a1c941ec0f..620e793331 100644 --- a/doc/internal/passthrough_authentication.md +++ b/doc/internal/passthrough_authentication.md @@ -279,7 +279,14 @@ Entry includes username, source IP, hostgroup probed, outcome. Useful for forens ### 7.5 RSA public key for non-TLS clients -MySQL's `caching_sha2_password` allows non-TLS clients to encrypt the cleartext password with the server's RSA public key. If we want to support non-TLS pass-through, ProxySQL needs to publish a public key (`caching_sha2_password_public_key_path`) and decrypt with the matching private key. Phase 1 ships without this; clients must use TLS. Phase 2 may add RSA support if there's demand. +ProxySQL 3.1 adds the frontend RSA public-key exchange for +`caching_sha2_password`; see +[`doc/caching_sha2_password_rsa.md`](../caching_sha2_password_rsa.md). This lets +frontend users complete full authentication without TLS. Pass-through keeps +its secure default (`mysql-passthrough_auth_require_tls=true`). If an operator +explicitly disables that gate, the same RSA exchange can supply the cleartext +credential used by the backend authentication probe; the public-key +substitution warning in the linked document applies. ## 8. The cache diff --git a/include/MySQL_Caching_Sha2_RSA.h b/include/MySQL_Caching_Sha2_RSA.h new file mode 100644 index 0000000000..6b00c0e751 --- /dev/null +++ b/include/MySQL_Caching_Sha2_RSA.h @@ -0,0 +1,57 @@ +#ifndef PROXYSQL_MYSQL_CACHING_SHA2_RSA_H +#define PROXYSQL_MYSQL_CACHING_SHA2_RSA_H + +#include +#include +#include + +#include + +struct CachingSha2RSAConfig { + bool auto_generate { true }; + std::string private_key_path; + std::string public_key_path; + std::string datadir; +}; + +class CachingSha2RSAKeySnapshot { +public: + const std::string& public_key_pem() const { return public_key_pem_; } + size_t ciphertext_size() const { return ciphertext_size_; } + +private: + friend class MySQL_Caching_Sha2_RSA; + std::shared_ptr private_key_; + std::string public_key_pem_; + std::string private_key_path_; + std::string public_key_path_; + size_t ciphertext_size_ { 0 }; +}; + +struct CachingSha2RSAReloadResult { + bool accepted { false }; + bool changed { false }; + bool available { false }; + std::string error; +}; + +class MySQL_Caching_Sha2_RSA { +public: + CachingSha2RSAReloadResult reload(const CachingSha2RSAConfig& config); + std::shared_ptr acquire() const; + bool decrypt_password( + const std::shared_ptr& snapshot, + const unsigned char* ciphertext, + size_t ciphertext_length, + const unsigned char* scramble, + size_t scramble_length, + std::string& password, + std::string* error = nullptr + ) const; + +private: + mutable std::mutex mutex_; + std::shared_ptr snapshot_; +}; + +#endif diff --git a/include/MySQL_Passthrough_Auth_Cache.h b/include/MySQL_Passthrough_Auth_Cache.h index 5257ad328a..6e9441210c 100644 --- a/include/MySQL_Passthrough_Auth_Cache.h +++ b/include/MySQL_Passthrough_Auth_Cache.h @@ -37,8 +37,9 @@ class MySQL_Passthrough_Auth_Cache { private: struct entry_t { std::string cleartext_password; - uint64_t learned_at_us; - int hostgroup_probed; + uint64_t learned_at_us { 0 }; + int hostgroup_probed { 0 }; + ~entry_t(); }; mutable pthread_rwlock_t lock; std::unordered_map entries; @@ -109,7 +110,7 @@ class MySQL_Passthrough_Auth_Cache { bool lookup(const std::string& username, std::string& out_cleartext, uint32_t ttl_s); // Insert or replace a cached credential. - void insert(const std::string& username, const std::string& cleartext, int hostgroup_probed); + void insert(const std::string& username, const char* cleartext, int hostgroup_probed); // Evict a single entry. Returns true if the entry was present. bool evict(const std::string& username); diff --git a/include/MySQL_Protocol.h b/include/MySQL_Protocol.h index c6b2e3e2f6..03ba7142ef 100644 --- a/include/MySQL_Protocol.h +++ b/include/MySQL_Protocol.h @@ -6,6 +6,17 @@ #include "MySQL_Variables.h" #include "MySQL_Prepared_Stmt_info.h" +#ifdef PROXYSQL31 +#include + +class CachingSha2RSAKeySnapshot; + +enum class MySQLFrontendAuthError : uint8_t { + NONE = 0, + CACHING_SHA2_RSA_UNAVAILABLE +}; +#endif + #define RESULTSET_BUFLEN 16300 extern MySQL_Variables mysql_variables; @@ -112,6 +123,9 @@ class MyProt_tmp_auth_vars { uint8_t zstd_compression_level = 0; bool use_ssl = false; bool use_zstd_compression = false; +#ifdef PROXYSQL31 + bool pass_is_sensitive = false; +#endif enum proxysql_session_type session_type; }; @@ -141,6 +155,10 @@ class MySQL_Protocol { enum proxysql_auth_plugins auth_plugin_id; uint16_t prot_status; bool more_data_needed; +#ifdef PROXYSQL31 + std::shared_ptr caching_sha2_rsa_snapshot_; + MySQLFrontendAuthError frontend_auth_error_ { MySQLFrontendAuthError::NONE }; +#endif MySQL_Data_Stream *get_myds() { return *myds; } MySQL_Protocol() : userinfo(nullptr), sess(nullptr), myds(nullptr), current_PreStmt(nullptr) @@ -214,6 +232,10 @@ class MySQL_Protocol { bool PPHR_verify_password_2(MyProt_tmp_auth_vars& vars1, account_details_t& account_details); void generate_one_byte_pkt(unsigned char b); +#ifdef PROXYSQL31 + void generate_auth_more_data(const unsigned char *data, size_t data_len); + MySQLFrontendAuthError consume_frontend_auth_error(); +#endif bool process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned int len); void * Query_String_to_packet(uint8_t sid, std::string *s, unsigned int *l); diff --git a/include/MySQL_Thread.h b/include/MySQL_Thread.h index 47b6de5222..01d816c7f8 100644 --- a/include/MySQL_Thread.h +++ b/include/MySQL_Thread.h @@ -13,6 +13,8 @@ #include #endif // IDLE_THREADS #include +#include +#include #include "prometheus_helpers.h" @@ -38,6 +40,14 @@ extern class MySQL_Variables mysql_variables; +#ifdef PROXYSQL31 +class MySQL_Caching_Sha2_RSA; +#endif + +struct MySQLThreadsCommitResult { + unsigned int rejected_variables { 0 }; +}; + #ifdef IDLE_THREADS typedef struct __attribute__((aligned(64))) _conn_exchange_t { pthread_mutex_t mutex_idles; @@ -427,6 +437,13 @@ class MySQL_Threads_Handler // variable address // special variable : if true, further input validation is required std::unordered_map> VariablesPointers_bool; +#ifdef PROXYSQL31 + std::unique_ptr caching_sha2_rsa_manager_; + bool caching_sha2_rsa_config_initialized_ { false }; + bool caching_sha2_rsa_accepted_auto_generate_ { true }; + std::string caching_sha2_rsa_accepted_private_path_; + std::string caching_sha2_rsa_accepted_public_path_; +#endif /** * @brief Holds the clients host cache. It keeps track of the number of * errors associated to a specific client: @@ -521,6 +538,11 @@ class MySQL_Threads_Handler int select_version_forwarding; char *keep_multiplexing_variables; char *default_authentication_plugin; +#ifdef PROXYSQL31 + bool caching_sha2_password_auto_generate_rsa_keys; + char *caching_sha2_password_private_key_path; + char *caching_sha2_password_public_key_path; +#endif char *proxy_protocol_networks; //unsigned int default_charset; // removed in 2.0.13 . Obsoleted previously using MySQL_Variables instead int handle_unknown_charset; @@ -790,11 +812,14 @@ class MySQL_Threads_Handler unsigned int get_global_version(); void wrlock(); void wrunlock(); - void commit(); + MySQLThreadsCommitResult commit(); char *get_variable(char *name); bool set_variable(char *name, const char *value); char **get_variables_list(); bool has_variable(const char * name); +#ifdef PROXYSQL31 + MySQL_Caching_Sha2_RSA* caching_sha2_rsa() const { return caching_sha2_rsa_manager_.get(); } +#endif MySQL_Threads_Handler(); ~MySQL_Threads_Handler(); diff --git a/include/mysql_connection.h b/include/mysql_connection.h index 0e34da620a..8b8bd7312d 100644 --- a/include/mysql_connection.h +++ b/include/mysql_connection.h @@ -55,6 +55,7 @@ class MySQL_Connection_userinfo { char *fe_username; MySQL_Connection_userinfo(); ~MySQL_Connection_userinfo(); + void clear_password(); void set(char *, char *, char *, char *); void set(MySQL_Connection_userinfo *); bool set_schemaname(char *, int); diff --git a/lib/Admin_FlushVariables.cpp b/lib/Admin_FlushVariables.cpp index 84db8e5e87..ca12901952 100644 --- a/lib/Admin_FlushVariables.cpp +++ b/lib/Admin_FlushVariables.cpp @@ -574,7 +574,11 @@ FlushVariableStats ProxySQL_Admin::flush_mysql_variables___database_to_runtime(S free(default_collation_connection); free(previous_default_charset); free(previous_default_collation_connection); - GloMTH->commit(); + const MySQLThreadsCommitResult commit_result = GloMTH->commit(); + if (commit_result.rejected_variables != 0) { + stats.updated = std::max(0, stats.updated - static_cast(commit_result.rejected_variables)); + stats.rejected += static_cast(commit_result.rejected_variables); + } GloMTH->wrunlock(); { diff --git a/lib/Makefile b/lib/Makefile index ff5e0f7c91..f9b3154434 100644 --- a/lib/Makefile +++ b/lib/Makefile @@ -121,6 +121,10 @@ _OBJ_CXX := ProxySQL_GloVars.oo network.oo debug.oo configfile.oo Query_Cache.oo Query_Processor_ParserSQL.oo \ proxy_sqlite3_symbols.oo +ifeq ($(PROXYSQL31),1) +_OBJ_CXX += MySQL_Caching_Sha2_RSA.oo +endif + # TSDB object files ifeq ($(PROXYSQLTSDB),1) _OBJ_CXX += TSDB_Dashboard_html.oo diff --git a/lib/MySQL_Authentication.cpp b/lib/MySQL_Authentication.cpp index 104e7af945..d568ea96fd 100644 --- a/lib/MySQL_Authentication.cpp +++ b/lib/MySQL_Authentication.cpp @@ -9,28 +9,33 @@ #include "MySQL_Authentication.hpp" +#include + #ifndef SPOOKYV2 #include "SpookyV2.h" #define SPOOKYV2 #endif -void free_account_details(account_details_t& ad) { - if (ad.password) { - free(ad.password); - ad.password = nullptr; +namespace { + +void cleanse_and_free_password(char*& password) { + if (password != nullptr) { + OPENSSL_cleanse(password, strlen(password)); + free(password); + password = nullptr; } +} + +} // namespace + +void free_account_details(account_details_t& ad) { + cleanse_and_free_password(ad.password); if (ad.sha1_pass) { free(ad.sha1_pass); ad.sha1_pass=NULL; } - if (ad.clear_text_password[PASSWORD_TYPE::PRIMARY]) { - free(ad.clear_text_password[PASSWORD_TYPE::PRIMARY]); - ad.clear_text_password[PASSWORD_TYPE::PRIMARY] = nullptr; - } - if (ad.clear_text_password[PASSWORD_TYPE::ADDITIONAL]) { - free(ad.clear_text_password[PASSWORD_TYPE::ADDITIONAL]); - ad.clear_text_password[PASSWORD_TYPE::ADDITIONAL] = nullptr; - } + cleanse_and_free_password(ad.clear_text_password[PASSWORD_TYPE::PRIMARY]); + cleanse_and_free_password(ad.clear_text_password[PASSWORD_TYPE::ADDITIONAL]); if (ad.default_schema) { free(ad.default_schema); ad.default_schema = nullptr; @@ -161,20 +166,14 @@ bool MySQL_Authentication::add(char * username, char * password, enum cred_usern ad=lookup->second; if (strcmp(ad->password,password)) { // the password has changed - free(ad->password); + cleanse_and_free_password(ad->password); ad->password=strdup(password); if (ad->sha1_pass) { free(ad->sha1_pass); ad->sha1_pass=NULL; } - if (ad->clear_text_password[0]) { - free(ad->clear_text_password[0]); - ad->clear_text_password[0]=NULL; - } - if (ad->clear_text_password[1]) { - free(ad->clear_text_password[1]); - ad->clear_text_password[1]=NULL; - } + cleanse_and_free_password(ad->clear_text_password[0]); + cleanse_and_free_password(ad->clear_text_password[1]); // FIXME: if the password is a clear text password, automatically generate sha1_pass and clear_text_password } if (strcmp(ad->default_schema,default_schema)) { @@ -519,10 +518,10 @@ bool MySQL_Authentication::del(char * username, enum cred_username_type usertype cg.cred_array->remove_fast(ad); cg.bt_map.erase(lookup); free(ad->username); - free(ad->password); + cleanse_and_free_password(ad->password); if (ad->sha1_pass) { free(ad->sha1_pass); ad->sha1_pass=NULL; } - if (ad->clear_text_password[0]) { free(ad->clear_text_password[0]); ad->clear_text_password[0]=NULL; } - if (ad->clear_text_password[1]) { free(ad->clear_text_password[1]); ad->clear_text_password[1]=NULL; } + cleanse_and_free_password(ad->clear_text_password[0]); + cleanse_and_free_password(ad->clear_text_password[1]); free(ad->default_schema); free(ad->attributes); free(ad->comment); @@ -596,18 +595,12 @@ bool MySQL_Authentication::set_clear_text_password( if (lookup != cg.bt_map.end()) { account_details_t *ad=lookup->second; if (passtype == PASSWORD_TYPE::PRIMARY) { - if (ad->clear_text_password[0]) { - free(ad->clear_text_password[0]); - ad->clear_text_password[0]=NULL; - } + cleanse_and_free_password(ad->clear_text_password[0]); if (clear_text_password) { ad->clear_text_password[0] = strdup(clear_text_password); } } else { - if (ad->clear_text_password[1]) { - free(ad->clear_text_password[1]); - ad->clear_text_password[1]=NULL; - } + cleanse_and_free_password(ad->clear_text_password[1]); if (clear_text_password) { ad->clear_text_password[1] = strdup(clear_text_password); } @@ -724,16 +717,10 @@ bool MySQL_Authentication::_reset(enum cred_username_type usertype) { account_details_t *ad=lookup->second; cg.bt_map.erase(lookup); free(ad->username); - free(ad->password); + cleanse_and_free_password(ad->password); if (ad->sha1_pass) { free(ad->sha1_pass); ad->sha1_pass=NULL; } - if (ad->clear_text_password[0]) { - free(ad->clear_text_password[0]); - ad->clear_text_password[0] = NULL; - } - if (ad->clear_text_password[1]) { - free(ad->clear_text_password[1]); - ad->clear_text_password[1] = NULL; - } + cleanse_and_free_password(ad->clear_text_password[0]); + cleanse_and_free_password(ad->clear_text_password[1]); free(ad->default_schema); free(ad->comment); free(ad->attributes); diff --git a/lib/MySQL_Caching_Sha2_RSA.cpp b/lib/MySQL_Caching_Sha2_RSA.cpp new file mode 100644 index 0000000000..633e71ff68 --- /dev/null +++ b/lib/MySQL_Caching_Sha2_RSA.cpp @@ -0,0 +1,879 @@ +#include "MySQL_Caching_Sha2_RSA.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr int kMinimumRSAKeyBits = 2048; +constexpr size_t kMaximumPEMFileSize = 1024 * 1024; + +class ScopedFd { +public: + explicit ScopedFd(int fd = -1) : fd_(fd) {} + ~ScopedFd() { if (fd_ >= 0) close(fd_); } + ScopedFd(const ScopedFd&) = delete; + ScopedFd& operator=(const ScopedFd&) = delete; + ScopedFd(ScopedFd&& other) noexcept : fd_(other.release()) {} + ScopedFd& operator=(ScopedFd&& other) noexcept { + if (this != &other) { + if (fd_ >= 0) close(fd_); + fd_ = other.release(); + } + return *this; + } + int get() const { return fd_; } + int release() { const int fd = fd_; fd_ = -1; return fd; } + +private: + int fd_; +}; + +class ScopedStringCleanser { +public: + explicit ScopedStringCleanser(std::string& value) : value_(value) {} + ~ScopedStringCleanser() { + if (!value_.empty()) { + OPENSSL_cleanse(value_.data(), value_.size()); + } + } + ScopedStringCleanser(const ScopedStringCleanser&) = delete; + ScopedStringCleanser& operator=(const ScopedStringCleanser&) = delete; + +private: + std::string& value_; +}; + +std::string errno_message(const std::string& operation, const std::string& path) { + return operation + " '" + path + "': " + std::strerror(errno); +} + +std::string parent_directory(const std::string& path) { + const std::string::size_type slash = path.rfind('/'); + if (slash == std::string::npos) { + return "."; + } + if (slash == 0) { + return "/"; + } + return path.substr(0, slash); +} + +struct ResolvedKeyPath { + std::string display_path; + std::string leaf; + ScopedFd parent_fd; +}; + +int directory_open_flags(bool reject_symlink) { + int flags = O_RDONLY; +#ifdef O_DIRECTORY + flags |= O_DIRECTORY; +#endif +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif +#ifdef O_NOFOLLOW + if (reject_symlink) { + flags |= O_NOFOLLOW; + } +#endif + return flags; +} + +bool valid_relative_component(const std::string& component) { + return !component.empty() && component != "." && component != ".."; +} + +bool resolve_key_path( + const std::string& path, + const std::string& datadir, + ResolvedKeyPath& resolved, + std::string& error +) { + if (path.empty()) { + error = "RSA key path is empty"; + return false; + } + + if (path.front() == '/') { + resolved.display_path = path; + resolved.leaf = path.substr(path.rfind('/') + 1); + if (resolved.leaf.empty()) { + error = "RSA key path '" + path + "' does not name a file"; + return false; + } + const std::string directory = parent_directory(path); + resolved.parent_fd = ScopedFd(open(directory.c_str(), directory_open_flags(false))); + if (resolved.parent_fd.get() < 0) { + error = errno_message("cannot open key directory", directory); + return false; + } + return true; + } + + if (datadir.empty()) { + error = "relative RSA key path '" + path + "' requires a data directory"; + return false; + } + resolved.display_path = datadir.back() == '/' ? datadir + path : datadir + "/" + path; + ScopedFd current(open(datadir.c_str(), directory_open_flags(false))); + if (current.get() < 0) { + error = errno_message("cannot open ProxySQL data directory", datadir); + return false; + } + + size_t offset = 0; + while (offset < path.size()) { + const size_t slash = path.find('/', offset); + const bool final_component = slash == std::string::npos; + const std::string component = path.substr( + offset, final_component ? std::string::npos : slash - offset + ); + if (!valid_relative_component(component)) { + error = "relative RSA key path '" + path + + "' contains an empty, current-directory, or parent-directory component"; + return false; + } + if (final_component) { + resolved.leaf = component; + resolved.parent_fd = std::move(current); + return true; + } + + const int child_fd = openat( + current.get(), component.c_str(), directory_open_flags(true) + ); + if (child_fd < 0) { + error = errno_message( + "cannot securely open relative key-directory component", component + ); + return false; + } + current = ScopedFd(child_fd); + offset = slash + 1; + } + + error = "RSA key path '" + path + "' does not name a file"; + return false; +} + +bool fsync_parent_directory(const ResolvedKeyPath& path, std::string& error) { + if (fsync(path.parent_fd.get()) != 0) { + error = errno_message("cannot sync key directory for", path.display_path); + return false; + } + return true; +} + +bool path_exists(const ResolvedKeyPath& path, bool& exists, std::string& error) { + struct stat st {}; + if (fstatat(path.parent_fd.get(), path.leaf.c_str(), &st, AT_SYMLINK_NOFOLLOW) == 0) { + exists = true; + return true; + } + if (errno == ENOENT) { + exists = false; + return true; + } + error = errno_message("cannot inspect key file", path.display_path); + return false; +} + +bool validate_open_file(int fd, const std::string& path, bool private_key, std::string& error) { + struct stat st {}; + if (fstat(fd, &st) != 0) { + error = errno_message("cannot inspect opened key file", path); + return false; + } + if (!S_ISREG(st.st_mode)) { + error = "key file '" + path + "' is not a regular file"; + return false; + } + if (private_key && (st.st_mode & (S_IRWXG | S_IRWXO)) != 0) { + error = "private key file '" + path + "' must not grant group or other permissions"; + return false; + } + return true; +} + +bool validate_key_path(const ResolvedKeyPath& path, std::string& error) { + struct stat st {}; + if (fstatat(path.parent_fd.get(), path.leaf.c_str(), &st, AT_SYMLINK_NOFOLLOW) != 0) { + error = errno_message("cannot inspect key file", path.display_path); + return false; + } + if (!S_ISREG(st.st_mode)) { + error = "key file '" + path.display_path + "' is not a regular file"; + return false; + } + return true; +} + +int open_key_file(const ResolvedKeyPath& path) { + int flags = O_RDONLY; +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif +#ifdef O_NONBLOCK + flags |= O_NONBLOCK; +#endif + return openat(path.parent_fd.get(), path.leaf.c_str(), flags); +} + +int reject_password_callback(char*, int, int, void*) { + return 0; +} + +bool read_key_file_content( + int fd, + const std::string& path, + std::string& content, + std::string& error +) { + char buffer[4096]; + for (;;) { + const ssize_t count = read(fd, buffer, sizeof(buffer)); + if (count == 0) { + return true; + } + if (count < 0 && errno == EINTR) { + continue; + } + if (count < 0) { + error = errno_message("cannot read RSA key file", path); + return false; + } + if (content.size() + static_cast(count) > kMaximumPEMFileSize) { + error = "RSA key file '" + path + "' exceeds the 1 MiB safety limit"; + return false; + } + content.append(buffer, static_cast(count)); + } +} + +bool is_pem_whitespace(char value) { + return value == ' ' || value == '\t' || value == '\r' || value == '\n' || + value == '\f' || value == '\v'; +} + +bool has_single_pem_envelope( + const std::string& content, + const std::string& begin_marker, + const std::string& end_marker +) { + size_t begin = 0; + while (begin < content.size() && is_pem_whitespace(content[begin])) { + ++begin; + } + if (content.compare(begin, begin_marker.size(), begin_marker) != 0 || + content.find(begin_marker, begin + begin_marker.size()) != std::string::npos) { + return false; + } + const size_t end = content.find(end_marker, begin + begin_marker.size()); + if (end == std::string::npos || + content.find(end_marker, end + end_marker.size()) != std::string::npos) { + return false; + } + for (size_t index = end + end_marker.size(); index < content.size(); ++index) { + if (!is_pem_whitespace(content[index])) { + return false; + } + } + return true; +} + +bool public_pem(EVP_PKEY* key, std::string& pem, std::string& error) { + BIO* raw_bio = BIO_new(BIO_s_mem()); + if (raw_bio == nullptr) { + error = "cannot allocate public-key serialization buffer"; + return false; + } + std::unique_ptr bio(raw_bio, BIO_free); + if (PEM_write_bio_PUBKEY(bio.get(), key) != 1) { + error = "cannot serialize RSA public key"; + return false; + } + BUF_MEM* memory = nullptr; + BIO_get_mem_ptr(bio.get(), &memory); + if (memory == nullptr || memory->data == nullptr || memory->length == 0) { + error = "serialized RSA public key is empty"; + return false; + } + pem.assign(memory->data, memory->length); + return true; +} + +bool private_pem(EVP_PKEY* key, std::string& pem, std::string& error) { + BIO* raw_bio = BIO_new(BIO_s_mem()); + if (raw_bio == nullptr) { + error = "cannot allocate private-key serialization buffer"; + return false; + } + std::unique_ptr bio(raw_bio, BIO_free); + if (PEM_write_bio_PKCS8PrivateKey( + bio.get(), key, nullptr, nullptr, 0, nullptr, nullptr + ) != 1) { + error = "cannot serialize RSA private key"; + return false; + } + BUF_MEM* memory = nullptr; + BIO_get_mem_ptr(bio.get(), &memory); + if (memory == nullptr || memory->data == nullptr || memory->length == 0) { + error = "serialized RSA private key is empty"; + return false; + } + pem.assign(memory->data, memory->length); + return true; +} + +bool validate_rsa_key( + EVP_PKEY* key, + const std::string& path, + bool private_key, + std::string& error +) { + if (EVP_PKEY_base_id(key) != EVP_PKEY_RSA) { + error = "key file '" + path + "' does not contain an RSA key"; + return false; + } + if (EVP_PKEY_bits(key) < kMinimumRSAKeyBits) { + error = "RSA key file '" + path + "' is weaker than 2048 bits"; + return false; + } + EVP_PKEY_CTX* raw_context = EVP_PKEY_CTX_new(key, nullptr); + if (raw_context == nullptr) { + error = "cannot allocate validation context for RSA key file '" + path + "'"; + return false; + } + std::unique_ptr context( + raw_context, EVP_PKEY_CTX_free + ); + const bool valid = private_key ? + EVP_PKEY_private_check(context.get()) > 0 && + EVP_PKEY_pairwise_check(context.get()) > 0 : + EVP_PKEY_public_check(context.get()) > 0; + if (!valid) { + error = "RSA key file '" + path + "' failed structural validation"; + return false; + } + return true; +} + +bool load_private_key( + const ResolvedKeyPath& path, + std::shared_ptr& key, + std::string& error +) { + if (!validate_key_path(path, error)) { + return false; + } + ScopedFd fd(open_key_file(path)); + if (fd.get() < 0) { + error = errno_message("cannot open private key", path.display_path); + return false; + } + if (!validate_open_file(fd.get(), path.display_path, true, error)) { + return false; + } + std::string content; + ScopedStringCleanser content_cleanser(content); + if (!read_key_file_content(fd.get(), path.display_path, content, error) || + !has_single_pem_envelope( + content, "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----" + )) { + if (error.empty()) { + error = "private key '" + path.display_path + + "' must contain exactly one unencrypted PKCS#8 PEM object"; + } + return false; + } + BIO* raw_bio = BIO_new_mem_buf(content.data(), static_cast(content.size())); + if (raw_bio == nullptr) { + error = "cannot allocate reader for private key '" + path.display_path + "'"; + return false; + } + std::unique_ptr bio(raw_bio, BIO_free); + PKCS8_PRIV_KEY_INFO* raw_key_info = PEM_read_bio_PKCS8_PRIV_KEY_INFO( + bio.get(), nullptr, reject_password_callback, nullptr + ); + if (raw_key_info == nullptr) { + error = "private key '" + path.display_path + + "' is malformed, encrypted, or not unencrypted PKCS#8 PEM"; + return false; + } + std::unique_ptr key_info( + raw_key_info, PKCS8_PRIV_KEY_INFO_free + ); + EVP_PKEY* raw_key = EVP_PKCS82PKEY(key_info.get()); + if (raw_key == nullptr) { + error = "cannot decode PKCS#8 private key '" + path.display_path + "'"; + return false; + } + key = std::shared_ptr(raw_key, EVP_PKEY_free); + return validate_rsa_key(key.get(), path.display_path, true, error); +} + +bool load_public_key( + const ResolvedKeyPath& path, + std::shared_ptr& key, + std::string& error +) { + if (!validate_key_path(path, error)) { + return false; + } + ScopedFd fd(open_key_file(path)); + if (fd.get() < 0) { + error = errno_message("cannot open public key", path.display_path); + return false; + } + if (!validate_open_file(fd.get(), path.display_path, false, error)) { + return false; + } + std::string content; + if (!read_key_file_content(fd.get(), path.display_path, content, error) || + !has_single_pem_envelope( + content, "-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----" + )) { + if (error.empty()) { + error = "public key '" + path.display_path + + "' must contain exactly one SPKI PEM object"; + } + return false; + } + BIO* raw_bio = BIO_new_mem_buf(content.data(), static_cast(content.size())); + if (raw_bio == nullptr) { + error = "cannot allocate reader for public key '" + path.display_path + "'"; + return false; + } + std::unique_ptr bio(raw_bio, BIO_free); + EVP_PKEY* raw_key = PEM_read_bio_PUBKEY(bio.get(), nullptr, nullptr, nullptr); + if (raw_key == nullptr) { + error = "public key '" + path.display_path + "' is malformed or not PKIX PEM"; + return false; + } + key = std::shared_ptr(raw_key, EVP_PKEY_free); + return validate_rsa_key(key.get(), path.display_path, false, error); +} + +struct LoadedKeyPair { + std::shared_ptr private_key; + std::string public_key_pem; + std::string private_key_path; + std::string public_key_path; + size_t ciphertext_size { 0 }; +}; + +bool load_key_pair( + const ResolvedKeyPath& private_path, + const ResolvedKeyPath& public_path, + LoadedKeyPair& loaded, + std::string& error +) { + std::shared_ptr private_key; + std::shared_ptr public_key; + if (!load_private_key(private_path, private_key, error) || + !load_public_key(public_path, public_key, error)) { + return false; + } + + std::string private_public_pem; + std::string supplied_public_pem; + if (!public_pem(private_key.get(), private_public_pem, error) || + !public_pem(public_key.get(), supplied_public_pem, error)) { + return false; + } + if (private_public_pem != supplied_public_pem) { + error = "RSA private and public key files do not form a matching pair"; + return false; + } + + loaded.private_key = std::move(private_key); + loaded.public_key_pem = std::move(private_public_pem); + loaded.private_key_path = private_path.display_path; + loaded.public_key_path = public_path.display_path; + loaded.ciphertext_size = static_cast(EVP_PKEY_size(loaded.private_key.get())); + return true; +} + +bool generate_rsa_key(std::shared_ptr& key, std::string& error) { + EVP_PKEY_CTX* raw_context = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr); + if (raw_context == nullptr) { + error = "cannot allocate RSA key-generation context"; + return false; + } + std::unique_ptr context( + raw_context, EVP_PKEY_CTX_free + ); + if (EVP_PKEY_keygen_init(context.get()) <= 0 || + EVP_PKEY_CTX_set_rsa_keygen_bits(context.get(), kMinimumRSAKeyBits) <= 0) { + error = "cannot initialize RSA-2048 key generation"; + return false; + } + EVP_PKEY* raw_key = nullptr; + if (EVP_PKEY_keygen(context.get(), &raw_key) <= 0 || raw_key == nullptr) { + error = "cannot generate RSA-2048 key"; + return false; + } + key = std::shared_ptr(raw_key, EVP_PKEY_free); + return true; +} + +bool write_all(int fd, const std::string& content, const std::string& path, std::string& error) { + size_t written = 0; + while (written < content.size()) { + const ssize_t rc = write(fd, content.data() + written, content.size() - written); + if (rc < 0 && errno == EINTR) { + continue; + } + if (rc <= 0) { + error = errno_message("cannot write temporary key file", path); + return false; + } + written += static_cast(rc); + } + if (fsync(fd) != 0) { + error = errno_message("cannot sync temporary key file", path); + return false; + } + return true; +} + +bool create_temporary_key_file( + const ResolvedKeyPath& final_path, + mode_t mode, + const std::string& content, + std::string& temporary_leaf, + std::string& error +) { + static std::atomic sequence { 0 }; + for (unsigned int attempt = 0; attempt < 100; ++attempt) { + temporary_leaf = final_path.leaf + ".tmp." + std::to_string(getpid()) + "." + + std::to_string(sequence.fetch_add(1, std::memory_order_relaxed)); + int flags = O_WRONLY | O_CREAT | O_EXCL; +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif + ScopedFd fd(openat(final_path.parent_fd.get(), temporary_leaf.c_str(), flags, mode)); + if (fd.get() < 0) { + if (errno == EEXIST) { + continue; + } + error = errno_message("cannot create temporary key file beside", final_path.display_path); + return false; + } + if (fchmod(fd.get(), mode) != 0) { + error = errno_message("cannot set temporary key-file permissions beside", final_path.display_path); + unlinkat(final_path.parent_fd.get(), temporary_leaf.c_str(), 0); + return false; + } + if (!write_all(fd.get(), content, final_path.display_path, error)) { + unlinkat(final_path.parent_fd.get(), temporary_leaf.c_str(), 0); + return false; + } + return true; + } + error = "cannot allocate a unique temporary file beside '" + final_path.display_path + "'"; + return false; +} + +void unlink_if_same_file(const ResolvedKeyPath& path, const std::string& temporary_leaf) { + struct stat published {}; + struct stat temporary {}; + if (fstatat(path.parent_fd.get(), path.leaf.c_str(), &published, AT_SYMLINK_NOFOLLOW) == 0 && + fstatat(path.parent_fd.get(), temporary_leaf.c_str(), &temporary, AT_SYMLINK_NOFOLLOW) == 0 && + published.st_dev == temporary.st_dev && published.st_ino == temporary.st_ino) { + unlinkat(path.parent_fd.get(), path.leaf.c_str(), 0); + } +} + +bool publish_generated_pair( + const ResolvedKeyPath& private_path, + const ResolvedKeyPath& public_path, + const std::string& private_content, + const std::string& public_content, + std::string& error +) { + std::string private_temp_leaf; + std::string public_temp_leaf; + if (!create_temporary_key_file(private_path, 0600, private_content, private_temp_leaf, error)) { + return false; + } + if (!create_temporary_key_file(public_path, 0644, public_content, public_temp_leaf, error)) { + unlinkat(private_path.parent_fd.get(), private_temp_leaf.c_str(), 0); + return false; + } + + bool success = false; + if (linkat( + private_path.parent_fd.get(), private_temp_leaf.c_str(), + private_path.parent_fd.get(), private_path.leaf.c_str(), 0 + ) != 0) { + error = errno_message("cannot publish private key without overwriting", private_path.display_path); + } else if (linkat( + public_path.parent_fd.get(), public_temp_leaf.c_str(), + public_path.parent_fd.get(), public_path.leaf.c_str(), 0 + ) != 0) { + error = errno_message("cannot publish public key without overwriting", public_path.display_path); + unlink_if_same_file(private_path, private_temp_leaf); + } else if (!fsync_parent_directory(private_path, error) || + !fsync_parent_directory(public_path, error)) { + // The pair is valid and published even if directory fsync failed. Report the + // durability failure so the caller keeps the previous in-memory snapshot. + } else { + success = true; + } + + unlinkat(private_path.parent_fd.get(), private_temp_leaf.c_str(), 0); + unlinkat(public_path.parent_fd.get(), public_temp_leaf.c_str(), 0); + return success; +} + +bool generate_pair( + const ResolvedKeyPath& private_path, + const ResolvedKeyPath& public_path, + std::string& error +) { + const std::string lock_leaf = private_path.leaf + ".lock"; + struct stat private_parent {}; + struct stat public_parent {}; + if (fstat(private_path.parent_fd.get(), &private_parent) != 0 || + fstat(public_path.parent_fd.get(), &public_parent) != 0) { + error = "cannot inspect RSA key parent directories before generation"; + return false; + } + const bool same_parent = private_parent.st_dev == public_parent.st_dev && + private_parent.st_ino == public_parent.st_ino; + const std::string private_temp_prefix = private_path.leaf + ".tmp."; + const std::string public_temp_prefix = public_path.leaf + ".tmp."; + if (same_parent && ( + private_path.leaf == public_path.leaf || + public_path.leaf == lock_leaf || + private_path.leaf.rfind(public_temp_prefix, 0) == 0 || + public_path.leaf.rfind(private_temp_prefix, 0) == 0 + )) { + error = "RSA key targets collide with the generation lock or temporary-file namespace"; + return false; + } + int lock_flags = O_RDWR | O_CREAT; +#ifdef O_CLOEXEC + lock_flags |= O_CLOEXEC; +#endif +#ifdef O_NOFOLLOW + lock_flags |= O_NOFOLLOW; +#endif + ScopedFd lock_fd(openat(private_path.parent_fd.get(), lock_leaf.c_str(), lock_flags, 0600)); + if (lock_fd.get() < 0) { + error = errno_message("cannot open RSA key-generation lock beside", private_path.display_path); + return false; + } + if (flock(lock_fd.get(), LOCK_EX) != 0) { + error = errno_message("cannot lock RSA key generation beside", private_path.display_path); + return false; + } + + bool private_exists = false; + bool public_exists = false; + if (!path_exists(private_path, private_exists, error) || + !path_exists(public_path, public_exists, error)) { + return false; + } + if (private_exists || public_exists) { + if (private_exists && public_exists) { + return true; + } + error = "only one RSA key file exists; refusing to generate or overwrite a partial pair"; + return false; + } + + std::shared_ptr key; + if (!generate_rsa_key(key, error)) { + return false; + } + std::string private_content; + std::string public_content; + if (!private_pem(key.get(), private_content, error) || + !public_pem(key.get(), public_content, error)) { + if (!private_content.empty()) { + OPENSSL_cleanse(private_content.data(), private_content.size()); + } + return false; + } + const bool published = publish_generated_pair( + private_path, public_path, private_content, public_content, error + ); + OPENSSL_cleanse(private_content.data(), private_content.size()); + return published; +} + +CachingSha2RSAReloadResult rejected_result( + const std::string& error, + const std::shared_ptr& current +) { + return { false, false, current != nullptr, error }; +} + +} // namespace + +CachingSha2RSAReloadResult MySQL_Caching_Sha2_RSA::reload( + const CachingSha2RSAConfig& config +) { + if (config.private_key_path.empty() != config.public_key_path.empty()) { + const auto current = acquire(); + return rejected_result("both RSA private and public key paths must be configured together", current); + } + if (config.private_key_path.empty()) { + if (config.auto_generate) { + const auto current = acquire(); + return rejected_result("automatic RSA key generation requires non-empty key paths", current); + } + std::lock_guard guard(mutex_); + const bool changed = snapshot_ != nullptr; + snapshot_.reset(); + return { true, changed, false, {} }; + } + + ResolvedKeyPath private_path; + ResolvedKeyPath public_path; + std::string error; + if (!resolve_key_path(config.private_key_path, config.datadir, private_path, error) || + !resolve_key_path(config.public_key_path, config.datadir, public_path, error)) { + return rejected_result(error, acquire()); + } + bool private_exists = false; + bool public_exists = false; + if (!path_exists(private_path, private_exists, error) || + !path_exists(public_path, public_exists, error)) { + return rejected_result(error, acquire()); + } + if (private_exists != public_exists) { + return rejected_result( + "only one RSA key file exists; refusing to load or generate a partial pair", + acquire() + ); + } + if (!private_exists) { + if (!config.auto_generate) { + return rejected_result("configured RSA key files do not exist", acquire()); + } + if (!generate_pair(private_path, public_path, error)) { + return rejected_result(error, acquire()); + } + } + + LoadedKeyPair loaded; + if (!load_key_pair(private_path, public_path, loaded, error)) { + return rejected_result(error, acquire()); + } + + std::lock_guard guard(mutex_); + if (snapshot_ != nullptr && + snapshot_->private_key_path_ == loaded.private_key_path && + snapshot_->public_key_path_ == loaded.public_key_path && + snapshot_->public_key_pem_ == loaded.public_key_pem) { + return { true, false, true, {} }; + } + auto candidate = std::make_shared(); + candidate->private_key_ = std::move(loaded.private_key); + candidate->public_key_pem_ = std::move(loaded.public_key_pem); + candidate->private_key_path_ = std::move(loaded.private_key_path); + candidate->public_key_path_ = std::move(loaded.public_key_path); + candidate->ciphertext_size_ = loaded.ciphertext_size; + snapshot_ = std::move(candidate); + return { true, true, true, {} }; +} + +std::shared_ptr MySQL_Caching_Sha2_RSA::acquire() const { + std::lock_guard guard(mutex_); + return snapshot_; +} + +bool MySQL_Caching_Sha2_RSA::decrypt_password( + const std::shared_ptr& snapshot, + const unsigned char* ciphertext, + size_t ciphertext_length, + const unsigned char* scramble, + size_t scramble_length, + std::string& password, + std::string* error +) const { + if (!password.empty()) { + OPENSSL_cleanse(password.data(), password.size()); + } + password.clear(); + auto fail = [error](const char* message) { + if (error != nullptr) { + *error = message; + } + return false; + }; + if (snapshot == nullptr || snapshot->private_key_ == nullptr) { + return fail("RSA key snapshot is unavailable"); + } + if (ciphertext == nullptr || ciphertext_length != snapshot->ciphertext_size_) { + return fail("RSA ciphertext has an invalid length"); + } + if (scramble == nullptr || scramble_length == 0) { + return fail("authentication scramble is unavailable"); + } + + EVP_PKEY_CTX* raw_context = EVP_PKEY_CTX_new(snapshot->private_key_.get(), nullptr); + if (raw_context == nullptr) { + return fail("cannot allocate RSA decryption context"); + } + std::unique_ptr context( + raw_context, EVP_PKEY_CTX_free + ); + if (EVP_PKEY_decrypt_init(context.get()) <= 0 || + EVP_PKEY_CTX_set_rsa_padding(context.get(), RSA_PKCS1_OAEP_PADDING) <= 0 || + EVP_PKEY_CTX_set_rsa_oaep_md(context.get(), EVP_sha1()) <= 0 || + EVP_PKEY_CTX_set_rsa_mgf1_md(context.get(), EVP_sha1()) <= 0) { + return fail("cannot initialize RSA OAEP decryption"); + } + + size_t plaintext_length = 0; + if (EVP_PKEY_decrypt( + context.get(), nullptr, &plaintext_length, ciphertext, ciphertext_length + ) <= 0 || plaintext_length == 0) { + return fail("RSA OAEP decryption failed"); + } + std::vector plaintext(plaintext_length); + if (EVP_PKEY_decrypt( + context.get(), plaintext.data(), &plaintext_length, ciphertext, ciphertext_length + ) <= 0 || plaintext_length == 0) { + OPENSSL_cleanse(plaintext.data(), plaintext.size()); + return fail("RSA OAEP decryption failed"); + } + plaintext.resize(plaintext_length); + for (size_t index = 0; index < plaintext.size(); ++index) { + plaintext[index] ^= scramble[index % scramble_length]; + } + if (plaintext.back() != '\0' || + std::memchr(plaintext.data(), '\0', plaintext.size() - 1) != nullptr) { + OPENSSL_cleanse(plaintext.data(), plaintext.size()); + return fail("decrypted password is not a single NUL-terminated string"); + } + password.assign(reinterpret_cast(plaintext.data()), plaintext.size() - 1); + OPENSSL_cleanse(plaintext.data(), plaintext.size()); + if (error != nullptr) { + error->clear(); + } + return true; +} diff --git a/lib/MySQL_Passthrough_Auth_Cache.cpp b/lib/MySQL_Passthrough_Auth_Cache.cpp index a655366f59..12e5c8cf71 100644 --- a/lib/MySQL_Passthrough_Auth_Cache.cpp +++ b/lib/MySQL_Passthrough_Auth_Cache.cpp @@ -5,6 +5,22 @@ #include "re2/re2.h" #include +#include + +namespace { + +void cleanse_string(std::string& value) { + if (!value.empty()) { + OPENSSL_cleanse(value.data(), value.size()); + } + value.clear(); +} + +} // namespace + +MySQL_Passthrough_Auth_Cache::entry_t::~entry_t() { + cleanse_string(cleartext_password); +} MySQL_Passthrough_Auth_Cache::MySQL_Passthrough_Auth_Cache() : inflight_probes(0), @@ -46,6 +62,7 @@ MySQL_Passthrough_Auth_Cache::~MySQL_Passthrough_Auth_Cache() { bool MySQL_Passthrough_Auth_Cache::lookup( const std::string& username, std::string& out_cleartext, uint32_t ttl_s ) { + cleanse_string(out_cleartext); /* * Reader fast-path: cache HIT and entry not expired. * @@ -114,11 +131,12 @@ bool MySQL_Passthrough_Auth_Cache::lookup( } void MySQL_Passthrough_Auth_Cache::insert( - const std::string& username, const std::string& cleartext, int hostgroup_probed + const std::string& username, const char* cleartext, int hostgroup_probed ) { pthread_rwlock_wrlock(&lock); entry_t& e = entries[username]; - e.cleartext_password = cleartext; + cleanse_string(e.cleartext_password); + e.cleartext_password.assign(cleartext); e.learned_at_us = monotonic_time(); e.hostgroup_probed = hostgroup_probed; pthread_rwlock_unlock(&lock); diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 8b3b989f5d..70c04db2f9 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -14,6 +14,10 @@ using json = nlohmann::json; #include "MySQL_Passthrough_Auth_Cache.h" #include "MySQL_LDAP_Authentication.hpp" #include "MySQL_Variables.h" +#ifdef PROXYSQL31 +#include "MySQL_Caching_Sha2_RSA.h" +#include +#endif #include #include @@ -40,16 +44,35 @@ extern ClickHouse_Authentication *GloClickHouseAuth; #include "proxysql_find_charset.h" mf_unique_ptr get_masked_pass(const char* pass) { - char* tmp_pass = strdup(pass); - int lpass = strlen(tmp_pass); + return mf_unique_ptr( + static_cast(strdup(pass == nullptr ? "(null)" : "(redacted)")) + ); +} - for (int i=2; i(static_cast(tmp_pass)); +void cleanse_and_free_password(char*& password) { + if (password != nullptr) { + OPENSSL_cleanse(password, strlen(password)); + free(password); + password = nullptr; + } } +class ScopedStringCleanser { + std::string& value_; + + public: + explicit ScopedStringCleanser(std::string& value) : value_(value) {} + ~ScopedStringCleanser() { + if (!value_.empty()) { + OPENSSL_cleanse(value_.data(), value_.size()); + } + } +}; + +} // namespace + extern "C" char * sha256_crypt_r (const char *key, const char *salt, char *buffer, int buflen); static const char *plugins[3] = { @@ -70,9 +93,13 @@ char* get_password(account_details_t& ad, PASSWORD_TYPE::E passtype) { } } else if (ad.attributes) { const nlohmann::json attrs = nlohmann::json::parse(ad.attributes, nullptr, false); - const string addl_pass { get_nested_elem_val(attrs, { "additional_password" }, string {}) }; - const string uh_addl_pass { unhex(addl_pass) }; - proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 3, "Password info length:%ld, val:`%s`, addl_val:`%s`\n", uh_addl_pass.length(), uh_addl_pass.c_str(), addl_pass.c_str()); + string addl_pass { get_nested_elem_val(attrs, { "additional_password" }, string {}) }; + ScopedStringCleanser addl_pass_cleanser(addl_pass); + string uh_addl_pass { unhex(addl_pass) }; + ScopedStringCleanser uh_addl_pass_cleanser(uh_addl_pass); + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 3, + "Additional password info length:%zu, value:`(redacted)`\n", + uh_addl_pass.length()); ret = reinterpret_cast(strdup(uh_addl_pass.c_str())); } } else { @@ -91,14 +118,8 @@ char* get_password(account_details_t& ad, PASSWORD_TYPE::E passtype) { #ifdef DEBUG void debug_spiffe_id(const unsigned char *user, const char *attributes, int __line, const char *__func) { if (attributes!=NULL && strlen(attributes)) { - json j = nlohmann::json::parse(attributes); - auto spiffe_id = j.find("spiffe_id"); - if (spiffe_id != j.end()) { - std::string spiffe_val = j["spiffe_id"].get(); - proxy_info("%d:%s(): Attributes for user %s: %s . Spiffe_id: %s\n" , __line, __func, user, attributes, spiffe_val.c_str()); - } else { - proxy_info("%d:%s(): Attributes for user %s: %s\n" , __line, __func, user, attributes); - } + proxy_info("%d:%s(): Attributes for user %s are present; values redacted\n", + __line, __func, user); } } #endif @@ -109,6 +130,10 @@ void MySQL_Protocol::init(MySQL_Data_Stream **__myds, MySQL_Connection_userinfo userinfo=__userinfo; sess=__sess; current_PreStmt=NULL; +#ifdef PROXYSQL31 + caching_sha2_rsa_snapshot_.reset(); + frontend_auth_error_ = MySQLFrontendAuthError::NONE; +#endif } static unsigned char protocol_version=10; @@ -260,6 +285,9 @@ bool MySQL_Protocol::generate_pkt_ERR(bool send, void **ptr, unsigned int *len, } void MySQL_Protocol::generate_one_byte_pkt(unsigned char b) { +#ifdef PROXYSQL31 + generate_auth_more_data(&b, 1); +#else assert((*myds) != NULL); uint8_t sequence_id; sequence_id = (*myds)->pkt_sid; @@ -276,8 +304,39 @@ void MySQL_Protocol::generate_one_byte_pkt(unsigned char b) { _ptr[l]=b; (*myds)->PSarrayOUT->add((void *)_ptr,size); (*myds)->pkt_sid=sequence_id; +#endif +} + +#ifdef PROXYSQL31 +void MySQL_Protocol::generate_auth_more_data(const unsigned char *data, size_t data_len) { + assert((*myds) != NULL); + assert(data != NULL || data_len == 0); + assert(data_len <= 0xFFFFFFU - 1); + + uint8_t sequence_id = (*myds)->pkt_sid + 1; + mysql_hdr myhdr; + myhdr.pkt_id = sequence_id; + myhdr.pkt_length = static_cast(data_len + 1); + + const unsigned int size = myhdr.pkt_length + sizeof(mysql_hdr); + unsigned char *_ptr = static_cast(l_alloc(size)); + memcpy(_ptr, &myhdr, sizeof(mysql_hdr)); + _ptr[sizeof(mysql_hdr)] = 0x01; + if (data_len != 0) { + memcpy(_ptr + sizeof(mysql_hdr) + 1, data, data_len); + } + + (*myds)->PSarrayOUT->add(static_cast(_ptr), size); + (*myds)->pkt_sid = sequence_id; } +MySQLFrontendAuthError MySQL_Protocol::consume_frontend_auth_error() { + const MySQLFrontendAuthError error = frontend_auth_error_; + frontend_auth_error_ = MySQLFrontendAuthError::NONE; + return error; +} +#endif + bool MySQL_Protocol::generate_pkt_OK(bool send, void **ptr, unsigned int *len, uint8_t sequence_id, unsigned int affected_rows, uint64_t last_insert_id, uint16_t status, uint16_t warnings, char *msg, bool eof_identifier) { if ((*myds)->sess->mirror==true) { return true; @@ -1564,10 +1623,10 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in ret = false; if (pass) { free(pass); pass = NULL; } if (userinfo->username) free(userinfo->username); - if (userinfo->password) free(userinfo->password); + userinfo->clear_password(); userinfo->username = strdup((const char *)user); userinfo->password = strdup((const char *)""); - if (password) { free(password); password = NULL; } + cleanse_and_free_password(password); free_account_details(account_details); userinfo->set(NULL, NULL, NULL, NULL); return ret; @@ -1616,7 +1675,7 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in pass=NULL; } if (userinfo->username) free(userinfo->username); - if (userinfo->password) free(userinfo->password); + userinfo->clear_password(); if (ret==true) { (*myds)->DSS=STATE_CLIENT_HANDSHAKE; @@ -1628,10 +1687,7 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in userinfo->username=strdup((const char *)user); userinfo->password=strdup((const char *)""); } - if (password) { - free(password); - password=NULL; - } + cleanse_and_free_password(password); free_account_details(account_details); userinfo->set(NULL,NULL,NULL,NULL); // just to call compute_hash() if (ret) { @@ -1716,6 +1772,56 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in // this function was inline in process_pkt_handshake_response() , split for readibility int MySQL_Protocol::PPHR_1(unsigned char *pkt, unsigned int len, bool& ret, MyProt_tmp_auth_vars& vars1) { // process_pkt_handshake_response inner 1 +#ifdef PROXYSQL31 + if ((*myds)->switching_auth_stage == 6) { + (*myds)->auth_in_progress = 0; + ret = false; + vars1.user = reinterpret_cast((*myds)->myconn->userinfo->username); + + const auto key_snapshot = caching_sha2_rsa_snapshot_; + caching_sha2_rsa_snapshot_.reset(); + const size_t ciphertext_length = + len >= sizeof(mysql_hdr) ? len - sizeof(mysql_hdr) : 0; + if (key_snapshot == nullptr || + ciphertext_length != key_snapshot->ciphertext_size() || + GloMTH == nullptr || GloMTH->caching_sha2_rsa() == nullptr) { + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, + "Session=%p , DS=%p , user='%s' . Invalid caching_sha2_password RSA response\n", + (*myds)->sess, (*myds), vars1.user); + return 1; + } + + std::string plaintext_password; + if (!GloMTH->caching_sha2_rsa()->decrypt_password( + key_snapshot, + pkt, + ciphertext_length, + reinterpret_cast((*myds)->myconn->scramble_buff), + SCRAMBLE_LENGTH, + plaintext_password)) { + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, + "Session=%p , DS=%p , user='%s' . Invalid caching_sha2_password RSA response\n", + (*myds)->sess, (*myds), vars1.user); + return 1; + } + + vars1.pass_len = plaintext_password.size(); + vars1.pass = static_cast(malloc(vars1.pass_len + 1)); + if (vars1.pass_len != 0) { + memcpy(vars1.pass, plaintext_password.data(), vars1.pass_len); + OPENSSL_cleanse(plaintext_password.data(), plaintext_password.size()); + } + vars1.pass[vars1.pass_len] = '\0'; + vars1.pass_is_sensitive = true; + vars1.db = (*myds)->myconn->userinfo->schemaname; + vars1.charset = (*myds)->tmp_charset; + vars1.capabilities = (*myds)->myconn->options.client_flag; + auth_plugin_id = (*myds)->switching_auth_type; + (*myds)->switching_auth_stage = 5; + frontend_auth_error_ = MySQLFrontendAuthError::NONE; + return 2; + } +#endif if ((*myds)->switching_auth_stage == 1) { // this was set in PPHR_4auth0() or PPHR_4auth1() (*myds)->switching_auth_stage=2; @@ -1728,43 +1834,83 @@ int MySQL_Protocol::PPHR_1(unsigned char *pkt, unsigned int len, bool& ret, MyPr if (len==5) { ret = false; vars1.user = (unsigned char *)(*myds)->myconn->userinfo->username; - // A 1-byte payload of 0x02 at this stage is not a disconnect: it is the - // caching_sha2_password 'request_public_key' packet. ProxySQL has no RSA - // key to serve (tracked in #5988), so the exchange cannot continue -- but - // reporting it as "client is disconnecting" sent operators looking in - // entirely the wrong place. Name the real cause. - // - // This only fixes the log line. The client still receives the generic - // error produced by the normal failure path in - // MySQL_Session::handler___status_CONNECTING_CLIENT___STATE_SERVER_HANDSHAKE; - // giving the client a specific message needs that path to carry one, and - // #5988 replaces this branch with a real RSA implementation anyway. if ((*myds)->switching_auth_stage == 5 && *pkt == 2) { proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, "Session=%p , DS=%p , user='%s' . Client requested the caching_sha2_password RSA public key\n", (*myds)->sess, (*myds), vars1.user); +#ifdef PROXYSQL31 + caching_sha2_rsa_snapshot_ = + GloMTH != nullptr && GloMTH->caching_sha2_rsa() != nullptr ? + GloMTH->caching_sha2_rsa()->acquire() : nullptr; + if (caching_sha2_rsa_snapshot_ != nullptr) { + const std::string& public_key = caching_sha2_rsa_snapshot_->public_key_pem(); + generate_auth_more_data( + reinterpret_cast(public_key.data()), public_key.size()); + (*myds)->switching_auth_stage = 6; + (*myds)->auth_in_progress = 1; + frontend_auth_error_ = MySQLFrontendAuthError::NONE; + return 1; + } + frontend_auth_error_ = MySQLFrontendAuthError::CACHING_SHA2_RSA_UNAVAILABLE; + proxy_error( + "User '%s'@'%s' requested the caching_sha2_password RSA public key, but no valid RSA key pair is available.\n", + vars1.user, (*myds)->addr.addr + ); +#else proxy_error( "User '%s'@'%s' requested the caching_sha2_password RSA public key, which ProxySQL does not" " serve. Connect using TLS instead.\n", vars1.user, (*myds)->addr.addr ); +#endif + (*myds)->auth_in_progress = 0; + return 1; + } + const bool tls_caching_sha2_empty_password = + (*myds)->switching_auth_stage == 5 && + (*myds)->switching_auth_type == AUTH_MYSQL_CACHING_SHA2_PASSWORD && + (*myds)->encrypted && *pkt == '\0'; + if (!tls_caching_sha2_empty_password) { + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, "Session=%p , DS=%p , user='%s' . Client is disconnecting\n", (*myds), (*myds)->sess, vars1.user); + proxy_error("User '%s'@'%s' is disconnecting during switch auth\n", vars1.user, (*myds)->addr.addr); (*myds)->auth_in_progress = 0; return 1; } - proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, "Session=%p , DS=%p , user='%s' . Client is disconnecting\n", (*myds), (*myds)->sess, vars1.user); - proxy_error("User '%s'@'%s' is disconnecting during switch auth\n", vars1.user, (*myds)->addr.addr); - (*myds)->auth_in_progress = 0; - return 1; } auth_plugin_id = (*myds)->switching_auth_type; + const size_t payload_length = len >= sizeof(mysql_hdr) ? len - sizeof(mysql_hdr) : 0; + if (auth_plugin_id == AUTH_MYSQL_CACHING_SHA2_PASSWORD && + (*myds)->switching_auth_stage == 5 && !(*myds)->encrypted) { + ret = false; + vars1.user = (unsigned char *)(*myds)->myconn->userinfo->username; + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, + "Session=%p , DS=%p , user='%s' . Rejected cleartext caching_sha2_password response without TLS\n", + (*myds)->sess, (*myds), vars1.user); + return 1; + } if (auth_plugin_id == AUTH_MYSQL_NATIVE_PASSWORD) { - vars1.pass_len = len - sizeof(mysql_hdr); + vars1.pass_len = payload_length; } else { - vars1.pass_len=strlen((char *)pkt); + const unsigned char* terminator = static_cast( + std::memchr(pkt, '\0', payload_length) + ); + if (terminator == nullptr || terminator != pkt + payload_length - 1) { + ret = false; + vars1.user = (unsigned char *)(*myds)->myconn->userinfo->username; + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, + "Session=%p , DS=%p , user='%s' . Rejected malformed NUL-terminated authentication response\n", + (*myds)->sess, (*myds), vars1.user); + return 1; + } + vars1.pass_len = payload_length - 1; } vars1.pass = (unsigned char *)malloc(vars1.pass_len+1); memcpy(vars1.pass, pkt, vars1.pass_len); vars1.pass[vars1.pass_len] = 0; +#ifdef PROXYSQL31 + vars1.pass_is_sensitive = auth_plugin_id == AUTH_MYSQL_CACHING_SHA2_PASSWORD && + (*myds)->switching_auth_stage == 5; +#endif vars1.user = (unsigned char *)(*myds)->myconn->userinfo->username; vars1.db = (*myds)->myconn->userinfo->schemaname; //(*myds)->switching_auth_stage=2; @@ -2225,15 +2371,9 @@ void MySQL_Protocol::PPHR_5passwordFalse_auth2( ) { if (GloMyLdapAuth) { #ifdef DEBUG - { - char *tmp_pass=strdup((const char *)vars1.pass); - int lpass = strlen(tmp_pass); - for (int i=2; isess, vars1.user, tmp_pass); - free(tmp_pass); - } + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, + "Session=%p , DS=%p , username='%s' , password='(redacted)'\n", + (*myds), (*myds)->sess, vars1.user); #endif // debug char *backend_username = NULL; (*myds)->sess->use_ldap_auth = true; @@ -2242,13 +2382,9 @@ void MySQL_Protocol::PPHR_5passwordFalse_auth2( &attr1.transaction_persistent, &attr1.fast_forward, &attr1.max_connections, &attr1.sha1_pass, &attr1.attributes, &backend_username); if (vars1.password) { #ifdef DEBUG - char *tmp_pass=strdup(vars1.password); - int lpass = strlen(tmp_pass); - for (int i=2; isess, backend_username, tmp_pass); - free(tmp_pass); + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, + "Session=%p , DS=%p , username='%s' , password='(redacted)'\n", + (*myds), (*myds)->sess, backend_username); #endif // debug (*myds)->sess->default_hostgroup=attr1.default_hostgroup; (*myds)->sess->default_schema=attr1.default_schema; // just the pointer is passed @@ -2279,7 +2415,8 @@ void MySQL_Protocol::PPHR_5passwordFalse_auth2( } (*myds)->sess->user_attributes = attr1.attributes; // just the pointer is passed #ifdef DEBUG - proxy_info("Attributes for user %s: %s\n" , acct.username, attr1.attributes); + proxy_info("Attributes for user %s are present; values redacted\n", + acct.username); #endif (*myds)->sess->schema_locked=attr1.schema_locked; (*myds)->sess->transaction_persistent=attr1.transaction_persistent; @@ -2565,7 +2702,7 @@ void MySQL_Protocol::PPHR_sha2full( // currently proxysql doesn't know the clear text password for that specific user, let's set it! GloMyAuth->set_clear_text_password((char *)vars1.user, USERNAME_FRONTEND, (const char *)vars1.pass, passtype); // Update 'vars1' password with 'clear text' one, so session can be later updated with it - if (vars1.password) { free(vars1.password); } + cleanse_and_free_password(vars1.password); vars1.password = strdup(reinterpret_cast(vars1.pass)); } } @@ -2613,9 +2750,7 @@ void MySQL_Protocol::PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1) { // userinfo->password at probe-acquire time (after the epilogue has // run), so it becomes the auth password for mysql_real_connect_start. if ((*myds)->passthrough_cleartext) { - memset((*myds)->passthrough_cleartext, 0, strlen((*myds)->passthrough_cleartext)); - free((*myds)->passthrough_cleartext); - (*myds)->passthrough_cleartext = NULL; + cleanse_and_free_password((*myds)->passthrough_cleartext); } if (vars1.pass && vars1.pass_len > 0) { (*myds)->passthrough_cleartext = @@ -2886,8 +3021,14 @@ bool MySQL_Protocol::PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_d if ((*myds) && (*myds)->sess) { (*myds)->sess->passthrough_credential = true; } - if (vars1.password) { free(vars1.password); } + if (vars1.password) { + OPENSSL_cleanse(vars1.password, strlen(vars1.password)); + free(vars1.password); + } vars1.password = strdup(cleartext.c_str()); + if (!cleartext.empty()) { + OPENSSL_cleanse(cleartext.data(), cleartext.size()); + } /** * @brief Mirror the synthesized session defaults into * @c account_details for the unknown-user cache-hit path. @@ -3286,12 +3427,12 @@ bool MySQL_Protocol::process_pkt_handshake_response(unsigned char *pkt, unsigned if (addl_pass) { if (strlen(addl_pass) > 0) { - if (vars1.password) { free(vars1.password); } + cleanse_and_free_password(vars1.password); vars1.password = addl_pass; vars1.passtype = PASSWORD_TYPE::ADDITIONAL; ret = PPHR_verify_password(vars1, account_details); } else { - free(addl_pass); + cleanse_and_free_password(addl_pass); } } } @@ -3334,9 +3475,7 @@ bool MySQL_Protocol::process_pkt_handshake_response(unsigned char *pkt, unsigned if (!userinfo->username) // if set already, ignore userinfo->username=strdup((const char *)vars1.user); - if (userinfo->password) { - free(userinfo->password); - } + userinfo->clear_password(); userinfo->password=strdup((const char *)vars1.password); if (vars1.db) userinfo->set_schemaname(vars1.db,strlen(vars1.db)); userinfo->passtype = vars1.passtype; @@ -3345,7 +3484,7 @@ bool MySQL_Protocol::process_pkt_handshake_response(unsigned char *pkt, unsigned if (!userinfo->username) // if set already, ignore userinfo->username=strdup((const char *)vars1.user); if (vars1.pass_len) { - if (userinfo->password) { free(userinfo->password); } + userinfo->clear_password(); userinfo->password=strdup((const char *)""); }; userinfo->passtype = vars1.passtype; @@ -3358,19 +3497,20 @@ bool MySQL_Protocol::process_pkt_handshake_response(unsigned char *pkt, unsigned { const auto get_debug_pass = [] (const char* pass, size_t len = 0) -> string { if (!pass) { return "(null)"; } - - const string_view pass_view { len > 0 ? string_view { pass, len } : string_view { pass } }; - const string hex_pass { hex(pass_view) }; - - if (GloVars.global.gdbg_lvl[PROXY_DEBUG_MYSQL_PROTOCOL].verbosity >= 5) { - return hex_pass; - } else { - return string { get_masked_pass(hex_pass.c_str()).get() }; - } + (void)len; + return "(redacted)"; }; +#ifdef PROXYSQL31 + const string tmp_pass = vars1.pass_is_sensitive ? + "(redacted RSA plaintext)" : get_debug_pass(vars1.password); + const string tmp_cpass = vars1.pass_is_sensitive ? + "(redacted RSA plaintext)" : + get_debug_pass(reinterpret_cast(vars1.pass), vars1.pass_len); +#else const string tmp_pass { get_debug_pass(vars1.password) }; const string tmp_cpass { get_debug_pass(reinterpret_cast(vars1.pass), vars1.pass_len) }; +#endif proxy_debug(PROXY_DEBUG_MYSQL_PROTOCOL, 1, "Handshake in progress session_id=%u user=\"%s\" password=\"%s\" client_pass=\"%s\" scramble=\"%s\"" @@ -3383,11 +3523,13 @@ bool MySQL_Protocol::process_pkt_handshake_response(unsigned char *pkt, unsigned } #endif - free(vars1.pass); - if (vars1.password) { - free(vars1.password); - vars1.password=NULL; +#ifdef PROXYSQL31 + if (vars1.pass_is_sensitive && vars1.pass != nullptr) { + OPENSSL_cleanse(vars1.pass, vars1.pass_len + 1); } +#endif + free(vars1.pass); + cleanse_and_free_password(vars1.password); if (vars1.db_tmp) { free(vars1.db_tmp); vars1.db_tmp=NULL; diff --git a/lib/MySQL_Session.cpp b/lib/MySQL_Session.cpp index bf143b39dd..db6611235a 100644 --- a/lib/MySQL_Session.cpp +++ b/lib/MySQL_Session.cpp @@ -12,6 +12,8 @@ using json = nlohmann::json; #include "re2/regexp.h" #include "mysqld_error.h" +#include + #include "MySQL_Data_Stream.h" #include "MySQL_Query_Processor.h" #include "Query_Processor_ParserSQL.h" @@ -1783,7 +1785,7 @@ int MySQL_Session::handler_again___status_AUTHENTICATING_BACKEND_FOR_CLIENT() { // probe resolves. Also clears auth_in_progress. auto scrub_cleartext = [&]() { if (client_myds && client_myds->passthrough_cleartext) { - memset(client_myds->passthrough_cleartext, 0, + OPENSSL_cleanse(client_myds->passthrough_cleartext, strlen(client_myds->passthrough_cleartext)); free(client_myds->passthrough_cleartext); client_myds->passthrough_cleartext = NULL; @@ -1915,7 +1917,7 @@ int MySQL_Session::handler_again___status_AUTHENTICATING_BACKEND_FOR_CLIENT() { // Cache the verified credential. The cleartext (from // passthrough_cleartext) was just used to auth the backend and is now // proven valid. - GloMyPTAuthCache->insert(user_key, std::string(cleartext), audit_hg); + GloMyPTAuthCache->insert(user_key, cleartext, audit_hg); // Mark the session: the credential on userinfo came from pass-through // (now in the cache). Authorizes the §8.4 eviction hook to invalidate @@ -1928,9 +1930,7 @@ int MySQL_Session::handler_again___status_AUTHENTICATING_BACKEND_FOR_CLIENT() { // backend authenticated successfully we overwrite it with the real // cleartext and recompute the hash, so the rest of the session (query // routing, multiplexing, change-user checks) authenticates consistently. - if (client_myds->myconn->userinfo->password) { - free(client_myds->myconn->userinfo->password); - } + client_myds->myconn->userinfo->clear_password(); client_myds->myconn->userinfo->password = strdup(cleartext); client_myds->myconn->userinfo->set(NULL, NULL, NULL, NULL); @@ -2127,9 +2127,7 @@ int MySQL_Session::handler_again___status_AUTHENTICATING_BACKEND_FOR_CLIENT() { // handler___client_DSS_QUERY_SENT___server_DSS_NOT_INITIALIZED__get_connection, // which copies userinfo (including a real password) before connect. mc->userinfo->set(client_myds->myconn->userinfo); - if (mc->userinfo->password) { - free(mc->userinfo->password); - } + mc->userinfo->clear_password(); mc->userinfo->password = strdup(cleartext); mc->userinfo->set(NULL, NULL, NULL, NULL); // recompute hash @@ -6463,6 +6461,10 @@ bool MySQL_Session::handler_again___multiple_statuses(int *rc) { void MySQL_Session::handler___status_CONNECTING_CLIENT___STATE_SERVER_HANDSHAKE_WrongCredentials(PtrSize_t *pkt, bool *wrong_pass) { l_free(pkt->size,pkt->ptr); +#ifdef PROXYSQL31 + const MySQLFrontendAuthError frontend_auth_error = + client_myds->myprot.consume_frontend_auth_error(); +#endif proxy_debug(PROXY_DEBUG_MYSQL_CONNECTION, 5, "Session=%p , DS=%p . Wrong credentials for frontend: disconnecting\n", this, client_myds); *wrong_pass=true; // FIXME: this should become close connection @@ -6495,29 +6497,41 @@ void MySQL_Session::handler___status_CONNECTING_CLIENT___STATE_SERVER_HANDSHAKE_ client_addr = strdup((char *)""); } if (client_myds->myconn->userinfo->username) { - char *_s=(char *)malloc(strlen(client_myds->myconn->userinfo->username)+100+strlen(client_addr)); + char *_s=(char *)malloc(strlen(client_myds->myconn->userinfo->username)+256+strlen(client_addr)); //uint8_t _pid = 2; //if (client_myds->switching_auth_stage) _pid+=2; //if (is_encrypted) _pid++; uint8_t _pid = client_myds->pkt_sid; _pid++; #ifdef DEBUG if (client_myds->myconn->userinfo->password) { - char *tmp_pass=strdup(client_myds->myconn->userinfo->password); - int lpass = strlen(tmp_pass); - for (int i=2; imyconn->userinfo->username, client_addr, tmp_pass); - free(tmp_pass); + proxy_debug(PROXY_DEBUG_MYSQL_CONNECTION, 5, + "Session=%p , DS=%p . Error: Access denied for user '%s'@'%s' , Password='(redacted)'. Disconnecting\n", + this, client_myds, client_myds->myconn->userinfo->username, client_addr); } else { proxy_debug(PROXY_DEBUG_MYSQL_CONNECTION, 5, "Session=%p , DS=%p . Error: Access denied for user '%s'@'%s' . No password. Disconnecting\n", this, client_myds, client_myds->myconn->userinfo->username, client_addr); } #endif // DEBUG - sprintf(_s,"ProxySQL Error: Access denied for user '%s'@'%s' (using password: %s)", client_myds->myconn->userinfo->username, client_addr, (client_myds->myconn->userinfo->password ? "YES" : "NO")); +#ifdef PROXYSQL31 + if (frontend_auth_error == MySQLFrontendAuthError::CACHING_SHA2_RSA_UNAVAILABLE) { + sprintf( + _s, + "ProxySQL Error: Access denied for user '%s'@'%s': caching_sha2_password RSA key exchange is unavailable; use TLS or configure RSA keys", + client_myds->myconn->userinfo->username, client_addr + ); + } else +#endif + { + sprintf(_s,"ProxySQL Error: Access denied for user '%s'@'%s' (using password: %s)", client_myds->myconn->userinfo->username, client_addr, (client_myds->myconn->userinfo->password ? "YES" : "NO")); + } client_myds->myprot.generate_pkt_ERR(true,NULL,NULL, _pid, 1045,(char *)"28000", _s, true); - proxy_error("ProxySQL Error: Access denied for user '%s'@'%s' (using password: %s)\n", client_myds->myconn->userinfo->username, client_addr, (client_myds->myconn->userinfo->password ? "YES" : "NO")); + proxy_error("%s\n", _s); free(_s); - __sync_fetch_and_add(&MyHGM->status.access_denied_wrong_password, 1); +#ifdef PROXYSQL31 + if (frontend_auth_error != MySQLFrontendAuthError::CACHING_SHA2_RSA_UNAVAILABLE) +#endif + { + __sync_fetch_and_add(&MyHGM->status.access_denied_wrong_password, 1); + } } if (client_addr) { free(client_addr); diff --git a/lib/MySQL_Thread.cpp b/lib/MySQL_Thread.cpp index 801cfeac3a..19e46dccf8 100644 --- a/lib/MySQL_Thread.cpp +++ b/lib/MySQL_Thread.cpp @@ -29,6 +29,9 @@ using json = nlohmann::json; #include "MySQL_PreparedStatement.h" #include "MySQL_Logger.hpp" #include "MySQL_Resolution.h" +#ifdef PROXYSQL31 +#include "MySQL_Caching_Sha2_RSA.h" +#endif #include #include @@ -490,6 +493,11 @@ static char * mysql_thread_variables_names[]= { (char *)"select_version_forwarding", (char *)"keep_multiplexing_variables", (char *)"default_authentication_plugin", +#ifdef PROXYSQL31 + (char *)"caching_sha2_password_auto_generate_rsa_keys", + (char *)"caching_sha2_password_private_key_path", + (char *)"caching_sha2_password_public_key_path", +#endif (char *)"passthrough_auth_enabled", (char *)"passthrough_auth_empty_password", (char *)"passthrough_auth_unknown_users", @@ -1459,6 +1467,14 @@ MySQL_Threads_Handler::MySQL_Threads_Handler() { variables.proxy_protocol_networks = strdup((char *)""); variables.default_authentication_plugin=strdup((char *)"mysql_native_password"); variables.default_authentication_plugin_int = 0; // mysql_native_password +#ifdef PROXYSQL31 + variables.caching_sha2_password_auto_generate_rsa_keys = true; + variables.caching_sha2_password_private_key_path = strdup("proxysql-caching-sha2-private-key.pem"); + variables.caching_sha2_password_public_key_path = strdup("proxysql-caching-sha2-public-key.pem"); + caching_sha2_rsa_accepted_private_path_ = variables.caching_sha2_password_private_key_path; + caching_sha2_rsa_accepted_public_path_ = variables.caching_sha2_password_public_key_path; + caching_sha2_rsa_manager_ = std::make_unique(); +#endif variables.passthrough_auth_enabled = false; variables.passthrough_auth_empty_password = true; variables.passthrough_auth_unknown_users = false; @@ -1580,7 +1596,81 @@ void MySQL_Threads_Handler::wrunlock() { pthread_rwlock_unlock(&rwlock); } -void MySQL_Threads_Handler::commit() { +MySQLThreadsCommitResult MySQL_Threads_Handler::commit() { + MySQLThreadsCommitResult commit_result; +#ifdef PROXYSQL31 + const char *private_path = variables.caching_sha2_password_private_key_path != nullptr + ? variables.caching_sha2_password_private_key_path : ""; + const char *public_path = variables.caching_sha2_password_public_key_path != nullptr + ? variables.caching_sha2_password_public_key_path : ""; + CachingSha2RSAConfig rsa_config { + variables.caching_sha2_password_auto_generate_rsa_keys, + private_path, + public_path, + GloVars.datadir != nullptr ? GloVars.datadir : "" + }; + CachingSha2RSAReloadResult rsa_reload = caching_sha2_rsa_manager_->reload(rsa_config); + if (rsa_reload.accepted) { + caching_sha2_rsa_accepted_auto_generate_ = rsa_config.auto_generate; + caching_sha2_rsa_accepted_private_path_ = rsa_config.private_key_path; + caching_sha2_rsa_accepted_public_path_ = rsa_config.public_key_path; + caching_sha2_rsa_config_initialized_ = true; + } else { + proxy_error("Rejected caching_sha2_password RSA key configuration: %s\n", rsa_reload.error.c_str()); + commit_result.rejected_variables = 3; + + if (!caching_sha2_rsa_config_initialized_) { + const std::string default_private_path = "proxysql-caching-sha2-private-key.pem"; + const std::string default_public_path = "proxysql-caching-sha2-public-key.pem"; + const bool candidate_is_default = rsa_config.auto_generate && + rsa_config.private_key_path == default_private_path && + rsa_config.public_key_path == default_public_path; + bool default_accepted = false; + std::string default_error = rsa_reload.error; + if (!candidate_is_default) { + CachingSha2RSAConfig fallback_config { + true, + default_private_path, + default_public_path, + rsa_config.datadir + }; + const CachingSha2RSAReloadResult fallback = caching_sha2_rsa_manager_->reload(fallback_config); + default_accepted = fallback.accepted; + default_error = fallback.error; + } + if (default_accepted) { + caching_sha2_rsa_accepted_auto_generate_ = true; + caching_sha2_rsa_accepted_private_path_ = default_private_path; + caching_sha2_rsa_accepted_public_path_ = default_public_path; + } else { + CachingSha2RSAConfig disabled_config { false, "", "", rsa_config.datadir }; + const CachingSha2RSAReloadResult disabled = + caching_sha2_rsa_manager_->reload(disabled_config); + if (!disabled.accepted) { + proxy_error("Failed to disable unavailable caching_sha2_password RSA configuration: %s\n", + disabled.error.c_str()); + } + caching_sha2_rsa_accepted_auto_generate_ = false; + caching_sha2_rsa_accepted_private_path_.clear(); + caching_sha2_rsa_accepted_public_path_.clear(); + proxy_error( + "Default caching_sha2_password RSA key configuration is unavailable: %s. " + "RSA public-key authentication is disabled; TLS authentication remains available.\n", + default_error.c_str()); + } + caching_sha2_rsa_config_initialized_ = true; + } + + variables.caching_sha2_password_auto_generate_rsa_keys = + caching_sha2_rsa_accepted_auto_generate_; + free(variables.caching_sha2_password_private_key_path); + free(variables.caching_sha2_password_public_key_path); + variables.caching_sha2_password_private_key_path = + strdup(caching_sha2_rsa_accepted_private_path_.c_str()); + variables.caching_sha2_password_public_key_path = + strdup(caching_sha2_rsa_accepted_public_path_.c_str()); + } +#endif __sync_add_and_fetch(&__global_MySQL_Thread_Variables_version,1); proxy_debug(PROXY_DEBUG_MYSQL_SERVER, 1, "Increasing version number to %d - all threads will notice this and refresh their variables\n", __global_MySQL_Thread_Variables_version); @@ -1652,6 +1742,7 @@ void MySQL_Threads_Handler::commit() { "doc/internal/passthrough_authentication.md §7.1.\n", variables.passthrough_default_hg); } + return commit_result; } @@ -1780,6 +1871,10 @@ char * MySQL_Threads_Handler::get_variable_string(char *name) { if (!strcmp(name,"resolution_family")) return strdup(variables.resolution_family); if (!strcmp(name,"keep_multiplexing_variables")) return strdup(variables.keep_multiplexing_variables); if (!strcmp(name,"default_authentication_plugin")) return strdup(variables.default_authentication_plugin); +#ifdef PROXYSQL31 + if (!strcmp(name,"caching_sha2_password_private_key_path")) return strdup(variables.caching_sha2_password_private_key_path); + if (!strcmp(name,"caching_sha2_password_public_key_path")) return strdup(variables.caching_sha2_password_public_key_path); +#endif if (!strcmp(name,"passthrough_default_schema")) return strdup(variables.passthrough_default_schema ? variables.passthrough_default_schema : ""); if (!strcmp(name,"passthrough_auth_username_pattern")) return strdup(variables.passthrough_auth_username_pattern ? variables.passthrough_auth_username_pattern : ""); if (!strcmp(name,"proxy_protocol_networks")) return strdup(variables.proxy_protocol_networks); @@ -1835,7 +1930,6 @@ int MySQL_Threads_Handler::get_variable_int(const char *name) { } } - //VALGRIND_DISABLE_ERROR_REPORTING; if (!strcmp(name,"stacksize")) return ( stacksize ? stacksize : DEFAULT_STACK_SIZE); // LCOV_EXCL_START @@ -1883,6 +1977,15 @@ char * MySQL_Threads_Handler::get_variable(char *name) { // this is the public f } } +#ifdef PROXYSQL31 + if (!strcasecmp(name,"caching_sha2_password_private_key_path")) { + return strdup(variables.caching_sha2_password_private_key_path); + } + if (!strcasecmp(name,"caching_sha2_password_public_key_path")) { + return strdup(variables.caching_sha2_password_public_key_path); + } +#endif + if (!strcasecmp(name,"firewall_whitelist_errormsg")) { if (variables.firewall_whitelist_errormsg==NULL || strlen(variables.firewall_whitelist_errormsg)==0) { @@ -2376,6 +2479,19 @@ bool MySQL_Threads_Handler::set_variable(char *name, const char *value) { // thi } } +#ifdef PROXYSQL31 + if (!strcasecmp(name,"caching_sha2_password_private_key_path")) { + free(variables.caching_sha2_password_private_key_path); + variables.caching_sha2_password_private_key_path = strdup(value); + return true; + } + if (!strcasecmp(name,"caching_sha2_password_public_key_path")) { + free(variables.caching_sha2_password_public_key_path); + variables.caching_sha2_password_public_key_path = strdup(value); + return true; + } +#endif + if (!strcasecmp(name,"keep_multiplexing_variables")) { if (vallen) { @@ -2691,6 +2807,10 @@ char ** MySQL_Threads_Handler::get_variables_list() { VariablesPointers_bool["passthrough_auth_empty_password"] = make_tuple(&variables.passthrough_auth_empty_password, false); VariablesPointers_bool["passthrough_auth_unknown_users"] = make_tuple(&variables.passthrough_auth_unknown_users, false); VariablesPointers_bool["passthrough_auth_require_tls"] = make_tuple(&variables.passthrough_auth_require_tls, false); +#ifdef PROXYSQL31 + VariablesPointers_bool["caching_sha2_password_auto_generate_rsa_keys"] = + make_tuple(&variables.caching_sha2_password_auto_generate_rsa_keys, false); +#endif // variables with special variable == true // the input validation for these variables MUST be EXPLICIT VariablesPointers_bool["have_compress"] = make_tuple(&variables.have_compress, true); @@ -3274,6 +3394,10 @@ MySQL_Threads_Handler::~MySQL_Threads_Handler() { if (variables.server_version) free(variables.server_version); if (variables.keep_multiplexing_variables) free(variables.keep_multiplexing_variables); if (variables.default_authentication_plugin) free(variables.default_authentication_plugin); +#ifdef PROXYSQL31 + if (variables.caching_sha2_password_private_key_path) free(variables.caching_sha2_password_private_key_path); + if (variables.caching_sha2_password_public_key_path) free(variables.caching_sha2_password_public_key_path); +#endif if (variables.passthrough_default_schema) free(variables.passthrough_default_schema); if (variables.passthrough_auth_username_pattern) free(variables.passthrough_auth_username_pattern); if (variables.proxy_protocol_networks) free(variables.proxy_protocol_networks); diff --git a/lib/mysql_connection.cpp b/lib/mysql_connection.cpp index 65ae39c070..3fe154f8a0 100644 --- a/lib/mysql_connection.cpp +++ b/lib/mysql_connection.cpp @@ -8,6 +8,7 @@ using json = nlohmann::json; //#include "SpookyV2.h" #include #include +#include #include "MySQL_PreparedStatement.h" #include "MySQL_Data_Stream.h" @@ -266,11 +267,19 @@ MySQL_Connection_userinfo::MySQL_Connection_userinfo() { MySQL_Connection_userinfo::~MySQL_Connection_userinfo() { if (username) free(username); if (fe_username) free(fe_username); - if (password) free(password); + clear_password(); if (sha1_pass) free(sha1_pass); if (schemaname) free(schemaname); } +void MySQL_Connection_userinfo::clear_password() { + if (password != nullptr) { + OPENSSL_cleanse(password, strlen(password)); + free(password); + password = nullptr; + } +} + void MySQL_Connection::compute_unknown_transaction_status() { if (mysql) { int _myerrno=mysql_errno(mysql); @@ -335,6 +344,7 @@ uint64_t MySQL_Connection_userinfo::compute_hash() { strcpy(buf+l,_COMPUTE_HASH_DEL2_); l+=strlen(_COMPUTE_HASH_DEL2_); hash=SpookyHash::Hash64(buf,l,0); + OPENSSL_cleanse(buf, l); free(buf); return hash; } @@ -353,7 +363,7 @@ void MySQL_Connection_userinfo::set(char *u, char *p, char *s, char *sh1) { if (p) { if (password) { if (strcmp(p,password)) { - free(password); + clear_password(); password=strdup(p); } } else { diff --git a/lib/mysql_data_stream.cpp b/lib/mysql_data_stream.cpp index bd4d971fc5..0733b6845f 100644 --- a/lib/mysql_data_stream.cpp +++ b/lib/mysql_data_stream.cpp @@ -14,6 +14,7 @@ using json = nlohmann::json; #include "MySQL_Data_Stream.h" #include "openssl/x509v3.h" +#include #define RESULTSET_BUFLEN_DS_16K 16000 #define RESULTSET_BUFLEN_DS_1M 1000*1024 @@ -399,9 +400,9 @@ MySQL_Data_Stream::~MySQL_Data_Stream() { } if (passthrough_cleartext) { - // Best-effort scrub before free; the cleartext password should + // Scrub before free; the cleartext password should // not linger in freed heap memory. - memset(passthrough_cleartext, 0, strlen(passthrough_cleartext)); + OPENSSL_cleanse(passthrough_cleartext, strlen(passthrough_cleartext)); free(passthrough_cleartext); passthrough_cleartext = NULL; } @@ -1930,7 +1931,7 @@ void MySQL_Data_Stream::get_client_myds_info_json(json& j) { jc1["userinfo"]["username"] = ( myconn->userinfo->username ? myconn->userinfo->username : "" ); jc1["userinfo"]["schemaname"] = ( myconn->userinfo->schemaname ? myconn->userinfo->schemaname : "" ); #ifdef DEBUG - jc1["userinfo"]["password"] = ( myconn->userinfo->password ? myconn->userinfo->password : "" ); + jc1["userinfo"]["password"] = ( myconn->userinfo->password ? "(redacted)" : "" ); #endif } jc2["session_track_gtids"] = ( myconn->options.session_track_gtids ? myconn->options.session_track_gtids : "") ; diff --git a/test/tap/groups/groups.json b/test/tap/groups/groups.json index 91d9e0d0fe..23b053d848 100644 --- a/test/tap/groups/groups.json +++ b/test/tap/groups/groups.json @@ -15,6 +15,7 @@ "backend_sync_unit-t" : [ "unit-tests-g1" ], "basic-t" : [ "legacy-g1","mariadb10-galera-g1","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1","mysql84-g1","mysql84-gr-g1","mysql90-g1","mysql90-gr-g1","mysql93-g1","mysql93-gr-g1","mysql95-g1","mysql95-gr-g1" ], "c_tokenizer_unit-t" : [ "unit-tests-g1" ], + "caching_sha2_rsa_unit-t" : [ "unit-tests-g1","@proxysql_min_version:3.1" ], "charset_find_unit-t" : [ "unit-tests-g1" ], "charset_unsigned_int-t" : [ "legacy-g1","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1","mysql84-g1","mysql90-g1","mysql95-g1" ], "clickhouse_php_conn-t" : [ "legacy-clickhouse-g1","mysql-auto_increment_delay_multiplex=0-g1","mysql-multiplexing=false-g1","mysql-query_digests=0-g1","mysql-query_digests_keep_comment=1-g1" ], @@ -284,6 +285,7 @@ "reg_test_5766_libconfig_escape_passthrough-t" : [ "mysql95-g4" ], "reg_test_5790-mariadb_collation_255-t" : [ "mariadb10-galera-g1" ], "reg_test_5977_fast_forward_unix_socket_compression-t" : [ "legacy-g9" ], + "reg_test_5988-caching_sha2_rsa-t" : [ "no-infra-g1","@proxysql_min_version:3.1" ], "reg_test__ssl_client_busy_wait-t" : [ "legacy-g2","mysql-auto_increment_delay_multiplex=0-g2","mysql-multiplexing=false-g2","mysql-query_digests=0-g2","mysql-query_digests_keep_comment=1-g2","mysql84-g2","mysql90-g2","mysql95-g2" ], "reg_test_com_change_user_malformed_packet-t" : [ "mysql84-g6","mysql95-g1" ], "reg_test_compression_split_packets-t" : [ "legacy-g2","mysql-auto_increment_delay_multiplex=0-g2","mysql-multiplexing=false-g2","mysql-query_digests=0-g2","mysql-query_digests_keep_comment=1-g2","mysql84-g2","mysql90-g2","mysql95-g2" ], diff --git a/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp b/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp new file mode 100644 index 0000000000..48aa3f825b --- /dev/null +++ b/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp @@ -0,0 +1,302 @@ +/** + * @file reg_test_5988-caching_sha2_rsa-t.cpp + * @brief Non-TLS caching_sha2_password authentication using ProxySQL's RSA key. + * + * The bundled Connector/C cannot request a server public key, so this test uses + * an Oracle MySQL CLI when one is available. A query rule returns an OK packet + * locally, keeping the regression independent from any backend server. + */ + +#include +#include +#include +#include +#include + +#include "mysql.h" + +#include "command_line.h" +#include "proxysql_utils.h" +#include "tap.h" +#include "utils.h" + +using std::string; +using std::vector; + +static bool run_query(MYSQL* connection, const string& query) { + if (mysql_query(connection, query.c_str()) == 0) { + return true; + } + diag("Query failed: %s; query: %s", mysql_error(connection), query.c_str()); + return false; +} + +static bool query_scalar(MYSQL* connection, const string& query, string& value) { + if (!run_query(connection, query)) { + return false; + } + MYSQL_RES* result = mysql_store_result(connection); + if (result == nullptr) { + return false; + } + MYSQL_ROW row = mysql_fetch_row(result); + const bool found = row != nullptr && row[0] != nullptr; + if (found) { + value = row[0]; + } + mysql_free_result(result); + return found; +} + +static string sql_literal(MYSQL* connection, const string& value) { + string escaped(value.size() * 2 + 1, '\0'); + const unsigned long escaped_length = mysql_real_escape_string( + connection, escaped.data(), value.data(), static_cast(value.size()) + ); + escaped.resize(escaped_length); + return "'" + escaped + "'"; +} + +static bool set_global_variable(MYSQL* admin, const string& name, const string& value) { + return run_query( + admin, + "UPDATE global_variables SET variable_value=" + sql_literal(admin, value) + + " WHERE variable_name=" + sql_literal(admin, name) + ); +} + +static int run_mysql_cli( + const CommandLine& cl, + const string& username, + const string& password, + bool request_server_public_key, + const string& query, + string& output +) { + const string host_arg = "--host=" + string(cl.host); + const string port_arg = "--port=" + std::to_string(cl.port); + const string user_arg = "--user=" + username; + const string password_arg = "--password=" + password; + vector args { + "--protocol=TCP", + host_arg.c_str(), + port_arg.c_str(), + user_arg.c_str(), + password_arg.c_str(), + "--default-auth=caching_sha2_password", + "--ssl-mode=DISABLED", + "--connect-timeout=5", + "--batch", + "--skip-column-names" + }; + if (request_server_public_key) { + args.push_back("--get-server-public-key"); + } + const string execute_arg = "--execute=" + query; + args.push_back(execute_arg.c_str()); + string error_output; + const to_opts_t opts { 10 * 1000 * 1000, 0, 0, 0 }; + const int rc = wexecvp("mysql", args, opts, output, error_output); + output += error_output; + return rc; +} + +static bool unlink_if_present(const string& path) { + if (unlink(path.c_str()) == 0 || errno == ENOENT) { + return true; + } + diag("Failed to remove test key artifact '%s': errno=%d", path.c_str(), errno); + return false; +} + +int main() { + CommandLine cl; + if (cl.getEnv()) { + diag("Failed to get the required environmental variables."); + return EXIT_FAILURE; + } + + plan(8); + + string mysql_help; + const vector help_args { "mysql", "--help" }; + const int help_rc = execvp("mysql", help_args, mysql_help); + if (help_rc != 0 || + mysql_help.find("get-server-public-key") == string::npos || + mysql_help.find("ssl-mode") == string::npos) { + skip(8, "Oracle MySQL CLI with --get-server-public-key and --ssl-mode is unavailable"); + return exit_status(); + } + const char* infra_datadir = getenv("REGULAR_INFRA_DATADIR"); + if (infra_datadir == nullptr || *infra_datadir == '\0') { + skip(8, "REGULAR_INFRA_DATADIR is required to clean generated RSA key artifacts"); + return exit_status(); + } + + MYSQL* admin = mysql_init(nullptr); + const bool admin_connected = admin != nullptr && + mysql_real_connect( + admin, cl.admin_host, cl.admin_username, cl.admin_password, + nullptr, cl.admin_port, nullptr, 0) != nullptr; + ok(admin_connected, "Connected to ProxySQL Admin"); + if (!admin_connected) { + skip(7, "Cannot continue without an Admin connection"); + if (admin != nullptr) { + mysql_close(admin); + } + return exit_status(); + } + + const string suffix = std::to_string(static_cast(getpid())); + const string username = "tap5988_" + suffix; + const string password = "issue5988-secret"; + const string wrong_password = "issue5988-wrong"; + const string comment = "reg_test_5988_" + suffix; + const long rule_id = 598800000L + (static_cast(getpid()) % 100000L); + const string test_private_key = comment + "-private.pem"; + const string test_public_key = comment + "-public.pem"; + string test_key_directory = infra_datadir; + if (test_key_directory.back() != '/') { + test_key_directory.push_back('/'); + } + + string original_plugin; + string original_auto_generate; + string original_private_key; + string original_public_key; + string password_hash; + const bool have_original_plugin = query_scalar( + admin, + "SELECT variable_value FROM global_variables " + "WHERE variable_name='mysql-default_authentication_plugin'", + original_plugin); + const bool have_original_rsa_config = query_scalar( + admin, + "SELECT variable_value FROM global_variables " + "WHERE variable_name='mysql-caching_sha2_password_auto_generate_rsa_keys'", + original_auto_generate) && query_scalar( + admin, + "SELECT variable_value FROM global_variables " + "WHERE variable_name='mysql-caching_sha2_password_private_key_path'", + original_private_key) && query_scalar( + admin, + "SELECT variable_value FROM global_variables " + "WHERE variable_name='mysql-caching_sha2_password_public_key_path'", + original_public_key); + bool setup_ok = query_scalar( + admin, + "SELECT CACHING_SHA2_PASSWORD('" + password + + "','12345678901234567890')", + password_hash) && have_original_plugin && have_original_rsa_config; + setup_ok = password_hash.rfind("$A$", 0) == 0 && setup_ok; + if (setup_ok) { + setup_ok = set_global_variable( + admin, "mysql-default_authentication_plugin", "caching_sha2_password") && + set_global_variable( + admin, "mysql-caching_sha2_password_auto_generate_rsa_keys", "true") && + set_global_variable( + admin, "mysql-caching_sha2_password_private_key_path", test_private_key) && + set_global_variable( + admin, "mysql-caching_sha2_password_public_key_path", test_public_key) && + run_query(admin, "LOAD MYSQL VARIABLES TO RUNTIME"); + } + if (setup_ok) { + setup_ok = run_query( + admin, + "INSERT INTO mysql_users(username,password,active,default_hostgroup) VALUES('" + + username + "','" + password_hash + "',1,0)") && + run_query(admin, "LOAD MYSQL USERS TO RUNTIME") && + run_query( + admin, + "INSERT INTO mysql_query_rules(rule_id,active,username,match_pattern,OK_msg,apply,comment) " + "VALUES(" + std::to_string(rule_id) + ",1,'" + username + + "','^SELECT 5988$','rsa-auth-ok',1,'" + comment + "')") && + run_query(admin, "LOAD MYSQL QUERY RULES TO RUNTIME"); + } + ok(setup_ok, + "Configured a hashed caching_sha2_password frontend user and local query rule"); + + if (setup_ok) { + string output; + const int no_key_rc = run_mysql_cli( + cl, username, password, false, "SELECT 5988", output + ); + ok(no_key_rc != 0, + "Non-TLS full authentication is rejected when the client does not request the public key"); + + output.clear(); + const int wrong_password_rc = + run_mysql_cli(cl, username, wrong_password, true, "SELECT 5988", output); + ok(wrong_password_rc != 0, + "RSA full authentication rejects an incorrect password"); + + const bool disabled_ok = set_global_variable( + admin, "mysql-caching_sha2_password_auto_generate_rsa_keys", "false") && + set_global_variable( + admin, "mysql-caching_sha2_password_private_key_path", "") && + set_global_variable( + admin, "mysql-caching_sha2_password_public_key_path", "") && + run_query(admin, "LOAD MYSQL VARIABLES TO RUNTIME"); + output.clear(); + const int unavailable_rc = disabled_ok ? run_mysql_cli( + cl, username, password, true, "SELECT 5988", output + ) : 0; + ok(disabled_ok && unavailable_rc != 0 && + output.find("RSA key exchange is unavailable") != string::npos, + "Disabled RSA keys return the caching_sha2_password TLS-or-key 1045 hint"); + + const bool enabled_ok = set_global_variable( + admin, "mysql-caching_sha2_password_auto_generate_rsa_keys", "true") && + set_global_variable( + admin, "mysql-caching_sha2_password_private_key_path", test_private_key) && + set_global_variable( + admin, "mysql-caching_sha2_password_public_key_path", test_public_key) && + run_query(admin, "LOAD MYSQL VARIABLES TO RUNTIME"); + output.clear(); + const int rsa_rc = enabled_ok ? run_mysql_cli( + cl, username, password, true, "SELECT 5988", output + ) : -1; + ok(enabled_ok && rsa_rc == 0, + "Non-TLS caching_sha2_password authentication succeeds with --get-server-public-key"); + + output.clear(); + const int internal_session_rc = enabled_ok ? run_mysql_cli( + cl, username, password, true, "PROXYSQL INTERNAL SESSION", output + ) : -1; + ok(enabled_ok && internal_session_rc == 0 && output.find(password) == string::npos, + "RSA-authenticated internal-session output does not expose the recovered password"); + } else { + skip(5, "Cannot run authentication assertions after setup failure"); + } + + bool cleanup_ok = run_query( + admin, "DELETE FROM mysql_query_rules WHERE comment='" + comment + "'"); + cleanup_ok = run_query(admin, "LOAD MYSQL QUERY RULES TO RUNTIME") && cleanup_ok; + cleanup_ok = run_query( + admin, "DELETE FROM mysql_users WHERE username='" + username + "'") && cleanup_ok; + cleanup_ok = run_query(admin, "LOAD MYSQL USERS TO RUNTIME") && cleanup_ok; + if (have_original_plugin) { + cleanup_ok = set_global_variable( + admin, "mysql-default_authentication_plugin", original_plugin) && cleanup_ok; + } + if (have_original_rsa_config) { + cleanup_ok = set_global_variable( + admin, "mysql-caching_sha2_password_auto_generate_rsa_keys", + original_auto_generate) && cleanup_ok; + cleanup_ok = set_global_variable( + admin, "mysql-caching_sha2_password_private_key_path", + original_private_key) && cleanup_ok; + cleanup_ok = set_global_variable( + admin, "mysql-caching_sha2_password_public_key_path", + original_public_key) && cleanup_ok; + } + cleanup_ok = run_query(admin, "LOAD MYSQL VARIABLES TO RUNTIME") && cleanup_ok; + cleanup_ok = unlink_if_present(test_key_directory + test_private_key) && cleanup_ok; + cleanup_ok = unlink_if_present(test_key_directory + test_public_key) && cleanup_ok; + cleanup_ok = unlink_if_present(test_key_directory + test_private_key + ".lock") && cleanup_ok; + ok(cleanup_ok, + "Removed test objects and key artifacts, then restored authentication variables"); + + mysql_close(admin); + return exit_status(); +} diff --git a/test/tap/tests/unit/Makefile b/test/tap/tests/unit/Makefile index 108778f95a..e9ed0ca9ac 100644 --- a/test/tap/tests/unit/Makefile +++ b/test/tap/tests/unit/Makefile @@ -431,6 +431,10 @@ UNIT_TESTS := smoke_test-t query_cache_unit-t query_processor_unit-t \ ffto_state_machine_unit-t \ restapi_server_unit-t +ifeq ($(PROXYSQL31),1) +UNIT_TESTS += caching_sha2_rsa_unit-t +endif + # Plugin-chassis + mysqlx-plugin unit tests — built only when # libproxysql.a was compiled with -DPROXYSQL40 (autodetected higher up # in this Makefile). v3.0/v3.1 builds have no plugin loader and the diff --git a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp new file mode 100644 index 0000000000..a011bf5ad0 --- /dev/null +++ b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp @@ -0,0 +1,544 @@ +#include "tap.h" + +#include "MySQL_Caching_Sha2_RSA.h" + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +class TempDir { +public: + TempDir() { + char path_template[] = "/tmp/proxysql-caching-sha2-rsa-XXXXXX"; + char* created = mkdtemp(path_template); + if (created != nullptr) { + path_ = created; + } + } + + ~TempDir() { + if (!path_.empty()) { + unlink((path_ + "/private.pem").c_str()); + unlink((path_ + "/public.pem").c_str()); + unlink((path_ + "/traditional-private.pem").c_str()); + unlink((path_ + "/malformed-private.pem").c_str()); + unlink((path_ + "/encrypted-private.pem").c_str()); + unlink((path_ + "/encrypted-public.pem").c_str()); + unlink((path_ + "/ec-private.pem").c_str()); + unlink((path_ + "/ec-public.pem").c_str()); + unlink((path_ + "/weak-private.pem").c_str()); + unlink((path_ + "/weak-public.pem").c_str()); + unlink((path_ + "/trailing-private.pem").c_str()); + unlink((path_ + "/trailing-public.pem").c_str()); + unlink((path_ + "/private.pem.lock").c_str()); + rmdir(path_.c_str()); + } + } + + const std::string& path() const { return path_; } + +private: + std::string path_; +}; + +using EVPKeyPtr = std::unique_ptr; + +static std::string first_line(const std::string& path) { + BIO* raw_bio = BIO_new_file(path.c_str(), "r"); + if (raw_bio == nullptr) { + return {}; + } + std::unique_ptr bio(raw_bio, BIO_free); + char line[128] {}; + const int length = BIO_gets(bio.get(), line, sizeof(line)); + return length > 0 ? std::string(line, static_cast(length)) : std::string(); +} + +static bool write_traditional_private_key( + const std::string& source_path, + const std::string& destination_path +) { + BIO* raw_input = BIO_new_file(source_path.c_str(), "r"); + if (raw_input == nullptr) { + return false; + } + std::unique_ptr input(raw_input, BIO_free); + EVP_PKEY* raw_key = PEM_read_bio_PrivateKey(input.get(), nullptr, nullptr, nullptr); + if (raw_key == nullptr) { + return false; + } + std::unique_ptr key(raw_key, EVP_PKEY_free); + BIO* raw_output = BIO_new_file(destination_path.c_str(), "w"); + if (raw_output == nullptr) { + return false; + } + std::unique_ptr output(raw_output, BIO_free); + const bool written = PEM_write_bio_PrivateKey_traditional( + output.get(), key.get(), nullptr, nullptr, 0, nullptr, nullptr + ) == 1; + return written && chmod(destination_path.c_str(), 0600) == 0; +} + +static EVPKeyPtr read_private_key(const std::string& path) { + BIO* raw_bio = BIO_new_file(path.c_str(), "r"); + if (raw_bio == nullptr) { + return EVPKeyPtr(nullptr, EVP_PKEY_free); + } + std::unique_ptr bio(raw_bio, BIO_free); + return EVPKeyPtr( + PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr), EVP_PKEY_free + ); +} + +static EVPKeyPtr generate_rsa_key(int bits) { + EVP_PKEY_CTX* raw_context = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr); + if (raw_context == nullptr) { + return EVPKeyPtr(nullptr, EVP_PKEY_free); + } + std::unique_ptr context( + raw_context, EVP_PKEY_CTX_free + ); + EVP_PKEY* raw_key = nullptr; + if (EVP_PKEY_keygen_init(context.get()) <= 0 || + EVP_PKEY_CTX_set_rsa_keygen_bits(context.get(), bits) <= 0 || + EVP_PKEY_keygen(context.get(), &raw_key) <= 0) { + EVP_PKEY_free(raw_key); + return EVPKeyPtr(nullptr, EVP_PKEY_free); + } + return EVPKeyPtr(raw_key, EVP_PKEY_free); +} + +static EVPKeyPtr generate_ec_key() { + EVP_PKEY_CTX* raw_context = EVP_PKEY_CTX_new_id(EVP_PKEY_EC, nullptr); + if (raw_context == nullptr) { + return EVPKeyPtr(nullptr, EVP_PKEY_free); + } + std::unique_ptr context( + raw_context, EVP_PKEY_CTX_free + ); + EVP_PKEY* raw_key = nullptr; + if (EVP_PKEY_keygen_init(context.get()) <= 0 || + EVP_PKEY_CTX_set_ec_paramgen_curve_nid( + context.get(), NID_X9_62_prime256v1 + ) <= 0 || EVP_PKEY_keygen(context.get(), &raw_key) <= 0) { + EVP_PKEY_free(raw_key); + return EVPKeyPtr(nullptr, EVP_PKEY_free); + } + return EVPKeyPtr(raw_key, EVP_PKEY_free); +} + +static bool write_pkcs8_key_pair( + EVP_PKEY* key, + const std::string& private_path, + const std::string& public_path, + bool encrypted = false +) { + if (key == nullptr) { + return false; + } + BIO* raw_private = BIO_new_file(private_path.c_str(), "w"); + if (raw_private == nullptr) { + return false; + } + std::unique_ptr private_bio(raw_private, BIO_free); + char passphrase[] = "test-passphrase"; + if (PEM_write_bio_PKCS8PrivateKey( + private_bio.get(), key, encrypted ? EVP_aes_256_cbc() : nullptr, + encrypted ? passphrase : nullptr, + encrypted ? static_cast(sizeof(passphrase) - 1) : 0, + nullptr, nullptr + ) != 1 || chmod(private_path.c_str(), 0600) != 0) { + return false; + } + BIO* raw_public = BIO_new_file(public_path.c_str(), "w"); + if (raw_public == nullptr) { + return false; + } + std::unique_ptr public_bio(raw_public, BIO_free); + return PEM_write_bio_PUBKEY(public_bio.get(), key) == 1 && + chmod(public_path.c_str(), 0644) == 0; +} + +static bool write_malformed_private_key(const std::string& path) { + BIO* raw_bio = BIO_new_file(path.c_str(), "w"); + if (raw_bio == nullptr) { + return false; + } + std::unique_ptr bio(raw_bio, BIO_free); + return BIO_puts(bio.get(), "-----BEGIN PRIVATE KEY-----\nnot-a-key\n") > 0 && + chmod(path.c_str(), 0600) == 0; +} + +static bool append_text(const std::string& path, const char* text) { + BIO* raw_bio = BIO_new_file(path.c_str(), "a"); + if (raw_bio == nullptr) { + return false; + } + std::unique_ptr bio(raw_bio, BIO_free); + return BIO_puts(bio.get(), text) > 0; +} + +static std::vector encrypt_password_payload( + const CachingSha2RSAKeySnapshot& snapshot, + const std::vector& cleartext, + const unsigned char* scramble, + size_t scramble_length +) { + std::vector scrambled = cleartext; + for (size_t index = 0; index < scrambled.size(); ++index) { + scrambled[index] ^= scramble[index % scramble_length]; + } + + BIO* raw_bio = BIO_new_mem_buf( + snapshot.public_key_pem().data(), + static_cast(snapshot.public_key_pem().size()) + ); + if (raw_bio == nullptr) { + return {}; + } + std::unique_ptr bio(raw_bio, BIO_free); + EVP_PKEY* raw_key = PEM_read_bio_PUBKEY(bio.get(), nullptr, nullptr, nullptr); + if (raw_key == nullptr) { + return {}; + } + std::unique_ptr key(raw_key, EVP_PKEY_free); + EVP_PKEY_CTX* raw_context = EVP_PKEY_CTX_new(key.get(), nullptr); + if (raw_context == nullptr) { + return {}; + } + std::unique_ptr context( + raw_context, EVP_PKEY_CTX_free + ); + if (EVP_PKEY_encrypt_init(context.get()) <= 0 || + EVP_PKEY_CTX_set_rsa_padding(context.get(), RSA_PKCS1_OAEP_PADDING) <= 0 || + EVP_PKEY_CTX_set_rsa_oaep_md(context.get(), EVP_sha1()) <= 0 || + EVP_PKEY_CTX_set_rsa_mgf1_md(context.get(), EVP_sha1()) <= 0) { + return {}; + } + size_t ciphertext_length = 0; + if (EVP_PKEY_encrypt( + context.get(), nullptr, &ciphertext_length, scrambled.data(), scrambled.size() + ) <= 0) { + return {}; + } + std::vector ciphertext(ciphertext_length); + if (EVP_PKEY_encrypt( + context.get(), ciphertext.data(), &ciphertext_length, scrambled.data(), scrambled.size() + ) <= 0) { + return {}; + } + ciphertext.resize(ciphertext_length); + return ciphertext; +} + +int main() { + plan(45); + + MySQL_Caching_Sha2_RSA manager; + CachingSha2RSAConfig config; + config.auto_generate = false; + + const CachingSha2RSAReloadResult result = manager.reload(config); + + ok(result.accepted, + "empty key paths are accepted when automatic generation is disabled"); + ok(manager.acquire() == nullptr, + "accepted empty key paths leave RSA authentication unavailable"); + + TempDir temp_dir; + ok(!temp_dir.path().empty(), "created an isolated key directory"); + + config.auto_generate = true; + config.datadir = temp_dir.path(); + config.private_key_path = "private.pem"; + config.public_key_path = "public.pem"; + const mode_t previous_umask = umask(0077); + const CachingSha2RSAReloadResult generated = manager.reload(config); + umask(previous_umask); + const auto snapshot = manager.acquire(); + + ok(generated.accepted && generated.available, + "missing key pair is generated when automatic generation is enabled"); + ok(snapshot != nullptr, "generated key pair is published as an active snapshot"); + if (snapshot == nullptr) { + return exit_status(); + } + + ok(snapshot->public_key_pem().find("-----BEGIN PUBLIC KEY-----") == 0, + "snapshot exposes a canonical PKIX public key"); + ok(snapshot->ciphertext_size() == 256, + "generated RSA-2048 key accepts a 256-byte ciphertext"); + + struct stat private_stat {}; + struct stat public_stat {}; + const int private_stat_rc = stat((temp_dir.path() + "/private.pem").c_str(), &private_stat); + const int public_stat_rc = stat((temp_dir.path() + "/public.pem").c_str(), &public_stat); + ok(private_stat_rc == 0 && (private_stat.st_mode & 0777) == 0600, + "generated private key is stored with mode 0600"); + ok(public_stat_rc == 0 && (public_stat.st_mode & 0777) == 0644, + "generated public key is stored with mode 0644"); + ok(first_line(temp_dir.path() + "/private.pem") == "-----BEGIN PRIVATE KEY-----\n", + "generated private key uses unencrypted PKCS#8 PEM format"); + + const CachingSha2RSAReloadResult unchanged = manager.reload(config); + ok(unchanged.accepted, "an unchanged valid key pair reload is accepted"); + ok(!unchanged.changed, "an unchanged valid key pair reload is a no-op"); + ok(manager.acquire() == snapshot, "an unchanged reload retains the published snapshot"); + + unsigned char scramble[20]; + for (size_t index = 0; index < sizeof(scramble); ++index) { + scramble[index] = static_cast(index + 1); + } + const std::string expected_password = "s3cret-password"; + std::vector cleartext(expected_password.begin(), expected_password.end()); + cleartext.push_back('\0'); + const std::vector ciphertext = encrypt_password_payload( + *snapshot, cleartext, scramble, sizeof(scramble) + ); + ok(ciphertext.size() == snapshot->ciphertext_size(), + "test client produced an RSA OAEP/SHA-1 ciphertext"); + std::string decrypted_password; + ok(manager.decrypt_password( + snapshot, ciphertext.data(), ciphertext.size(), scramble, sizeof(scramble), decrypted_password + ), "RSA manager decrypts a MySQL caching_sha2_password payload"); + ok(decrypted_password == expected_password, + "RSA manager reverses the scramble XOR and strips the trailing NUL"); + ok(!manager.decrypt_password( + snapshot, ciphertext.data(), ciphertext.size() - 1, scramble, sizeof(scramble), decrypted_password + ), "RSA manager rejects ciphertext with the wrong size"); + + const std::vector malformed_cleartext { 'n', 'o', '-', 'n', 'u', 'l' }; + const std::vector malformed_ciphertext = encrypt_password_payload( + *snapshot, malformed_cleartext, scramble, sizeof(scramble) + ); + ok(!malformed_ciphertext.empty(), "test client encrypted malformed plaintext"); + ok(!manager.decrypt_password( + snapshot, malformed_ciphertext.data(), malformed_ciphertext.size(), + scramble, sizeof(scramble), decrypted_password + ), "RSA manager rejects decrypted plaintext without one trailing NUL"); + + CachingSha2RSAConfig invalid_config = config; + invalid_config.public_key_path.clear(); + const CachingSha2RSAReloadResult partial_paths = manager.reload(invalid_config); + ok(!partial_paths.accepted, "reload rejects a configuration with only one key path"); + ok(manager.acquire() == snapshot, "rejected path configuration preserves the active snapshot"); + + chmod((temp_dir.path() + "/private.pem").c_str(), 0644); + const CachingSha2RSAReloadResult insecure_permissions = manager.reload(config); + ok(!insecure_permissions.accepted, "reload rejects group-readable private keys"); + ok(manager.acquire() == snapshot, "rejected private-key permissions preserve the active snapshot"); + chmod((temp_dir.path() + "/private.pem").c_str(), 0600); + + const std::string public_link = temp_dir.path() + "/public-link.pem"; + const int symlink_rc = symlink((temp_dir.path() + "/public.pem").c_str(), public_link.c_str()); + CachingSha2RSAConfig symlink_config = config; + symlink_config.public_key_path = "public-link.pem"; + const CachingSha2RSAReloadResult symlink_result = manager.reload(symlink_config); + ok(symlink_rc == 0 && !symlink_result.accepted, + "reload rejects a symbolic link used as a key path"); + ok(manager.acquire() == snapshot, + "rejected symbolic-link configuration preserves the active snapshot"); + unlink(public_link.c_str()); + + TempDir rotated_dir; + ok(!rotated_dir.path().empty(), "created an isolated rotation directory"); + CachingSha2RSAConfig rotated_config = config; + rotated_config.datadir = rotated_dir.path(); + const CachingSha2RSAReloadResult rotated = manager.reload(rotated_config); + const auto rotated_snapshot = manager.acquire(); + ok(rotated.accepted && rotated.changed && rotated.available, + "reload publishes a newly generated valid key pair"); + ok(rotated_snapshot != nullptr && rotated_snapshot != snapshot, + "key rotation atomically replaces the acquired snapshot"); + ok(manager.decrypt_password( + snapshot, ciphertext.data(), ciphertext.size(), scramble, sizeof(scramble), decrypted_password + ) && decrypted_password == expected_password, + "an acquired old snapshot remains usable after key rotation"); + + CachingSha2RSAConfig mismatched_config; + mismatched_config.auto_generate = false; + mismatched_config.private_key_path = temp_dir.path() + "/private.pem"; + mismatched_config.public_key_path = rotated_dir.path() + "/public.pem"; + const CachingSha2RSAReloadResult mismatched = manager.reload(mismatched_config); + ok(!mismatched.accepted, "reload rejects mismatched RSA private and public keys"); + ok(manager.acquire() == rotated_snapshot, + "rejected mismatched keys preserve the rotated snapshot"); + + CachingSha2RSAConfig missing_config; + missing_config.auto_generate = false; + missing_config.datadir = rotated_dir.path(); + missing_config.private_key_path = "missing-private.pem"; + missing_config.public_key_path = "missing-public.pem"; + const CachingSha2RSAReloadResult missing = manager.reload(missing_config); + ok(!missing.accepted, "reload rejects missing configured keys when generation is disabled"); + ok(manager.acquire() == rotated_snapshot, + "rejected missing keys preserve the rotated snapshot"); + + const std::string escaped_parent = temp_dir.path() + "/escaped-parent"; + const int parent_symlink_rc = symlink(rotated_dir.path().c_str(), escaped_parent.c_str()); + CachingSha2RSAConfig escaped_config; + escaped_config.auto_generate = false; + escaped_config.datadir = temp_dir.path(); + escaped_config.private_key_path = "escaped-parent/private.pem"; + escaped_config.public_key_path = "escaped-parent/public.pem"; + const CachingSha2RSAReloadResult escaped = manager.reload(escaped_config); + ok(parent_symlink_rc == 0 && !escaped.accepted, + "relative key paths cannot escape the datadir through a symlinked parent"); + unlink(escaped_parent.c_str()); + manager.reload(rotated_config); + + const std::string lexical_private = + "proxysql-rsa-escape-private-" + std::to_string(static_cast(getpid())) + ".pem"; + const std::string lexical_public = + "proxysql-rsa-escape-public-" + std::to_string(static_cast(getpid())) + ".pem"; + CachingSha2RSAConfig lexical_escape_config; + lexical_escape_config.auto_generate = true; + lexical_escape_config.datadir = temp_dir.path(); + lexical_escape_config.private_key_path = "../" + lexical_private; + lexical_escape_config.public_key_path = "../" + lexical_public; + const CachingSha2RSAReloadResult lexical_escape = manager.reload(lexical_escape_config); + const std::string lexical_private_path = "/tmp/" + lexical_private; + const std::string lexical_public_path = "/tmp/" + lexical_public; + ok(!lexical_escape.accepted && access(lexical_private_path.c_str(), F_OK) != 0 && + access(lexical_public_path.c_str(), F_OK) != 0, + "relative parent-directory components cannot generate keys outside the datadir"); + unlink(lexical_private_path.c_str()); + unlink(lexical_public_path.c_str()); + + const std::string traditional_path = temp_dir.path() + "/traditional-private.pem"; + const bool traditional_written = write_traditional_private_key( + temp_dir.path() + "/private.pem", traditional_path + ); + CachingSha2RSAConfig traditional_config; + traditional_config.auto_generate = false; + traditional_config.private_key_path = traditional_path; + traditional_config.public_key_path = temp_dir.path() + "/public.pem"; + const CachingSha2RSAReloadResult traditional = manager.reload(traditional_config); + ok(traditional_written && !traditional.accepted, + "reload rejects a traditional PKCS#1 RSA private-key PEM"); + + const std::string malformed_path = temp_dir.path() + "/malformed-private.pem"; + const bool malformed_written = write_malformed_private_key(malformed_path); + CachingSha2RSAConfig malformed_config = traditional_config; + malformed_config.private_key_path = malformed_path; + const CachingSha2RSAReloadResult malformed = manager.reload(malformed_config); + ok(malformed_written && !malformed.accepted, + "reload rejects malformed PKCS#8 private-key data"); + + EVPKeyPtr encrypted_key = read_private_key(temp_dir.path() + "/private.pem"); + const std::string encrypted_private = temp_dir.path() + "/encrypted-private.pem"; + const std::string encrypted_public = temp_dir.path() + "/encrypted-public.pem"; + const bool encrypted_written = write_pkcs8_key_pair( + encrypted_key.get(), encrypted_private, encrypted_public, true + ); + CachingSha2RSAConfig encrypted_config; + encrypted_config.auto_generate = false; + encrypted_config.private_key_path = encrypted_private; + encrypted_config.public_key_path = encrypted_public; + const CachingSha2RSAReloadResult encrypted = manager.reload(encrypted_config); + ok(encrypted_written && !encrypted.accepted, + "reload rejects an encrypted PKCS#8 RSA private key"); + + EVPKeyPtr ec_key = generate_ec_key(); + const std::string ec_private = temp_dir.path() + "/ec-private.pem"; + const std::string ec_public = temp_dir.path() + "/ec-public.pem"; + const bool ec_written = write_pkcs8_key_pair( + ec_key.get(), ec_private, ec_public + ); + CachingSha2RSAConfig ec_config; + ec_config.auto_generate = false; + ec_config.private_key_path = ec_private; + ec_config.public_key_path = ec_public; + const CachingSha2RSAReloadResult ec = manager.reload(ec_config); + ok(ec_written && !ec.accepted, + "reload rejects a matching non-RSA PKCS#8 key pair"); + + const std::string trailing_private = temp_dir.path() + "/trailing-private.pem"; + const std::string trailing_public = temp_dir.path() + "/trailing-public.pem"; + const bool trailing_private_written = write_pkcs8_key_pair( + encrypted_key.get(), trailing_private, trailing_public + ) && append_text(trailing_private, "unexpected trailing data\n"); + CachingSha2RSAConfig trailing_config; + trailing_config.auto_generate = false; + trailing_config.private_key_path = trailing_private; + trailing_config.public_key_path = trailing_public; + const CachingSha2RSAReloadResult trailing_private_result = manager.reload(trailing_config); + ok(trailing_private_written && !trailing_private_result.accepted, + "reload rejects trailing data after a PKCS#8 private key"); + + const bool trailing_public_written = write_pkcs8_key_pair( + encrypted_key.get(), trailing_private, trailing_public + ) && append_text(trailing_public, "unexpected trailing data\n"); + const CachingSha2RSAReloadResult trailing_public_result = manager.reload(trailing_config); + ok(trailing_public_written && !trailing_public_result.accepted, + "reload rejects trailing data after an SPKI public key"); + + EVPKeyPtr weak_key = generate_rsa_key(1024); + const std::string weak_private = temp_dir.path() + "/weak-private.pem"; + const std::string weak_public = temp_dir.path() + "/weak-public.pem"; + if (weak_key == nullptr) { + skip(1, "active OpenSSL provider forbids RSA-1024 test-key generation"); + } else { + const bool weak_written = write_pkcs8_key_pair( + weak_key.get(), weak_private, weak_public + ); + CachingSha2RSAConfig weak_config; + weak_config.auto_generate = false; + weak_config.private_key_path = weak_private; + weak_config.public_key_path = weak_public; + const CachingSha2RSAReloadResult weak = manager.reload(weak_config); + ok(weak_written && !weak.accepted, + "reload rejects a matching RSA key pair weaker than 2048 bits"); + } + + TempDir collision_dir; + CachingSha2RSAConfig collision_config; + collision_config.auto_generate = true; + collision_config.datadir = collision_dir.path(); + collision_config.private_key_path = "private.pem"; + collision_config.public_key_path = "private.pem.lock"; + const CachingSha2RSAReloadResult collision = manager.reload(collision_config); + ok(!collision.accepted && + access((collision_dir.path() + "/private.pem").c_str(), F_OK) != 0 && + access((collision_dir.path() + "/private.pem.lock").c_str(), F_OK) != 0, + "generation rejects a public target that collides with the lock namespace without creating files"); + + TempDir concurrent_dir; + CachingSha2RSAConfig concurrent_config; + concurrent_config.auto_generate = true; + concurrent_config.datadir = concurrent_dir.path(); + concurrent_config.private_key_path = "private.pem"; + concurrent_config.public_key_path = "public.pem"; + MySQL_Caching_Sha2_RSA concurrent_manager_one; + MySQL_Caching_Sha2_RSA concurrent_manager_two; + CachingSha2RSAReloadResult concurrent_result_one; + CachingSha2RSAReloadResult concurrent_result_two; + std::thread first_reload([&]() { + concurrent_result_one = concurrent_manager_one.reload(concurrent_config); + }); + std::thread second_reload([&]() { + concurrent_result_two = concurrent_manager_two.reload(concurrent_config); + }); + first_reload.join(); + second_reload.join(); + const auto concurrent_snapshot_one = concurrent_manager_one.acquire(); + const auto concurrent_snapshot_two = concurrent_manager_two.acquire(); + ok(!concurrent_dir.path().empty() && concurrent_result_one.accepted && + concurrent_result_two.accepted && concurrent_snapshot_one != nullptr && + concurrent_snapshot_two != nullptr, + "concurrent managers both load a safely generated RSA pair"); + ok(concurrent_snapshot_one != nullptr && concurrent_snapshot_two != nullptr && + concurrent_snapshot_one->public_key_pem() == concurrent_snapshot_two->public_key_pem(), + "concurrent generation publishes one consistent key pair"); + + return exit_status(); +} diff --git a/test/tap/tests/unit/mysql_variables_unit-t.cpp b/test/tap/tests/unit/mysql_variables_unit-t.cpp index fcf42acfcb..e70e06a3ef 100644 --- a/test/tap/tests/unit/mysql_variables_unit-t.cpp +++ b/test/tap/tests/unit/mysql_variables_unit-t.cpp @@ -2,6 +2,24 @@ #include "test_globals.h" #include "MySQL_Thread.h" +#ifdef PROXYSQL31 +#include "MySQL_Caching_Sha2_RSA.h" +#endif + +#include +#include +#include + +#include + +static bool contains_variable(char **variables, const char *name) { + for (char **current = variables; current != nullptr && *current != nullptr; ++current) { + if (strcmp(*current, name) == 0) { + return true; + } + } + return false; +} static void test_mysql_integer_variables_are_registered() { test_globals_init(); @@ -13,6 +31,31 @@ static void test_mysql_integer_variables_are_registered() { ok(handler.get_variable_int("session_track_variables") == 0, "session_track_variables is registered as an integer variable"); +#ifdef PROXYSQL31 + ok(contains_variable(variables, "caching_sha2_password_auto_generate_rsa_keys"), + "caching_sha2 RSA auto-generation variable is registered in 3.1"); + ok(contains_variable(variables, "caching_sha2_password_private_key_path"), + "caching_sha2 RSA private-key path variable is registered in 3.1"); + ok(contains_variable(variables, "caching_sha2_password_public_key_path"), + "caching_sha2 RSA public-key path variable is registered in 3.1"); + + char auto_generate_name[] = "caching_sha2_password_auto_generate_rsa_keys"; + char private_path_name[] = "caching_sha2_password_private_key_path"; + char public_path_name[] = "caching_sha2_password_public_key_path"; + char *auto_generate = handler.get_variable(auto_generate_name); + char *private_path = handler.get_variable(private_path_name); + char *public_path = handler.get_variable(public_path_name); + ok(auto_generate != nullptr && strcmp(auto_generate, "true") == 0, + "caching_sha2 RSA auto-generation defaults to true"); + ok(private_path != nullptr && strcmp(private_path, "proxysql-caching-sha2-private-key.pem") == 0, + "caching_sha2 RSA private-key path has the compiled default"); + ok(public_path != nullptr && strcmp(public_path, "proxysql-caching-sha2-public-key.pem") == 0, + "caching_sha2 RSA public-key path has the compiled default"); + free(auto_generate); + free(private_path); + free(public_path); +#endif + if (variables) { for (char **p = variables; *p != nullptr; ++p) { free(*p); @@ -44,9 +87,130 @@ static void test_mysql_integer_boolean_aliases() { test_globals_cleanup(); } +#ifdef PROXYSQL31 +static void free_variables_list(char **variables) { + if (variables != nullptr) { + for (char **current = variables; *current != nullptr; ++current) { + free(*current); + } + free(variables); + } +} + +static void test_caching_sha2_rsa_commit_is_atomic() { + test_globals_init(); + char path_template[] = "/tmp/proxysql-mth-caching-sha2-rsa-XXXXXX"; + char *temporary_directory = mkdtemp(path_template); + ok(temporary_directory != nullptr, "created an isolated handler RSA directory"); + if (temporary_directory == nullptr) { + test_globals_cleanup(); + return; + } + free(GloVars.datadir); + GloVars.datadir = strdup(temporary_directory); + + { + MySQL_Threads_Handler handler; + free_variables_list(handler.get_variables_list()); + char auto_name[] = "caching_sha2_password_auto_generate_rsa_keys"; + char private_name[] = "caching_sha2_password_private_key_path"; + char public_name[] = "caching_sha2_password_public_key_path"; + + handler.set_variable(auto_name, "false"); + handler.set_variable(private_name, ""); + handler.set_variable(public_name, ""); + const MySQLThreadsCommitResult disabled = handler.commit(); + ok(disabled.rejected_variables == 0, + "commit accepts intentional RSA unavailability"); + ok(handler.caching_sha2_rsa()->acquire() == nullptr, + "intentional RSA unavailability publishes no snapshot"); + + handler.set_variable(auto_name, "true"); + const MySQLThreadsCommitResult invalid_empty = handler.commit(); + ok(invalid_empty.rejected_variables == 3, + "invalid grouped RSA reload rejects all three variables"); + ok(handler.get_variable_int(auto_name) == 0, + "invalid grouped reload restores the accepted boolean value"); + char *restored_private = handler.get_variable(private_name); + char *restored_public = handler.get_variable(public_name); + ok(restored_private != nullptr && restored_private[0] == '\0' && + restored_public != nullptr && restored_public[0] == '\0', + "invalid grouped reload restores both accepted paths"); + free(restored_private); + free(restored_public); + + handler.set_variable(auto_name, "true"); + handler.set_variable(private_name, "rsa-private.pem"); + handler.set_variable(public_name, "rsa-public.pem"); + const MySQLThreadsCommitResult generated = handler.commit(); + const auto generated_snapshot = handler.caching_sha2_rsa()->acquire(); + ok(generated.rejected_variables == 0, + "commit accepts and generates a complete RSA key pair"); + ok(generated_snapshot != nullptr, + "accepted generated pair is visible through the handler-owned manager"); + + handler.set_variable(auto_name, "false"); + handler.set_variable(public_name, "missing-public.pem"); + const MySQLThreadsCommitResult missing_public = handler.commit(); + ok(missing_public.rejected_variables == 3, + "commit rejects a partial on-disk key pair as one grouped update"); + ok(handler.caching_sha2_rsa()->acquire() == generated_snapshot, + "rejected handler reload preserves the previously published snapshot"); + char *restored_public_after_partial = handler.get_variable(public_name); + ok(handler.get_variable_int(auto_name) == 1 && + restored_public_after_partial != nullptr && + strcmp(restored_public_after_partial, "rsa-public.pem") == 0, + "rejected handler reload restores all prior accepted runtime values"); + free(restored_public_after_partial); + } + + const std::string default_private = + std::string(temporary_directory) + "/proxysql-caching-sha2-private-key.pem"; + const int partial_fd = open(default_private.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0600); + if (partial_fd >= 0) { + close(partial_fd); + } + { + MySQL_Threads_Handler handler; + free_variables_list(handler.get_variables_list()); + const MySQLThreadsCommitResult initial_invalid = handler.commit(); + ok(partial_fd >= 0 && initial_invalid.rejected_variables == 3 && + handler.caching_sha2_rsa()->acquire() == nullptr, + "initial invalid default key pair is rejected without publishing a snapshot"); + + char auto_name[] = "caching_sha2_password_auto_generate_rsa_keys"; + char private_name[] = "caching_sha2_password_private_key_path"; + char public_name[] = "caching_sha2_password_public_key_path"; + char *fallback_private = handler.get_variable(private_name); + char *fallback_public = handler.get_variable(public_name); + ok(handler.get_variable_int(auto_name) == 0 && + fallback_private != nullptr && fallback_private[0] == '\0' && + fallback_public != nullptr && fallback_public[0] == '\0', + "failed initial defaults adopt an explicit TLS-only runtime configuration"); + free(fallback_private); + free(fallback_public); + } + unlink(default_private.c_str()); + + const std::string directory = temporary_directory; + unlink((directory + "/rsa-private.pem").c_str()); + unlink((directory + "/rsa-public.pem").c_str()); + unlink((directory + "/rsa-private.pem.lock").c_str()); + rmdir(directory.c_str()); + test_globals_cleanup(); +} +#endif + int main() { +#ifdef PROXYSQL31 + plan(23); +#else plan(4); +#endif test_mysql_integer_variables_are_registered(); test_mysql_integer_boolean_aliases(); +#ifdef PROXYSQL31 + test_caching_sha2_rsa_commit_is_atomic(); +#endif return exit_status(); } diff --git a/test/tap/tests/unit/protocol_unit-t.cpp b/test/tap/tests/unit/protocol_unit-t.cpp index 83db246500..43af23468b 100644 --- a/test/tap/tests/unit/protocol_unit-t.cpp +++ b/test/tap/tests/unit/protocol_unit-t.cpp @@ -21,13 +21,19 @@ #include "proxysql.h" #include "MySQL_Protocol.h" +#include "MySQL_Data_Stream.h" +#include "mysql_connection.h" #include "MySQL_encode.h" #include "c_tokenizer.h" #include "gen_utils.h" +#include + #include #include +mf_unique_ptr get_masked_pass(const char* pass); + // ============================================================================ // 1. MySQL length-encoded integer decoding // ============================================================================ @@ -217,6 +223,109 @@ static void test_mysql_hdr() { "mysql_hdr: max pkt_length (16MB-1)"); } +#ifdef PROXYSQL31 +static void test_auth_more_data_packet() { + MySQL_Data_Stream stream; + stream.PSarrayOUT = new PtrSizeArray(); + stream.pkt_sid = 7; + MySQL_Data_Stream *stream_pointer = &stream; + MySQL_Protocol protocol; + protocol.myds = &stream_pointer; + const unsigned char public_key[] = "-----BEGIN PUBLIC KEY-----\nkey\n-----END PUBLIC KEY-----\n"; + const size_t public_key_length = sizeof(public_key) - 1; + + protocol.generate_auth_more_data(public_key, public_key_length); + + ok(stream.PSarrayOUT->len == 1, + "AuthMoreData queues exactly one packet"); + const PtrSize_t packet = stream.PSarrayOUT->pdata[0]; + const mysql_hdr *header = static_cast(packet.ptr); + ok(packet.size == sizeof(mysql_hdr) + 1 + public_key_length && + header->pkt_length == 1 + public_key_length && header->pkt_id == 8, + "AuthMoreData header accounts for marker and exact data length"); + const unsigned char *payload = static_cast(packet.ptr) + sizeof(mysql_hdr); + ok(payload[0] == 0x01, + "AuthMoreData payload starts with the 0x01 protocol marker"); + ok(memcmp(payload + 1, public_key, public_key_length) == 0 && + packet.size == sizeof(mysql_hdr) + 1 + public_key_length, + "AuthMoreData carries public PEM bytes without a terminating NUL"); + + l_free(packet.size, packet.ptr); + stream.PSarrayOUT->len = 0; +} + +static void test_caching_sha2_stage5_payload_validation() { + auto run_stage5 = []( + bool encrypted, + unsigned char *payload, + size_t payload_length, + std::string* recovered = nullptr, + bool* pass_is_sensitive = nullptr + ) { + MySQL_Data_Stream stream; + stream.myds_type = MYDS_FRONTEND; + stream.myconn = new MySQL_Connection(); + stream.myconn->userinfo->username = strdup("rsa-stage5-user"); + stream.switching_auth_stage = 5; + stream.switching_auth_type = AUTH_MYSQL_CACHING_SHA2_PASSWORD; + stream.encrypted = encrypted; + MySQL_Data_Stream *stream_pointer = &stream; + MySQL_Protocol protocol; + protocol.myds = &stream_pointer; + MyProt_tmp_auth_vars vars; + bool authenticated = true; + const int result = protocol.PPHR_1( + payload, static_cast(sizeof(mysql_hdr) + payload_length), + authenticated, vars + ); + if (recovered != nullptr && vars.pass != nullptr) { + recovered->assign(reinterpret_cast(vars.pass), vars.pass_len); + } + if (pass_is_sensitive != nullptr) { + *pass_is_sensitive = vars.pass_is_sensitive; + } + if (vars.pass != nullptr) { + OPENSSL_cleanse(vars.pass, vars.pass_len + 1); + } + free(vars.pass); + return result; + }; + + unsigned char raw_cleartext[] = { 's', 'e', 'c', 'r', 'e', 't', '\0' }; + ok(run_stage5(false, raw_cleartext, sizeof(raw_cleartext)) == 1, + "non-TLS caching_sha2 stage 5 rejects raw cleartext instead of bypassing RSA"); + + // A NUL exists just beyond the declared payload. An unbounded strlen() + // incorrectly accepts this packet by reading outside its protocol length. + unsigned char unterminated_storage[] = { 'n', 'o', '\0' }; + ok(run_stage5(true, unterminated_storage, 2) == 1, + "caching_sha2 stage 5 rejects a payload without an in-bounds trailing NUL"); + + unsigned char tls_cleartext[] = { 's', 'e', 'c', 'r', 'e', 't', '\0' }; + std::string recovered; + bool pass_is_sensitive = false; + ok(run_stage5( + true, tls_cleartext, sizeof(tls_cleartext), &recovered, &pass_is_sensitive + ) == 2 && recovered == "secret", + "TLS caching_sha2 stage 5 accepts exactly one trailing-NUL cleartext payload"); + ok(pass_is_sensitive, + "TLS caching_sha2 stage 5 marks copied cleartext for cleansing"); +} + +static void test_internal_session_redacts_password() { + MySQL_Data_Stream stream; + stream.myds_type = MYDS_FRONTEND; + stream.myconn = new MySQL_Connection(); + stream.myconn->userinfo->username = strdup("rsa-dump-user"); + stream.myconn->userinfo->password = strdup("recovered-rsa-secret"); + nlohmann::json internal_session; + stream.get_client_myds_info_json(internal_session); + const std::string serialized = internal_session.dump(); + ok(serialized.find("recovered-rsa-secret") == std::string::npos, + "internal-session JSON never exposes a frontend password"); +} +#endif + // ============================================================================ // 4. CPY3 and CPY8 byte copy helpers // ============================================================================ @@ -347,6 +456,16 @@ static void test_escape_single_quotes() { free(escaped3); } +static void test_password_log_redaction() { + auto short_password = get_masked_pass("xy"); + auto long_password = get_masked_pass("a-much-longer-secret"); + + ok(strcmp(short_password.get(), "(redacted)") == 0, + "password logging fully redacts short credentials"); + ok(strcmp(long_password.get(), "(redacted)") == 0, + "password logging fully redacts long credentials"); +} + /** * @brief Test mywildcmp() — wildcard pattern matching with % and _. */ @@ -391,7 +510,11 @@ static void test_wildcard_matching() { // ============================================================================ int main() { - plan(43); +#ifdef PROXYSQL31 + plan(54); +#else + plan(45); +#endif test_init_minimal(); @@ -405,6 +528,11 @@ int main() { // Packet header test_mysql_hdr(); // 3 tests +#ifdef PROXYSQL31 + test_auth_more_data_packet(); // 4 tests + test_caching_sha2_stage5_payload_validation(); // 4 tests + test_internal_session_redacts_password(); // 1 test +#endif // Byte copy helpers test_cpy3(); // 2 tests @@ -416,8 +544,9 @@ int main() { // String utilities test_escape_single_quotes(); // 3 tests + test_password_log_redaction(); // 2 tests test_wildcard_matching(); // 12 tests - // Total: 3+2+1+2+6+1+3+2+2+4+2+3+12 = 43 + // Total on the stable tier: 3+2+1+2+6+1+3+2+2+4+2+3+2+12 = 45 test_cleanup_minimal(); From f5f4794228cbccc5f3274981d80529bb83a07aff Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 18:09:26 +0000 Subject: [PATCH 02/18] fix: persist rejected caching SHA-2 RSA config --- include/MySQL_Thread.h | 3 +- lib/Admin_FlushVariables.cpp | 40 +++++- lib/MySQL_Thread.cpp | 6 +- .../reg_test_5988-caching_sha2_rsa-t.cpp | 56 ++++++++- .../tap/tests/unit/mysql_variables_unit-t.cpp | 115 +++++++++++++++++- 5 files changed, 205 insertions(+), 15 deletions(-) diff --git a/include/MySQL_Thread.h b/include/MySQL_Thread.h index 01d816c7f8..d715655892 100644 --- a/include/MySQL_Thread.h +++ b/include/MySQL_Thread.h @@ -15,6 +15,7 @@ #include #include #include +#include #include "prometheus_helpers.h" @@ -45,7 +46,7 @@ class MySQL_Caching_Sha2_RSA; #endif struct MySQLThreadsCommitResult { - unsigned int rejected_variables { 0 }; + std::vector rejected_variables; }; #ifdef IDLE_THREADS diff --git a/lib/Admin_FlushVariables.cpp b/lib/Admin_FlushVariables.cpp index ca12901952..0fa0016e66 100644 --- a/lib/Admin_FlushVariables.cpp +++ b/lib/Admin_FlushVariables.cpp @@ -465,6 +465,10 @@ FlushVariableStats ProxySQL_Admin::flush_mysql_variables___database_to_runtime(S int affected_rows=0; SQLite3_result *resultset=NULL; if (flush_GENERIC_variables__retrieve__database_to_runtime("mysql", error, cols, affected_rows, resultset) == true) { + std::unordered_set database_variables; + for (const SQLite3_row* row : resultset->rows) { + database_variables.emplace(row->fields[0]); + } GloMTH->wrlock(); char * previous_default_charset = GloMTH->get_variable_string((char *)"default_charset"); char * previous_default_collation_connection = GloMTH->get_variable_string((char *)"default_collation_connection"); @@ -575,9 +579,39 @@ FlushVariableStats ProxySQL_Admin::flush_mysql_variables___database_to_runtime(S free(previous_default_charset); free(previous_default_collation_connection); const MySQLThreadsCommitResult commit_result = GloMTH->commit(); - if (commit_result.rejected_variables != 0) { - stats.updated = std::max(0, stats.updated - static_cast(commit_result.rejected_variables)); - stats.rejected += static_cast(commit_result.rejected_variables); + if (!commit_result.rejected_variables.empty()) { + int rejected_variables_in_resultset = 0; + for (const std::string& variable_name : commit_result.rejected_variables) { + if (database_variables.count(variable_name) != 0) { + rejected_variables_in_resultset++; + } + } + stats.updated = std::max(0, stats.updated - rejected_variables_in_resultset); + stats.rejected += rejected_variables_in_resultset; + + const char* query = + "INSERT OR REPLACE INTO global_variables(variable_name, variable_value) VALUES(?1, ?2)"; + auto [rc, statement_unique] = db->prepare_v2(query); + ASSERT_SQLITE_OK(rc, db); + sqlite3_stmt* statement = statement_unique.get(); + for (const std::string& variable_name : commit_result.rejected_variables) { + char* value = GloMTH->get_variable(const_cast(variable_name.c_str())); + const std::string qualified_name = "mysql-" + variable_name; + rc = (*proxy_sqlite3_bind_text)( + statement, 1, qualified_name.c_str(), -1, SQLITE_TRANSIENT + ); + ASSERT_SQLITE_OK(rc, db); + rc = (*proxy_sqlite3_bind_text)( + statement, 2, value != nullptr ? value : "", -1, SQLITE_TRANSIENT + ); + ASSERT_SQLITE_OK(rc, db); + SAFE_SQLITE3_STEP2(statement); + rc = (*proxy_sqlite3_clear_bindings)(statement); + ASSERT_SQLITE_OK(rc, db); + rc = (*proxy_sqlite3_reset)(statement); + ASSERT_SQLITE_OK(rc, db); + free(value); + } } GloMTH->wrunlock(); diff --git a/lib/MySQL_Thread.cpp b/lib/MySQL_Thread.cpp index 19e46dccf8..f72542c32a 100644 --- a/lib/MySQL_Thread.cpp +++ b/lib/MySQL_Thread.cpp @@ -1617,7 +1617,11 @@ MySQLThreadsCommitResult MySQL_Threads_Handler::commit() { caching_sha2_rsa_config_initialized_ = true; } else { proxy_error("Rejected caching_sha2_password RSA key configuration: %s\n", rsa_reload.error.c_str()); - commit_result.rejected_variables = 3; + commit_result.rejected_variables = { + "caching_sha2_password_auto_generate_rsa_keys", + "caching_sha2_password_private_key_path", + "caching_sha2_password_public_key_path" + }; if (!caching_sha2_rsa_config_initialized_) { const std::string default_private_path = "proxysql-caching-sha2-private-key.pem"; diff --git a/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp b/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp index 48aa3f825b..e7dfd8fc3a 100644 --- a/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp +++ b/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp @@ -116,7 +116,7 @@ int main() { return EXIT_FAILURE; } - plan(8); + plan(11); string mysql_help; const vector help_args { "mysql", "--help" }; @@ -124,12 +124,12 @@ int main() { if (help_rc != 0 || mysql_help.find("get-server-public-key") == string::npos || mysql_help.find("ssl-mode") == string::npos) { - skip(8, "Oracle MySQL CLI with --get-server-public-key and --ssl-mode is unavailable"); + skip(11, "Oracle MySQL CLI with --get-server-public-key and --ssl-mode is unavailable"); return exit_status(); } const char* infra_datadir = getenv("REGULAR_INFRA_DATADIR"); if (infra_datadir == nullptr || *infra_datadir == '\0') { - skip(8, "REGULAR_INFRA_DATADIR is required to clean generated RSA key artifacts"); + skip(11, "REGULAR_INFRA_DATADIR is required to clean generated RSA key artifacts"); return exit_status(); } @@ -140,7 +140,7 @@ int main() { nullptr, cl.admin_port, nullptr, 0) != nullptr; ok(admin_connected, "Connected to ProxySQL Admin"); if (!admin_connected) { - skip(7, "Cannot continue without an Admin connection"); + skip(10, "Cannot continue without an Admin connection"); if (admin != nullptr) { mysql_close(admin); } @@ -245,6 +245,54 @@ int main() { output.find("RSA key exchange is unavailable") != string::npos, "Disabled RSA keys return the caching_sha2_password TLS-or-key 1045 hint"); + const bool rejected_update_ok = + run_query( + admin, + "DELETE FROM global_variables WHERE variable_name IN " + "('mysql-caching_sha2_password_private_key_path'," + "'mysql-caching_sha2_password_public_key_path')" + ) && + set_global_variable( + admin, "mysql-caching_sha2_password_auto_generate_rsa_keys", "true" + ) && + run_query(admin, "LOAD MYSQL VARIABLES TO RUNTIME"); + const char* rejected_update_info = mysql_info(admin); + ok(rejected_update_ok && rejected_update_info != nullptr && + string(rejected_update_info).find("Rejected: 1") != string::npos, + "Grouped RSA rejection counts only the submitted configuration variable"); + + string restored_runtime_auto_generate; + ok(query_scalar( + admin, + "SELECT variable_value FROM runtime_global_variables WHERE " + "variable_name='mysql-caching_sha2_password_auto_generate_rsa_keys'", + restored_runtime_auto_generate + ) && restored_runtime_auto_generate == "false", + "Grouped RSA rejection restores the accepted runtime configuration"); + + string restored_global_auto_generate; + string restored_global_private_key; + string restored_global_public_key; + ok(query_scalar( + admin, + "SELECT variable_value FROM global_variables WHERE " + "variable_name='mysql-caching_sha2_password_auto_generate_rsa_keys'", + restored_global_auto_generate + ) && restored_global_auto_generate == "false" && + query_scalar( + admin, + "SELECT variable_value FROM global_variables WHERE " + "variable_name='mysql-caching_sha2_password_private_key_path'", + restored_global_private_key + ) && restored_global_private_key.empty() && + query_scalar( + admin, + "SELECT variable_value FROM global_variables WHERE " + "variable_name='mysql-caching_sha2_password_public_key_path'", + restored_global_public_key + ) && restored_global_public_key.empty(), + "Grouped RSA rejection persists the accepted configuration in global_variables"); + const bool enabled_ok = set_global_variable( admin, "mysql-caching_sha2_password_auto_generate_rsa_keys", "true") && set_global_variable( diff --git a/test/tap/tests/unit/mysql_variables_unit-t.cpp b/test/tap/tests/unit/mysql_variables_unit-t.cpp index e70e06a3ef..90fa3cb07c 100644 --- a/test/tap/tests/unit/mysql_variables_unit-t.cpp +++ b/test/tap/tests/unit/mysql_variables_unit-t.cpp @@ -2,6 +2,9 @@ #include "test_globals.h" #include "MySQL_Thread.h" +#include "ProxySQL_Statistics.hpp" +#include "proxysql_admin.h" +#include "sqlite3db.h" #ifdef PROXYSQL31 #include "MySQL_Caching_Sha2_RSA.h" #endif @@ -9,9 +12,13 @@ #include #include #include +#include #include +extern ProxySQL_Admin* GloAdmin; +extern ProxySQL_Statistics* GloProxyStats; + static bool contains_variable(char **variables, const char *name) { for (char **current = variables; current != nullptr && *current != nullptr; ++current) { if (strcmp(*current, name) == 0) { @@ -21,6 +28,20 @@ static bool contains_variable(char **variables, const char *name) { return false; } +#ifdef PROXYSQL31 +static bool has_all_rejected_rsa_variables(const std::vector& variables) { + return variables == std::vector { + "caching_sha2_password_auto_generate_rsa_keys", + "caching_sha2_password_private_key_path", + "caching_sha2_password_public_key_path" + }; +} +#endif + +#ifdef PROXYSQL31 +static void test_caching_sha2_rsa_rejection_restores_database_values(MySQL_Threads_Handler& handler); +#endif + static void test_mysql_integer_variables_are_registered() { test_globals_init(); MySQL_Threads_Handler handler; @@ -62,6 +83,9 @@ static void test_mysql_integer_variables_are_registered() { } free(reinterpret_cast(variables)); } +#ifdef PROXYSQL31 + test_caching_sha2_rsa_rejection_restores_database_values(handler); +#endif test_globals_cleanup(); } @@ -120,14 +144,14 @@ static void test_caching_sha2_rsa_commit_is_atomic() { handler.set_variable(private_name, ""); handler.set_variable(public_name, ""); const MySQLThreadsCommitResult disabled = handler.commit(); - ok(disabled.rejected_variables == 0, + ok(disabled.rejected_variables.empty(), "commit accepts intentional RSA unavailability"); ok(handler.caching_sha2_rsa()->acquire() == nullptr, "intentional RSA unavailability publishes no snapshot"); handler.set_variable(auto_name, "true"); const MySQLThreadsCommitResult invalid_empty = handler.commit(); - ok(invalid_empty.rejected_variables == 3, + ok(has_all_rejected_rsa_variables(invalid_empty.rejected_variables), "invalid grouped RSA reload rejects all three variables"); ok(handler.get_variable_int(auto_name) == 0, "invalid grouped reload restores the accepted boolean value"); @@ -144,7 +168,7 @@ static void test_caching_sha2_rsa_commit_is_atomic() { handler.set_variable(public_name, "rsa-public.pem"); const MySQLThreadsCommitResult generated = handler.commit(); const auto generated_snapshot = handler.caching_sha2_rsa()->acquire(); - ok(generated.rejected_variables == 0, + ok(generated.rejected_variables.empty(), "commit accepts and generates a complete RSA key pair"); ok(generated_snapshot != nullptr, "accepted generated pair is visible through the handler-owned manager"); @@ -152,7 +176,7 @@ static void test_caching_sha2_rsa_commit_is_atomic() { handler.set_variable(auto_name, "false"); handler.set_variable(public_name, "missing-public.pem"); const MySQLThreadsCommitResult missing_public = handler.commit(); - ok(missing_public.rejected_variables == 3, + ok(has_all_rejected_rsa_variables(missing_public.rejected_variables), "commit rejects a partial on-disk key pair as one grouped update"); ok(handler.caching_sha2_rsa()->acquire() == generated_snapshot, "rejected handler reload preserves the previously published snapshot"); @@ -174,7 +198,7 @@ static void test_caching_sha2_rsa_commit_is_atomic() { MySQL_Threads_Handler handler; free_variables_list(handler.get_variables_list()); const MySQLThreadsCommitResult initial_invalid = handler.commit(); - ok(partial_fd >= 0 && initial_invalid.rejected_variables == 3 && + ok(partial_fd >= 0 && has_all_rejected_rsa_variables(initial_invalid.rejected_variables) && handler.caching_sha2_rsa()->acquire() == nullptr, "initial invalid default key pair is rejected without publishing a snapshot"); @@ -199,11 +223,90 @@ static void test_caching_sha2_rsa_commit_is_atomic() { rmdir(directory.c_str()); test_globals_cleanup(); } + +static std::string query_variable(SQLite3DB* db, const char* table, const char* name) { + char* error = nullptr; + const std::string query = std::string("SELECT variable_value FROM ") + table + + " WHERE variable_name='" + name + "'"; + SQLite3_result* result = db->execute_statement(query.c_str(), &error); + std::string value; + if (result != nullptr && result->rows_count == 1 && result->rows[0]->fields[0] != nullptr) { + value = result->rows[0]->fields[0]; + } + if (error != nullptr) { + free(error); + } + delete result; + return value; +} + +static void test_caching_sha2_rsa_rejection_restores_database_values(MySQL_Threads_Handler& handler) { + GloMTH = &handler; + + char auto_name[] = "caching_sha2_password_auto_generate_rsa_keys"; + char private_name[] = "caching_sha2_password_private_key_path"; + char public_name[] = "caching_sha2_password_public_key_path"; + handler.set_variable(auto_name, "false"); + handler.set_variable(private_name, ""); + handler.set_variable(public_name, ""); + handler.commit(); + + const std::string statsdb_path = "/tmp/proxysql-mysql-variables-unit-stats-" + + std::to_string(getpid()) + ".db"; + char* previous_statsdb_path = GloVars.statsdb_disk; + GloVars.statsdb_disk = strdup(statsdb_path.c_str()); + GloProxyStats = new ProxySQL_Statistics(); + GloProxyStats->init(); + ProxySQL_Admin* admin = new ProxySQL_Admin(); + admin->admindb = new SQLite3DB(); + admin->admindb->open( + (char*)":memory:", SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX + ); + admin->admindb->execute( + "CREATE TABLE global_variables (variable_name VARCHAR NOT NULL PRIMARY KEY, variable_value VARCHAR NOT NULL)" + ); + admin->admindb->execute( + "CREATE TABLE runtime_global_variables (variable_name VARCHAR NOT NULL PRIMARY KEY, variable_value VARCHAR NOT NULL)" + ); + admin->admindb->execute( + "INSERT INTO global_variables VALUES " + "('mysql-caching_sha2_password_auto_generate_rsa_keys', 'true')" + ); + GloAdmin = admin; + + const FlushVariableStats stats = admin->load_mysql_variables_to_runtime(); + char* restored_runtime_auto_generate = handler.get_variable(auto_name); + ok(stats.records == 1 && stats.updated == 0 && stats.rejected == 1, + "Grouped RSA rejection counts only the submitted database variable"); + ok(restored_runtime_auto_generate != nullptr && strcmp(restored_runtime_auto_generate, "false") == 0, + "Grouped RSA rejection restores the accepted runtime value"); + ok(query_variable( + admin->admindb, "global_variables", "mysql-caching_sha2_password_auto_generate_rsa_keys" + ) == "false", + "Grouped RSA rejection persists the accepted value in global_variables"); + ok(query_variable( + admin->admindb, "runtime_global_variables", "mysql-caching_sha2_password_auto_generate_rsa_keys" + ) == "false", + "Grouped RSA rejection publishes the accepted value to runtime_global_variables"); + free(restored_runtime_auto_generate); + + GloAdmin = nullptr; + GloMTH = nullptr; + delete admin->admindb; + admin->admindb = nullptr; + delete GloProxyStats; + GloProxyStats = nullptr; + free(GloVars.statsdb_disk); + GloVars.statsdb_disk = previous_statsdb_path; + unlink(statsdb_path.c_str()); + unlink((statsdb_path + "-wal").c_str()); + unlink((statsdb_path + "-shm").c_str()); +} #endif int main() { #ifdef PROXYSQL31 - plan(23); + plan(27); #else plan(4); #endif From c7ae9cb0e5b5c8531e67c0bfcea3696d1e66feaf Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 18:19:37 +0000 Subject: [PATCH 03/18] fix: avoid double counting rejected RSA config --- include/proxysql_admin.h | 3 ++- lib/Admin_FlushVariables.cpp | 16 +++++++++------- .../tests/reg_test_5988-caching_sha2_rsa-t.cpp | 2 +- test/tap/tests/unit/mysql_variables_unit-t.cpp | 16 +++++++++++++++- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/include/proxysql_admin.h b/include/proxysql_admin.h index a4b947bfb4..24049a3e0e 100644 --- a/include/proxysql_admin.h +++ b/include/proxysql_admin.h @@ -530,7 +530,8 @@ class ProxySQL_Admin { const std::unordered_set& variables_to_delete_silently, const std::unordered_set& variables_deprecated, const std::unordered_set& variables_special_values, - std::function special_variable_action = nullptr + std::function special_variable_action = nullptr, + std::unordered_set* accepted_variables = nullptr ); char **get_variables_list(); diff --git a/lib/Admin_FlushVariables.cpp b/lib/Admin_FlushVariables.cpp index 0fa0016e66..8cdb9ef1f2 100644 --- a/lib/Admin_FlushVariables.cpp +++ b/lib/Admin_FlushVariables.cpp @@ -179,7 +179,8 @@ FlushVariableStats ProxySQL_Admin::flush_GENERIC_variables__process__database_to const std::unordered_set& variables_to_delete_silently, const std::unordered_set& variables_deprecated, const std::unordered_set& variables_special_values, - std::function special_variable_action + std::function special_variable_action, + std::unordered_set* accepted_variables ) { FlushVariableStats stats; for (std::vector::iterator it = resultset->rows.begin() ; it != resultset->rows.end(); ++it) { @@ -256,6 +257,9 @@ FlushVariableStats ProxySQL_Admin::flush_GENERIC_variables__process__database_to } } else { stats.updated++; + if (accepted_variables != nullptr) { + accepted_variables->emplace(v); + } proxy_debug(PROXY_DEBUG_ADMIN, 4, "Set variable %s with value \"%s\"\n", r->fields[0],r->fields[1]); if (variables_special_values.count(v) > 0) { if (special_variable_action != nullptr) { @@ -465,10 +469,7 @@ FlushVariableStats ProxySQL_Admin::flush_mysql_variables___database_to_runtime(S int affected_rows=0; SQLite3_result *resultset=NULL; if (flush_GENERIC_variables__retrieve__database_to_runtime("mysql", error, cols, affected_rows, resultset) == true) { - std::unordered_set database_variables; - for (const SQLite3_row* row : resultset->rows) { - database_variables.emplace(row->fields[0]); - } + std::unordered_set accepted_database_variables; GloMTH->wrlock(); char * previous_default_charset = GloMTH->get_variable_string((char *)"default_charset"); char * previous_default_collation_connection = GloMTH->get_variable_string((char *)"default_collation_connection"); @@ -505,7 +506,8 @@ FlushVariableStats ProxySQL_Admin::flush_mysql_variables___database_to_runtime(S } else if (varname == "processlist_max_query_length") { GloAdmin->variables.mysql_processlist.max_query_length = atoi(varvalue); } - } + }, + &accepted_database_variables ); char q[1000]; char * default_charset = GloMTH->get_variable_string((char *)"default_charset"); @@ -582,7 +584,7 @@ FlushVariableStats ProxySQL_Admin::flush_mysql_variables___database_to_runtime(S if (!commit_result.rejected_variables.empty()) { int rejected_variables_in_resultset = 0; for (const std::string& variable_name : commit_result.rejected_variables) { - if (database_variables.count(variable_name) != 0) { + if (accepted_database_variables.count(variable_name) != 0) { rejected_variables_in_resultset++; } } diff --git a/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp b/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp index e7dfd8fc3a..c18ac36bd6 100644 --- a/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp +++ b/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp @@ -314,7 +314,7 @@ int main() { ok(enabled_ok && internal_session_rc == 0 && output.find(password) == string::npos, "RSA-authenticated internal-session output does not expose the recovered password"); } else { - skip(5, "Cannot run authentication assertions after setup failure"); + skip(8, "Cannot run authentication assertions after setup failure"); } bool cleanup_ok = run_query( diff --git a/test/tap/tests/unit/mysql_variables_unit-t.cpp b/test/tap/tests/unit/mysql_variables_unit-t.cpp index 90fa3cb07c..80d3addc20 100644 --- a/test/tap/tests/unit/mysql_variables_unit-t.cpp +++ b/test/tap/tests/unit/mysql_variables_unit-t.cpp @@ -290,6 +290,20 @@ static void test_caching_sha2_rsa_rejection_restores_database_values(MySQL_Threa "Grouped RSA rejection publishes the accepted value to runtime_global_variables"); free(restored_runtime_auto_generate); + admin->admindb->execute("DELETE FROM global_variables"); + admin->admindb->execute( + "INSERT INTO global_variables VALUES " + "('mysql-caching_sha2_password_auto_generate_rsa_keys', 'not-a-boolean')" + ); + admin->admindb->execute( + "INSERT INTO global_variables VALUES " + "('mysql-caching_sha2_password_public_key_path', 'rsa-public.pem')" + ); + const FlushVariableStats invalid_boolean_stats = admin->load_mysql_variables_to_runtime(); + ok(invalid_boolean_stats.records == 2 && invalid_boolean_stats.updated == 0 && + invalid_boolean_stats.rejected == 2, + "Grouped RSA rejection does not double-count an already rejected boolean"); + GloAdmin = nullptr; GloMTH = nullptr; delete admin->admindb; @@ -306,7 +320,7 @@ static void test_caching_sha2_rsa_rejection_restores_database_values(MySQL_Threa int main() { #ifdef PROXYSQL31 - plan(27); + plan(28); #else plan(4); #endif From a8f0114efc70b00aa0660ce45a526fe27a3fee19 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 18:28:02 +0000 Subject: [PATCH 04/18] fix: harden caching SHA-2 RSA buffers --- include/MySQL_Caching_Sha2_RSA.h | 4 +- lib/MySQL_Caching_Sha2_RSA.cpp | 67 ++++++++++++------- .../tests/unit/caching_sha2_rsa_unit-t.cpp | 45 +++++++------ 3 files changed, 69 insertions(+), 47 deletions(-) diff --git a/include/MySQL_Caching_Sha2_RSA.h b/include/MySQL_Caching_Sha2_RSA.h index 6b00c0e751..201edc46f0 100644 --- a/include/MySQL_Caching_Sha2_RSA.h +++ b/include/MySQL_Caching_Sha2_RSA.h @@ -1,5 +1,5 @@ -#ifndef PROXYSQL_MYSQL_CACHING_SHA2_RSA_H -#define PROXYSQL_MYSQL_CACHING_SHA2_RSA_H +#ifndef __CLASS_MYSQL_CACHING_SHA2_RSA_H +#define __CLASS_MYSQL_CACHING_SHA2_RSA_H #include #include diff --git a/lib/MySQL_Caching_Sha2_RSA.cpp b/lib/MySQL_Caching_Sha2_RSA.cpp index 633e71ff68..0d7239fc40 100644 --- a/lib/MySQL_Caching_Sha2_RSA.cpp +++ b/lib/MySQL_Caching_Sha2_RSA.cpp @@ -19,8 +19,8 @@ namespace { -constexpr int kMinimumRSAKeyBits = 2048; -constexpr size_t kMaximumPEMFileSize = 1024 * 1024; +constexpr int MINIMUM_RSA_KEY_BITS = 2048; +constexpr size_t MAXIMUM_PEM_FILE_SIZE = 1024 * 1024; class ScopedFd { public: @@ -58,6 +58,23 @@ class ScopedStringCleanser { std::string& value_; }; +class ScopedBufferCleanser { +public: + ScopedBufferCleanser(std::vector& value, size_t allocation_size) + : value_(value), allocation_size_(allocation_size) {} + ~ScopedBufferCleanser() { + if (allocation_size_ > 0) { + OPENSSL_cleanse(value_.data(), allocation_size_); + } + } + ScopedBufferCleanser(const ScopedBufferCleanser&) = delete; + ScopedBufferCleanser& operator=(const ScopedBufferCleanser&) = delete; + +private: + std::vector& value_; + size_t allocation_size_; +}; + std::string errno_message(const std::string& operation, const std::string& path) { return operation + " '" + path + "': " + std::strerror(errno); } @@ -261,7 +278,7 @@ bool read_key_file_content( error = errno_message("cannot read RSA key file", path); return false; } - if (content.size() + static_cast(count) > kMaximumPEMFileSize) { + if (content.size() + static_cast(count) > MAXIMUM_PEM_FILE_SIZE) { error = "RSA key file '" + path + "' exceeds the 1 MiB safety limit"; return false; } @@ -306,7 +323,7 @@ bool public_pem(EVP_PKEY* key, std::string& pem, std::string& error) { error = "cannot allocate public-key serialization buffer"; return false; } - std::unique_ptr bio(raw_bio, BIO_free); + std::unique_ptr bio(raw_bio, &BIO_free); if (PEM_write_bio_PUBKEY(bio.get(), key) != 1) { error = "cannot serialize RSA public key"; return false; @@ -327,7 +344,7 @@ bool private_pem(EVP_PKEY* key, std::string& pem, std::string& error) { error = "cannot allocate private-key serialization buffer"; return false; } - std::unique_ptr bio(raw_bio, BIO_free); + std::unique_ptr bio(raw_bio, &BIO_free); if (PEM_write_bio_PKCS8PrivateKey( bio.get(), key, nullptr, nullptr, 0, nullptr, nullptr ) != 1) { @@ -354,7 +371,7 @@ bool validate_rsa_key( error = "key file '" + path + "' does not contain an RSA key"; return false; } - if (EVP_PKEY_bits(key) < kMinimumRSAKeyBits) { + if (EVP_PKEY_bits(key) < MINIMUM_RSA_KEY_BITS) { error = "RSA key file '" + path + "' is weaker than 2048 bits"; return false; } @@ -364,7 +381,7 @@ bool validate_rsa_key( return false; } std::unique_ptr context( - raw_context, EVP_PKEY_CTX_free + raw_context, &EVP_PKEY_CTX_free ); const bool valid = private_key ? EVP_PKEY_private_check(context.get()) > 0 && @@ -410,7 +427,7 @@ bool load_private_key( error = "cannot allocate reader for private key '" + path.display_path + "'"; return false; } - std::unique_ptr bio(raw_bio, BIO_free); + std::unique_ptr bio(raw_bio, &BIO_free); PKCS8_PRIV_KEY_INFO* raw_key_info = PEM_read_bio_PKCS8_PRIV_KEY_INFO( bio.get(), nullptr, reject_password_callback, nullptr ); @@ -420,14 +437,14 @@ bool load_private_key( return false; } std::unique_ptr key_info( - raw_key_info, PKCS8_PRIV_KEY_INFO_free + raw_key_info, &PKCS8_PRIV_KEY_INFO_free ); EVP_PKEY* raw_key = EVP_PKCS82PKEY(key_info.get()); if (raw_key == nullptr) { error = "cannot decode PKCS#8 private key '" + path.display_path + "'"; return false; } - key = std::shared_ptr(raw_key, EVP_PKEY_free); + key = std::shared_ptr(raw_key, &EVP_PKEY_free); return validate_rsa_key(key.get(), path.display_path, true, error); } @@ -463,13 +480,13 @@ bool load_public_key( error = "cannot allocate reader for public key '" + path.display_path + "'"; return false; } - std::unique_ptr bio(raw_bio, BIO_free); + std::unique_ptr bio(raw_bio, &BIO_free); EVP_PKEY* raw_key = PEM_read_bio_PUBKEY(bio.get(), nullptr, nullptr, nullptr); if (raw_key == nullptr) { error = "public key '" + path.display_path + "' is malformed or not PKIX PEM"; return false; } - key = std::shared_ptr(raw_key, EVP_PKEY_free); + key = std::shared_ptr(raw_key, &EVP_PKEY_free); return validate_rsa_key(key.get(), path.display_path, false, error); } @@ -520,10 +537,10 @@ bool generate_rsa_key(std::shared_ptr& key, std::string& error) { return false; } std::unique_ptr context( - raw_context, EVP_PKEY_CTX_free + raw_context, &EVP_PKEY_CTX_free ); if (EVP_PKEY_keygen_init(context.get()) <= 0 || - EVP_PKEY_CTX_set_rsa_keygen_bits(context.get(), kMinimumRSAKeyBits) <= 0) { + EVP_PKEY_CTX_set_rsa_keygen_bits(context.get(), MINIMUM_RSA_KEY_BITS) <= 0) { error = "cannot initialize RSA-2048 key generation"; return false; } @@ -532,7 +549,7 @@ bool generate_rsa_key(std::shared_ptr& key, std::string& error) { error = "cannot generate RSA-2048 key"; return false; } - key = std::shared_ptr(raw_key, EVP_PKEY_free); + key = std::shared_ptr(raw_key, &EVP_PKEY_free); return true; } @@ -684,9 +701,11 @@ bool generate_pair( error = errno_message("cannot open RSA key-generation lock beside", private_path.display_path); return false; } - if (flock(lock_fd.get(), LOCK_EX) != 0) { - error = errno_message("cannot lock RSA key generation beside", private_path.display_path); - return false; + while (flock(lock_fd.get(), LOCK_EX) != 0) { + if (errno != EINTR) { + error = errno_message("cannot lock RSA key generation beside", private_path.display_path); + return false; + } } bool private_exists = false; @@ -839,7 +858,7 @@ bool MySQL_Caching_Sha2_RSA::decrypt_password( return fail("cannot allocate RSA decryption context"); } std::unique_ptr context( - raw_context, EVP_PKEY_CTX_free + raw_context, &EVP_PKEY_CTX_free ); if (EVP_PKEY_decrypt_init(context.get()) <= 0 || EVP_PKEY_CTX_set_rsa_padding(context.get(), RSA_PKCS1_OAEP_PADDING) <= 0 || @@ -854,24 +873,26 @@ bool MySQL_Caching_Sha2_RSA::decrypt_password( ) <= 0 || plaintext_length == 0) { return fail("RSA OAEP decryption failed"); } - std::vector plaintext(plaintext_length); + const size_t plaintext_allocation_size = plaintext_length; + std::vector plaintext(plaintext_allocation_size); + ScopedBufferCleanser plaintext_cleanser(plaintext, plaintext_allocation_size); if (EVP_PKEY_decrypt( context.get(), plaintext.data(), &plaintext_length, ciphertext, ciphertext_length ) <= 0 || plaintext_length == 0) { - OPENSSL_cleanse(plaintext.data(), plaintext.size()); return fail("RSA OAEP decryption failed"); } + if (plaintext_length > plaintext_allocation_size) { + return fail("RSA OAEP decryption returned an invalid length"); + } plaintext.resize(plaintext_length); for (size_t index = 0; index < plaintext.size(); ++index) { plaintext[index] ^= scramble[index % scramble_length]; } if (plaintext.back() != '\0' || std::memchr(plaintext.data(), '\0', plaintext.size() - 1) != nullptr) { - OPENSSL_cleanse(plaintext.data(), plaintext.size()); return fail("decrypted password is not a single NUL-terminated string"); } password.assign(reinterpret_cast(plaintext.data()), plaintext.size() - 1); - OPENSSL_cleanse(plaintext.data(), plaintext.size()); if (error != nullptr) { error->clear(); } diff --git a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp index a011bf5ad0..c0f920f048 100644 --- a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp +++ b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -57,7 +58,7 @@ static std::string first_line(const std::string& path) { if (raw_bio == nullptr) { return {}; } - std::unique_ptr bio(raw_bio, BIO_free); + std::unique_ptr bio(raw_bio, &BIO_free); char line[128] {}; const int length = BIO_gets(bio.get(), line, sizeof(line)); return length > 0 ? std::string(line, static_cast(length)) : std::string(); @@ -71,17 +72,17 @@ static bool write_traditional_private_key( if (raw_input == nullptr) { return false; } - std::unique_ptr input(raw_input, BIO_free); + std::unique_ptr input(raw_input, &BIO_free); EVP_PKEY* raw_key = PEM_read_bio_PrivateKey(input.get(), nullptr, nullptr, nullptr); if (raw_key == nullptr) { return false; } - std::unique_ptr key(raw_key, EVP_PKEY_free); + std::unique_ptr key(raw_key, &EVP_PKEY_free); BIO* raw_output = BIO_new_file(destination_path.c_str(), "w"); if (raw_output == nullptr) { return false; } - std::unique_ptr output(raw_output, BIO_free); + std::unique_ptr output(raw_output, &BIO_free); const bool written = PEM_write_bio_PrivateKey_traditional( output.get(), key.get(), nullptr, nullptr, 0, nullptr, nullptr ) == 1; @@ -91,39 +92,39 @@ static bool write_traditional_private_key( static EVPKeyPtr read_private_key(const std::string& path) { BIO* raw_bio = BIO_new_file(path.c_str(), "r"); if (raw_bio == nullptr) { - return EVPKeyPtr(nullptr, EVP_PKEY_free); + return EVPKeyPtr(nullptr, &EVP_PKEY_free); } - std::unique_ptr bio(raw_bio, BIO_free); + std::unique_ptr bio(raw_bio, &BIO_free); return EVPKeyPtr( - PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr), EVP_PKEY_free + PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr), &EVP_PKEY_free ); } static EVPKeyPtr generate_rsa_key(int bits) { EVP_PKEY_CTX* raw_context = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr); if (raw_context == nullptr) { - return EVPKeyPtr(nullptr, EVP_PKEY_free); + return EVPKeyPtr(nullptr, &EVP_PKEY_free); } std::unique_ptr context( - raw_context, EVP_PKEY_CTX_free + raw_context, &EVP_PKEY_CTX_free ); EVP_PKEY* raw_key = nullptr; if (EVP_PKEY_keygen_init(context.get()) <= 0 || EVP_PKEY_CTX_set_rsa_keygen_bits(context.get(), bits) <= 0 || EVP_PKEY_keygen(context.get(), &raw_key) <= 0) { EVP_PKEY_free(raw_key); - return EVPKeyPtr(nullptr, EVP_PKEY_free); + return EVPKeyPtr(nullptr, &EVP_PKEY_free); } - return EVPKeyPtr(raw_key, EVP_PKEY_free); + return EVPKeyPtr(raw_key, &EVP_PKEY_free); } static EVPKeyPtr generate_ec_key() { EVP_PKEY_CTX* raw_context = EVP_PKEY_CTX_new_id(EVP_PKEY_EC, nullptr); if (raw_context == nullptr) { - return EVPKeyPtr(nullptr, EVP_PKEY_free); + return EVPKeyPtr(nullptr, &EVP_PKEY_free); } std::unique_ptr context( - raw_context, EVP_PKEY_CTX_free + raw_context, &EVP_PKEY_CTX_free ); EVP_PKEY* raw_key = nullptr; if (EVP_PKEY_keygen_init(context.get()) <= 0 || @@ -131,9 +132,9 @@ static EVPKeyPtr generate_ec_key() { context.get(), NID_X9_62_prime256v1 ) <= 0 || EVP_PKEY_keygen(context.get(), &raw_key) <= 0) { EVP_PKEY_free(raw_key); - return EVPKeyPtr(nullptr, EVP_PKEY_free); + return EVPKeyPtr(nullptr, &EVP_PKEY_free); } - return EVPKeyPtr(raw_key, EVP_PKEY_free); + return EVPKeyPtr(raw_key, &EVP_PKEY_free); } static bool write_pkcs8_key_pair( @@ -149,7 +150,7 @@ static bool write_pkcs8_key_pair( if (raw_private == nullptr) { return false; } - std::unique_ptr private_bio(raw_private, BIO_free); + std::unique_ptr private_bio(raw_private, &BIO_free); char passphrase[] = "test-passphrase"; if (PEM_write_bio_PKCS8PrivateKey( private_bio.get(), key, encrypted ? EVP_aes_256_cbc() : nullptr, @@ -163,7 +164,7 @@ static bool write_pkcs8_key_pair( if (raw_public == nullptr) { return false; } - std::unique_ptr public_bio(raw_public, BIO_free); + std::unique_ptr public_bio(raw_public, &BIO_free); return PEM_write_bio_PUBKEY(public_bio.get(), key) == 1 && chmod(public_path.c_str(), 0644) == 0; } @@ -173,7 +174,7 @@ static bool write_malformed_private_key(const std::string& path) { if (raw_bio == nullptr) { return false; } - std::unique_ptr bio(raw_bio, BIO_free); + std::unique_ptr bio(raw_bio, &BIO_free); return BIO_puts(bio.get(), "-----BEGIN PRIVATE KEY-----\nnot-a-key\n") > 0 && chmod(path.c_str(), 0600) == 0; } @@ -183,7 +184,7 @@ static bool append_text(const std::string& path, const char* text) { if (raw_bio == nullptr) { return false; } - std::unique_ptr bio(raw_bio, BIO_free); + std::unique_ptr bio(raw_bio, &BIO_free); return BIO_puts(bio.get(), text) > 0; } @@ -205,18 +206,18 @@ static std::vector encrypt_password_payload( if (raw_bio == nullptr) { return {}; } - std::unique_ptr bio(raw_bio, BIO_free); + std::unique_ptr bio(raw_bio, &BIO_free); EVP_PKEY* raw_key = PEM_read_bio_PUBKEY(bio.get(), nullptr, nullptr, nullptr); if (raw_key == nullptr) { return {}; } - std::unique_ptr key(raw_key, EVP_PKEY_free); + std::unique_ptr key(raw_key, &EVP_PKEY_free); EVP_PKEY_CTX* raw_context = EVP_PKEY_CTX_new(key.get(), nullptr); if (raw_context == nullptr) { return {}; } std::unique_ptr context( - raw_context, EVP_PKEY_CTX_free + raw_context, &EVP_PKEY_CTX_free ); if (EVP_PKEY_encrypt_init(context.get()) <= 0 || EVP_PKEY_CTX_set_rsa_padding(context.get(), RSA_PKCS1_OAEP_PADDING) <= 0 || From 346cf3bf7eeaecdd4367ac8faff3dbf3b2a16d7d Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 18:31:02 +0000 Subject: [PATCH 05/18] fix: retain RSA plaintext allocation for cleansing --- lib/MySQL_Caching_Sha2_RSA.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/MySQL_Caching_Sha2_RSA.cpp b/lib/MySQL_Caching_Sha2_RSA.cpp index 0d7239fc40..4de723a456 100644 --- a/lib/MySQL_Caching_Sha2_RSA.cpp +++ b/lib/MySQL_Caching_Sha2_RSA.cpp @@ -884,15 +884,14 @@ bool MySQL_Caching_Sha2_RSA::decrypt_password( if (plaintext_length > plaintext_allocation_size) { return fail("RSA OAEP decryption returned an invalid length"); } - plaintext.resize(plaintext_length); - for (size_t index = 0; index < plaintext.size(); ++index) { + for (size_t index = 0; index < plaintext_length; ++index) { plaintext[index] ^= scramble[index % scramble_length]; } - if (plaintext.back() != '\0' || - std::memchr(plaintext.data(), '\0', plaintext.size() - 1) != nullptr) { + if (plaintext[plaintext_length - 1] != '\0' || + std::memchr(plaintext.data(), '\0', plaintext_length - 1) != nullptr) { return fail("decrypted password is not a single NUL-terminated string"); } - password.assign(reinterpret_cast(plaintext.data()), plaintext.size() - 1); + password.assign(reinterpret_cast(plaintext.data()), plaintext_length - 1); if (error != nullptr) { error->clear(); } From 958dff53c77d6cf19baf1d5622359b2ae1737731 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 18:52:01 +0000 Subject: [PATCH 06/18] fix: harden auth packet construction --- .../issue5988-review-fixes/task-3-report.md | 112 ++++++++++++++++++ include/MySQL_Protocol.h | 6 +- lib/MySQL_Protocol.cpp | 49 ++++++-- lib/MySQL_Session.cpp | 18 +-- test/tap/tests/unit/protocol_unit-t.cpp | 7 +- 5 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 .superpowers/sdd/issue5988-review-fixes/task-3-report.md diff --git a/.superpowers/sdd/issue5988-review-fixes/task-3-report.md b/.superpowers/sdd/issue5988-review-fixes/task-3-report.md new file mode 100644 index 0000000000..ec1482cffa --- /dev/null +++ b/.superpowers/sdd/issue5988-review-fixes/task-3-report.md @@ -0,0 +1,112 @@ +# Task 3 report: authentication packet/error-message hardening + +## Scope attempted + +- `include/MySQL_Protocol.h`: proposed `bool` results for + `generate_auth_more_data`, `generate_one_byte_pkt`, and + `PPHR_passthrough_init`. +- `lib/MySQL_Protocol.cpp`: proposed atomic AuthMoreData allocation handling + and result propagation through `PPHR_1`, `PPHR_5passwordFalse_0`, + `PPHR_sha2full`, `PPHR_passthrough_init`/`PPHR_verify_password`, and the + cleartext cache-hit fast-auth marker. +- `lib/MySQL_Session.cpp`: proposed replacement of both access-denied + `sprintf` branches with exactly sized `string_format` into `std::string`. +- `test/tap/tests/unit/protocol_unit-t.cpp`: added a positive return-value + assertion while retaining all four pre-existing wire-format assertions. + +## RED evidence + +Before production changes, the focused regression was compiled with: + +``` +make -C test/tap/tests/unit protocol_unit-t +``` + +The command exited 2. The relevant expected API failures were: + +``` +protocol_unit-t.cpp:240:44: error: invalid use of void expression +protocol_unit-t.cpp:290:57: error: void value not ignored as it ought to be +``` + +These are the new observable contract assertions: successful construction +must return true, and forced allocation failure must return false without +queueing or advancing the sequence. + +## RLIMIT_AS attempt and revised test decision + +The full unit Makefile automatically adds `-DDEBUG` based on an unconditional +library symbol even when `libproxysql.a` was compiled without `-DDEBUG`. That +ABI mismatch shifts `MySQL_Data_Stream` fields and makes the pre-existing +AuthMoreData unit setup crash in `PtrSizeArray::add`. A no-debug test-only +rebuild was used to remove that unrelated mismatch: + +``` +make -C test/tap/tests/unit -W protocol_unit-t.cpp PSQLDEBUG= protocol_unit-t +./test/tap/tests/unit/protocol_unit-t +``` + +The executable reached the child regression. Its successful packet checks all +passed (TAP 19--23), but the forced allocation printed: + +``` +: Error in malloc(): out of memory +not ok 24 - AuthMoreData reports allocation failure +not ok 26 - AuthMoreData allocation failure does not advance the packet sequence +# Failed 2 tests! +``` + +The test helper defines jemalloc as `xmalloc:true`, so the child aborts on +allocation failure rather than returning `nullptr`. It cannot call the +builder's false-return path or write the POD result. This is an allocator +policy of the test executable, not a ProxySQL builder failure. The task rules +prohibit allocator hooks/interposition, so the infeasible RLIMIT_AS regression +was removed. The null-return branch is intentionally not dynamically covered +by this harness. + +## GREEN evidence + +The final focused unit build and run used `PSQLDEBUG=` because the unit +Makefile's automatic DEBUG-symbol detection otherwise compiles the test with a +different `MySQL_Data_Stream` layout than the non-DEBUG library: + +``` +make -C test/tap/tests/unit PSQLDEBUG= protocol_unit-t +./test/tap/tests/unit/protocol_unit-t +``` + +Both commands exited 0. The unit output is TAP `1..55`, including the five +AuthMoreData checks at TAP 19--23: true result, one queued packet, exact header +and sequence, `0x01` marker, and exact non-NUL-terminated PEM bytes. + +The issue-5988 E2E binary was rebuilt successfully with: + +``` +make -B -C test/tap/tests reg_test_5988-caching_sha2_rsa-t +``` + +This command exited 0. It only builds the binary; it does not start the +ProxySQL/MySQL integration environment, so the E2E executable was not run. + +`git diff --check` also exited 0. + +## Self-review + +- Successful AuthMoreData bytes, marker, packet-id calculation, and existing + post-send sequence increments were left unchanged by the proposed code. +- The proposed failure paths return before enqueue/state changes and do not + reuse `CACHING_SHA2_RSA_UNAVAILABLE` for OOM. +- The special RSA-unavailable text, logging, error code, SQLSTATE, and counter + suppression remain unchanged in the proposed `string_format` conversion. +- Active call sites named in the design were searched and covered: RSA public + key, monitor fast-auth, full-auth, pass-through init/caller, and cache-hit + fast-auth. The only remaining textual `generate_one_byte_pkt` occurrence is + in the commented-out template. + +## Concerns + +The malloc-null failure path has static/ordering review rather than dynamic +coverage: `generate_auth_more_data` returns immediately after a null result, +before writing, enqueueing, or assigning `pkt_sid`; each propagated caller +returns before its later stage/sequence writes. This is the maximum coverage +available without violating the allocator-hook/interposition constraint. diff --git a/include/MySQL_Protocol.h b/include/MySQL_Protocol.h index 03ba7142ef..38f0f7be27 100644 --- a/include/MySQL_Protocol.h +++ b/include/MySQL_Protocol.h @@ -223,7 +223,7 @@ class MySQL_Protocol { // the session to AUTHENTICATING_BACKEND_FOR_CLIENT so the backend // probe (handler_again___status_AUTHENTICATING_BACKEND_FOR_CLIENT) // can validate the credential. - void PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1); + bool PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1); void PPHR_7auth1(bool& ret, MyProt_tmp_auth_vars& vars1, char * reply, account_details_t& attr1); void PPHR_7auth2(bool& ret, MyProt_tmp_auth_vars& vars1, char * reply, account_details_t& attr1); void PPHR_next_auth_stage(MyProt_tmp_auth_vars& vars1, PASSWORD_TYPE::E passtype); @@ -231,9 +231,9 @@ class MySQL_Protocol { bool PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_details_t& account_details); bool PPHR_verify_password_2(MyProt_tmp_auth_vars& vars1, account_details_t& account_details); - void generate_one_byte_pkt(unsigned char b); + bool generate_one_byte_pkt(unsigned char b); #ifdef PROXYSQL31 - void generate_auth_more_data(const unsigned char *data, size_t data_len); + bool generate_auth_more_data(const unsigned char *data, size_t data_len); MySQLFrontendAuthError consume_frontend_auth_error(); #endif diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 70c04db2f9..afd17a9abe 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -284,9 +284,9 @@ bool MySQL_Protocol::generate_pkt_ERR(bool send, void **ptr, unsigned int *len, return true; } -void MySQL_Protocol::generate_one_byte_pkt(unsigned char b) { +bool MySQL_Protocol::generate_one_byte_pkt(unsigned char b) { #ifdef PROXYSQL31 - generate_auth_more_data(&b, 1); + return generate_auth_more_data(&b, 1); #else assert((*myds) != NULL); uint8_t sequence_id; @@ -304,11 +304,12 @@ void MySQL_Protocol::generate_one_byte_pkt(unsigned char b) { _ptr[l]=b; (*myds)->PSarrayOUT->add((void *)_ptr,size); (*myds)->pkt_sid=sequence_id; + return true; #endif } #ifdef PROXYSQL31 -void MySQL_Protocol::generate_auth_more_data(const unsigned char *data, size_t data_len) { +bool MySQL_Protocol::generate_auth_more_data(const unsigned char *data, size_t data_len) { assert((*myds) != NULL); assert(data != NULL || data_len == 0); assert(data_len <= 0xFFFFFFU - 1); @@ -320,6 +321,9 @@ void MySQL_Protocol::generate_auth_more_data(const unsigned char *data, size_t d const unsigned int size = myhdr.pkt_length + sizeof(mysql_hdr); unsigned char *_ptr = static_cast(l_alloc(size)); + if (_ptr == nullptr) { + return false; + } memcpy(_ptr, &myhdr, sizeof(mysql_hdr)); _ptr[sizeof(mysql_hdr)] = 0x01; if (data_len != 0) { @@ -328,6 +332,7 @@ void MySQL_Protocol::generate_auth_more_data(const unsigned char *data, size_t d (*myds)->PSarrayOUT->add(static_cast(_ptr), size); (*myds)->pkt_sid = sequence_id; + return true; } MySQLFrontendAuthError MySQL_Protocol::consume_frontend_auth_error() { @@ -1844,8 +1849,16 @@ int MySQL_Protocol::PPHR_1(unsigned char *pkt, unsigned int len, bool& ret, MyPr GloMTH->caching_sha2_rsa()->acquire() : nullptr; if (caching_sha2_rsa_snapshot_ != nullptr) { const std::string& public_key = caching_sha2_rsa_snapshot_->public_key_pem(); - generate_auth_more_data( - reinterpret_cast(public_key.data()), public_key.size()); + if (!generate_auth_more_data( + reinterpret_cast(public_key.data()), public_key.size())) { + caching_sha2_rsa_snapshot_.reset(); + frontend_auth_error_ = MySQLFrontendAuthError::NONE; + proxy_error( + "User '%s'@'%s' requested the caching_sha2_password RSA public key, but ProxySQL could not allocate the response packet.\n", + vars1.user, (*myds)->addr.addr + ); + return 1; + } (*myds)->switching_auth_stage = 6; (*myds)->auth_in_progress = 1; frontend_auth_error_ = MySQLFrontendAuthError::NONE; @@ -2359,7 +2372,9 @@ void MySQL_Protocol::PPHR_5passwordFalse_0( (*myds)->switching_auth_stage == 0 ) { const unsigned char fast_auth_success = '\3'; - generate_one_byte_pkt(fast_auth_success); + if (!generate_one_byte_pkt(fast_auth_success)) { + ret = false; + } } } @@ -2653,7 +2668,10 @@ void MySQL_Protocol::PPHR_sha2full( ) { if ((*myds)->switching_auth_stage == 0) { const unsigned char perform_full_authentication = '\4'; - generate_one_byte_pkt(perform_full_authentication); + if (!generate_one_byte_pkt(perform_full_authentication)) { + ret = false; + return; + } (*myds)->pkt_sid++; // increment pkt_sid by one // Required to be set; later used in 'PPHR_1' for setting current 'auth_plugin_id'. E.g: // - mysql-default_authentication_plugin: 'caching_sha2_password' @@ -2711,19 +2729,21 @@ void MySQL_Protocol::PPHR_sha2full( } } -void MySQL_Protocol::PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1) { +bool MySQL_Protocol::PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1) { // Stage 0: first call — client just sent the HandshakeResponse with a // scrambled password. Reply with AuthMoreData{0x04} so the client // follows up with its cleartext (under TLS or RSA-encrypted per the // caching_sha2_password protocol). if ((*myds)->switching_auth_stage == 0) { const unsigned char perform_full_authentication = '\4'; - generate_one_byte_pkt(perform_full_authentication); + if (!generate_one_byte_pkt(perform_full_authentication)) { + return false; + } (*myds)->pkt_sid++; (*myds)->switching_auth_type = AUTH_MYSQL_CACHING_SHA2_PASSWORD; (*myds)->switching_auth_stage = 4; (*myds)->auth_in_progress = 1; - return; + return true; } // Stage 5: client has replied with the cleartext password (now in @@ -2762,11 +2782,12 @@ void MySQL_Protocol::PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1) { // authenticate against a real backend account. (*myds)->auth_in_progress = 1; (*myds)->sess->set_status(AUTHENTICATING_BACKEND_FOR_CLIENT); - return; + return true; } // Any other stage is a protocol bug; assert in debug builds. assert(0); + return false; } void MySQL_Protocol::PPHR_SetConnAttrs(MyProt_tmp_auth_vars& vars1, account_details_t& attr1) { @@ -3163,7 +3184,9 @@ bool MySQL_Protocol::PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_d // Cache miss → drive the caching_sha2_password full-auth // exchange so the client emits its cleartext, which we will // then probe against the backend. - PPHR_passthrough_init(vars1); + if (!PPHR_passthrough_init(vars1)) { + return false; + } return ret; // not done yet; protocol state machine continues } } @@ -3248,7 +3271,7 @@ bool MySQL_Protocol::PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_d if (ret == true) { if ((*myds)->switching_auth_stage == 0) { const unsigned char fast_auth_success = '\3'; - generate_one_byte_pkt(fast_auth_success); + ret = generate_one_byte_pkt(fast_auth_success); } } } diff --git a/lib/MySQL_Session.cpp b/lib/MySQL_Session.cpp index db6611235a..e8d4e936ca 100644 --- a/lib/MySQL_Session.cpp +++ b/lib/MySQL_Session.cpp @@ -6497,7 +6497,7 @@ void MySQL_Session::handler___status_CONNECTING_CLIENT___STATE_SERVER_HANDSHAKE_ client_addr = strdup((char *)""); } if (client_myds->myconn->userinfo->username) { - char *_s=(char *)malloc(strlen(client_myds->myconn->userinfo->username)+256+strlen(client_addr)); + std::string error_message; //uint8_t _pid = 2; //if (client_myds->switching_auth_stage) _pid+=2; //if (is_encrypted) _pid++; @@ -6513,19 +6513,23 @@ void MySQL_Session::handler___status_CONNECTING_CLIENT___STATE_SERVER_HANDSHAKE_ #endif // DEBUG #ifdef PROXYSQL31 if (frontend_auth_error == MySQLFrontendAuthError::CACHING_SHA2_RSA_UNAVAILABLE) { - sprintf( - _s, + string_format( "ProxySQL Error: Access denied for user '%s'@'%s': caching_sha2_password RSA key exchange is unavailable; use TLS or configure RSA keys", + error_message, client_myds->myconn->userinfo->username, client_addr ); } else #endif { - sprintf(_s,"ProxySQL Error: Access denied for user '%s'@'%s' (using password: %s)", client_myds->myconn->userinfo->username, client_addr, (client_myds->myconn->userinfo->password ? "YES" : "NO")); + string_format( + "ProxySQL Error: Access denied for user '%s'@'%s' (using password: %s)", + error_message, + client_myds->myconn->userinfo->username, client_addr, + (client_myds->myconn->userinfo->password ? "YES" : "NO") + ); } - client_myds->myprot.generate_pkt_ERR(true,NULL,NULL, _pid, 1045,(char *)"28000", _s, true); - proxy_error("%s\n", _s); - free(_s); + client_myds->myprot.generate_pkt_ERR(true,NULL,NULL, _pid, 1045,(char *)"28000", error_message.c_str(), true); + proxy_error("%s\n", error_message.c_str()); #ifdef PROXYSQL31 if (frontend_auth_error != MySQLFrontendAuthError::CACHING_SHA2_RSA_UNAVAILABLE) #endif diff --git a/test/tap/tests/unit/protocol_unit-t.cpp b/test/tap/tests/unit/protocol_unit-t.cpp index 43af23468b..6a9b35d034 100644 --- a/test/tap/tests/unit/protocol_unit-t.cpp +++ b/test/tap/tests/unit/protocol_unit-t.cpp @@ -234,7 +234,8 @@ static void test_auth_more_data_packet() { const unsigned char public_key[] = "-----BEGIN PUBLIC KEY-----\nkey\n-----END PUBLIC KEY-----\n"; const size_t public_key_length = sizeof(public_key) - 1; - protocol.generate_auth_more_data(public_key, public_key_length); + ok(protocol.generate_auth_more_data(public_key, public_key_length), + "AuthMoreData reports successful packet construction"); ok(stream.PSarrayOUT->len == 1, "AuthMoreData queues exactly one packet"); @@ -511,7 +512,7 @@ static void test_wildcard_matching() { int main() { #ifdef PROXYSQL31 - plan(54); + plan(55); #else plan(45); #endif @@ -529,7 +530,7 @@ int main() { // Packet header test_mysql_hdr(); // 3 tests #ifdef PROXYSQL31 - test_auth_more_data_packet(); // 4 tests + test_auth_more_data_packet(); // 5 tests test_caching_sha2_stage5_payload_validation(); // 4 tests test_internal_session_redacts_password(); // 1 test #endif From c2a05a556a35890775f9ad6c032b2efa48635632 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 18:55:57 +0000 Subject: [PATCH 07/18] chore: untrack task 3 report --- .../issue5988-review-fixes/task-3-report.md | 112 ------------------ 1 file changed, 112 deletions(-) delete mode 100644 .superpowers/sdd/issue5988-review-fixes/task-3-report.md diff --git a/.superpowers/sdd/issue5988-review-fixes/task-3-report.md b/.superpowers/sdd/issue5988-review-fixes/task-3-report.md deleted file mode 100644 index ec1482cffa..0000000000 --- a/.superpowers/sdd/issue5988-review-fixes/task-3-report.md +++ /dev/null @@ -1,112 +0,0 @@ -# Task 3 report: authentication packet/error-message hardening - -## Scope attempted - -- `include/MySQL_Protocol.h`: proposed `bool` results for - `generate_auth_more_data`, `generate_one_byte_pkt`, and - `PPHR_passthrough_init`. -- `lib/MySQL_Protocol.cpp`: proposed atomic AuthMoreData allocation handling - and result propagation through `PPHR_1`, `PPHR_5passwordFalse_0`, - `PPHR_sha2full`, `PPHR_passthrough_init`/`PPHR_verify_password`, and the - cleartext cache-hit fast-auth marker. -- `lib/MySQL_Session.cpp`: proposed replacement of both access-denied - `sprintf` branches with exactly sized `string_format` into `std::string`. -- `test/tap/tests/unit/protocol_unit-t.cpp`: added a positive return-value - assertion while retaining all four pre-existing wire-format assertions. - -## RED evidence - -Before production changes, the focused regression was compiled with: - -``` -make -C test/tap/tests/unit protocol_unit-t -``` - -The command exited 2. The relevant expected API failures were: - -``` -protocol_unit-t.cpp:240:44: error: invalid use of void expression -protocol_unit-t.cpp:290:57: error: void value not ignored as it ought to be -``` - -These are the new observable contract assertions: successful construction -must return true, and forced allocation failure must return false without -queueing or advancing the sequence. - -## RLIMIT_AS attempt and revised test decision - -The full unit Makefile automatically adds `-DDEBUG` based on an unconditional -library symbol even when `libproxysql.a` was compiled without `-DDEBUG`. That -ABI mismatch shifts `MySQL_Data_Stream` fields and makes the pre-existing -AuthMoreData unit setup crash in `PtrSizeArray::add`. A no-debug test-only -rebuild was used to remove that unrelated mismatch: - -``` -make -C test/tap/tests/unit -W protocol_unit-t.cpp PSQLDEBUG= protocol_unit-t -./test/tap/tests/unit/protocol_unit-t -``` - -The executable reached the child regression. Its successful packet checks all -passed (TAP 19--23), but the forced allocation printed: - -``` -: Error in malloc(): out of memory -not ok 24 - AuthMoreData reports allocation failure -not ok 26 - AuthMoreData allocation failure does not advance the packet sequence -# Failed 2 tests! -``` - -The test helper defines jemalloc as `xmalloc:true`, so the child aborts on -allocation failure rather than returning `nullptr`. It cannot call the -builder's false-return path or write the POD result. This is an allocator -policy of the test executable, not a ProxySQL builder failure. The task rules -prohibit allocator hooks/interposition, so the infeasible RLIMIT_AS regression -was removed. The null-return branch is intentionally not dynamically covered -by this harness. - -## GREEN evidence - -The final focused unit build and run used `PSQLDEBUG=` because the unit -Makefile's automatic DEBUG-symbol detection otherwise compiles the test with a -different `MySQL_Data_Stream` layout than the non-DEBUG library: - -``` -make -C test/tap/tests/unit PSQLDEBUG= protocol_unit-t -./test/tap/tests/unit/protocol_unit-t -``` - -Both commands exited 0. The unit output is TAP `1..55`, including the five -AuthMoreData checks at TAP 19--23: true result, one queued packet, exact header -and sequence, `0x01` marker, and exact non-NUL-terminated PEM bytes. - -The issue-5988 E2E binary was rebuilt successfully with: - -``` -make -B -C test/tap/tests reg_test_5988-caching_sha2_rsa-t -``` - -This command exited 0. It only builds the binary; it does not start the -ProxySQL/MySQL integration environment, so the E2E executable was not run. - -`git diff --check` also exited 0. - -## Self-review - -- Successful AuthMoreData bytes, marker, packet-id calculation, and existing - post-send sequence increments were left unchanged by the proposed code. -- The proposed failure paths return before enqueue/state changes and do not - reuse `CACHING_SHA2_RSA_UNAVAILABLE` for OOM. -- The special RSA-unavailable text, logging, error code, SQLSTATE, and counter - suppression remain unchanged in the proposed `string_format` conversion. -- Active call sites named in the design were searched and covered: RSA public - key, monitor fast-auth, full-auth, pass-through init/caller, and cache-hit - fast-auth. The only remaining textual `generate_one_byte_pkt` occurrence is - in the commented-out template. - -## Concerns - -The malloc-null failure path has static/ordering review rather than dynamic -coverage: `generate_auth_more_data` returns immediately after a null result, -before writing, enqueueing, or assigning `pkt_sid`; each propagated caller -returns before its later stage/sequence writes. This is the maximum coverage -available without violating the allocator-hook/interposition constraint. From 875fa65101346dadea929db09d6c076d1b0b8001 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 19:10:26 +0000 Subject: [PATCH 08/18] fix: remediate caching SHA-2 RSA quality findings --- include/MySQL_Caching_Sha2_RSA.h | 44 ++++++-- include/MySQL_Passthrough_Auth_Cache.h | 5 + include/MySQL_Protocol.h | 4 +- lib/MySQL_Caching_Sha2_RSA.cpp | 50 +++++---- lib/MySQL_Protocol.cpp | 26 +++-- lib/MySQL_Thread.cpp | 12 +-- .../reg_test_5988-caching_sha2_rsa-t.cpp | 4 +- .../tests/unit/caching_sha2_rsa_unit-t.cpp | 100 +++++++++--------- .../tap/tests/unit/mysql_variables_unit-t.cpp | 2 +- 9 files changed, 153 insertions(+), 94 deletions(-) diff --git a/include/MySQL_Caching_Sha2_RSA.h b/include/MySQL_Caching_Sha2_RSA.h index 201edc46f0..468ee2ff3c 100644 --- a/include/MySQL_Caching_Sha2_RSA.h +++ b/include/MySQL_Caching_Sha2_RSA.h @@ -2,21 +2,28 @@ #define __CLASS_MYSQL_CACHING_SHA2_RSA_H #include -#include +#include #include #include -struct CachingSha2RSAConfig { +/** + * @brief Requested caching_sha2_password RSA key configuration. + * @details Private and public paths are configured together; relative paths resolve beneath @p datadir. + */ +struct MySQL_Caching_Sha2_RSA_Config { bool auto_generate { true }; std::string private_key_path; std::string public_key_path; std::string datadir; }; -class CachingSha2RSAKeySnapshot { +/** @brief Immutable RSA key material retained by authentication exchanges. */ +class MySQL_Caching_Sha2_RSA_Key_Snapshot { public: + /** @brief Return the canonical SPKI PEM public key supplied to a MySQL client. */ const std::string& public_key_pem() const { return public_key_pem_; } + /** @brief Return the exact RSA ciphertext size accepted by this snapshot. */ size_t ciphertext_size() const { return ciphertext_size_; } private: @@ -28,19 +35,38 @@ class CachingSha2RSAKeySnapshot { size_t ciphertext_size_ { 0 }; }; -struct CachingSha2RSAReloadResult { +/** @brief Result of an RSA reload; a rejected reload preserves the active snapshot. */ +struct MySQL_Caching_Sha2_RSA_Reload_Result { + /** @brief The requested configuration passed validation and was accepted. */ bool accepted { false }; + /** @brief A newly prepared key snapshot replaced the previously active one. */ bool changed { false }; + /** @brief An active snapshot is available after this reload attempt. */ bool available { false }; + /** @brief Validation or preparation failure when @c accepted is false. */ std::string error; }; +/** @brief Atomically publishes immutable RSA snapshots for caching_sha2_password exchanges. */ class MySQL_Caching_Sha2_RSA { public: - CachingSha2RSAReloadResult reload(const CachingSha2RSAConfig& config); - std::shared_ptr acquire() const; + MySQL_Caching_Sha2_RSA() = default; + ~MySQL_Caching_Sha2_RSA(); + MySQL_Caching_Sha2_RSA(const MySQL_Caching_Sha2_RSA&) = delete; + MySQL_Caching_Sha2_RSA& operator=(const MySQL_Caching_Sha2_RSA&) = delete; + MySQL_Caching_Sha2_RSA(MySQL_Caching_Sha2_RSA&&) = delete; + MySQL_Caching_Sha2_RSA& operator=(MySQL_Caching_Sha2_RSA&&) = delete; + + /** @brief Prepare and atomically publish a valid key pair without replacing a rejected snapshot. */ + MySQL_Caching_Sha2_RSA_Reload_Result reload(const MySQL_Caching_Sha2_RSA_Config& config); + /** @brief Acquire a snapshot that remains valid even if a later reload publishes another one. */ + std::shared_ptr acquire() const; + /** + * @brief Decrypt an exact-size OAEP ciphertext containing one trailing-NUL password. + * @details On success, @p password contains cleartext and its caller must cleanse it after use. + */ bool decrypt_password( - const std::shared_ptr& snapshot, + const std::shared_ptr& snapshot, const unsigned char* ciphertext, size_t ciphertext_length, const unsigned char* scramble, @@ -50,8 +76,8 @@ class MySQL_Caching_Sha2_RSA { ) const; private: - mutable std::mutex mutex_; - std::shared_ptr snapshot_; + mutable pthread_mutex_t mutex_ = PTHREAD_MUTEX_INITIALIZER; + std::shared_ptr snapshot_; }; #endif diff --git a/include/MySQL_Passthrough_Auth_Cache.h b/include/MySQL_Passthrough_Auth_Cache.h index 6e9441210c..3589d8d505 100644 --- a/include/MySQL_Passthrough_Auth_Cache.h +++ b/include/MySQL_Passthrough_Auth_Cache.h @@ -39,7 +39,12 @@ class MySQL_Passthrough_Auth_Cache { std::string cleartext_password; uint64_t learned_at_us { 0 }; int hostgroup_probed { 0 }; + entry_t() = default; ~entry_t(); + entry_t(const entry_t&) = delete; + entry_t& operator=(const entry_t&) = delete; + entry_t(entry_t&&) = delete; + entry_t& operator=(entry_t&&) = delete; }; mutable pthread_rwlock_t lock; std::unordered_map entries; diff --git a/include/MySQL_Protocol.h b/include/MySQL_Protocol.h index 38f0f7be27..bc2c1e29e7 100644 --- a/include/MySQL_Protocol.h +++ b/include/MySQL_Protocol.h @@ -9,7 +9,7 @@ #ifdef PROXYSQL31 #include -class CachingSha2RSAKeySnapshot; +class MySQL_Caching_Sha2_RSA_Key_Snapshot; enum class MySQLFrontendAuthError : uint8_t { NONE = 0, @@ -156,7 +156,7 @@ class MySQL_Protocol { uint16_t prot_status; bool more_data_needed; #ifdef PROXYSQL31 - std::shared_ptr caching_sha2_rsa_snapshot_; + std::shared_ptr caching_sha2_rsa_snapshot_; MySQLFrontendAuthError frontend_auth_error_ { MySQLFrontendAuthError::NONE }; #endif MySQL_Data_Stream *get_myds() { return *myds; } diff --git a/lib/MySQL_Caching_Sha2_RSA.cpp b/lib/MySQL_Caching_Sha2_RSA.cpp index 4de723a456..b95fb7d768 100644 --- a/lib/MySQL_Caching_Sha2_RSA.cpp +++ b/lib/MySQL_Caching_Sha2_RSA.cpp @@ -583,7 +583,7 @@ bool create_temporary_key_file( static std::atomic sequence { 0 }; for (unsigned int attempt = 0; attempt < 100; ++attempt) { temporary_leaf = final_path.leaf + ".tmp." + std::to_string(getpid()) + "." + - std::to_string(sequence.fetch_add(1, std::memory_order_relaxed)); + std::to_string(sequence.fetch_add(1, std::memory_order_relaxed)); // NOSONAR(cpp:S8417): suffix uniqueness needs no publication ordering. int flags = O_WRONLY | O_CREAT | O_EXCL; #ifdef O_CLOEXEC flags |= O_CLOEXEC; @@ -742,17 +742,21 @@ bool generate_pair( return published; } -CachingSha2RSAReloadResult rejected_result( +MySQL_Caching_Sha2_RSA_Reload_Result rejected_result( const std::string& error, - const std::shared_ptr& current + const std::shared_ptr& current ) { return { false, false, current != nullptr, error }; } } // namespace -CachingSha2RSAReloadResult MySQL_Caching_Sha2_RSA::reload( - const CachingSha2RSAConfig& config +MySQL_Caching_Sha2_RSA::~MySQL_Caching_Sha2_RSA() { + pthread_mutex_destroy(&mutex_); +} + +MySQL_Caching_Sha2_RSA_Reload_Result MySQL_Caching_Sha2_RSA::reload( + const MySQL_Caching_Sha2_RSA_Config& config ) { if (config.private_key_path.empty() != config.public_key_path.empty()) { const auto current = acquire(); @@ -763,9 +767,10 @@ CachingSha2RSAReloadResult MySQL_Caching_Sha2_RSA::reload( const auto current = acquire(); return rejected_result("automatic RSA key generation requires non-empty key paths", current); } - std::lock_guard guard(mutex_); + pthread_mutex_lock(&mutex_); const bool changed = snapshot_ != nullptr; snapshot_.reset(); + pthread_mutex_unlock(&mutex_); return { true, changed, false, {} }; } @@ -802,30 +807,35 @@ CachingSha2RSAReloadResult MySQL_Caching_Sha2_RSA::reload( return rejected_result(error, acquire()); } - std::lock_guard guard(mutex_); - if (snapshot_ != nullptr && - snapshot_->private_key_path_ == loaded.private_key_path && - snapshot_->public_key_path_ == loaded.public_key_path && - snapshot_->public_key_pem_ == loaded.public_key_pem) { - return { true, false, true, {} }; - } - auto candidate = std::make_shared(); + auto candidate = std::make_shared(); candidate->private_key_ = std::move(loaded.private_key); candidate->public_key_pem_ = std::move(loaded.public_key_pem); candidate->private_key_path_ = std::move(loaded.private_key_path); candidate->public_key_path_ = std::move(loaded.public_key_path); candidate->ciphertext_size_ = loaded.ciphertext_size; + + pthread_mutex_lock(&mutex_); + if (snapshot_ != nullptr && + snapshot_->private_key_path_ == candidate->private_key_path_ && + snapshot_->public_key_path_ == candidate->public_key_path_ && + snapshot_->public_key_pem_ == candidate->public_key_pem_) { + pthread_mutex_unlock(&mutex_); + return { true, false, true, {} }; + } snapshot_ = std::move(candidate); + pthread_mutex_unlock(&mutex_); return { true, true, true, {} }; } -std::shared_ptr MySQL_Caching_Sha2_RSA::acquire() const { - std::lock_guard guard(mutex_); - return snapshot_; +std::shared_ptr MySQL_Caching_Sha2_RSA::acquire() const { + pthread_mutex_lock(&mutex_); + auto snapshot = snapshot_; + pthread_mutex_unlock(&mutex_); + return snapshot; } bool MySQL_Caching_Sha2_RSA::decrypt_password( - const std::shared_ptr& snapshot, + const std::shared_ptr& snapshot, const unsigned char* ciphertext, size_t ciphertext_length, const unsigned char* scramble, @@ -862,8 +872,8 @@ bool MySQL_Caching_Sha2_RSA::decrypt_password( ); if (EVP_PKEY_decrypt_init(context.get()) <= 0 || EVP_PKEY_CTX_set_rsa_padding(context.get(), RSA_PKCS1_OAEP_PADDING) <= 0 || - EVP_PKEY_CTX_set_rsa_oaep_md(context.get(), EVP_sha1()) <= 0 || - EVP_PKEY_CTX_set_rsa_mgf1_md(context.get(), EVP_sha1()) <= 0) { + EVP_PKEY_CTX_set_rsa_oaep_md(context.get(), EVP_sha1()) <= 0 || // NOSONAR(cpp:S4790): MySQL caching_sha2_password requires OAEP SHA-1. + EVP_PKEY_CTX_set_rsa_mgf1_md(context.get(), EVP_sha1()) <= 0) { // NOSONAR(cpp:S4790): MySQL caching_sha2_password requires MGF1 SHA-1. return fail("cannot initialize RSA OAEP decryption"); } diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index afd17a9abe..2874a7e567 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -69,6 +69,10 @@ class ScopedStringCleanser { OPENSSL_cleanse(value_.data(), value_.size()); } } + ScopedStringCleanser(const ScopedStringCleanser&) = delete; + ScopedStringCleanser& operator=(const ScopedStringCleanser&) = delete; + ScopedStringCleanser(ScopedStringCleanser&&) = delete; + ScopedStringCleanser& operator=(ScopedStringCleanser&&) = delete; }; } // namespace @@ -1797,6 +1801,7 @@ int MySQL_Protocol::PPHR_1(unsigned char *pkt, unsigned int len, bool& ret, MyPr } std::string plaintext_password; + ScopedStringCleanser plaintext_password_cleanser(plaintext_password); if (!GloMTH->caching_sha2_rsa()->decrypt_password( key_snapshot, pkt, @@ -1810,13 +1815,22 @@ int MySQL_Protocol::PPHR_1(unsigned char *pkt, unsigned int len, bool& ret, MyPr return 1; } - vars1.pass_len = plaintext_password.size(); - vars1.pass = static_cast(malloc(vars1.pass_len + 1)); - if (vars1.pass_len != 0) { - memcpy(vars1.pass, plaintext_password.data(), vars1.pass_len); - OPENSSL_cleanse(plaintext_password.data(), plaintext_password.size()); + const size_t plaintext_password_length = plaintext_password.size(); + unsigned char* plaintext_password_copy = static_cast( + malloc(plaintext_password_length + 1) + ); + if (plaintext_password_copy == nullptr) { + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, + "Session=%p , DS=%p , user='%s' . Cannot allocate caching_sha2_password RSA response\n", + (*myds)->sess, (*myds), vars1.user); + return 1; + } + if (plaintext_password_length != 0) { + memcpy(plaintext_password_copy, plaintext_password.data(), plaintext_password_length); } - vars1.pass[vars1.pass_len] = '\0'; + plaintext_password_copy[plaintext_password_length] = '\0'; + vars1.pass_len = plaintext_password_length; + vars1.pass = plaintext_password_copy; vars1.pass_is_sensitive = true; vars1.db = (*myds)->myconn->userinfo->schemaname; vars1.charset = (*myds)->tmp_charset; diff --git a/lib/MySQL_Thread.cpp b/lib/MySQL_Thread.cpp index f72542c32a..9e68cee6d6 100644 --- a/lib/MySQL_Thread.cpp +++ b/lib/MySQL_Thread.cpp @@ -1603,13 +1603,13 @@ MySQLThreadsCommitResult MySQL_Threads_Handler::commit() { ? variables.caching_sha2_password_private_key_path : ""; const char *public_path = variables.caching_sha2_password_public_key_path != nullptr ? variables.caching_sha2_password_public_key_path : ""; - CachingSha2RSAConfig rsa_config { + MySQL_Caching_Sha2_RSA_Config rsa_config { variables.caching_sha2_password_auto_generate_rsa_keys, private_path, public_path, GloVars.datadir != nullptr ? GloVars.datadir : "" }; - CachingSha2RSAReloadResult rsa_reload = caching_sha2_rsa_manager_->reload(rsa_config); + MySQL_Caching_Sha2_RSA_Reload_Result rsa_reload = caching_sha2_rsa_manager_->reload(rsa_config); if (rsa_reload.accepted) { caching_sha2_rsa_accepted_auto_generate_ = rsa_config.auto_generate; caching_sha2_rsa_accepted_private_path_ = rsa_config.private_key_path; @@ -1632,13 +1632,13 @@ MySQLThreadsCommitResult MySQL_Threads_Handler::commit() { bool default_accepted = false; std::string default_error = rsa_reload.error; if (!candidate_is_default) { - CachingSha2RSAConfig fallback_config { + MySQL_Caching_Sha2_RSA_Config fallback_config { true, default_private_path, default_public_path, rsa_config.datadir }; - const CachingSha2RSAReloadResult fallback = caching_sha2_rsa_manager_->reload(fallback_config); + const MySQL_Caching_Sha2_RSA_Reload_Result fallback = caching_sha2_rsa_manager_->reload(fallback_config); default_accepted = fallback.accepted; default_error = fallback.error; } @@ -1647,8 +1647,8 @@ MySQLThreadsCommitResult MySQL_Threads_Handler::commit() { caching_sha2_rsa_accepted_private_path_ = default_private_path; caching_sha2_rsa_accepted_public_path_ = default_public_path; } else { - CachingSha2RSAConfig disabled_config { false, "", "", rsa_config.datadir }; - const CachingSha2RSAReloadResult disabled = + MySQL_Caching_Sha2_RSA_Config disabled_config { false, "", "", rsa_config.datadir }; + const MySQL_Caching_Sha2_RSA_Reload_Result disabled = caching_sha2_rsa_manager_->reload(disabled_config); if (!disabled.accepted) { proxy_error("Failed to disable unavailable caching_sha2_password RSA configuration: %s\n", diff --git a/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp b/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp index c18ac36bd6..b2dacf626a 100644 --- a/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp +++ b/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp @@ -149,8 +149,8 @@ int main() { const string suffix = std::to_string(static_cast(getpid())); const string username = "tap5988_" + suffix; - const string password = "issue5988-secret"; - const string wrong_password = "issue5988-wrong"; + const string password = "issue5988-secret"; // NOSONAR(cpp:S2068): deterministic process-local E2E test credential. + const string wrong_password = "issue5988-wrong"; // NOSONAR(cpp:S2068): deliberate authentication-rejection test credential. const string comment = "reg_test_5988_" + suffix; const long rule_id = 598800000L + (static_cast(getpid()) % 100000L); const string test_private_key = comment + "-private.pem"; diff --git a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp index c0f920f048..93d55bfd20 100644 --- a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp +++ b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp @@ -20,7 +20,7 @@ class TempDir { public: TempDir() { char path_template[] = "/tmp/proxysql-caching-sha2-rsa-XXXXXX"; - char* created = mkdtemp(path_template); + char* created = mkdtemp(path_template); // NOSONAR(cpp:S5443): mkdtemp atomically creates a unique owner-only test directory. if (created != nullptr) { path_ = created; } @@ -44,6 +44,10 @@ class TempDir { rmdir(path_.c_str()); } } + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; + TempDir(TempDir&&) = delete; + TempDir& operator=(TempDir&&) = delete; const std::string& path() const { return path_; } @@ -166,7 +170,7 @@ static bool write_pkcs8_key_pair( } std::unique_ptr public_bio(raw_public, &BIO_free); return PEM_write_bio_PUBKEY(public_bio.get(), key) == 1 && - chmod(public_path.c_str(), 0644) == 0; + chmod(public_path.c_str(), 0644) == 0; // NOSONAR(cpp:S2612): RSA public keys are intentionally world-readable. } static bool write_malformed_private_key(const std::string& path) { @@ -189,7 +193,7 @@ static bool append_text(const std::string& path, const char* text) { } static std::vector encrypt_password_payload( - const CachingSha2RSAKeySnapshot& snapshot, + const MySQL_Caching_Sha2_RSA_Key_Snapshot& snapshot, const std::vector& cleartext, const unsigned char* scramble, size_t scramble_length @@ -221,8 +225,8 @@ static std::vector encrypt_password_payload( ); if (EVP_PKEY_encrypt_init(context.get()) <= 0 || EVP_PKEY_CTX_set_rsa_padding(context.get(), RSA_PKCS1_OAEP_PADDING) <= 0 || - EVP_PKEY_CTX_set_rsa_oaep_md(context.get(), EVP_sha1()) <= 0 || - EVP_PKEY_CTX_set_rsa_mgf1_md(context.get(), EVP_sha1()) <= 0) { + EVP_PKEY_CTX_set_rsa_oaep_md(context.get(), EVP_sha1()) <= 0 || // NOSONAR(cpp:S4790): test client must match MySQL OAEP SHA-1. + EVP_PKEY_CTX_set_rsa_mgf1_md(context.get(), EVP_sha1()) <= 0) { // NOSONAR(cpp:S4790): test client must match MySQL MGF1 SHA-1. return {}; } size_t ciphertext_length = 0; @@ -245,10 +249,10 @@ int main() { plan(45); MySQL_Caching_Sha2_RSA manager; - CachingSha2RSAConfig config; + MySQL_Caching_Sha2_RSA_Config config; config.auto_generate = false; - const CachingSha2RSAReloadResult result = manager.reload(config); + const MySQL_Caching_Sha2_RSA_Reload_Result result = manager.reload(config); ok(result.accepted, "empty key paths are accepted when automatic generation is disabled"); @@ -262,8 +266,8 @@ int main() { config.datadir = temp_dir.path(); config.private_key_path = "private.pem"; config.public_key_path = "public.pem"; - const mode_t previous_umask = umask(0077); - const CachingSha2RSAReloadResult generated = manager.reload(config); + const mode_t previous_umask = umask(0077); // NOSONAR(cpp:S5849): test temporarily restricts generated-file permissions and restores the previous mask. + const MySQL_Caching_Sha2_RSA_Reload_Result generated = manager.reload(config); umask(previous_umask); const auto snapshot = manager.acquire(); @@ -290,7 +294,7 @@ int main() { ok(first_line(temp_dir.path() + "/private.pem") == "-----BEGIN PRIVATE KEY-----\n", "generated private key uses unencrypted PKCS#8 PEM format"); - const CachingSha2RSAReloadResult unchanged = manager.reload(config); + const MySQL_Caching_Sha2_RSA_Reload_Result unchanged = manager.reload(config); ok(unchanged.accepted, "an unchanged valid key pair reload is accepted"); ok(!unchanged.changed, "an unchanged valid key pair reload is a no-op"); ok(manager.acquire() == snapshot, "an unchanged reload retains the published snapshot"); @@ -327,23 +331,23 @@ int main() { scramble, sizeof(scramble), decrypted_password ), "RSA manager rejects decrypted plaintext without one trailing NUL"); - CachingSha2RSAConfig invalid_config = config; + MySQL_Caching_Sha2_RSA_Config invalid_config = config; invalid_config.public_key_path.clear(); - const CachingSha2RSAReloadResult partial_paths = manager.reload(invalid_config); + const MySQL_Caching_Sha2_RSA_Reload_Result partial_paths = manager.reload(invalid_config); ok(!partial_paths.accepted, "reload rejects a configuration with only one key path"); ok(manager.acquire() == snapshot, "rejected path configuration preserves the active snapshot"); - chmod((temp_dir.path() + "/private.pem").c_str(), 0644); - const CachingSha2RSAReloadResult insecure_permissions = manager.reload(config); + chmod((temp_dir.path() + "/private.pem").c_str(), 0644); // NOSONAR(cpp:S2612): deliberate insecure-mode negative test. + const MySQL_Caching_Sha2_RSA_Reload_Result insecure_permissions = manager.reload(config); ok(!insecure_permissions.accepted, "reload rejects group-readable private keys"); ok(manager.acquire() == snapshot, "rejected private-key permissions preserve the active snapshot"); chmod((temp_dir.path() + "/private.pem").c_str(), 0600); const std::string public_link = temp_dir.path() + "/public-link.pem"; const int symlink_rc = symlink((temp_dir.path() + "/public.pem").c_str(), public_link.c_str()); - CachingSha2RSAConfig symlink_config = config; + MySQL_Caching_Sha2_RSA_Config symlink_config = config; symlink_config.public_key_path = "public-link.pem"; - const CachingSha2RSAReloadResult symlink_result = manager.reload(symlink_config); + const MySQL_Caching_Sha2_RSA_Reload_Result symlink_result = manager.reload(symlink_config); ok(symlink_rc == 0 && !symlink_result.accepted, "reload rejects a symbolic link used as a key path"); ok(manager.acquire() == snapshot, @@ -352,9 +356,9 @@ int main() { TempDir rotated_dir; ok(!rotated_dir.path().empty(), "created an isolated rotation directory"); - CachingSha2RSAConfig rotated_config = config; + MySQL_Caching_Sha2_RSA_Config rotated_config = config; rotated_config.datadir = rotated_dir.path(); - const CachingSha2RSAReloadResult rotated = manager.reload(rotated_config); + const MySQL_Caching_Sha2_RSA_Reload_Result rotated = manager.reload(rotated_config); const auto rotated_snapshot = manager.acquire(); ok(rotated.accepted && rotated.changed && rotated.available, "reload publishes a newly generated valid key pair"); @@ -365,33 +369,33 @@ int main() { ) && decrypted_password == expected_password, "an acquired old snapshot remains usable after key rotation"); - CachingSha2RSAConfig mismatched_config; + MySQL_Caching_Sha2_RSA_Config mismatched_config; mismatched_config.auto_generate = false; mismatched_config.private_key_path = temp_dir.path() + "/private.pem"; mismatched_config.public_key_path = rotated_dir.path() + "/public.pem"; - const CachingSha2RSAReloadResult mismatched = manager.reload(mismatched_config); + const MySQL_Caching_Sha2_RSA_Reload_Result mismatched = manager.reload(mismatched_config); ok(!mismatched.accepted, "reload rejects mismatched RSA private and public keys"); ok(manager.acquire() == rotated_snapshot, "rejected mismatched keys preserve the rotated snapshot"); - CachingSha2RSAConfig missing_config; + MySQL_Caching_Sha2_RSA_Config missing_config; missing_config.auto_generate = false; missing_config.datadir = rotated_dir.path(); missing_config.private_key_path = "missing-private.pem"; missing_config.public_key_path = "missing-public.pem"; - const CachingSha2RSAReloadResult missing = manager.reload(missing_config); + const MySQL_Caching_Sha2_RSA_Reload_Result missing = manager.reload(missing_config); ok(!missing.accepted, "reload rejects missing configured keys when generation is disabled"); ok(manager.acquire() == rotated_snapshot, "rejected missing keys preserve the rotated snapshot"); const std::string escaped_parent = temp_dir.path() + "/escaped-parent"; const int parent_symlink_rc = symlink(rotated_dir.path().c_str(), escaped_parent.c_str()); - CachingSha2RSAConfig escaped_config; + MySQL_Caching_Sha2_RSA_Config escaped_config; escaped_config.auto_generate = false; escaped_config.datadir = temp_dir.path(); escaped_config.private_key_path = "escaped-parent/private.pem"; escaped_config.public_key_path = "escaped-parent/public.pem"; - const CachingSha2RSAReloadResult escaped = manager.reload(escaped_config); + const MySQL_Caching_Sha2_RSA_Reload_Result escaped = manager.reload(escaped_config); ok(parent_symlink_rc == 0 && !escaped.accepted, "relative key paths cannot escape the datadir through a symlinked parent"); unlink(escaped_parent.c_str()); @@ -401,14 +405,14 @@ int main() { "proxysql-rsa-escape-private-" + std::to_string(static_cast(getpid())) + ".pem"; const std::string lexical_public = "proxysql-rsa-escape-public-" + std::to_string(static_cast(getpid())) + ".pem"; - CachingSha2RSAConfig lexical_escape_config; + MySQL_Caching_Sha2_RSA_Config lexical_escape_config; lexical_escape_config.auto_generate = true; lexical_escape_config.datadir = temp_dir.path(); lexical_escape_config.private_key_path = "../" + lexical_private; lexical_escape_config.public_key_path = "../" + lexical_public; - const CachingSha2RSAReloadResult lexical_escape = manager.reload(lexical_escape_config); - const std::string lexical_private_path = "/tmp/" + lexical_private; - const std::string lexical_public_path = "/tmp/" + lexical_public; + const MySQL_Caching_Sha2_RSA_Reload_Result lexical_escape = manager.reload(lexical_escape_config); + const std::string lexical_private_path = "/tmp/" + lexical_private; // NOSONAR(cpp:S5443): negative test asserts traversal cannot create this path. + const std::string lexical_public_path = "/tmp/" + lexical_public; // NOSONAR(cpp:S5443): negative test asserts traversal cannot create this path. ok(!lexical_escape.accepted && access(lexical_private_path.c_str(), F_OK) != 0 && access(lexical_public_path.c_str(), F_OK) != 0, "relative parent-directory components cannot generate keys outside the datadir"); @@ -419,19 +423,19 @@ int main() { const bool traditional_written = write_traditional_private_key( temp_dir.path() + "/private.pem", traditional_path ); - CachingSha2RSAConfig traditional_config; + MySQL_Caching_Sha2_RSA_Config traditional_config; traditional_config.auto_generate = false; traditional_config.private_key_path = traditional_path; traditional_config.public_key_path = temp_dir.path() + "/public.pem"; - const CachingSha2RSAReloadResult traditional = manager.reload(traditional_config); + const MySQL_Caching_Sha2_RSA_Reload_Result traditional = manager.reload(traditional_config); ok(traditional_written && !traditional.accepted, "reload rejects a traditional PKCS#1 RSA private-key PEM"); const std::string malformed_path = temp_dir.path() + "/malformed-private.pem"; const bool malformed_written = write_malformed_private_key(malformed_path); - CachingSha2RSAConfig malformed_config = traditional_config; + MySQL_Caching_Sha2_RSA_Config malformed_config = traditional_config; malformed_config.private_key_path = malformed_path; - const CachingSha2RSAReloadResult malformed = manager.reload(malformed_config); + const MySQL_Caching_Sha2_RSA_Reload_Result malformed = manager.reload(malformed_config); ok(malformed_written && !malformed.accepted, "reload rejects malformed PKCS#8 private-key data"); @@ -441,11 +445,11 @@ int main() { const bool encrypted_written = write_pkcs8_key_pair( encrypted_key.get(), encrypted_private, encrypted_public, true ); - CachingSha2RSAConfig encrypted_config; + MySQL_Caching_Sha2_RSA_Config encrypted_config; encrypted_config.auto_generate = false; encrypted_config.private_key_path = encrypted_private; encrypted_config.public_key_path = encrypted_public; - const CachingSha2RSAReloadResult encrypted = manager.reload(encrypted_config); + const MySQL_Caching_Sha2_RSA_Reload_Result encrypted = manager.reload(encrypted_config); ok(encrypted_written && !encrypted.accepted, "reload rejects an encrypted PKCS#8 RSA private key"); @@ -455,11 +459,11 @@ int main() { const bool ec_written = write_pkcs8_key_pair( ec_key.get(), ec_private, ec_public ); - CachingSha2RSAConfig ec_config; + MySQL_Caching_Sha2_RSA_Config ec_config; ec_config.auto_generate = false; ec_config.private_key_path = ec_private; ec_config.public_key_path = ec_public; - const CachingSha2RSAReloadResult ec = manager.reload(ec_config); + const MySQL_Caching_Sha2_RSA_Reload_Result ec = manager.reload(ec_config); ok(ec_written && !ec.accepted, "reload rejects a matching non-RSA PKCS#8 key pair"); @@ -468,18 +472,18 @@ int main() { const bool trailing_private_written = write_pkcs8_key_pair( encrypted_key.get(), trailing_private, trailing_public ) && append_text(trailing_private, "unexpected trailing data\n"); - CachingSha2RSAConfig trailing_config; + MySQL_Caching_Sha2_RSA_Config trailing_config; trailing_config.auto_generate = false; trailing_config.private_key_path = trailing_private; trailing_config.public_key_path = trailing_public; - const CachingSha2RSAReloadResult trailing_private_result = manager.reload(trailing_config); + const MySQL_Caching_Sha2_RSA_Reload_Result trailing_private_result = manager.reload(trailing_config); ok(trailing_private_written && !trailing_private_result.accepted, "reload rejects trailing data after a PKCS#8 private key"); const bool trailing_public_written = write_pkcs8_key_pair( encrypted_key.get(), trailing_private, trailing_public ) && append_text(trailing_public, "unexpected trailing data\n"); - const CachingSha2RSAReloadResult trailing_public_result = manager.reload(trailing_config); + const MySQL_Caching_Sha2_RSA_Reload_Result trailing_public_result = manager.reload(trailing_config); ok(trailing_public_written && !trailing_public_result.accepted, "reload rejects trailing data after an SPKI public key"); @@ -492,41 +496,41 @@ int main() { const bool weak_written = write_pkcs8_key_pair( weak_key.get(), weak_private, weak_public ); - CachingSha2RSAConfig weak_config; + MySQL_Caching_Sha2_RSA_Config weak_config; weak_config.auto_generate = false; weak_config.private_key_path = weak_private; weak_config.public_key_path = weak_public; - const CachingSha2RSAReloadResult weak = manager.reload(weak_config); + const MySQL_Caching_Sha2_RSA_Reload_Result weak = manager.reload(weak_config); ok(weak_written && !weak.accepted, "reload rejects a matching RSA key pair weaker than 2048 bits"); } TempDir collision_dir; - CachingSha2RSAConfig collision_config; + MySQL_Caching_Sha2_RSA_Config collision_config; collision_config.auto_generate = true; collision_config.datadir = collision_dir.path(); collision_config.private_key_path = "private.pem"; collision_config.public_key_path = "private.pem.lock"; - const CachingSha2RSAReloadResult collision = manager.reload(collision_config); + const MySQL_Caching_Sha2_RSA_Reload_Result collision = manager.reload(collision_config); ok(!collision.accepted && access((collision_dir.path() + "/private.pem").c_str(), F_OK) != 0 && access((collision_dir.path() + "/private.pem.lock").c_str(), F_OK) != 0, "generation rejects a public target that collides with the lock namespace without creating files"); TempDir concurrent_dir; - CachingSha2RSAConfig concurrent_config; + MySQL_Caching_Sha2_RSA_Config concurrent_config; concurrent_config.auto_generate = true; concurrent_config.datadir = concurrent_dir.path(); concurrent_config.private_key_path = "private.pem"; concurrent_config.public_key_path = "public.pem"; MySQL_Caching_Sha2_RSA concurrent_manager_one; MySQL_Caching_Sha2_RSA concurrent_manager_two; - CachingSha2RSAReloadResult concurrent_result_one; - CachingSha2RSAReloadResult concurrent_result_two; - std::thread first_reload([&]() { + MySQL_Caching_Sha2_RSA_Reload_Result concurrent_result_one; + MySQL_Caching_Sha2_RSA_Reload_Result concurrent_result_two; + std::thread first_reload([&concurrent_result_one, &concurrent_manager_one, &concurrent_config]() { concurrent_result_one = concurrent_manager_one.reload(concurrent_config); }); - std::thread second_reload([&]() { + std::thread second_reload([&concurrent_result_two, &concurrent_manager_two, &concurrent_config]() { concurrent_result_two = concurrent_manager_two.reload(concurrent_config); }); first_reload.join(); diff --git a/test/tap/tests/unit/mysql_variables_unit-t.cpp b/test/tap/tests/unit/mysql_variables_unit-t.cpp index 80d3addc20..f33684990a 100644 --- a/test/tap/tests/unit/mysql_variables_unit-t.cpp +++ b/test/tap/tests/unit/mysql_variables_unit-t.cpp @@ -124,7 +124,7 @@ static void free_variables_list(char **variables) { static void test_caching_sha2_rsa_commit_is_atomic() { test_globals_init(); char path_template[] = "/tmp/proxysql-mth-caching-sha2-rsa-XXXXXX"; - char *temporary_directory = mkdtemp(path_template); + char *temporary_directory = mkdtemp(path_template); // NOSONAR(cpp:S5443): mkdtemp atomically creates a unique owner-only test directory. ok(temporary_directory != nullptr, "created an isolated handler RSA directory"); if (temporary_directory == nullptr) { test_globals_cleanup(); From 3f8bac99c3684f4038aad40aeef3ca3cd583c0c7 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 19:23:21 +0000 Subject: [PATCH 09/18] fix: initialize protocol auth failure fields --- include/MySQL_Protocol.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/MySQL_Protocol.h b/include/MySQL_Protocol.h index bc2c1e29e7..cd0f52bfd6 100644 --- a/include/MySQL_Protocol.h +++ b/include/MySQL_Protocol.h @@ -116,10 +116,10 @@ class MyProt_tmp_auth_vars { unsigned char *auth_plugin = NULL; void *sha1_pass=NULL; unsigned char *_ptr = NULL;; - unsigned int charset; + unsigned int charset = 0; uint32_t capabilities = 0; uint32_t max_pkt; - uint32_t pass_len; + uint32_t pass_len = 0; uint8_t zstd_compression_level = 0; bool use_ssl = false; bool use_zstd_compression = false; From 1a8ff95a6e341b3fa86d3aa8304aabc4b585831a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 19:24:58 +0000 Subject: [PATCH 10/18] docs: clarify RSA auth helper contracts --- include/MySQL_Passthrough_Auth_Cache.h | 6 +++++- include/MySQL_Protocol.h | 19 ++++++++++--------- include/MySQL_Thread.h | 4 ++++ include/mysql_connection.h | 1 + include/proxysql_admin.h | 4 ++++ 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/include/MySQL_Passthrough_Auth_Cache.h b/include/MySQL_Passthrough_Auth_Cache.h index 3589d8d505..a6c947bfd8 100644 --- a/include/MySQL_Passthrough_Auth_Cache.h +++ b/include/MySQL_Passthrough_Auth_Cache.h @@ -35,6 +35,7 @@ struct passthrough_entry_view { class MySQL_Passthrough_Auth_Cache { private: + /** @brief Cache entry whose destructor cleanses its owned cleartext credential. */ struct entry_t { std::string cleartext_password; uint64_t learned_at_us { 0 }; @@ -114,7 +115,10 @@ class MySQL_Passthrough_Auth_Cache { // than ttl_s, the entry is evicted and a miss is returned. bool lookup(const std::string& username, std::string& out_cleartext, uint32_t ttl_s); - // Insert or replace a cached credential. + /** + * @brief Copy a non-null cleartext credential into the cache. + * @details Replacing an entry cleanses the previously owned credential. + */ void insert(const std::string& username, const char* cleartext, int hostgroup_probed); // Evict a single entry. Returns true if the entry was present. diff --git a/include/MySQL_Protocol.h b/include/MySQL_Protocol.h index cd0f52bfd6..89e3ae271c 100644 --- a/include/MySQL_Protocol.h +++ b/include/MySQL_Protocol.h @@ -11,6 +11,7 @@ class MySQL_Caching_Sha2_RSA_Key_Snapshot; +/** @brief Frontend authentication failures that require a specific client-facing diagnostic. */ enum class MySQLFrontendAuthError : uint8_t { NONE = 0, CACHING_SHA2_RSA_UNAVAILABLE @@ -214,15 +215,12 @@ class MySQL_Protocol { void PPHR_6auth2(bool& ret, MyProt_tmp_auth_vars& vars1); bool PPHR_verify_sha2(MyProt_tmp_auth_vars& vars1, enum proxysql_auth_plugins passformat, PASSWORD_TYPE::E passtype); void PPHR_sha2full(bool& ret, MyProt_tmp_auth_vars& vars1, enum proxysql_auth_plugins passformat, PASSWORD_TYPE::E passtype); - // Pass-through authentication (see doc/internal/passthrough_authentication.md). - // PPHR_passthrough_init runs the protocol-side state machine for the - // caching_sha2_password full-auth exchange when ProxySQL doesn't yet - // have a password for the user. At switching_auth_stage==0 it sends - // AuthMoreData{0x04} so the client emits its cleartext; at stage 5 it - // stashes the captured cleartext on the data stream and transitions - // the session to AUTHENTICATING_BACKEND_FOR_CLIENT so the backend - // probe (handler_again___status_AUTHENTICATING_BACKEND_FOR_CLIENT) - // can validate the credential. + /** + * @brief Drive caching_sha2_password full authentication for pass-through users. + * @details At stage 0 this sends AuthMoreData{0x04}; at stage 5 it transfers the + * cleartext to the data stream and schedules the backend credential probe. + * @return False when the request packet could not be allocated; no auth state is advanced. + */ bool PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1); void PPHR_7auth1(bool& ret, MyProt_tmp_auth_vars& vars1, char * reply, account_details_t& attr1); void PPHR_7auth2(bool& ret, MyProt_tmp_auth_vars& vars1, char * reply, account_details_t& attr1); @@ -231,9 +229,12 @@ class MySQL_Protocol { bool PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_details_t& account_details); bool PPHR_verify_password_2(MyProt_tmp_auth_vars& vars1, account_details_t& account_details); + /** @brief Queue a one-byte auth packet, leaving the queue and sequence unchanged on failure. */ bool generate_one_byte_pkt(unsigned char b); #ifdef PROXYSQL31 + /** @brief Queue AuthMoreData atomically; false means no packet or sequence update occurred. */ bool generate_auth_more_data(const unsigned char *data, size_t data_len); + /** @brief Return and clear the pending frontend authentication diagnostic. */ MySQLFrontendAuthError consume_frontend_auth_error(); #endif diff --git a/include/MySQL_Thread.h b/include/MySQL_Thread.h index d715655892..473a57cc70 100644 --- a/include/MySQL_Thread.h +++ b/include/MySQL_Thread.h @@ -45,7 +45,9 @@ extern class MySQL_Variables mysql_variables; class MySQL_Caching_Sha2_RSA; #endif +/** @brief Outcome details for a staged MySQL-variable commit. */ struct MySQLThreadsCommitResult { + /** @brief Exact variable names rejected as members of an invalid grouped configuration. */ std::vector rejected_variables; }; @@ -813,12 +815,14 @@ class MySQL_Threads_Handler unsigned int get_global_version(); void wrlock(); void wrunlock(); + /** @brief Commit staged variables and report grouped variables that retained prior values. */ MySQLThreadsCommitResult commit(); char *get_variable(char *name); bool set_variable(char *name, const char *value); char **get_variables_list(); bool has_variable(const char * name); #ifdef PROXYSQL31 + /** @brief Return the handler-owned RSA snapshot manager; ownership is not transferred. */ MySQL_Caching_Sha2_RSA* caching_sha2_rsa() const { return caching_sha2_rsa_manager_.get(); } #endif diff --git a/include/mysql_connection.h b/include/mysql_connection.h index 8b8bd7312d..0094ec700f 100644 --- a/include/mysql_connection.h +++ b/include/mysql_connection.h @@ -55,6 +55,7 @@ class MySQL_Connection_userinfo { char *fe_username; MySQL_Connection_userinfo(); ~MySQL_Connection_userinfo(); + /** @brief Cleanse and release the owned cleartext password, if present. */ void clear_password(); void set(char *, char *, char *, char *); void set(MySQL_Connection_userinfo *); diff --git a/include/proxysql_admin.h b/include/proxysql_admin.h index 24049a3e0e..d642b2756e 100644 --- a/include/proxysql_admin.h +++ b/include/proxysql_admin.h @@ -523,6 +523,10 @@ class ProxySQL_Admin { void flush_GENERIC_variables__checksum__database_to_runtime(const std::string& modname, const std::string& checksum, const time_t epoch); bool flush_GENERIC_variables__retrieve__database_to_runtime(const std::string& modname, char* &error, int& cols, int& affected_rows, SQLite3_result* &resultset); + /** + * @brief Apply generic variables from a result set and report per-row statistics. + * @param accepted_variables Optional output containing only names successfully applied by this generic pass. + */ FlushVariableStats flush_GENERIC_variables__process__database_to_runtime( const std::string& modname, SQLite3DB *db, SQLite3_result* resultset, const bool& lock, const bool& replace, From c38d04e524ccc416fb8fcb2e315c42d5e7724948 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 19:31:08 +0000 Subject: [PATCH 11/18] test: escape wildcard TAP descriptions --- test/tap/tests/unit/protocol_unit-t.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/tap/tests/unit/protocol_unit-t.cpp b/test/tap/tests/unit/protocol_unit-t.cpp index 6a9b35d034..31fd09bd04 100644 --- a/test/tap/tests/unit/protocol_unit-t.cpp +++ b/test/tap/tests/unit/protocol_unit-t.cpp @@ -477,13 +477,13 @@ static void test_wildcard_matching() { // % matches any sequence ok(mywildcmp("hel%", "hello") == true, - "wildcard: % suffix matches"); + "wildcard: %% suffix matches"); ok(mywildcmp("%llo", "hello") == true, - "wildcard: % prefix matches"); + "wildcard: %% prefix matches"); ok(mywildcmp("%ll%", "hello") == true, - "wildcard: % on both sides matches"); + "wildcard: %% on both sides matches"); ok(mywildcmp("%", "anything") == true, - "wildcard: lone % matches anything"); + "wildcard: lone %% matches anything"); // _ matches single character ok(mywildcmp("h_llo", "hello") == true, @@ -495,7 +495,7 @@ static void test_wildcard_matching() { ok(mywildcmp("hello", "world") == false, "wildcard: no match on different strings"); ok(mywildcmp("hel%", "world") == false, - "wildcard: % prefix doesn't match unrelated"); + "wildcard: %% prefix doesn't match unrelated"); ok(mywildcmp("h_llo", "hllo") == false, "wildcard: _ requires exactly one char"); @@ -503,7 +503,7 @@ static void test_wildcard_matching() { ok(mywildcmp("", "") == true, "wildcard: empty pattern matches empty string"); ok(mywildcmp("%", "") == true, - "wildcard: % matches empty string"); + "wildcard: %% matches empty string"); } // ============================================================================ From bb74515712dac91f50bee51d24b0b0ef82f8b5b5 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 19:37:14 +0000 Subject: [PATCH 12/18] test: align RSA rejection E2E with Admin refresh --- test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp b/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp index b2dacf626a..901e89552b 100644 --- a/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp +++ b/test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp @@ -257,9 +257,13 @@ int main() { ) && run_query(admin, "LOAD MYSQL VARIABLES TO RUNTIME"); const char* rejected_update_info = mysql_info(admin); + diag("Rejected grouped RSA LOAD info: %s", + rejected_update_info != nullptr ? rejected_update_info : "(null)"); + // The Admin interface refreshes known variables from runtime before reading + // global_variables, so this external LOAD submits all three grouped RSA rows. ok(rejected_update_ok && rejected_update_info != nullptr && - string(rejected_update_info).find("Rejected: 1") != string::npos, - "Grouped RSA rejection counts only the submitted configuration variable"); + string(rejected_update_info).find("Rejected: 3") != string::npos, + "Grouped RSA rejection reports all submitted configuration variables"); string restored_runtime_auto_generate; ok(query_scalar( From 27b853d7f5674f6b690953a48382f2a8b441c429 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 19:52:30 +0000 Subject: [PATCH 13/18] fix: wait for in-flight RSA key publication --- lib/MySQL_Caching_Sha2_RSA.cpp | 16 ++--- .../tests/unit/caching_sha2_rsa_unit-t.cpp | 63 ++++++++++++++++++- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/lib/MySQL_Caching_Sha2_RSA.cpp b/lib/MySQL_Caching_Sha2_RSA.cpp index b95fb7d768..28a4940bbe 100644 --- a/lib/MySQL_Caching_Sha2_RSA.cpp +++ b/lib/MySQL_Caching_Sha2_RSA.cpp @@ -787,16 +787,16 @@ MySQL_Caching_Sha2_RSA_Reload_Result MySQL_Caching_Sha2_RSA::reload( !path_exists(public_path, public_exists, error)) { return rejected_result(error, acquire()); } - if (private_exists != public_exists) { - return rejected_result( - "only one RSA key file exists; refusing to load or generate a partial pair", - acquire() - ); - } - if (!private_exists) { + if (!private_exists || !public_exists) { if (!config.auto_generate) { - return rejected_result("configured RSA key files do not exist", acquire()); + const std::string missing_error = private_exists != public_exists + ? "only one RSA key file exists; refusing to load or generate a partial pair" + : "configured RSA key files do not exist"; + return rejected_result(missing_error, acquire()); } + // Another process publishes the pair under the generation lock using two + // non-overwriting links. Recheck every missing/partial state while holding + // that same lock so observers wait for an in-flight publisher. if (!generate_pair(private_path, public_path, error)) { return rejected_result(error, acquire()); } diff --git a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp index 93d55bfd20..a9e1b9f8c6 100644 --- a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp +++ b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp @@ -2,9 +2,13 @@ #include "MySQL_Caching_Sha2_RSA.h" +#include +#include #include #include +#include +#include #include #include #include @@ -173,6 +177,19 @@ static bool write_pkcs8_key_pair( chmod(public_path.c_str(), 0644) == 0; // NOSONAR(cpp:S2612): RSA public keys are intentionally world-readable. } +static bool write_public_key(EVP_PKEY* key, const std::string& public_path) { + if (key == nullptr) { + return false; + } + BIO* raw_public = BIO_new_file(public_path.c_str(), "w"); + if (raw_public == nullptr) { + return false; + } + std::unique_ptr public_bio(raw_public, &BIO_free); + return PEM_write_bio_PUBKEY(public_bio.get(), key) == 1 && + chmod(public_path.c_str(), 0644) == 0; // NOSONAR(cpp:S2612): RSA public keys are intentionally world-readable. +} + static bool write_malformed_private_key(const std::string& path) { BIO* raw_bio = BIO_new_file(path.c_str(), "w"); if (raw_bio == nullptr) { @@ -246,7 +263,7 @@ static std::vector encrypt_password_payload( } int main() { - plan(45); + plan(47); MySQL_Caching_Sha2_RSA manager; MySQL_Caching_Sha2_RSA_Config config; @@ -545,5 +562,49 @@ int main() { concurrent_snapshot_one->public_key_pem() == concurrent_snapshot_two->public_key_pem(), "concurrent generation publishes one consistent key pair"); + TempDir publication_dir; + const std::string publication_private = publication_dir.path() + "/private.pem"; + const std::string publication_public = publication_dir.path() + "/public.pem"; + const std::string publication_lock = publication_private + ".lock"; + EVPKeyPtr publication_key = generate_rsa_key(2048); + const int publication_lock_fd = open( + publication_lock.c_str(), O_RDWR | O_CREAT, 0600 + ); + const bool publication_prepared = publication_lock_fd >= 0 && + flock(publication_lock_fd, LOCK_EX) == 0 && + write_pkcs8_key_pair( + publication_key.get(), publication_private, publication_public + ) && unlink(publication_public.c_str()) == 0; + MySQL_Caching_Sha2_RSA publication_observer; + MySQL_Caching_Sha2_RSA_Config publication_config; + publication_config.auto_generate = true; + publication_config.private_key_path = publication_private; + publication_config.public_key_path = publication_public; + MySQL_Caching_Sha2_RSA_Reload_Result publication_result; + std::atomic publication_reload_started { false }; + std::atomic publication_reload_finished { false }; + std::thread publication_reload([&]() { + publication_reload_started.store(true, std::memory_order_release); + publication_result = publication_observer.reload(publication_config); + publication_reload_finished.store(true, std::memory_order_release); + }); + while (!publication_reload_started.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + ok(publication_prepared && + !publication_reload_finished.load(std::memory_order_acquire), + "auto-generating reload waits for a locked partial-pair publisher"); + const bool publication_completed = publication_prepared && + write_public_key(publication_key.get(), publication_public); + if (publication_lock_fd >= 0) { + flock(publication_lock_fd, LOCK_UN); + close(publication_lock_fd); + } + publication_reload.join(); + ok(publication_completed && publication_result.accepted && + publication_observer.acquire() != nullptr, + "reload accepts the pair completed by the locked publisher"); + return exit_status(); } From 9a3fe8849a245d085b2002328e80cc861733cb87 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 20:02:57 +0000 Subject: [PATCH 14/18] fix: serialize admin MySQL variable commits --- include/MySQL_Thread.h | 10 ++++++++ lib/Admin_Handler.cpp | 24 +++++++------------ lib/MySQL_Thread.cpp | 18 ++++++++++++++ .../tap/tests/unit/mysql_variables_unit-t.cpp | 15 +++++++++++- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/include/MySQL_Thread.h b/include/MySQL_Thread.h index 473a57cc70..c6021e1de6 100644 --- a/include/MySQL_Thread.h +++ b/include/MySQL_Thread.h @@ -817,6 +817,16 @@ class MySQL_Threads_Handler void wrunlock(); /** @brief Commit staged variables and report grouped variables that retained prior values. */ MySQLThreadsCommitResult commit(); + /** + * @brief Atomically replace and commit a registered integer variable. + * + * The previous value is read and the replacement is staged and committed while + * holding the handler write lock. The caller must supply a valid value for a + * registered integer variable. + * + * @return The variable value observed before the replacement. + */ + int set_int_variable_and_commit(const char* name, const char* value); char *get_variable(char *name); bool set_variable(char *name, const char *value); char **get_variables_list(); diff --git a/lib/Admin_Handler.cpp b/lib/Admin_Handler.cpp index ce4c54a8bc..8875adc903 100644 --- a/lib/Admin_Handler.cpp +++ b/lib/Admin_Handler.cpp @@ -821,14 +821,12 @@ bool admin_handler_command_proxysql(char *query_no_space, unsigned int query_no_ char buf[32]; // ----- MySQL module stop ----- - int admin_old_wait_timeout = GloMTH->get_variable_int((char*)"wait_timeout"); - GloMTH->set_variable((char*)"wait_timeout", (char*)"0"); - GloMTH->commit(); + int admin_old_wait_timeout = + GloMTH->set_int_variable_and_commit("wait_timeout", "0"); GloMTH->signal_all_threads(0); GloMTH->stop_listeners(); sprintf(buf, "%d", admin_old_wait_timeout); - GloMTH->set_variable((char*)"wait_timeout", buf); - GloMTH->commit(); + (void)GloMTH->set_int_variable_and_commit("wait_timeout", buf); // ----- PgSQL module stop ----- admin_old_wait_timeout = GloPTH->get_variable_int((char*)"wait_timeout"); @@ -879,17 +877,15 @@ bool admin_handler_command_proxysql(char *query_no_space, unsigned int query_no_ if (admin_proxysql_mysql_paused==false) { // to speed up this process we first change poll_timeout to 10 // MySQL_thread will call poll() with a maximum timeout of 10ms - admin_old_wait_timeout=GloMTH->get_variable_int((char *)"poll_timeout"); - GloMTH->set_variable((char *)"poll_timeout",(char *)"10"); - GloMTH->commit(); + admin_old_wait_timeout = + GloMTH->set_int_variable_and_commit("poll_timeout", "10"); GloMTH->signal_all_threads(0); GloMTH->stop_listeners(); admin_proxysql_mysql_paused=true; // we now rollback poll_timeout char buf[32]; sprintf(buf,"%d",admin_old_wait_timeout); - GloMTH->set_variable((char *)"poll_timeout",buf); - GloMTH->commit(); + (void)GloMTH->set_int_variable_and_commit("poll_timeout", buf); } if (admin_proxysql_pgsql_paused == false) { @@ -933,9 +929,8 @@ bool admin_handler_command_proxysql(char *query_no_space, unsigned int query_no_ if (admin_proxysql_mysql_paused==true) { // to speed up this process we first change poll_timeout to 10 // MySQL_thread will call poll() with a maximum timeout of 10ms - admin_old_wait_timeout=GloMTH->get_variable_int((char *)"poll_timeout"); - GloMTH->set_variable((char *)"poll_timeout",(char *)"10"); - GloMTH->commit(); + admin_old_wait_timeout = + GloMTH->set_int_variable_and_commit("poll_timeout", "10"); GloMTH->signal_all_threads(0); GloMTH->start_listeners(); //char buf[32]; @@ -946,8 +941,7 @@ bool admin_handler_command_proxysql(char *query_no_space, unsigned int query_no_ // we now rollback poll_timeout char buf[32]; sprintf(buf,"%d",admin_old_wait_timeout); - GloMTH->set_variable((char *)"poll_timeout",buf); - GloMTH->commit(); + (void)GloMTH->set_int_variable_and_commit("poll_timeout", buf); } if (admin_proxysql_pgsql_paused == true) { diff --git a/lib/MySQL_Thread.cpp b/lib/MySQL_Thread.cpp index 9e68cee6d6..89ee3ba2a7 100644 --- a/lib/MySQL_Thread.cpp +++ b/lib/MySQL_Thread.cpp @@ -1596,6 +1596,24 @@ void MySQL_Threads_Handler::wrunlock() { pthread_rwlock_unlock(&rwlock); } +int MySQL_Threads_Handler::set_int_variable_and_commit( + const char* name, const char* value +) { + wrlock(); + struct WriteUnlockGuard { + MySQL_Threads_Handler& handler; + ~WriteUnlockGuard() { handler.wrunlock(); } + } unlock_guard { *this }; + + const int previous_value = get_variable_int(name); + const bool variable_set = set_variable(const_cast(name), value); + assert(variable_set); + if (variable_set) { + (void)commit(); + } + return previous_value; +} + MySQLThreadsCommitResult MySQL_Threads_Handler::commit() { MySQLThreadsCommitResult commit_result; #ifdef PROXYSQL31 diff --git a/test/tap/tests/unit/mysql_variables_unit-t.cpp b/test/tap/tests/unit/mysql_variables_unit-t.cpp index f33684990a..cb28b9db02 100644 --- a/test/tap/tests/unit/mysql_variables_unit-t.cpp +++ b/test/tap/tests/unit/mysql_variables_unit-t.cpp @@ -149,6 +149,19 @@ static void test_caching_sha2_rsa_commit_is_atomic() { ok(handler.caching_sha2_rsa()->acquire() == nullptr, "intentional RSA unavailability publishes no snapshot"); + const int previous_poll_timeout = + handler.set_int_variable_and_commit("poll_timeout", "10"); + ok(previous_poll_timeout > 0 && + handler.get_variable_int("poll_timeout") == 10, + "atomic variable update returns the previous value and commits the replacement"); + const std::string restored_poll_timeout = std::to_string(previous_poll_timeout); + const int temporary_poll_timeout = handler.set_int_variable_and_commit( + "poll_timeout", restored_poll_timeout.c_str() + ); + ok(temporary_poll_timeout == 10 && + handler.get_variable_int("poll_timeout") == previous_poll_timeout, + "atomic variable update restores the prior value"); + handler.set_variable(auto_name, "true"); const MySQLThreadsCommitResult invalid_empty = handler.commit(); ok(has_all_rejected_rsa_variables(invalid_empty.rejected_variables), @@ -320,7 +333,7 @@ static void test_caching_sha2_rsa_rejection_restores_database_values(MySQL_Threa int main() { #ifdef PROXYSQL31 - plan(28); + plan(30); #else plan(4); #endif From d484a5e0680594b2ce8aca0fc86496ab8ba4730e Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Sun, 9 Aug 2026 20:15:20 +0000 Subject: [PATCH 15/18] test: make RSA publication race deterministic --- test/tap/tests/unit/Makefile | 5 + .../tests/unit/caching_sha2_rsa_unit-t.cpp | 148 +++++++++++++----- 2 files changed, 116 insertions(+), 37 deletions(-) diff --git a/test/tap/tests/unit/Makefile b/test/tap/tests/unit/Makefile index e9ed0ca9ac..f89a7faa14 100644 --- a/test/tap/tests/unit/Makefile +++ b/test/tap/tests/unit/Makefile @@ -860,6 +860,11 @@ genai_mysql_catalog_unit-t: genai_mysql_catalog_unit-t.cpp $(GENAI_ALL_SRCS) $(T $(GENAI_PLUGIN_DEFINES) $(IDIRS) $(LDIRS) $(OPT) \ $(WHOLE_LIBPROXYSQL) $(STATIC_LIBS) $(MYLIBS) -ldl $(ALLOW_MULTI_DEF) -o $@ +ifeq ($(UNAME_S),Linux) +# Let the RSA unit test observe the exact flock attempt without a production hook. +caching_sha2_rsa_unit-t: ALLOW_MULTI_DEF += -Wl,--wrap=flock +endif + # Pattern rule: all unit tests use the same compile + link flags. # Each test binary is built from its .cpp source, linked against # the test harness objects and libproxysql.a with all dependencies. diff --git a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp index a9e1b9f8c6..9d46971972 100644 --- a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp +++ b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp @@ -7,10 +7,13 @@ #include #include -#include -#include +#include #include #include +#ifdef __linux__ +#include +#include +#endif #include #include #include @@ -61,6 +64,45 @@ class TempDir { using EVPKeyPtr = std::unique_ptr; +#ifdef __linux__ +static std::mutex publication_lock_observer_mutex; +static std::condition_variable publication_lock_observer_cv; +static bool publication_lock_observer_enabled = false; +static bool publication_lock_attempted = false; +static bool publication_lock_contended = false; +static bool publication_reload_finished = false; + +extern "C" int __real_flock(int fd, int operation); + +extern "C" int __wrap_flock(int fd, int operation) { + bool observe_attempt = false; + { + std::lock_guard lock(publication_lock_observer_mutex); + observe_attempt = publication_lock_observer_enabled && + (operation & LOCK_EX) != 0 && (operation & LOCK_NB) == 0; + } + if (!observe_attempt) { + return __real_flock(fd, operation); + } + + const int probe_result = __real_flock(fd, operation | LOCK_NB); + const int probe_errno = errno; + const bool contended = probe_result < 0 && + (probe_errno == EAGAIN || probe_errno == EWOULDBLOCK); + if (probe_result == 0) { + (void)__real_flock(fd, LOCK_UN); + } + { + std::lock_guard lock(publication_lock_observer_mutex); + publication_lock_attempted = true; + publication_lock_contended = contended; + } + publication_lock_observer_cv.notify_all(); + errno = probe_errno; + return __real_flock(fd, operation); +} +#endif + static std::string first_line(const std::string& path) { BIO* raw_bio = BIO_new_file(path.c_str(), "r"); if (raw_bio == nullptr) { @@ -263,7 +305,7 @@ static std::vector encrypt_password_payload( } int main() { - plan(47); + plan(48); MySQL_Caching_Sha2_RSA manager; MySQL_Caching_Sha2_RSA_Config config; @@ -563,48 +605,80 @@ int main() { "concurrent generation publishes one consistent key pair"); TempDir publication_dir; - const std::string publication_private = publication_dir.path() + "/private.pem"; - const std::string publication_public = publication_dir.path() + "/public.pem"; + const bool publication_directory_created = !publication_dir.path().empty(); + ok(publication_directory_created, + "created an isolated key-publication directory"); +#ifdef __linux__ + const std::string publication_private = publication_directory_created + ? publication_dir.path() + "/private.pem" : std::string(); + const std::string publication_public = publication_directory_created + ? publication_dir.path() + "/public.pem" : std::string(); const std::string publication_lock = publication_private + ".lock"; EVPKeyPtr publication_key = generate_rsa_key(2048); - const int publication_lock_fd = open( - publication_lock.c_str(), O_RDWR | O_CREAT, 0600 - ); - const bool publication_prepared = publication_lock_fd >= 0 && + const int publication_lock_fd = publication_directory_created + ? open(publication_lock.c_str(), O_RDWR | O_CREAT, 0600) : -1; + const bool publication_prepared = publication_directory_created && + publication_lock_fd >= 0 && flock(publication_lock_fd, LOCK_EX) == 0 && write_pkcs8_key_pair( publication_key.get(), publication_private, publication_public ) && unlink(publication_public.c_str()) == 0; - MySQL_Caching_Sha2_RSA publication_observer; - MySQL_Caching_Sha2_RSA_Config publication_config; - publication_config.auto_generate = true; - publication_config.private_key_path = publication_private; - publication_config.public_key_path = publication_public; - MySQL_Caching_Sha2_RSA_Reload_Result publication_result; - std::atomic publication_reload_started { false }; - std::atomic publication_reload_finished { false }; - std::thread publication_reload([&]() { - publication_reload_started.store(true, std::memory_order_release); - publication_result = publication_observer.reload(publication_config); - publication_reload_finished.store(true, std::memory_order_release); - }); - while (!publication_reload_started.load(std::memory_order_acquire)) { - std::this_thread::yield(); - } - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - ok(publication_prepared && - !publication_reload_finished.load(std::memory_order_acquire), - "auto-generating reload waits for a locked partial-pair publisher"); - const bool publication_completed = publication_prepared && - write_public_key(publication_key.get(), publication_public); - if (publication_lock_fd >= 0) { - flock(publication_lock_fd, LOCK_UN); + if (!publication_prepared) { + if (publication_lock_fd >= 0) { + (void)flock(publication_lock_fd, LOCK_UN); + close(publication_lock_fd); + } + ok(false, "auto-generating reload waits for a locked partial-pair publisher"); + ok(false, "reload accepts the pair completed by the locked publisher"); + } else { + MySQL_Caching_Sha2_RSA publication_observer; + MySQL_Caching_Sha2_RSA_Config publication_config; + publication_config.auto_generate = true; + publication_config.private_key_path = publication_private; + publication_config.public_key_path = publication_public; + MySQL_Caching_Sha2_RSA_Reload_Result publication_result; + { + std::lock_guard lock(publication_lock_observer_mutex); + publication_lock_observer_enabled = true; + publication_lock_attempted = false; + publication_lock_contended = false; + publication_reload_finished = false; + } + std::thread publication_reload([&]() { + publication_result = publication_observer.reload(publication_config); + { + std::lock_guard lock(publication_lock_observer_mutex); + publication_reload_finished = true; + } + publication_lock_observer_cv.notify_all(); + }); + bool observed_contended_attempt = false; + { + std::unique_lock lock(publication_lock_observer_mutex); + publication_lock_observer_cv.wait(lock, []() { + return publication_lock_attempted || publication_reload_finished; + }); + observed_contended_attempt = publication_lock_attempted && + publication_lock_contended && !publication_reload_finished; + } + ok(observed_contended_attempt, + "auto-generating reload waits for a locked partial-pair publisher"); + const bool publication_completed = + write_public_key(publication_key.get(), publication_public); + (void)flock(publication_lock_fd, LOCK_UN); close(publication_lock_fd); + publication_reload.join(); + { + std::lock_guard lock(publication_lock_observer_mutex); + publication_lock_observer_enabled = false; + } + ok(publication_completed && publication_result.accepted && + publication_observer.acquire() != nullptr, + "reload accepts the pair completed by the locked publisher"); } - publication_reload.join(); - ok(publication_completed && publication_result.accepted && - publication_observer.acquire() != nullptr, - "reload accepts the pair completed by the locked publisher"); +#else + skip(2, "deterministic flock interposition is only available on Linux"); +#endif return exit_status(); } From 0d1c9f5f70098cd5497716201d2d89f3896e6a2b Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Mon, 10 Aug 2026 05:24:27 +0000 Subject: [PATCH 16/18] fix: address Sonar RSA review findings --- include/MySQL_Protocol.h | 2 +- include/MySQL_Thread.h | 4 +- lib/Admin_FlushVariables.cpp | 5 +- lib/MySQL_Protocol.cpp | 2 +- lib/MySQL_Session.cpp | 2 +- lib/MySQL_Thread.cpp | 6 +- .../tests/unit/caching_sha2_rsa_unit-t.cpp | 10 +- .../tap/tests/unit/mysql_variables_unit-t.cpp | 118 +++++++----------- 8 files changed, 65 insertions(+), 84 deletions(-) diff --git a/include/MySQL_Protocol.h b/include/MySQL_Protocol.h index 89e3ae271c..2ef6efb7ba 100644 --- a/include/MySQL_Protocol.h +++ b/include/MySQL_Protocol.h @@ -178,7 +178,7 @@ class MySQL_Protocol { // - a pointer to unsigned int, used to return the size of the packet if not NULL // for now, they all return true bool generate_pkt_OK(bool send, void **ptr, unsigned int *len, uint8_t sequence_id, unsigned int affected_rows, uint64_t last_insert_id, uint16_t status, uint16_t warnings, char *msg, bool eof_identifier=false); - bool generate_pkt_ERR(bool send, void **ptr, unsigned int *len, uint8_t sequence_id, uint16_t error_code, char *sql_state, const char *sql_message, bool track=false); + bool generate_pkt_ERR(bool send, void **ptr, unsigned int *len, uint8_t sequence_id, uint16_t error_code, const char *sql_state, const char *sql_message, bool track=false); bool generate_pkt_EOF(bool send, void **ptr, unsigned int *len, uint8_t sequence_id, uint16_t warnings, uint16_t status, MySQL_ResultSet *myrs=NULL); // bool generate_COM_INIT_DB(bool send, void **ptr, unsigned int *len, char *schema); //bool generate_COM_PING(bool send, void **ptr, unsigned int *len); diff --git a/include/MySQL_Thread.h b/include/MySQL_Thread.h index c6021e1de6..4e95eedc7b 100644 --- a/include/MySQL_Thread.h +++ b/include/MySQL_Thread.h @@ -827,8 +827,8 @@ class MySQL_Threads_Handler * @return The variable value observed before the replacement. */ int set_int_variable_and_commit(const char* name, const char* value); - char *get_variable(char *name); - bool set_variable(char *name, const char *value); + char *get_variable(const char *name); + bool set_variable(const char *name, const char *value); char **get_variables_list(); bool has_variable(const char * name); #ifdef PROXYSQL31 diff --git a/lib/Admin_FlushVariables.cpp b/lib/Admin_FlushVariables.cpp index 8cdb9ef1f2..3d76359b0b 100644 --- a/lib/Admin_FlushVariables.cpp +++ b/lib/Admin_FlushVariables.cpp @@ -597,14 +597,14 @@ FlushVariableStats ProxySQL_Admin::flush_mysql_variables___database_to_runtime(S ASSERT_SQLITE_OK(rc, db); sqlite3_stmt* statement = statement_unique.get(); for (const std::string& variable_name : commit_result.rejected_variables) { - char* value = GloMTH->get_variable(const_cast(variable_name.c_str())); + mf_unique_ptr value { GloMTH->get_variable(variable_name.c_str()) }; const std::string qualified_name = "mysql-" + variable_name; rc = (*proxy_sqlite3_bind_text)( statement, 1, qualified_name.c_str(), -1, SQLITE_TRANSIENT ); ASSERT_SQLITE_OK(rc, db); rc = (*proxy_sqlite3_bind_text)( - statement, 2, value != nullptr ? value : "", -1, SQLITE_TRANSIENT + statement, 2, value != nullptr ? value.get() : "", -1, SQLITE_TRANSIENT ); ASSERT_SQLITE_OK(rc, db); SAFE_SQLITE3_STEP2(statement); @@ -612,7 +612,6 @@ FlushVariableStats ProxySQL_Admin::flush_mysql_variables___database_to_runtime(S ASSERT_SQLITE_OK(rc, db); rc = (*proxy_sqlite3_reset)(statement); ASSERT_SQLITE_OK(rc, db); - free(value); } } GloMTH->wrunlock(); diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 2874a7e567..39c220806e 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -233,7 +233,7 @@ bool MySQL_Protocol::generate_pkt_EOF(bool send, void **ptr, unsigned int *len, return true; } -bool MySQL_Protocol::generate_pkt_ERR(bool send, void **ptr, unsigned int *len, uint8_t sequence_id, uint16_t error_code, char *sql_state, const char *sql_message, bool track) { +bool MySQL_Protocol::generate_pkt_ERR(bool send, void **ptr, unsigned int *len, uint8_t sequence_id, uint16_t error_code, const char *sql_state, const char *sql_message, bool track) { if ((*myds)->sess->mirror==true) { return true; } diff --git a/lib/MySQL_Session.cpp b/lib/MySQL_Session.cpp index e8d4e936ca..03a0c8b046 100644 --- a/lib/MySQL_Session.cpp +++ b/lib/MySQL_Session.cpp @@ -6528,7 +6528,7 @@ void MySQL_Session::handler___status_CONNECTING_CLIENT___STATE_SERVER_HANDSHAKE_ (client_myds->myconn->userinfo->password ? "YES" : "NO") ); } - client_myds->myprot.generate_pkt_ERR(true,NULL,NULL, _pid, 1045,(char *)"28000", error_message.c_str(), true); + client_myds->myprot.generate_pkt_ERR(true, NULL, NULL, _pid, 1045, "28000", error_message.c_str(), true); proxy_error("%s\n", error_message.c_str()); #ifdef PROXYSQL31 if (frontend_auth_error != MySQLFrontendAuthError::CACHING_SHA2_RSA_UNAVAILABLE) diff --git a/lib/MySQL_Thread.cpp b/lib/MySQL_Thread.cpp index 89ee3ba2a7..8021a3ddcd 100644 --- a/lib/MySQL_Thread.cpp +++ b/lib/MySQL_Thread.cpp @@ -1606,7 +1606,7 @@ int MySQL_Threads_Handler::set_int_variable_and_commit( } unlock_guard { *this }; const int previous_value = get_variable_int(name); - const bool variable_set = set_variable(const_cast(name), value); + const bool variable_set = set_variable(name, value); assert(variable_set); if (variable_set) { (void)commit(); @@ -1972,7 +1972,7 @@ int MySQL_Threads_Handler::get_variable_int(const char *name) { * @param name The name of the variable to retrieve. * @return The value of the variable as a char pointer, or NULL if the variable does not exist. */ -char * MySQL_Threads_Handler::get_variable(char *name) { // this is the public function, accessible from admin +char * MySQL_Threads_Handler::get_variable(const char *name) { // this is the public function, accessible from admin //VALGRIND_DISABLE_ERROR_REPORTING; #define INTBUFSIZE 4096 char intbuf[INTBUFSIZE]; @@ -2162,7 +2162,7 @@ char * MySQL_Threads_Handler::get_variable(char *name) { // this is the public f * @param value The new value for the variable, passed as a const char pointer. * @return True if the variable was successfully updated, false otherwise. */ -bool MySQL_Threads_Handler::set_variable(char *name, const char *value) { // this is the public function, accessible from admin +bool MySQL_Threads_Handler::set_variable(const char *name, const char *value) { // this is the public function, accessible from admin if (!value) return false; size_t vallen=strlen(value); diff --git a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp index 9d46971972..215b7f6529 100644 --- a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp +++ b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp @@ -26,8 +26,8 @@ class TempDir { public: TempDir() { - char path_template[] = "/tmp/proxysql-caching-sha2-rsa-XXXXXX"; - char* created = mkdtemp(path_template); // NOSONAR(cpp:S5443): mkdtemp atomically creates a unique owner-only test directory. + char path_template[] = "/tmp/proxysql-caching-sha2-rsa-XXXXXX"; // NOSONAR: mkdtemp creates this test directory atomically with owner-only permissions. + char* created = mkdtemp(path_template); if (created != nullptr) { path_ = created; } @@ -644,7 +644,11 @@ int main() { publication_lock_contended = false; publication_reload_finished = false; } - std::thread publication_reload([&]() { + std::thread publication_reload([ + &publication_result, + &publication_observer, + &publication_config + ]() { publication_result = publication_observer.reload(publication_config); { std::lock_guard lock(publication_lock_observer_mutex); diff --git a/test/tap/tests/unit/mysql_variables_unit-t.cpp b/test/tap/tests/unit/mysql_variables_unit-t.cpp index cb28b9db02..6b227816b5 100644 --- a/test/tap/tests/unit/mysql_variables_unit-t.cpp +++ b/test/tap/tests/unit/mysql_variables_unit-t.cpp @@ -4,6 +4,7 @@ #include "MySQL_Thread.h" #include "ProxySQL_Statistics.hpp" #include "proxysql_admin.h" +#include "proxysql_utils.h" #include "sqlite3db.h" #ifdef PROXYSQL31 #include "MySQL_Caching_Sha2_RSA.h" @@ -11,6 +12,7 @@ #include #include +#include #include #include @@ -19,6 +21,15 @@ extern ProxySQL_Admin* GloAdmin; extern ProxySQL_Statistics* GloProxyStats; +static void free_variables_list(char **variables) { + if (variables != nullptr) { + for (char **current = variables; *current != nullptr; ++current) { + free(*current); // NOSONAR: get_variables_list() transfers strdup-owned C strings. + } + free(variables); // NOSONAR: get_variables_list() transfers its malloc-owned array. + } +} + static bool contains_variable(char **variables, const char *name) { for (char **current = variables; current != nullptr && *current != nullptr; ++current) { if (strcmp(*current, name) == 0) { @@ -63,26 +74,18 @@ static void test_mysql_integer_variables_are_registered() { char auto_generate_name[] = "caching_sha2_password_auto_generate_rsa_keys"; char private_path_name[] = "caching_sha2_password_private_key_path"; char public_path_name[] = "caching_sha2_password_public_key_path"; - char *auto_generate = handler.get_variable(auto_generate_name); - char *private_path = handler.get_variable(private_path_name); - char *public_path = handler.get_variable(public_path_name); - ok(auto_generate != nullptr && strcmp(auto_generate, "true") == 0, + mf_unique_ptr auto_generate { handler.get_variable(auto_generate_name) }; + mf_unique_ptr private_path { handler.get_variable(private_path_name) }; + mf_unique_ptr public_path { handler.get_variable(public_path_name) }; + ok(auto_generate != nullptr && strcmp(auto_generate.get(), "true") == 0, "caching_sha2 RSA auto-generation defaults to true"); - ok(private_path != nullptr && strcmp(private_path, "proxysql-caching-sha2-private-key.pem") == 0, + ok(private_path != nullptr && strcmp(private_path.get(), "proxysql-caching-sha2-private-key.pem") == 0, "caching_sha2 RSA private-key path has the compiled default"); - ok(public_path != nullptr && strcmp(public_path, "proxysql-caching-sha2-public-key.pem") == 0, + ok(public_path != nullptr && strcmp(public_path.get(), "proxysql-caching-sha2-public-key.pem") == 0, "caching_sha2 RSA public-key path has the compiled default"); - free(auto_generate); - free(private_path); - free(public_path); #endif - if (variables) { - for (char **p = variables; *p != nullptr; ++p) { - free(*p); - } - free(reinterpret_cast(variables)); - } + free_variables_list(variables); #ifdef PROXYSQL31 test_caching_sha2_rsa_rejection_restores_database_values(handler); #endif @@ -102,35 +105,21 @@ static void test_mysql_integer_boolean_aliases() { handler.get_variable_int(variable_name) == 0, "aws_blue_green_deployment_auto_discovery accepts false"); - if (variables) { - for (char **p = variables; *p != nullptr; ++p) { - free(*p); - } - free(reinterpret_cast(variables)); - } + free_variables_list(variables); test_globals_cleanup(); } #ifdef PROXYSQL31 -static void free_variables_list(char **variables) { - if (variables != nullptr) { - for (char **current = variables; *current != nullptr; ++current) { - free(*current); - } - free(variables); - } -} - static void test_caching_sha2_rsa_commit_is_atomic() { test_globals_init(); - char path_template[] = "/tmp/proxysql-mth-caching-sha2-rsa-XXXXXX"; - char *temporary_directory = mkdtemp(path_template); // NOSONAR(cpp:S5443): mkdtemp atomically creates a unique owner-only test directory. + char path_template[] = "/tmp/proxysql-mth-caching-sha2-rsa-XXXXXX"; // NOSONAR: mkdtemp creates this test directory atomically with owner-only permissions. + char *temporary_directory = mkdtemp(path_template); ok(temporary_directory != nullptr, "created an isolated handler RSA directory"); if (temporary_directory == nullptr) { test_globals_cleanup(); return; } - free(GloVars.datadir); + free(GloVars.datadir); // NOSONAR: test_globals_init() initializes this legacy C-owned field. GloVars.datadir = strdup(temporary_directory); { @@ -168,13 +157,11 @@ static void test_caching_sha2_rsa_commit_is_atomic() { "invalid grouped RSA reload rejects all three variables"); ok(handler.get_variable_int(auto_name) == 0, "invalid grouped reload restores the accepted boolean value"); - char *restored_private = handler.get_variable(private_name); - char *restored_public = handler.get_variable(public_name); - ok(restored_private != nullptr && restored_private[0] == '\0' && - restored_public != nullptr && restored_public[0] == '\0', + mf_unique_ptr restored_private { handler.get_variable(private_name) }; + mf_unique_ptr restored_public { handler.get_variable(public_name) }; + ok(restored_private != nullptr && restored_private.get()[0] == '\0' && + restored_public != nullptr && restored_public.get()[0] == '\0', "invalid grouped reload restores both accepted paths"); - free(restored_private); - free(restored_public); handler.set_variable(auto_name, "true"); handler.set_variable(private_name, "rsa-private.pem"); @@ -193,12 +180,11 @@ static void test_caching_sha2_rsa_commit_is_atomic() { "commit rejects a partial on-disk key pair as one grouped update"); ok(handler.caching_sha2_rsa()->acquire() == generated_snapshot, "rejected handler reload preserves the previously published snapshot"); - char *restored_public_after_partial = handler.get_variable(public_name); + mf_unique_ptr restored_public_after_partial { handler.get_variable(public_name) }; ok(handler.get_variable_int(auto_name) == 1 && restored_public_after_partial != nullptr && - strcmp(restored_public_after_partial, "rsa-public.pem") == 0, + strcmp(restored_public_after_partial.get(), "rsa-public.pem") == 0, "rejected handler reload restores all prior accepted runtime values"); - free(restored_public_after_partial); } const std::string default_private = @@ -218,14 +204,12 @@ static void test_caching_sha2_rsa_commit_is_atomic() { char auto_name[] = "caching_sha2_password_auto_generate_rsa_keys"; char private_name[] = "caching_sha2_password_private_key_path"; char public_name[] = "caching_sha2_password_public_key_path"; - char *fallback_private = handler.get_variable(private_name); - char *fallback_public = handler.get_variable(public_name); + mf_unique_ptr fallback_private { handler.get_variable(private_name) }; + mf_unique_ptr fallback_public { handler.get_variable(public_name) }; ok(handler.get_variable_int(auto_name) == 0 && - fallback_private != nullptr && fallback_private[0] == '\0' && - fallback_public != nullptr && fallback_public[0] == '\0', + fallback_private != nullptr && fallback_private.get()[0] == '\0' && + fallback_public != nullptr && fallback_public.get()[0] == '\0', "failed initial defaults adopt an explicit TLS-only runtime configuration"); - free(fallback_private); - free(fallback_public); } unlink(default_private.c_str()); @@ -238,18 +222,15 @@ static void test_caching_sha2_rsa_commit_is_atomic() { } static std::string query_variable(SQLite3DB* db, const char* table, const char* name) { - char* error = nullptr; + char* raw_error = nullptr; const std::string query = std::string("SELECT variable_value FROM ") + table + " WHERE variable_name='" + name + "'"; - SQLite3_result* result = db->execute_statement(query.c_str(), &error); + std::unique_ptr result { db->execute_statement(query.c_str(), &raw_error) }; + mf_unique_ptr error { raw_error }; std::string value; if (result != nullptr && result->rows_count == 1 && result->rows[0]->fields[0] != nullptr) { value = result->rows[0]->fields[0]; } - if (error != nullptr) { - free(error); - } - delete result; return value; } @@ -264,16 +245,18 @@ static void test_caching_sha2_rsa_rejection_restores_database_values(MySQL_Threa handler.set_variable(public_name, ""); handler.commit(); - const std::string statsdb_path = "/tmp/proxysql-mysql-variables-unit-stats-" + - std::to_string(getpid()) + ".db"; char* previous_statsdb_path = GloVars.statsdb_disk; - GloVars.statsdb_disk = strdup(statsdb_path.c_str()); - GloProxyStats = new ProxySQL_Statistics(); + mf_unique_ptr in_memory_statsdb { strdup(":memory:") }; + GloVars.statsdb_disk = in_memory_statsdb.get(); + auto proxy_stats = std::make_unique(); + GloProxyStats = proxy_stats.get(); GloProxyStats->init(); - ProxySQL_Admin* admin = new ProxySQL_Admin(); - admin->admindb = new SQLite3DB(); + ProxySQL_Admin* admin = new ProxySQL_Admin(); // NOSONAR: this process-scoped partial fixture cannot invoke the production shutdown destructor. + auto admin_db = std::make_unique(); + admin->admindb = admin_db.get(); + char in_memory_admin_db[] = ":memory:"; admin->admindb->open( - (char*)":memory:", SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX + in_memory_admin_db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX ); admin->admindb->execute( "CREATE TABLE global_variables (variable_name VARCHAR NOT NULL PRIMARY KEY, variable_value VARCHAR NOT NULL)" @@ -288,10 +271,10 @@ static void test_caching_sha2_rsa_rejection_restores_database_values(MySQL_Threa GloAdmin = admin; const FlushVariableStats stats = admin->load_mysql_variables_to_runtime(); - char* restored_runtime_auto_generate = handler.get_variable(auto_name); + mf_unique_ptr restored_runtime_auto_generate { handler.get_variable(auto_name) }; ok(stats.records == 1 && stats.updated == 0 && stats.rejected == 1, "Grouped RSA rejection counts only the submitted database variable"); - ok(restored_runtime_auto_generate != nullptr && strcmp(restored_runtime_auto_generate, "false") == 0, + ok(restored_runtime_auto_generate != nullptr && strcmp(restored_runtime_auto_generate.get(), "false") == 0, "Grouped RSA rejection restores the accepted runtime value"); ok(query_variable( admin->admindb, "global_variables", "mysql-caching_sha2_password_auto_generate_rsa_keys" @@ -301,7 +284,6 @@ static void test_caching_sha2_rsa_rejection_restores_database_values(MySQL_Threa admin->admindb, "runtime_global_variables", "mysql-caching_sha2_password_auto_generate_rsa_keys" ) == "false", "Grouped RSA rejection publishes the accepted value to runtime_global_variables"); - free(restored_runtime_auto_generate); admin->admindb->execute("DELETE FROM global_variables"); admin->admindb->execute( @@ -319,15 +301,11 @@ static void test_caching_sha2_rsa_rejection_restores_database_values(MySQL_Threa GloAdmin = nullptr; GloMTH = nullptr; - delete admin->admindb; admin->admindb = nullptr; - delete GloProxyStats; + admin_db.reset(); GloProxyStats = nullptr; - free(GloVars.statsdb_disk); + proxy_stats.reset(); GloVars.statsdb_disk = previous_statsdb_path; - unlink(statsdb_path.c_str()); - unlink((statsdb_path + "-wal").c_str()); - unlink((statsdb_path + "-shm").c_str()); } #endif From ba03b99303bb2e9e4e19b6a6f4967277096efa2c Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Mon, 10 Aug 2026 08:50:56 +0000 Subject: [PATCH 17/18] test: gate caching SHA-2 RSA expectations by version --- ...026-08-10-auth-methods-rsa-version-gate.md | 48 ++++ ...10-auth-methods-rsa-version-gate-design.md | 26 ++ test/tap/tests/test_auth_methods-t.cpp | 228 +++++++++++++++--- 3 files changed, 263 insertions(+), 39 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-10-auth-methods-rsa-version-gate.md create mode 100644 docs/superpowers/specs/2026-08-10-auth-methods-rsa-version-gate-design.md diff --git a/docs/superpowers/plans/2026-08-10-auth-methods-rsa-version-gate.md b/docs/superpowers/plans/2026-08-10-auth-methods-rsa-version-gate.md new file mode 100644 index 0000000000..b1bdadc632 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-auth-methods-rsa-version-gate.md @@ -0,0 +1,48 @@ +# Auth Methods RSA Version Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `test_auth_methods-t` select the correct RSA full-authentication expectations from the runtime ProxySQL version boundary at 3.1. + +**Architecture:** Read and parse the ProxySQL Admin `SELECT @@version` result once, then pass the derived RSA capability through the existing authentication expectation functions. Keep the legacy failure oracle below 3.1 and recognize the RSA packet exchange at or above 3.1. + +**Tech Stack:** C++11, MariaDB/MySQL C API, TAP test helpers. + +## Global Constraints + +- ProxySQL versions 3.1 and newer support non-TLS `caching_sha2_password` RSA full authentication. +- ProxySQL versions below 3.1 retain the legacy expected-failure behavior. +- The runtime ProxySQL version, not the TAP binary's compile flags, determines the expectation. +- Do not push the branch without explicit user approval. + +--- + +### Task 1: Runtime version capability and authentication oracle + +**Files:** +- Modify: `test/tap/tests/test_auth_methods-t.cpp` + +**Interfaces:** +- Produces: `parse_proxysql_version(const std::string&, int&, int&) -> bool`. +- Produces: `supports_caching_sha2_rsa(int, int) -> bool`. +- Consumes: ProxySQL Admin `SELECT @@version` result and the derived `supports_rsa` flag. + +- [ ] **Step 1: Write failing boundary tests** + +Add TAP assertions with literal expectations for `2.7`, `3.0`, `3.1`, `4.0.11-113-g...`, and malformed input before defining the new helpers. + +- [ ] **Step 2: Run the focused build and verify RED** + +Run `make -C test/tap/tests test_auth_methods-t` with the branch's normal feature flags. Expect compilation to fail because the version helpers do not exist yet. + +- [ ] **Step 3: Implement runtime detection and capability plumbing** + +Implement strict leading major/minor parsing, query `SELECT @@version` on the Admin connection, and fail with a diagnostic on query/result/parse errors. Apply the derived boolean only to the pre-3.1 non-TLS hashed SHA-2 exception, expected success/failure counts, and RSA full-auth packet classification. + +- [ ] **Step 4: Run focused verification and verify GREEN** + +Rebuild `test_auth_methods-t`, run it against the local test environment, and confirm the TAP plan and all assertions pass. Run `git diff --check` and inspect the focused diff. + +- [ ] **Step 5: Leave the verified changes local until publication is approved** + +Report the exact files changed, ProxySQL version detected, commands run, and test results. Commit and push only after explicit user approval. diff --git a/docs/superpowers/specs/2026-08-10-auth-methods-rsa-version-gate-design.md b/docs/superpowers/specs/2026-08-10-auth-methods-rsa-version-gate-design.md new file mode 100644 index 0000000000..15d16e9a0a --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-auth-methods-rsa-version-gate-design.md @@ -0,0 +1,26 @@ +# Auth Methods RSA Version Gate Design + +## Goal + +Make `test_auth_methods-t` expect non-TLS `caching_sha2_password` RSA full authentication to succeed only when the ProxySQL instance under test is version 3.1 or newer. ProxySQL versions older than 3.1 must retain the existing expected-failure behavior. + +## Runtime capability detection + +The test will query `SELECT @@version` through the already-established ProxySQL Admin connection. It will parse the leading major and minor numeric components and derive one capability flag: + +- `false` for versions below 3.1; +- `true` for versions 3.1 and newer. + +The runtime version is authoritative. Compile-time flags are unsuitable because the TAP executable may be used against a separately built ProxySQL binary. Failure to query or parse the ProxySQL version will terminate the test with a diagnostic instead of silently selecting the wrong expectations. + +## Authentication expectations + +The existing exceptional-failure rule for non-TLS, hashed `caching_sha2_password` credentials will apply only when RSA full authentication is unavailable. On ProxySQL 3.1 and newer, valid credentials will follow the normal success path on their first attempt. + +RSA full authentication adds one server packet for the public-key response. The session-packet classifier will recognize that exchange for RSA-capable ProxySQL versions so the existing full-auth assertions continue to describe the actual protocol rather than merely accepting the connection. + +All unrelated authentication limitations and expectations remain unchanged. + +## Verification + +The TAP test will contain literal boundary checks covering pre-3.1, 3.1, later, suffixed, and malformed version strings. After the red/green cycle, the focused test binary will be built and run against the local ProxySQL test environment. The verified change will be pushed only after explicit user approval. diff --git a/test/tap/tests/test_auth_methods-t.cpp b/test/tap/tests/test_auth_methods-t.cpp index 9efe7a5dbd..62041630df 100644 --- a/test/tap/tests/test_auth_methods-t.cpp +++ b/test/tap/tests/test_auth_methods-t.cpp @@ -19,6 +19,7 @@ * - Check for correct concurrent clear_text_pass caching ('caching_sha2_password'). */ +#include #include #include #include @@ -150,6 +151,57 @@ using chk_exp_seq_scs_t = function 3 || (major == 3 && minor >= 1); +} + +bool get_proxysql_version(MYSQL* admin, string& version) { + if (mysql_query(admin, "SELECT @@version") != 0) { + diag("Failed to query ProxySQL version: %s", mysql_error(admin)); + return false; + } + + MYSQL_RES* result = mysql_store_result(admin); + if (result == nullptr) { + diag("Failed to read ProxySQL version result: %s", mysql_error(admin)); + return false; + } + + MYSQL_ROW row = mysql_fetch_row(result); + if (row == nullptr || row[0] == nullptr) { + diag("ProxySQL version query returned no version"); + mysql_free_result(result); + return false; + } + + version = row[0]; + mysql_free_result(result); + return true; +} + void ssl_keylog_callback(SSL*, const char* line) { if (!F_SSLKEYLOGFILE) { return; } @@ -252,7 +304,8 @@ bool chk_exp_scs_basic(const test_conf_t& conf, const test_creds_t& creds) { bool chk_exp_seq_fail_except( const test_conf_t& conf, const test_creds_t& creds, - const user_auth_stats_t& auth_info + const user_auth_stats_t& auth_info, + bool supports_rsa ) { // Short circuit for empty pass; no exceptional failures if (is_empty_pass(creds.pass.get())) { @@ -261,10 +314,17 @@ bool chk_exp_seq_fail_except( // TODO: MAKE EXPLICIT TEST // - // 'caching_sha2_password' auth should fail for NON-SSL if no previous scs auth: + // Before ProxySQL 3.1, 'caching_sha2_password' auth should fail for NON-SSL if no previous scs auth: // - No clear_text pass on ProxySQL side - // - Full authentication is required - if (!conf.use_ssl && conf.hashed_pass && creds.info.auth == "caching_sha2_password") { + // - RSA full authentication is unavailable + const bool rsa_auth_available = + supports_rsa && + conf.req_auth == "caching_sha2_password" && + conf.def_auth == "caching_sha2_password"; + if ( + !conf.use_ssl && conf.hashed_pass && creds.info.auth == "caching_sha2_password" + && !rsa_auth_available + ) { if (creds.info.type == PASS_TYPE::PRIMARY) { return auth_info.prim_pass_auths == 0; } else { @@ -279,6 +339,7 @@ bool chk_exp_seq_fail_except( if ( !conf.use_ssl && conf.hashed_pass && creds.info.auth == "mysql_native_password" && conf.req_auth == "caching_sha2_password" && conf.def_auth == "caching_sha2_password" + && !rsa_auth_available ) { if (creds.info.type == PASS_TYPE::PRIMARY) { return auth_info.prim_pass_auths == 0; @@ -320,8 +381,13 @@ bool chk_exp_seq_fail_except( return false; } -bool chk_seq_exp_scs(const test_conf_t& conf, const test_creds_t& creds, const user_auth_stats_t& auth_info) { - return chk_exp_scs_basic(conf, creds) && !chk_exp_seq_fail_except(conf, creds, auth_info); +bool chk_seq_exp_scs( + const test_conf_t& conf, + const test_creds_t& creds, + const user_auth_stats_t& auth_info, + bool supports_rsa +) { + return chk_exp_scs_basic(conf, creds) && !chk_exp_seq_fail_except(conf, creds, auth_info, supports_rsa); } bool chk_exp_auth_switch(const test_conf_t& conf, const test_creds_t& creds) { @@ -390,16 +456,15 @@ string get_exp_auth_switch(const test_conf_t& conf, const test_creds_t& creds, c return exp_auth_switch_type; } -bool detect_sha2_cached_auth(const sess_info_t& sess_info) { +bool detect_sha2_cached_auth(const sess_info_t& sess_info, bool supports_rsa) { return sess_info.switching_auth_sent == -1 && - sess_info.recv_pkts == 4 && sess_info.sent_pkts == 3; + sess_info.recv_pkts == 4 && + (sess_info.sent_pkts == 3 || (supports_rsa && sess_info.sent_pkts == 4)); } -bool detect_sha2_full_auth(const sess_info_t& sess_info) { - return - sess_info.switching_auth_sent == -1 && - sess_info.recv_pkts == 4 && sess_info.sent_pkts == 3; +bool detect_sha2_full_auth(const sess_info_t& sess_info, bool supports_rsa) { + return detect_sha2_cached_auth(sess_info, supports_rsa); } bool chk_exp_sha2_full_auth( @@ -448,16 +513,18 @@ bool chk_exp_sha2_full_auth( } } -bool chk_exp_fail_except_no_warmup(const test_conf_t& conf, const test_creds_t& creds) { - return chk_exp_seq_fail_except(conf, creds, user_auth_stats_t { {}, 0, 0 }); +bool chk_exp_fail_except_no_warmup( + const test_conf_t& conf, const test_creds_t& creds, bool supports_rsa +) { + return chk_exp_seq_fail_except(conf, creds, user_auth_stats_t { {}, 0, 0 }, supports_rsa); } -bool chk_exp_fail_no_warmup(const test_conf_t& conf, const test_creds_t& creds) { - return !chk_exp_scs_basic(conf, creds) || chk_exp_fail_except_no_warmup(conf, creds); +bool chk_exp_fail_no_warmup(const test_conf_t& conf, const test_creds_t& creds, bool supports_rsa) { + return !chk_exp_scs_basic(conf, creds) || chk_exp_fail_except_no_warmup(conf, creds, supports_rsa); } -bool chk_exp_scs_no_warmup(const test_conf_t& conf, const test_creds_t& creds) { - return chk_exp_scs_basic(conf, creds) && !chk_exp_fail_except_no_warmup(conf, creds); +bool chk_exp_scs_no_warmup(const test_conf_t& conf, const test_creds_t& creds, bool supports_rsa) { + return chk_exp_scs_basic(conf, creds) && !chk_exp_fail_except_no_warmup(conf, creds, supports_rsa); } user_auth_stats_t update_auth_reg(MYSQL* mysql, const string& user, const char* pass, auth_reg_t& auth_reg) { @@ -500,7 +567,8 @@ user_auth_stats_t update_auth_reg(MYSQL* mysql, const string& user, const char* pair count_exp_scs( const vector& confs, const vector& user_creds, - const vector& test_creds + const vector& test_creds, + bool supports_rsa ) { pair stats {}; @@ -518,7 +586,7 @@ pair count_exp_scs( continue; } - bool exp_scs = chk_seq_exp_scs(conf, f_creds, it->second); + bool exp_scs = chk_seq_exp_scs(conf, f_creds, it->second, supports_rsa); if (exp_scs) { MYSQL* mock = mysql_init(NULL); @@ -562,7 +630,11 @@ int config_mysql_conn(const CommandLine& cl, const test_conf_t& conf, MYSQL* pro } void test_creds_frontend_backend( - const CommandLine& cl, const test_conf_t& conf, const test_creds_t& creds, auth_reg_t& auth_reg + const CommandLine& cl, + const test_conf_t& conf, + const test_creds_t& creds, + auth_reg_t& auth_reg, + bool supports_rsa ) { MYSQL* proxy = mysql_init(NULL); int cflags = config_mysql_conn(cl, conf, proxy); @@ -573,7 +645,7 @@ void test_creds_frontend_backend( }; user_auth_stats_t auth_info { update_auth_reg(myconn, creds.name, creds.pass.get(), auth_reg) }; - bool exp_success = chk_seq_exp_scs(conf, creds, auth_info); + bool exp_success = chk_seq_exp_scs(conf, creds, auth_info, supports_rsa); if (exp_success) { ok( @@ -595,7 +667,7 @@ void test_creds_frontend_backend( ); const bool exp_full_sha2 = chk_exp_sha2_full_auth(conf, creds, auth_info); - const bool act_full_sha2 = detect_sha2_full_auth(sess_info); + const bool act_full_sha2 = detect_sha2_full_auth(sess_info, supports_rsa); ok( exp_full_sha2 == act_full_sha2, @@ -676,7 +748,7 @@ void test_creds_frontend( } user_auth_stats_t check_auth_creds( - const CommandLine& cl, const test_conf_t& conf, const test_creds_t& creds + const CommandLine& cl, const test_conf_t& conf, const test_creds_t& creds, bool supports_rsa ) { MYSQL* proxy = mysql_init(NULL); int cflags = config_mysql_conn(cl, conf, proxy); @@ -695,7 +767,7 @@ user_auth_stats_t check_auth_creds( sess_info_t sess_info { ext_sess_info(proxy) }; diag("Extracted session info thread:`%lu`, sess_info:`%s`", th_id, to_string(sess_info).c_str()); - bool full_sha2_auth = detect_sha2_cached_auth(sess_info); + bool full_sha2_auth = detect_sha2_cached_auth(sess_info, supports_rsa); if (creds.info.type == PASS_TYPE::PRIMARY) { auth_stats = user_auth_stats_t { user_def_t { creds.name }, 1, 0, full_sha2_auth }; @@ -913,15 +985,20 @@ vector>> filter_tests( return non_warmup_tests; } -bool req_sha2_auth(const test_conf_t& conf, const test_creds_t& creds) { +bool req_sha2_auth(const test_conf_t& conf, const test_creds_t& creds, bool supports_rsa) { // otherwise SHA2 auth shouldn't take place - if (!is_empty_pass(creds.pass.get()) && conf.hashed_pass && conf.use_ssl) { + if (!is_empty_pass(creds.pass.get()) && conf.hashed_pass) { if (creds.info.auth == "caching_sha2_password") { - return true; + return + conf.use_ssl || + (supports_rsa && + conf.req_auth == "caching_sha2_password" && + conf.def_auth == "caching_sha2_password"); } // current limitation; auth switch shouldn't be requested to 'caching_sha2_password'; since // the pass isn't store as such; the real passtype should be requested else if ( + conf.use_ssl && conf.def_auth == "caching_sha2_password" && conf.req_auth == "caching_sha2_password" && creds.info.auth == "mysql_native_password" ) { @@ -982,7 +1059,8 @@ int test_all_confs_creds( const vector& all_conf_combs, const vector& users_creds, const vector& tests_creds, - uint64_t non_warmup_tests_scs_count + uint64_t non_warmup_tests_scs_count, + bool supports_rsa ) { uint64_t auth_scs_total = 0; uint64_t full_sha2_total = 0; @@ -1014,19 +1092,20 @@ int test_all_confs_creds( for (uint32_t i = 0; i < NUM_CLIENT_THREADS; i++) { client_thds.push_back( std::thread( - [&cl, &conf, &thds_auth_regs, &thds_exp_sha2_auths, i, &users_creds, &tests_creds] () { + [&cl, &conf, &thds_auth_regs, &thds_exp_sha2_auths, i, &users_creds, &tests_creds, + supports_rsa] () { auth_reg_t& auth_reg { thds_auth_regs[i] }; for (const auto& creds : tests_creds) { test_creds_t f_creds { map_user_creds(users_creds, creds) }; - user_auth_stats_t auth_stats { check_auth_creds(cl, conf, f_creds) }; + user_auth_stats_t auth_stats { check_auth_creds(cl, conf, f_creds, supports_rsa) }; if (auth_stats.prim_pass_auths || auth_stats.addl_pass_auths) { auto user_stats_it = auth_reg.find(f_creds.name); if (user_stats_it != auth_reg.end()) { if ( - chk_exp_scs_basic(conf, f_creds) && req_sha2_auth(conf, f_creds) + chk_exp_scs_basic(conf, f_creds) && req_sha2_auth(conf, f_creds, supports_rsa) && user_stats_it->second.prim_pass_auths == 0 ) { thds_exp_sha2_auths[i] += 1; @@ -1176,6 +1255,25 @@ int main(int argc, char** argv) { } diag("ProxySQL Admin connection successful."); + string proxysql_version; + int proxysql_major = 0; + int proxysql_minor = 0; + if ( + !get_proxysql_version(admin, proxysql_version) || + !parse_proxysql_version(proxysql_version, proxysql_major, proxysql_minor) + ) { + diag("Unable to determine RSA expectations from ProxySQL version '%s'", proxysql_version.c_str()); + mysql_close(mysql); + mysql_close(admin); + stop_internal_noise_threads(); + return EXIT_FAILURE; + } + const bool supports_rsa = supports_caching_sha2_rsa(proxysql_major, proxysql_minor); + diag( + "ProxySQL version '%s': caching_sha2_password RSA full authentication %s", + proxysql_version.c_str(), supports_rsa ? "supported" : "unsupported" + ); + // Setup SSLKEYLOGFILE for debugging purposes if (getenv("SSLKEYLOGFILE") != nullptr) { const string datadir { string { cl.workdir } + "/test_auth_methods_datadir" }; @@ -1268,20 +1366,28 @@ int main(int argc, char** argv) { get_auth_conf_combs(def_auths, req_auths, hash_pass, use_ssl, use_comp) }; - const auto scs_stats { count_exp_scs(all_conf_combs, cbres.second, tests_creds) }; + const auto scs_stats { count_exp_scs(all_conf_combs, cbres.second, tests_creds, supports_rsa) }; pair rnd_scs_stats {}; if (getenv("TAP_DISABLE_SEQ_CHECKS_RAND_PASS") == nullptr) { - rnd_scs_stats = count_exp_scs(all_conf_combs, rnd_cbres.second, rnd_tests_creds); + rnd_scs_stats = count_exp_scs(all_conf_combs, rnd_cbres.second, rnd_tests_creds, supports_rsa); } // Partial logic tests; no-warmup, expected failure concurrent access const vector>> non_warmup_tests_fail { - filter_tests(all_conf_combs, cbres.second, tests_creds, chk_exp_fail_no_warmup) + filter_tests(all_conf_combs, cbres.second, tests_creds, + [supports_rsa] (const test_conf_t& conf, const test_creds_t& creds) { + return chk_exp_fail_no_warmup(conf, creds, supports_rsa); + } + ) }; const vector>> non_warmup_tests_scs { - filter_tests(all_conf_combs, cbres.second, tests_creds, chk_exp_scs_no_warmup) + filter_tests(all_conf_combs, cbres.second, tests_creds, + [supports_rsa] (const test_conf_t& conf, const test_creds_t& creds) { + return chk_exp_scs_no_warmup(conf, creds, supports_rsa); + } + ) }; uint64_t non_warmup_tests_fail_count = 0; @@ -1312,9 +1418,52 @@ int main(int argc, char** argv) { + non_warmup_tests_fail_count * NUM_CLIENT_THREADS + non_warmup_tests_scs_count * NUM_CLIENT_THREADS * 2 + non_warmup_tests_scs_ratio + + 9 + (cl.use_noise ? 4 : 0) ); + int version_major = 0; + int version_minor = 0; + ok( + parse_proxysql_version("4.0.11-113-g855abce_DEBUG", version_major, version_minor) + && version_major == 4 && version_minor == 0, + "ProxySQL version parser accepts build suffixes" + ); + ok( + !parse_proxysql_version("invalid", version_major, version_minor), + "ProxySQL version parser rejects malformed versions" + ); + ok(!supports_caching_sha2_rsa(3, 0), "ProxySQL 3.0 retains the legacy RSA expectation"); + ok(supports_caching_sha2_rsa(3, 1), "ProxySQL 3.1 enables RSA authentication expectations"); + ok(supports_caching_sha2_rsa(4, 0), "ProxySQL versions after 3.1 enable RSA authentication expectations"); + + const test_conf_t rsa_conf { + "caching_sha2_password", "caching_sha2_password", true, false, false + }; + const user_auth_stats_t no_previous_auth {}; + const test_creds_t sha2_creds { + "sha2_user", MF_CHAR_("password"), { PASS_TYPE::PRIMARY, "caching_sha2_password" } + }; + const test_creds_t native_creds { + "native_user", MF_CHAR_("password"), { PASS_TYPE::PRIMARY, "mysql_native_password" } + }; + ok( + chk_exp_seq_fail_except(rsa_conf, sha2_creds, no_previous_auth, false), + "ProxySQL before 3.1 rejects initial non-TLS RSA auth for SHA-2 hashes" + ); + ok( + chk_exp_seq_fail_except(rsa_conf, native_creds, no_previous_auth, false), + "ProxySQL before 3.1 rejects initial non-TLS RSA auth for native hashes" + ); + ok( + !chk_exp_seq_fail_except(rsa_conf, sha2_creds, no_previous_auth, true), + "ProxySQL 3.1 accepts initial non-TLS RSA auth for SHA-2 hashes" + ); + ok( + !chk_exp_seq_fail_except(rsa_conf, native_creds, no_previous_auth, true), + "ProxySQL 3.1 accepts initial non-TLS RSA auth for native hashes" + ); + // sequential; verify correctness in the procedure; KNOWN passwords for (const auto& conf : all_conf_combs) { diag("--- Testing Config (KNOWN passwords): %s ---", to_string(conf).c_str()); @@ -1335,7 +1484,7 @@ int main(int argc, char** argv) { for (const auto& creds : tests_creds) { test_creds_t f_creds { map_user_creds(cbres.second, creds) }; diag(" * Testing Creds: %s", to_string(f_creds).c_str()); - test_creds_frontend_backend(cl, conf, f_creds, auth_reg); + test_creds_frontend_backend(cl, conf, f_creds, auth_reg, supports_rsa); } } @@ -1360,7 +1509,7 @@ int main(int argc, char** argv) { for (const auto& creds : rnd_tests_creds) { test_creds_t f_creds { map_user_creds(rnd_cbres.second, creds) }; diag(" * Testing Creds (RANDOM): %s", to_string(f_creds).c_str()); - test_creds_frontend_backend(cl, conf, f_creds, auth_reg); + test_creds_frontend_backend(cl, conf, f_creds, auth_reg, supports_rsa); } } } @@ -1392,7 +1541,8 @@ int main(int argc, char** argv) { diag("Starting frontend non-warmup ALL_COMBS tests; predicting SUCCESS/FAILURE ratio"); int res = test_all_confs_creds( - cl, admin, all_conf_combs, cbres.second, tests_creds, non_warmup_tests_scs_count + cl, admin, all_conf_combs, cbres.second, tests_creds, non_warmup_tests_scs_count, + supports_rsa ); if (res) { goto cleanup; } From 8870c4f5b4e83e8c026499b4ea7e8980f67c30d3 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Mon, 10 Aug 2026 13:53:27 +0000 Subject: [PATCH 18/18] test: address final CodeRabbit findings --- ...2026-08-10-auth-methods-rsa-version-gate.md | 4 ++-- ...-10-auth-methods-rsa-version-gate-design.md | 2 +- test/tap/tests/test_auth_methods-t.cpp | 14 +++++++++++--- .../tap/tests/unit/caching_sha2_rsa_unit-t.cpp | 18 ++++++++++++------ 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-08-10-auth-methods-rsa-version-gate.md b/docs/superpowers/plans/2026-08-10-auth-methods-rsa-version-gate.md index b1bdadc632..ea872ea94e 100644 --- a/docs/superpowers/plans/2026-08-10-auth-methods-rsa-version-gate.md +++ b/docs/superpowers/plans/2026-08-10-auth-methods-rsa-version-gate.md @@ -6,7 +6,7 @@ **Architecture:** Read and parse the ProxySQL Admin `SELECT @@version` result once, then pass the derived RSA capability through the existing authentication expectation functions. Keep the legacy failure oracle below 3.1 and recognize the RSA packet exchange at or above 3.1. -**Tech Stack:** C++11, MariaDB/MySQL C API, TAP test helpers. +**Tech Stack:** C++17, MariaDB/MySQL C API, TAP test helpers. ## Global Constraints @@ -29,7 +29,7 @@ - [ ] **Step 1: Write failing boundary tests** -Add TAP assertions with literal expectations for `2.7`, `3.0`, `3.1`, `4.0.11-113-g...`, and malformed input before defining the new helpers. +Add TAP assertions with literal expectations for `2.7`, `3.0`, `3.1`, `4.0.11-113-g...`, negative components, and malformed input before defining the new helpers. - [ ] **Step 2: Run the focused build and verify RED** diff --git a/docs/superpowers/specs/2026-08-10-auth-methods-rsa-version-gate-design.md b/docs/superpowers/specs/2026-08-10-auth-methods-rsa-version-gate-design.md index 15d16e9a0a..5460f15435 100644 --- a/docs/superpowers/specs/2026-08-10-auth-methods-rsa-version-gate-design.md +++ b/docs/superpowers/specs/2026-08-10-auth-methods-rsa-version-gate-design.md @@ -6,7 +6,7 @@ Make `test_auth_methods-t` expect non-TLS `caching_sha2_password` RSA full authe ## Runtime capability detection -The test will query `SELECT @@version` through the already-established ProxySQL Admin connection. It will parse the leading major and minor numeric components and derive one capability flag: +The test will query `SELECT @@version` through the already-established ProxySQL Admin connection. It will parse non-negative leading major and minor numeric components and derive one capability flag: - `false` for versions below 3.1; - `true` for versions 3.1 and newer. diff --git a/test/tap/tests/test_auth_methods-t.cpp b/test/tap/tests/test_auth_methods-t.cpp index 62041630df..01a5bd9acf 100644 --- a/test/tap/tests/test_auth_methods-t.cpp +++ b/test/tap/tests/test_auth_methods-t.cpp @@ -159,13 +159,13 @@ bool parse_proxysql_version(const string& version, int& major, int& minor) { const auto major_result = std::from_chars(first, last, parsed_major); if (major_result.ec != std::errc() || major_result.ptr == first || - major_result.ptr == last || *major_result.ptr != '.') { + major_result.ptr == last || *major_result.ptr != '.' || parsed_major < 0) { return false; } const char* minor_first = major_result.ptr + 1; const auto minor_result = std::from_chars(minor_first, last, parsed_minor); - if (minor_result.ec != std::errc() || minor_result.ptr == minor_first) { + if (minor_result.ec != std::errc() || minor_result.ptr == minor_first || parsed_minor < 0) { return false; } @@ -1418,7 +1418,7 @@ int main(int argc, char** argv) { + non_warmup_tests_fail_count * NUM_CLIENT_THREADS + non_warmup_tests_scs_count * NUM_CLIENT_THREADS * 2 + non_warmup_tests_scs_ratio - + 9 + + 11 + (cl.use_noise ? 4 : 0) ); @@ -1433,6 +1433,14 @@ int main(int argc, char** argv) { !parse_proxysql_version("invalid", version_major, version_minor), "ProxySQL version parser rejects malformed versions" ); + ok( + !parse_proxysql_version("-3.1", version_major, version_minor), + "ProxySQL version parser rejects a negative major version" + ); + ok( + !parse_proxysql_version("3.-1", version_major, version_minor), + "ProxySQL version parser rejects a negative minor version" + ); ok(!supports_caching_sha2_rsa(3, 0), "ProxySQL 3.0 retains the legacy RSA expectation"); ok(supports_caching_sha2_rsa(3, 1), "ProxySQL 3.1 enables RSA authentication expectations"); ok(supports_caching_sha2_rsa(4, 0), "ProxySQL versions after 3.1 enable RSA authentication expectations"); diff --git a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp index 215b7f6529..84576ddcbd 100644 --- a/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp +++ b/test/tap/tests/unit/caching_sha2_rsa_unit-t.cpp @@ -11,6 +11,7 @@ #include #include #ifdef __linux__ +#include #include #include #endif @@ -28,9 +29,10 @@ class TempDir { TempDir() { char path_template[] = "/tmp/proxysql-caching-sha2-rsa-XXXXXX"; // NOSONAR: mkdtemp creates this test directory atomically with owner-only permissions. char* created = mkdtemp(path_template); - if (created != nullptr) { - path_ = created; + if (created == nullptr) { + BAIL_OUT("failed to create an isolated RSA test directory"); } + path_ = created; } ~TempDir() { @@ -659,10 +661,14 @@ int main() { bool observed_contended_attempt = false; { std::unique_lock lock(publication_lock_observer_mutex); - publication_lock_observer_cv.wait(lock, []() { - return publication_lock_attempted || publication_reload_finished; - }); - observed_contended_attempt = publication_lock_attempted && + const bool observer_signaled = publication_lock_observer_cv.wait_for( + lock, + std::chrono::seconds(5), + []() { + return publication_lock_attempted || publication_reload_finished; + } + ); + observed_contended_attempt = observer_signaled && publication_lock_attempted && publication_lock_contended && !publication_reload_finished; } ok(observed_contended_attempt,