Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cd642d9
feat(auth): add caching_sha2_password RSA key exchange
renecannao Aug 9, 2026
f5f4794
fix: persist rejected caching SHA-2 RSA config
renecannao Aug 9, 2026
c7ae9cb
fix: avoid double counting rejected RSA config
renecannao Aug 9, 2026
a8f0114
fix: harden caching SHA-2 RSA buffers
renecannao Aug 9, 2026
346cf3b
fix: retain RSA plaintext allocation for cleansing
renecannao Aug 9, 2026
958dff5
fix: harden auth packet construction
renecannao Aug 9, 2026
c2a05a5
chore: untrack task 3 report
renecannao Aug 9, 2026
875fa65
fix: remediate caching SHA-2 RSA quality findings
renecannao Aug 9, 2026
3f8bac9
fix: initialize protocol auth failure fields
renecannao Aug 9, 2026
1a8ff95
docs: clarify RSA auth helper contracts
renecannao Aug 9, 2026
c38d04e
test: escape wildcard TAP descriptions
renecannao Aug 9, 2026
bb74515
test: align RSA rejection E2E with Admin refresh
renecannao Aug 9, 2026
27b853d
fix: wait for in-flight RSA key publication
renecannao Aug 9, 2026
9a3fe88
fix: serialize admin MySQL variable commits
renecannao Aug 9, 2026
d484a5e
test: make RSA publication race deterministic
renecannao Aug 9, 2026
0d1c9f5
fix: address Sonar RSA review findings
renecannao Aug 10, 2026
855abce
Merge remote-tracking branch 'origin/v3.0' into feature/caching-sha2-…
renecannao Aug 10, 2026
ba03b99
test: gate caching SHA-2 RSA expectations by version
renecannao Aug 10, 2026
8870c4f
test: address final CodeRabbit findings
renecannao Aug 10, 2026
2fdc294
Merge remote-tracking branch 'origin/v3.0' into feature/caching-sha2-…
renecannao Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions doc/caching_sha2_password_rsa.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion doc/internal/passthrough_authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
83 changes: 83 additions & 0 deletions include/MySQL_Caching_Sha2_RSA.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#ifndef __CLASS_MYSQL_CACHING_SHA2_RSA_H
#define __CLASS_MYSQL_CACHING_SHA2_RSA_H

#include <memory>
#include <pthread.h>
#include <string>

#include <openssl/types.h>

/**
* @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;
};

/** @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:
friend class MySQL_Caching_Sha2_RSA;
std::shared_ptr<EVP_PKEY> private_key_;
std::string public_key_pem_;
std::string private_key_path_;
std::string public_key_path_;
size_t ciphertext_size_ { 0 };
};

/** @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:
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<const MySQL_Caching_Sha2_RSA_Key_Snapshot> 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<const MySQL_Caching_Sha2_RSA_Key_Snapshot>& 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 pthread_mutex_t mutex_ = PTHREAD_MUTEX_INITIALIZER;
std::shared_ptr<const MySQL_Caching_Sha2_RSA_Key_Snapshot> snapshot_;
};

#endif
18 changes: 14 additions & 4 deletions include/MySQL_Passthrough_Auth_Cache.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,17 @@ 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;
int hostgroup_probed;
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<std::string, entry_t> entries;
Expand Down Expand Up @@ -108,8 +115,11 @@ 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.
void insert(const std::string& username, const std::string& cleartext, int hostgroup_probed);
/**
* @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.
bool evict(const std::string& username);
Expand Down
49 changes: 36 additions & 13 deletions include/MySQL_Protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@
#include "MySQL_Variables.h"
#include "MySQL_Prepared_Stmt_info.h"

#ifdef PROXYSQL31
#include <memory>

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
};
#endif

#define RESULTSET_BUFLEN 16300

extern MySQL_Variables mysql_variables;
Expand Down Expand Up @@ -105,13 +117,16 @@ 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;
#ifdef PROXYSQL31
bool pass_is_sensitive = false;
#endif
enum proxysql_session_type session_type;
};

Expand Down Expand Up @@ -141,6 +156,10 @@ class MySQL_Protocol {
enum proxysql_auth_plugins auth_plugin_id;
uint16_t prot_status;
bool more_data_needed;
#ifdef PROXYSQL31
std::shared_ptr<const MySQL_Caching_Sha2_RSA_Key_Snapshot> 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)
Expand Down Expand Up @@ -196,24 +215,28 @@ 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.
void PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1);
/**
* @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);
void PPHR_next_auth_stage(MyProt_tmp_auth_vars& vars1, PASSWORD_TYPE::E passtype);
void PPHR_SetConnAttrs(MyProt_tmp_auth_vars& vars1, account_details_t& attr1);
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);
/** @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

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);
Expand Down
Loading
Loading