diff --git a/doc/caching_sha2_password_rsa.md b/doc/caching_sha2_password_rsa.md index cc0a5ecfd3..616e3e7a8a 100644 --- a/doc/caching_sha2_password_rsa.md +++ b/doc/caching_sha2_password_rsa.md @@ -1,9 +1,11 @@ # RSA key exchange for `caching_sha2_password` -ProxySQL 3.1 can authenticate MySQL clients that use +ProxySQL 3.1 and 4.0 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. +authentication is required, the client can either request ProxySQL's RSA +public key or use a trusted copy provisioned locally, encrypt its password, and +send the ciphertext back to ProxySQL. This feature is not available in +ProxySQL 3.0. TLS remains the recommended configuration. Requesting a public key over an unauthenticated connection encrypts the password on the wire, but it does not @@ -13,7 +15,7 @@ integrity are required. ## Configuration -The following MySQL variables are available in ProxySQL 3.1 and later: +The following MySQL variables are available in ProxySQL 3.1 and 4.0: | Variable | Default | Description | | --- | --- | --- | @@ -68,19 +70,32 @@ 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 +full-authentication challenge through RSA decryption. This applies whether the +client requests the public key or already has a pinned copy. A concurrent `LOAD MYSQL VARIABLES TO RUNTIME` can therefore rotate keys without breaking an exchange already in progress. +Clients that use a pinned public key must be updated when the ProxySQL key pair +is rotated. Coordinate publication of the new public key, client configuration +changes, and the ProxySQL runtime reload: a client using a key that does not +match the key snapshot selected by ProxySQL cannot authenticate. ProxySQL does +not provide a grace period in which new connections can use both the old and +new private keys. + 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 +## Client modes + +The client must use `caching_sha2_password` and disable TLS only when intended. +Oracle's MySQL CLI supports two RSA modes. -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: +### Request ProxySQL's public key + +With `--get-server-public-key`, the client asks ProxySQL for its current public +key during authentication: ```bash mysql --default-auth=caching_sha2_password \ @@ -88,6 +103,39 @@ mysql --default-auth=caching_sha2_password \ --host=127.0.0.1 --port=6033 --user=app --password ``` +After ProxySQL sends the full-authentication challenge (`0x04`), the client +requests the key (`0x02`). ProxySQL returns the public key and decrypts the +client's following RSA ciphertext with the same retained key snapshot. + +This mode prevents passive observers from learning the password, but it does +not authenticate ProxySQL. An active attacker can substitute another public +key. Prefer TLS or the pinned-key mode when server identity matters. + +### Use a provisioned public key + +With `--server-public-key-path`, the client reads a trusted public key from a +local file: + +```bash +mysql --default-auth=caching_sha2_password \ + --ssl-mode=DISABLED \ + --server-public-key-path=/etc/proxysql/proxysql-caching-sha2-public-key.pem \ + --host=127.0.0.1 --port=6033 --user=app --password +``` + +After the `0x04` challenge, the client encrypts the password immediately and +sends the RSA ciphertext without first requesting a key with `0x02`. ProxySQL +decrypts it with the key snapshot retained when it emitted the challenge. + +Provision the public-key file through a trusted channel and protect its +integrity. This mode verifies that the endpoint possesses the corresponding +private key and avoids unauthenticated in-band key substitution. RSA protects +only the password exchange; subsequent queries, results, and other session +traffic remain unencrypted and unauthenticated. Use TLS when the complete +connection needs confidentiality and integrity. + +## Failures + 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 diff --git a/docs/superpowers/plans/2026-08-11-caching-sha2-server-public-key-path.md b/docs/superpowers/plans/2026-08-11-caching-sha2-server-public-key-path.md new file mode 100644 index 0000000000..bbfd598847 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-caching-sha2-server-public-key-path.md @@ -0,0 +1,557 @@ +# Client-Pinned caching_sha2_password RSA Authentication 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:** Allow non-TLS Oracle MySQL clients using `--server-public-key-path` to complete `caching_sha2_password` RSA full authentication without changing Stable 3.0 behavior. + +**Architecture:** Capture the existing immutable RSA key snapshot when ProxySQL emits the `0x04` full-authentication challenge. Route both the stage-6 requested-key ciphertext and the stage-5 direct ciphertext through one guarded `MySQL_Protocol` decryption helper, then reuse the existing stage-5 password and pass-through verification paths. + +**Tech Stack:** C++17, OpenSSL EVP RSA-OAEP, ProxySQL MySQL frontend protocol, TAP, Oracle MySQL CLI, GNU Make, isolated Docker TAP harness. + +## Global Constraints + +- Every new product declaration, field access, branch, and diagnostic is compiled only under `PROXYSQL31`. +- ProxySQL 3.0 Stable must not expose or execute the direct-ciphertext path. +- ProxySQL 3.1 must support both `--get-server-public-key` and `--server-public-key-path`. +- ProxySQL 4.0 must support both paths because `PROXYSQL40=1` implies `PROXYSQL31=1`. +- Non-TLS cleartext `caching_sha2_password` responses remain forbidden. +- Retain one immutable RSA snapshot from the `0x04` challenge through ciphertext decryption. +- Recovered cleartext and ciphertext must never be logged or exposed through internal-session output. +- Use the existing RSA key manager, key formats, variables, OAEP implementation, and error mapping; do not add configuration surface. +- Modify only the approved protocol header/source, existing #5988 TAP, RSA documentation, and design/plan documents. +- Use `make clean` before every tier switch and pass the same tier flag to every make in that build sequence. +- Use `run-tests-isolated.bash`; do not create, start, restart, or reconfigure Docker containers manually. +- Incorporate upstream changes with rebase, never merge. + +--- + +### Task 1: Add the direct-ciphertext regression and prove RED + +**Files:** +- Modify: `test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp` + +**Interfaces:** +- Consumes: the existing generated key pair beneath `REGULAR_INFRA_DATADIR` and `run_mysql_cli()` fixture. +- Produces: `enum class ServerPublicKeyMode` and a 13-assertion E2E matrix covering the pinned-key CLI path. + +- [ ] **Step 1: Establish the unchanged Innovative baseline** + +Run a clean 3.1 DEBUG build and the unmodified #5988 test: + +```bash +make clean +PROXYSQL31=1 make -j4 debug +PROXYSQL31=1 make -C test/tap/tests reg_test_5988-caching_sha2_rsa-t +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-baseline TAP_GROUP=no-infra-g1 \ + test/infra/control/ensure-infras.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-baseline TAP_GROUP=no-infra-g1 \ + TEST_PY_TAP_INCL='^reg_test_5988-caching_sha2_rsa-t$' \ + test/infra/control/run-tests-isolated.bash +``` + +Expected: ProxySQL reports a 3.1 DEBUG version and the existing TAP completes +`1..11` with 11 `ok` assertions and RC 0. + +- [ ] **Step 2: Replace the Boolean CLI switch with an explicit key mode** + +Add the enum immediately before `run_mysql_cli()`: + +```cpp +enum class ServerPublicKeyMode { + NONE, + REQUEST, + PATH +}; +``` + +Change the helper signature to accept both the mode and a pinned-key path: + +```cpp +static int run_mysql_cli( + const CommandLine& cl, + const string& username, + const string& password, + ServerPublicKeyMode public_key_mode, + const string& public_key_path, + const string& query, + string& output +) +``` + +Build the arguments without shell interpolation: + +```cpp +const string server_public_key_path_arg = + "--server-public-key-path=" + public_key_path; +if (public_key_mode == ServerPublicKeyMode::REQUEST) { + args.push_back("--get-server-public-key"); +} else if (public_key_mode == ServerPublicKeyMode::PATH) { + args.push_back(server_public_key_path_arg.c_str()); +} +``` + +Update every existing call to pass `ServerPublicKeyMode::NONE` or +`ServerPublicKeyMode::REQUEST` and an empty path. + +- [ ] **Step 3: Extend capability checks and TAP accounting** + +Change the plan to `plan(13)`. Require both CLI options: + +```cpp +if (help_rc != 0 || + mysql_help.find("get-server-public-key") == string::npos || + mysql_help.find("server-public-key-path") == string::npos || + mysql_help.find("ssl-mode") == string::npos) { + skip(13, "Oracle MySQL CLI with RSA public-key options and --ssl-mode is unavailable"); + return exit_status(); +} +``` + +Adjust early skip counts to preserve the plan: + +- missing `REGULAR_INFRA_DATADIR`: + `skip(13, "REGULAR_INFRA_DATADIR is required to clean generated RSA key artifacts")`; +- failed Admin connection after assertion 1: + `skip(12, "Cannot continue without an Admin connection")`; +- failed setup after assertion 2: + `skip(10, "Cannot run authentication assertions after setup failure")`. + +- [ ] **Step 4: Add pinned-key success and wrong-password assertions** + +After the fixture re-enables and loads the generated RSA pair, construct the +shared absolute public-key path: + +```cpp +const string pinned_public_key_path = test_key_directory + test_public_key; +``` + +Add these assertions before the existing internal-session redaction check: + +```cpp +output.clear(); +const int pinned_key_rc = enabled_ok ? run_mysql_cli( + cl, username, password, ServerPublicKeyMode::PATH, + pinned_public_key_path, "SELECT 5988", output +) : -1; +ok(enabled_ok && pinned_key_rc == 0, + "Non-TLS caching_sha2_password authentication succeeds with --server-public-key-path"); + +output.clear(); +const int pinned_wrong_password_rc = enabled_ok ? run_mysql_cli( + cl, username, wrong_password, ServerPublicKeyMode::PATH, + pinned_public_key_path, "SELECT 5988", output +) : 0; +ok(enabled_ok && pinned_wrong_password_rc != 0 && + output.find("ERROR 1045") != string::npos, + "Pinned RSA full authentication rejects an incorrect password with 1045"); +``` + +- [ ] **Step 5: Run TAP static analysis and compile the changed test** + +```bash +make lint-tests FILES=test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp +PROXYSQL31=1 make -C test/tap/tests reg_test_5988-caching_sha2_rsa-t +``` + +Expected: TAP lint prints `OK (1 files)` and the test binary links. + +- [ ] **Step 6: Run the changed test and verify the intended RED** + +Reuse the baseline runtime because production has not changed: + +```bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-baseline TAP_GROUP=no-infra-g1 \ + TEST_PY_TAP_INCL='^reg_test_5988-caching_sha2_rsa-t$' \ + test/infra/control/run-tests-isolated.bash +``` + +Expected: setup and the existing RSA exchange pass, while +`Non-TLS caching_sha2_password authentication succeeds with --server-public-key-path` +is `not ok` because the direct ciphertext is rejected in stage 5. The test +returns non-zero for the missing production behavior, not for a fixture, +compiler, or CLI-option error. + +- [ ] **Step 7: Commit the RED regression** + +```bash +git add test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp +git diff --cached --check +git commit -m "test: cover client-pinned caching SHA-2 RSA auth" +``` + +--- + +### Task 2: Capture the challenge snapshot and share RSA decryption + +**Files:** +- Modify: `include/MySQL_Protocol.h` +- Modify: `lib/MySQL_Protocol.cpp` + +**Interfaces:** +- Consumes: `MySQL_Caching_Sha2_RSA::acquire()`, `decrypt_password()`, `MyProt_tmp_auth_vars`, and the existing `caching_sha2_rsa_snapshot_` field. +- Produces: guarded `capture_caching_sha2_rsa_snapshot()` and `PPHR_decrypt_caching_sha2_rsa_response()` member helpers. + +- [ ] **Step 1: Declare guarded internal helpers** + +Inside the existing `#ifdef PROXYSQL31` block near +`generate_auth_more_data()`, add private declarations and restore public access +after them: + +```cpp +private: + /** @brief Retain the active RSA snapshot for a non-TLS full-auth exchange. */ + void capture_caching_sha2_rsa_snapshot(); + /** + * @brief Decrypt one exact-size RSA response and prepare stage-5 verification. + * @return The existing PPHR status: 2 on decrypted input, 1 on rejection. + */ + int PPHR_decrypt_caching_sha2_rsa_response( + unsigned char* pkt, + unsigned int len, + bool& ret, + MyProt_tmp_auth_vars& vars1 + ); +public: +``` + +Keep the declarations inside `#ifdef PROXYSQL31`; Stable must not contain them. + +- [ ] **Step 2: Extract the existing stage-6 decryption body** + +Move the current stage-6 validation/decryption/allocation logic from +`PPHR_1()` into `PPHR_decrypt_caching_sha2_rsa_response()`. Preserve these +properties exactly: + +```cpp +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; +``` + +The helper must: + +1. set `auth_in_progress=0`, `ret=false`, and the current username; +2. reject a null snapshot or a length unequal to `ciphertext_size()`; +3. call the existing `decrypt_password()` with the retained snapshot and + connection scramble; +4. use `ScopedStringCleanser` for temporary cleartext; +5. allocate/copy one NUL-terminated sensitive password buffer; +6. fill `pass_len`, `pass`, `pass_is_sensitive`, `db`, `charset`, and + `capabilities`; +7. restore `auth_plugin_id` from `switching_auth_type` and stage 5; and +8. return 2 on success or 1 on rejection. + +The stage-6 branch at the top of `PPHR_1()` becomes: + +```cpp +#ifdef PROXYSQL31 + if ((*myds)->switching_auth_stage == 6) { + return PPHR_decrypt_caching_sha2_rsa_response(pkt, len, ret, vars1); + } +#endif +``` + +- [ ] **Step 3: Capture the snapshot at both active challenge producers** + +Implement the capture helper without logging key material: + +```cpp +void MySQL_Protocol::capture_caching_sha2_rsa_snapshot() { + caching_sha2_rsa_snapshot_.reset(); + if (!(*myds)->encrypted && GloMTH != nullptr && + GloMTH->caching_sha2_rsa() != nullptr) { + caching_sha2_rsa_snapshot_ = GloMTH->caching_sha2_rsa()->acquire(); + } +} +``` + +Call the helper under `#ifdef PROXYSQL31` immediately before +`generate_one_byte_pkt(0x04)` in both `PPHR_sha2full()` and +`PPHR_passthrough_init()`. If packet generation fails, reset the retained +snapshot before returning; only a successfully queued challenge may publish +stage 4/auth-in-progress state: + +```cpp +#ifdef PROXYSQL31 + capture_caching_sha2_rsa_snapshot(); +#endif + if (!generate_one_byte_pkt(perform_full_authentication)) { +#ifdef PROXYSQL31 + caching_sha2_rsa_snapshot_.reset(); +#endif + return; + } +``` + +- [ ] **Step 4: Reuse the retained snapshot for the `0x02` request path** + +Remove the request-time `acquire()` assignment from the existing `0x02` +branch. Keep the existing null check, public-key packet allocation, stage-6 +transition, error mapping, and diagnostics. The public key must come from the +snapshot retained at the preceding `0x04` challenge. + +- [ ] **Step 5: Dispatch direct stage-5 ciphertext only in Innovative tiers** + +Keep the outer non-TLS caching-SHA2 stage-5 condition so Stable sees the same +guard. Split its body by tier: + +```cpp +if (auth_plugin_id == AUTH_MYSQL_CACHING_SHA2_PASSWORD && + (*myds)->switching_auth_stage == 5 && !(*myds)->encrypted) { +#ifdef PROXYSQL31 + if (caching_sha2_rsa_snapshot_ == nullptr) { + frontend_auth_error_ = MySQLFrontendAuthError::CACHING_SHA2_RSA_UNAVAILABLE; + } + return PPHR_decrypt_caching_sha2_rsa_response(pkt, len, ret, vars1); +#else + 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; +#endif +} +``` + +The exact-size check inside the helper is the discriminator between valid RSA +ciphertext and every malformed or cleartext response. The one-byte `0x02` +branch remains earlier in `PPHR_1()` and therefore never reaches this dispatch. + +- [ ] **Step 6: Compile the Innovative product and focused TAP** + +```bash +PROXYSQL31=1 make -j4 debug +PROXYSQL31=1 make -C test/tap/tests reg_test_5988-caching_sha2_rsa-t +``` + +Expected: both commands exit 0 with `-DDEBUG -DPROXYSQL31` in changed-source +compiler commands. + +- [ ] **Step 7: Restart only the isolated ProxySQL and verify GREEN** + +```bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-baseline TAP_GROUP=no-infra-g1 \ + test/infra/control/start-proxysql-isolated.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-baseline TAP_GROUP=no-infra-g1 \ + TEST_PY_TAP_INCL='^reg_test_5988-caching_sha2_rsa-t$' \ + test/infra/control/run-tests-isolated.bash +``` + +Expected: TAP `1..13`, all 13 assertions `ok`, RC 0. Both CLI forms execute; +the pinned wrong-password case reports `ERROR 1045`. + +- [ ] **Step 8: Build and run focused unit regressions** + +```bash +PROXYSQL31=1 make -C test/tap/tests/unit caching_sha2_rsa_unit-t protocol_unit-t +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-unit TAP_GROUP=unit-tests-g1 \ + test/infra/control/ensure-infras.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-unit TAP_GROUP=unit-tests-g1 \ + TEST_PY_TAP_INCL='^(caching_sha2_rsa_unit|protocol_unit)-t$' \ + test/infra/control/run-tests-isolated.bash +``` + +Expected: both unit TAPs pass without skipped or `not ok` assertions. + +- [ ] **Step 9: Commit the minimal product implementation** + +```bash +git add include/MySQL_Protocol.h lib/MySQL_Protocol.cpp +git diff --cached --check +git commit -m "feat(auth): support client-pinned caching SHA-2 RSA auth" +``` + +--- + +### Task 3: Document both client modes and verify all tiers + +**Files:** +- Modify: `doc/caching_sha2_password_rsa.md` + +**Interfaces:** +- Consumes: the final protocol behavior and existing RSA operator documentation. +- Produces: explicit CLI examples and key-rotation guidance for requested and pinned keys. + +- [ ] **Step 1: Document the requested-key and pinned-key CLI forms** + +Retain the current `--get-server-public-key` example and add: + +```bash +mysql --default-auth=caching_sha2_password \ + --ssl-mode=DISABLED \ + --server-public-key-path=/secure/config/proxysql-caching-sha2-public-key.pem \ + --host=127.0.0.1 --port=6033 --user=app --password +``` + +State that requested-key clients receive the active key during each exchange, +whereas pinned-key clients skip `0x02` and immediately send ciphertext. Explain +that operators must securely distribute a matching public key and coordinate +client updates with ProxySQL key rotation. Preserve the TLS recommendation and +clarify that RSA protects only the password exchange, not session integrity. + +- [ ] **Step 2: Commit the documentation** + +```bash +git add doc/caching_sha2_password_rsa.md +git diff --cached --check +git commit -m "docs: describe client-pinned caching SHA-2 RSA auth" +``` + +- [ ] **Step 3: Run focused source and TAP lint** + +Generate a 3.1 compile database from clean objects, then inspect normalized +diagnostics for the changed product files: + +```bash +make clean +PROXYSQL31=1 ./scripts/lint/generate-compile-commands.sh \ + "make PROXYSQL31=1 build_src -j4" +./scripts/lint/run-local.sh lib/MySQL_Protocol.cpp +! rg -n '(^|/)(MySQL_Protocol\.cpp|MySQL_Protocol\.h)' \ + lint/clang-tidy.txt lint/cppcheck.txt +make lint-tests FILES=test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp +python3 test/tap/groups/lint_groups_json.py +python3 test/tap/groups/lint_group_coverage.py +git diff --check +``` + +Expected: no changed-file source diagnostics, TAP lint `OK (1 files)`, both +group linters exit 0, and no whitespace errors. + +- [ ] **Step 4: Run the final Innovative 3.1 matrix from a clean build** + +```bash +make clean +PROXYSQL31=1 make -j4 debug +PROXYSQL31=1 make -j4 build_tap_test_debug +``` + +Confirm `src/proxysql --version` reports 3.1 and run these isolated selections: + +```bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-rsa TAP_GROUP=no-infra-g1 \ + test/infra/control/ensure-infras.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-rsa TAP_GROUP=no-infra-g1 \ + TEST_PY_TAP_INCL='^reg_test_5988-caching_sha2_rsa-t$' \ + test/infra/control/run-tests-isolated.bash + +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-g4 TAP_GROUP=mysql84-g4 \ + test/infra/control/ensure-infras.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-g4 TAP_GROUP=mysql84-g4 \ + TEST_PY_TAP_INCL='^(test_frontend_x509_passthrough|test_passthrough_auth_e2e|test_passthrough_auth_security|test_passthrough_auth_unknown_user)-t$' \ + test/infra/control/run-tests-isolated.bash + +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-g6 TAP_GROUP=mysql84-g6 \ + test/infra/control/ensure-infras.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-g6 TAP_GROUP=mysql84-g6 \ + TEST_PY_TAP_INCL='^(reg_test_3504-change_user|test_frontend_x509_auth)-t$' \ + test/infra/control/run-tests-isolated.bash + +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-g7 TAP_GROUP=mysql84-g7 \ + test/infra/control/ensure-infras.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-g7 TAP_GROUP=mysql84-g7 \ + TEST_PY_TAP_INCL='^test_auth_methods-t$' \ + test/infra/control/run-tests-isolated.bash +``` + +Expected: #5988 is 13/13; all selected pass-through, X.509, change-user, and +authentication TAPs return RC 0 with no in-test skips or `not ok` lines. + +- [ ] **Step 5: Prove Stable 3.0 exclusion and regressions** + +```bash +make clean +make -j4 debug +make -j4 build_tap_test_debug +src/proxysql --version +``` + +Expected: version 3.0, compiler commands omit `-DPROXYSQL31`, and the build +succeeds. Scan the changed production objects: + +```bash +! strings lib/obj/MySQL_Protocol.oo | rg \ + 'capture_caching_sha2_rsa_snapshot|PPHR_decrypt_caching_sha2_rsa_response|server-public-key-path' +``` + +Run Stable regressions: + +```bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-stable-g6 TAP_GROUP=mysql84-g6 \ + test/infra/control/ensure-infras.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-stable-g6 TAP_GROUP=mysql84-g6 \ + TEST_PY_TAP_INCL='^(reg_test_3504-change_user|test_frontend_x509_tier_gate)-t$' \ + test/infra/control/run-tests-isolated.bash + +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-stable-g7 TAP_GROUP=mysql84-g7 \ + test/infra/control/ensure-infras.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-stable-g7 TAP_GROUP=mysql84-g7 \ + TEST_PY_TAP_INCL='^test_auth_methods-t$' \ + test/infra/control/run-tests-isolated.bash +``` + +Expected: Stable change-user, tier-gate, and authentication TAPs all return RC +0; the tier gate confirms 3.0 behavior, and #5988 remains excluded by its +minimum-version registration. + +- [ ] **Step 6: Prove 4.0 inheritance with a clean build and runtime** + +```bash +make clean +PROXYSQL40=1 make -j4 debug +PROXYSQL40=1 make -C test/tap/tests reg_test_5988-caching_sha2_rsa-t +src/proxysql --version +``` + +Expected: version 4.0 and compiler commands include both `-DPROXYSQL40` and +`-DPROXYSQL31`. + +```bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-40 TAP_GROUP=no-infra-g1 \ + test/infra/control/ensure-infras.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-40 TAP_GROUP=no-infra-g1 \ + TEST_PY_TAP_INCL='^reg_test_5988-caching_sha2_rsa-t$' \ + test/infra/control/run-tests-isolated.bash +``` + +Expected: #5988 is 13/13 under the 4.0 binary, including both RSA client modes. + +- [ ] **Step 7: Perform final hygiene and tear down only task runtimes** + +Call the supported stop script for every task runtime; do not touch unrelated +containers: + +```bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-baseline TAP_GROUP=no-infra-g1 \ + test/infra/control/stop-proxysql-isolated.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-rsa TAP_GROUP=no-infra-g1 \ + test/infra/control/stop-proxysql-isolated.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-g4 TAP_GROUP=mysql84-g4 \ + test/infra/control/stop-proxysql-isolated.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-g6 TAP_GROUP=mysql84-g6 \ + test/infra/control/stop-proxysql-isolated.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-31-g7 TAP_GROUP=mysql84-g7 \ + test/infra/control/stop-proxysql-isolated.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-stable-g6 TAP_GROUP=mysql84-g6 \ + test/infra/control/stop-proxysql-isolated.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-stable-g7 TAP_GROUP=mysql84-g7 \ + test/infra/control/stop-proxysql-isolated.bash +WORKSPACE=$(pwd) INFRA_ID=caching-sha2-pinned-final-40 TAP_GROUP=no-infra-g1 \ + test/infra/control/stop-proxysql-isolated.bash +``` + +Then verify repository state: + +```bash +git diff --check +git diff HEAD~3 --check +git status --short +git log --oneline --decorate origin/v3.0..HEAD +git diff --stat origin/v3.0...HEAD +``` + +Expected: no tracked or untracked build/report artifacts, no whitespace errors, +no merge commits, and only the approved source, test, documentation, design, +and plan files differ from `origin/v3.0`. diff --git a/docs/superpowers/specs/2026-08-11-caching-sha2-server-public-key-path-design.md b/docs/superpowers/specs/2026-08-11-caching-sha2-server-public-key-path-design.md new file mode 100644 index 0000000000..85c8d9fee5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-caching-sha2-server-public-key-path-design.md @@ -0,0 +1,200 @@ +# Client-Pinned caching_sha2_password RSA Key Design + +## Purpose + +Extend ProxySQL's `caching_sha2_password` RSA full-authentication support so a +non-TLS Oracle MySQL client configured with `--server-public-key-path` can send +its RSA-encrypted password directly after ProxySQL's `0x04` full-authentication +challenge. The existing `--get-server-public-key` exchange remains supported +without behavior changes. + +## Product tiers + +- The extension is available only when `PROXYSQL31` is compiled in. +- ProxySQL 3.1 enables it directly. +- ProxySQL 4.0 inherits it because `PROXYSQL40=1` implies `PROXYSQL31=1`. +- ProxySQL Stable 3.0 must not expose or execute the new path. Stable product + objects must not contain new helper names or diagnostics introduced by this + work, and existing Stable authentication behavior must remain unchanged. +- The focused integration test remains registered with + `@proxysql_min_version:3.1` and therefore does not run against Stable 3.0. + +## Current behavior + +ProxySQL currently supports the key-request form of MySQL RSA authentication: + +1. ProxySQL sends `AuthMoreData{0x04}` to request full authentication. +2. The client sends the one-byte public-key request `0x02`. +3. ProxySQL acquires an immutable RSA key snapshot, sends its PEM public key, + and advances to authentication stage 6. +4. The client encrypts its password with that key and returns the ciphertext. +5. ProxySQL validates and decrypts the ciphertext, then reuses the existing + stage-5 cleartext password verifier. + +With `--server-public-key-path`, the client already has a public key and skips +step 2. Its ciphertext therefore arrives while ProxySQL is in stage 5. The +current non-TLS stage-5 guard rejects every packet other than `0x02`, so the +valid ciphertext never reaches RSA decryption. + +## Approaches considered + +### Capture the key when ciphertext arrives + +This is the smallest source change and naturally covers every caller that can +initiate full authentication. It is not selected because a concurrent key +reload between the `0x04` challenge and the response could select a different +private key from the one active when the exchange began. + +### Add a dedicated pinned-key protocol stage + +This makes the source of ciphertext explicit but creates an artificial extra +state. The pinned-key client sends its ciphertext as the direct response to +the existing full-authentication challenge, so no additional network stage is +present to model. + +### Capture at the full-authentication challenge and share decryption + +This is the selected approach. It preserves one immutable key snapshot across +the complete authentication exchange and routes both ciphertext forms through +one security-sensitive decryption implementation. + +## Protocol design + +Before ProxySQL queues the `0x04` full-authentication packet for a non-TLS +`caching_sha2_password` exchange, it acquires and stores the current immutable +RSA snapshot on `MySQL_Protocol`. If packet generation fails, it discards that +snapshot without advancing the authentication state. Both active challenge +producers must follow this ordering: + +- `MySQL_Protocol::PPHR_sha2full()` for a configured frontend user; and +- `MySQL_Protocol::PPHR_passthrough_init()` for pass-through authentication. + +Snapshot acquisition and all new dispatch/decryption code are enclosed in +`#ifdef PROXYSQL31`. TLS authentication does not acquire an RSA snapshot. + +After stage 4 advances to stage 5, a non-TLS caching-SHA2 response is handled +as follows: + +1. An exact one-byte `0x02` payload is the existing key-request form. ProxySQL + serves the public key from the snapshot captured at the `0x04` challenge, + advances to stage 6, and waits for ciphertext. +2. Any other payload is treated as a candidate direct RSA response. ProxySQL + requires an available snapshot and requires the payload length to equal the + snapshot's RSA ciphertext size before invoking OpenSSL decryption. +3. Successful decryption restores stage 5 and populates the existing sensitive + password fields. Existing configured-user or pass-through verification then + determines the authentication result. +4. No non-TLS stage-5 packet is ever interpreted as cleartext. + +The stage-6 response used by `--get-server-public-key` and the direct stage-5 +response used by `--server-public-key-path` call one private +`MySQL_Protocol` decryption helper. The helper consumes and resets the retained +snapshot, validates exact ciphertext length, calls the existing +`MySQL_Caching_Sha2_RSA::decrypt_password()`, marks the recovered allocation as +sensitive, and prepares the existing verifier inputs. It does not log either +ciphertext or recovered cleartext. + +## Key lifecycle and rotation + +The connection retains the snapshot that was active when ProxySQL emitted the +full-authentication challenge. A concurrent `LOAD MYSQL VARIABLES TO RUNTIME` +may publish a new snapshot without invalidating the in-flight exchange. + +A client-pinned public key must correspond to the configured ProxySQL private +key. A stale or unrelated public key produces ciphertext that the retained +private key cannot decrypt and authentication fails. Operators must distribute +a replacement public key to pinned clients as part of a coordinated rotation. +There is no fallback to a different or newer key and no downgrade to cleartext. + +The snapshot is reset after a ciphertext response is consumed and by the +existing protocol initialization path. Error exits must not retain recovered +password material. + +## Failure behavior + +- No RSA snapshot: fail with the existing RSA-unavailable `1045` behavior and + TLS-or-key guidance. +- Wrong-size payload: reject before OpenSSL decryption with normal access + denied behavior. +- OAEP failure, stale key, malformed plaintext, or wrong password: fail with + normal `1045` / `28000` access denied without disclosing which validation + failed. +- Allocation failure: terminate authentication without exposing sensitive + data. +- A one-byte payload other than `0x02` remains invalid; it is never accepted as + a password. +- TLS cleartext full authentication and empty-password handling remain + unchanged. + +## Test design + +Extend `test/tap/tests/reg_test_5988-caching_sha2_rsa-t.cpp`; do not create a +second fixture with duplicate RSA configuration. Replace the helper's Boolean +key-request argument with an explicit client key mode: + +- no key option; +- `--get-server-public-key`; or +- `--server-public-key-path=`. + +The generated public key is already written beneath `REGULAR_INFRA_DATADIR`, +which is shared with the isolated test runner and already used by the fixture +for cleanup. The test must confirm that the installed Oracle MySQL CLI exposes +`server-public-key-path` before running the matrix. + +Add two assertions: + +1. A non-TLS client with the generated public key and correct password connects + successfully without requesting the key from ProxySQL. +2. The same pinned-key flow with a wrong password fails with MySQL error 1045. + +Keep all existing assertions for no-key rejection, requested-key success, +wrong-password rejection, unavailable keys, grouped variable rollback, +internal-session password redaction, and cleanup. The focused test continues +to use a local OK query rule and therefore remains backend-independent. + +## Documentation + +Update `doc/caching_sha2_password_rsa.md` to show both client forms. Explain +that `--get-server-public-key` obtains the currently active key during the +connection, while `--server-public-key-path` relies on an operator-distributed +key and requires coordinated rotation. Continue recommending TLS because RSA +password encryption alone does not provide transport integrity or protect the +rest of the session. + +## Verification + +Verification must start from clean objects whenever the tier changes. + +For Innovative 3.1: + +- build a clean DEBUG binary and all relevant TAP binaries with + `PROXYSQL31=1`; +- observe the new pinned-key assertions fail before production changes; +- pass the complete #5988 test after implementation; +- pass the caching-SHA2 RSA unit and protocol unit tests; +- pass relevant pass-through, COM_CHANGE_USER, and authentication regression + tests. + +For Stable 3.0: + +- build a clean default DEBUG binary; +- verify the changed production objects contain none of the new helper or + diagnostic symbols; +- pass existing authentication and COM_CHANGE_USER regressions. + +For 4.0: + +- build a clean DEBUG binary with `PROXYSQL40=1`; +- run the focused #5988 integration test and confirm both RSA client forms are + available. + +Finally run repository formatting/lint checks and `git diff --check`. + +## Non-goals + +- Changing RSA key formats, generation, variable names, or cluster transport. +- Adding a new public-key configuration option to ProxySQL. +- Supporting additional client drivers in this change. +- Allowing cleartext caching-SHA2 passwords over non-TLS connections. +- Changing TLS, SPIFFE, X.509 policy, or COM_CHANGE_USER semantics. +- Backporting any part of this behavior to ProxySQL Stable 3.0. diff --git a/include/MySQL_Protocol.h b/include/MySQL_Protocol.h index eef70bc5ea..83ebb8d098 100644 --- a/include/MySQL_Protocol.h +++ b/include/MySQL_Protocol.h @@ -229,6 +229,17 @@ 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); +#ifdef PROXYSQL31 + private: + /** @brief Retain the active RSA key pair for the caching SHA-2 full-auth challenge. */ + void capture_caching_sha2_rsa_snapshot(); + /** @brief Decrypt an RSA full-auth response using the retained challenge key pair. */ + int PPHR_decrypt_caching_sha2_rsa_response( + unsigned char *pkt, unsigned int len, bool& ret, MyProt_tmp_auth_vars& vars1 + ); + public: +#endif + /** @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 diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index c1b3075a34..16888393ff 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -1951,66 +1951,92 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in return ret; } +#ifdef PROXYSQL31 +void MySQL_Protocol::capture_caching_sha2_rsa_snapshot() { + caching_sha2_rsa_snapshot_.reset(); + if (!(*myds)->encrypted && GloMTH != nullptr && GloMTH->caching_sha2_rsa() != nullptr) { + caching_sha2_rsa_snapshot_ = GloMTH->caching_sha2_rsa()->acquire(); + } +} + +int MySQL_Protocol::PPHR_decrypt_caching_sha2_rsa_response( + unsigned char *pkt, + unsigned int len, + bool& ret, + MyProt_tmp_auth_vars& vars1 +) { + (*myds)->auth_in_progress = 0; + ret = false; + vars1.user = reinterpret_cast((*myds)->myconn->userinfo->username); + frontend_auth_error_ = MySQLFrontendAuthError::NONE; + + 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; + MySQL_Caching_Sha2_RSA* rsa_manager = + GloMTH != nullptr ? GloMTH->caching_sha2_rsa() : nullptr; + if (key_snapshot == nullptr || rsa_manager == nullptr) { + frontend_auth_error_ = MySQLFrontendAuthError::CACHING_SHA2_RSA_UNAVAILABLE; + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, + "Session=%p , DS=%p , user='%s' . Missing caching_sha2_password RSA key snapshot\n", + (*myds)->sess, (*myds), vars1.user); + return 1; + } + if (ciphertext_length != key_snapshot->ciphertext_size()) { + 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; + ScopedStringCleanser plaintext_password_cleanser(plaintext_password); + if (!rsa_manager->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; + } + + 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); + } + 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; + 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 + // 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; - ScopedStringCleanser plaintext_password_cleanser(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; - } - - 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); - } - 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; - 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; + return PPHR_decrypt_caching_sha2_rsa_response(pkt, len, ret, vars1); } #endif if ((*myds)->switching_auth_stage == 1) { @@ -2030,9 +2056,6 @@ int MySQL_Protocol::PPHR_1(unsigned char *pkt, unsigned int len, bool& ret, MyPr "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(); if (!generate_auth_more_data( @@ -2080,12 +2103,16 @@ int MySQL_Protocol::PPHR_1(unsigned char *pkt, unsigned int len, bool& ret, MyPr 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) { +#ifdef PROXYSQL31 + return PPHR_decrypt_caching_sha2_rsa_response(pkt, len, ret, vars1); +#else 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; +#endif } if (auth_plugin_id == AUTH_MYSQL_NATIVE_PASSWORD) { vars1.pass_len = payload_length; @@ -2854,7 +2881,13 @@ void MySQL_Protocol::PPHR_sha2full( ) { if ((*myds)->switching_auth_stage == 0) { const unsigned char perform_full_authentication = '\4'; +#ifdef PROXYSQL31 + capture_caching_sha2_rsa_snapshot(); +#endif if (!generate_one_byte_pkt(perform_full_authentication)) { +#ifdef PROXYSQL31 + caching_sha2_rsa_snapshot_.reset(); +#endif ret = false; return; } @@ -2922,7 +2955,13 @@ bool MySQL_Protocol::PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1) { // caching_sha2_password protocol). if ((*myds)->switching_auth_stage == 0) { const unsigned char perform_full_authentication = '\4'; +#ifdef PROXYSQL31 + capture_caching_sha2_rsa_snapshot(); +#endif if (!generate_one_byte_pkt(perform_full_authentication)) { +#ifdef PROXYSQL31 + caching_sha2_rsa_snapshot_.reset(); +#endif return false; } (*myds)->pkt_sid++; 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 901e89552b..575f932ab1 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 @@ -8,6 +8,7 @@ */ #include +#include #include #include #include @@ -23,6 +24,12 @@ using std::string; using std::vector; +enum class ServerPublicKeyMode : std::uint8_t { + NONE, + REQUEST, + PATH +}; + static bool run_query(MYSQL* connection, const string& query) { if (mysql_query(connection, query.c_str()) == 0) { return true; @@ -69,7 +76,8 @@ static int run_mysql_cli( const CommandLine& cl, const string& username, const string& password, - bool request_server_public_key, + ServerPublicKeyMode public_key_mode, + const string& public_key_path, const string& query, string& output ) { @@ -78,6 +86,7 @@ static int run_mysql_cli( const string user_arg = "--user=" + username; const string password_arg = "--password=" + password; vector args { + "--no-defaults", "--protocol=TCP", host_arg.c_str(), port_arg.c_str(), @@ -89,8 +98,12 @@ static int run_mysql_cli( "--batch", "--skip-column-names" }; - if (request_server_public_key) { + const string server_public_key_path_arg = + "--server-public-key-path=" + public_key_path; + if (public_key_mode == ServerPublicKeyMode::REQUEST) { args.push_back("--get-server-public-key"); + } else if (public_key_mode == ServerPublicKeyMode::PATH) { + args.push_back(server_public_key_path_arg.c_str()); } const string execute_arg = "--execute=" + query; args.push_back(execute_arg.c_str()); @@ -116,20 +129,22 @@ int main() { return EXIT_FAILURE; } - plan(11); + plan(13); 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("server-public-key-path") == string::npos || mysql_help.find("ssl-mode") == string::npos) { - skip(11, "Oracle MySQL CLI with --get-server-public-key and --ssl-mode is unavailable"); + skip(13, + "Oracle MySQL CLI with RSA public-key and --ssl-mode options is unavailable"); return exit_status(); } const char* infra_datadir = getenv("REGULAR_INFRA_DATADIR"); if (infra_datadir == nullptr || *infra_datadir == '\0') { - skip(11, "REGULAR_INFRA_DATADIR is required to clean generated RSA key artifacts"); + skip(13, "REGULAR_INFRA_DATADIR is required to clean generated RSA key artifacts"); return exit_status(); } @@ -140,7 +155,7 @@ int main() { nullptr, cl.admin_port, nullptr, 0) != nullptr; ok(admin_connected, "Connected to ProxySQL Admin"); if (!admin_connected) { - skip(10, "Cannot continue without an Admin connection"); + skip(12, "Cannot continue without an Admin connection"); if (admin != nullptr) { mysql_close(admin); } @@ -149,6 +164,7 @@ int main() { const string suffix = std::to_string(static_cast(getpid())); const string username = "tap5988_" + suffix; + const string pinned_username = "tap5988_pinned_" + suffix; 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; @@ -203,30 +219,35 @@ int main() { if (setup_ok) { setup_ok = run_query( admin, - "INSERT INTO mysql_users(username,password,active,default_hostgroup) VALUES('" + - username + "','" + password_hash + "',1,0)") && + "INSERT INTO mysql_users(username,password,active,default_hostgroup) VALUES" + "('" + username + "','" + password_hash + "',1,0)," + "('" + pinned_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 + "'),(" + + std::to_string(rule_id + 1) + ",1,'" + pinned_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"); + "Configured hashed caching_sha2_password frontend users and local query rules"); if (setup_ok) { string output; const int no_key_rc = run_mysql_cli( - cl, username, password, false, "SELECT 5988", output + cl, username, password, ServerPublicKeyMode::NONE, "", "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); + run_mysql_cli( + cl, username, wrong_password, ServerPublicKeyMode::REQUEST, "", + "SELECT 5988", output); ok(wrong_password_rc != 0, "RSA full authentication rejects an incorrect password"); @@ -239,7 +260,8 @@ int main() { 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 + cl, username, password, ServerPublicKeyMode::REQUEST, "", + "SELECT 5988", output ) : 0; ok(disabled_ok && unavailable_rc != 0 && output.find("RSA key exchange is unavailable") != string::npos, @@ -306,26 +328,47 @@ int main() { 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 + cl, username, password, ServerPublicKeyMode::REQUEST, "", + "SELECT 5988", output ) : -1; ok(enabled_ok && rsa_rc == 0, "Non-TLS caching_sha2_password authentication succeeds with --get-server-public-key"); + const string pinned_public_key_path = test_key_directory + test_public_key; + output.clear(); + const int pinned_wrong_password_rc = enabled_ok ? run_mysql_cli( + cl, pinned_username, wrong_password, ServerPublicKeyMode::PATH, + pinned_public_key_path, "SELECT 5988", output + ) : 0; + ok(enabled_ok && pinned_wrong_password_rc != 0 && + output.find("ERROR 1045") != string::npos, + "Pinned RSA full authentication rejects an incorrect password with 1045"); + + output.clear(); + const int pinned_key_rc = enabled_ok ? run_mysql_cli( + cl, pinned_username, password, ServerPublicKeyMode::PATH, + pinned_public_key_path, "SELECT 5988", output + ) : -1; + ok(enabled_ok && pinned_key_rc == 0, + "Non-TLS caching_sha2_password authentication succeeds with --server-public-key-path"); + output.clear(); const int internal_session_rc = enabled_ok ? run_mysql_cli( - cl, username, password, true, "PROXYSQL INTERNAL SESSION", output + cl, username, password, ServerPublicKeyMode::REQUEST, "", + "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(8, "Cannot run authentication assertions after setup failure"); + skip(10, "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; + admin, "DELETE FROM mysql_users WHERE username IN ('" + username + "','" + + pinned_username + "')") && cleanup_ok; cleanup_ok = run_query(admin, "LOAD MYSQL USERS TO RUNTIME") && cleanup_ok; if (have_original_plugin) { cleanup_ok = set_global_variable( diff --git a/test/tap/tests/unit/protocol_unit-t.cpp b/test/tap/tests/unit/protocol_unit-t.cpp index 31fd09bd04..d62fdcec8f 100644 --- a/test/tap/tests/unit/protocol_unit-t.cpp +++ b/test/tap/tests/unit/protocol_unit-t.cpp @@ -256,18 +256,20 @@ static void test_auth_more_data_packet() { } static void test_caching_sha2_stage5_payload_validation() { - auto run_stage5 = []( + auto run_caching_sha2_response = []( + unsigned int switching_auth_stage, bool encrypted, unsigned char *payload, size_t payload_length, std::string* recovered = nullptr, - bool* pass_is_sensitive = nullptr + bool* pass_is_sensitive = nullptr, + MySQLFrontendAuthError* auth_error = 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_stage = switching_auth_stage; stream.switching_auth_type = AUTH_MYSQL_CACHING_SHA2_PASSWORD; stream.encrypted = encrypted; MySQL_Data_Stream *stream_pointer = &stream; @@ -285,6 +287,9 @@ static void test_caching_sha2_stage5_payload_validation() { if (pass_is_sensitive != nullptr) { *pass_is_sensitive = vars.pass_is_sensitive; } + if (auth_error != nullptr) { + *auth_error = protocol.consume_frontend_auth_error(); + } if (vars.pass != nullptr) { OPENSSL_cleanse(vars.pass, vars.pass_len + 1); } @@ -293,20 +298,28 @@ static void test_caching_sha2_stage5_payload_validation() { }; unsigned char raw_cleartext[] = { 's', 'e', 'c', 'r', 'e', 't', '\0' }; - ok(run_stage5(false, raw_cleartext, sizeof(raw_cleartext)) == 1, + ok(run_caching_sha2_response(5, false, raw_cleartext, sizeof(raw_cleartext)) == 1, "non-TLS caching_sha2 stage 5 rejects raw cleartext instead of bypassing RSA"); + MySQLFrontendAuthError missing_stage6_key_error = MySQLFrontendAuthError::NONE; + unsigned char stage6_ciphertext[] = { 0x01 }; + ok(run_caching_sha2_response( + 6, false, stage6_ciphertext, sizeof(stage6_ciphertext), nullptr, nullptr, + &missing_stage6_key_error + ) == 1 && missing_stage6_key_error == MySQLFrontendAuthError::CACHING_SHA2_RSA_UNAVAILABLE, + "caching_sha2 stage 6 reports RSA unavailability when its retained key is missing"); + // 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, + ok(run_caching_sha2_response(5, 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 + ok(run_caching_sha2_response( + 5, 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, @@ -512,7 +525,7 @@ static void test_wildcard_matching() { int main() { #ifdef PROXYSQL31 - plan(55); + plan(56); #else plan(45); #endif @@ -531,7 +544,7 @@ int main() { test_mysql_hdr(); // 3 tests #ifdef PROXYSQL31 test_auth_more_data_packet(); // 5 tests - test_caching_sha2_stage5_payload_validation(); // 4 tests + test_caching_sha2_stage5_payload_validation(); // 5 tests test_internal_session_redacts_password(); // 1 test #endif