From 8a6989ed4477aefb850081901875418e52e1d92a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 07:54:59 +0000 Subject: [PATCH 01/23] docs: add design spec for MariaDB ed25519 client authentication Approved brainstorming outcome for supporting MariaDB's client_ed25519 / auth_ed25519 scheme on both frontend and backend: - Frontend verification gated behind PROXYSQL31 via new PROXYSQLED25519 macro; crypto reuses the connector's public-domain ref10 sources. - Backend support via flipping client_ed25519 to STATIC in the existing connector plugin patch (applies to all tiers, deps are not tier-aware). - Credentials in mysql_users.password: cleartext (full function) or new $ED$ public-key format (frontend-verification-only). - Auth-switch-only protocol flow with 32-byte scramble; COM_CHANGE_USER supported; passthrough auth documented as incompatible by construction. - Unit tests with MariaDB known-answer vectors plus end-to-end TAP on infra-mariadb10. --- ...026-08-11-ed25519-authentication-design.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md diff --git a/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md b/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md new file mode 100644 index 0000000000..58c11de5a4 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md @@ -0,0 +1,201 @@ +# Ed25519 Authentication for MySQL Client Connections — Design + +**Date:** 2026-08-11 +**Status:** Approved +**Tier:** v3.1+ (`PROXYSQL31`) + +## Goal + +Support MariaDB's ed25519 authentication scheme (`client_ed25519` client plugin / +`auth_ed25519` server plugin) on both sides of ProxySQL: + +- **Frontend**: MySQL/MariaDB clients authenticate to ProxySQL using `client_ed25519`. +- **Backend**: ProxySQL authenticates to MariaDB backends whose users are defined + `IDENTIFIED VIA ed25519`. + +Oracle MySQL has no ed25519 plugin; this is a MariaDB-ecosystem feature. + +## Background and key constraint + +MariaDB ed25519 is challenge–response: the server sends a 32-byte scramble, the +client returns a 64-byte Ed25519 signature over it, and the server verifies the +signature against a stored public key (43-char base64 in +`mysql.user.authentication_string`). + +Key derivation is a MariaDB variant of Ed25519: the secret expansion is +`az = SHA512(password)` where the password is arbitrary-length (standard Ed25519 +hashes a fixed 32-byte seed). Consequences: + +- Signature **verification** is standard Ed25519 verify. +- Deriving the public key from a cleartext password requires ref10-style + `ge_scalarmult_base`, which OpenSSL's EVP API does not expose. +- **ProxySQL can never learn the cleartext password from the handshake**, and a + signature cannot be replayed to a backend (different scramble). This makes + ed25519 fundamentally incompatible with passthrough-auth learning. + +Current frontend auth supports exactly three plugins hard-coded at +`lib/MySQL_Protocol.cpp:91` (`mysql_native_password`, `mysql_clear_password`, +`caching_sha2_password`). Backend auth is delegated entirely to the vendored +MariaDB Connector/C, which already contains the full ref10 Ed25519 implementation +and a `client_ed25519` plugin — currently built DYNAMIC and not shipped. + +## Approach (chosen: ref10 reuse) + +Reuse the connector's public-domain ref10 sources as the single crypto path for +both public-key derivation and signature verification. Alternatives rejected: + +- *OpenSSL verify + ref10 derive*: two crypto paths, ref10 still required. +- *libsodium*: new vendored dependency, and its `crypto_sign` API uses standard + seed derivation, so the MariaDB variant would still need hand-rolling. + +## 1. Build & feature gating + +- New feature macro **`PROXYSQLED25519`**, implied by `PROXYSQL31` (same Makefile + pattern as `PROXYSQLFFTO`/`PROXYSQLTSDB`). All frontend ed25519 code is guarded + by `#ifdef PROXYSQLED25519`. +- **Crypto sources**: the connector patch (§4) flips `client_ed25519` from + DYNAMIC to STATIC, pulling the ref10 objects (`sign.c`, `open.c`, `keypair.c`, + `ge_*.c`, `fe_*.c`, `sc_*.c`) into `libmariadbclient.a`, which ProxySQL already + links. Preferred: call those symbols directly from a thin wrapper — + `lib/MySQL_Ed25519.cpp` + `include/MySQL_Ed25519.h` — exposing exactly: + - `derive_public_key(const char *password, size_t len, uint8_t out[32])` + - `verify(const uint8_t sig[64], const uint8_t scramble[32], const uint8_t pubkey[32]) → bool` + + Fallback if symbol naming/visibility is unusable: compile the ref10 `.c` files + into `libproxysql.a` via a `lib/Makefile` rule, sourcing them from `deps/` + (no file copying). +- **Accepted asymmetry**: `deps/` builds are not tier-parameterized, so the + STATIC connector patch applies to every tier. Backend ed25519 therefore works + passively even in stable 3.0 builds (zero ProxySQL code involved); frontend + ed25519 is 3.1+ only. Making deps tier-aware was rejected — the build system + does not support it. + +## 2. Credential storage in `mysql_users.password` + +Runtime format detection, extending the existing chain (`*<40 hex>` → SHA1 +native, `$A$0…` length 70 → caching_sha2): + +| Stored format | Detection | Capability | +|---|---|---| +| `$ED$<43-char base64>` (47 chars) | `strncasecmp(password, "$ED$", 4) == 0` | Frontend verification only | +| cleartext (no known-hash format match) | existing fallthrough | Frontend verification **and** backend ed25519 auth | + +- The base64 payload is MariaDB's encoding of the 32-byte public key; migration + from MariaDB = prefix the `authentication_string` value with `$ED$`. +- A bare 43-char base64 string is **not** auto-detected (indistinguishable from a + legitimate cleartext password); the prefix is mandatory. +- Dual-password (PRIMARY/ADDITIONAL) works for both formats: the verify loop + retries against the additional credential, mirroring the existing retry at + `lib/MySQL_Protocol.cpp:3654`. +- Derived public keys are computed per-auth (one SHA512 + one scalar mult; + microseconds). No caching in v1. + +## 3. Frontend protocol flow + +- **Greeting unchanged.** `mysql-default_authentication_plugin` keeps its two + allowed values (`mysql_native_password`, `caching_sha2_password`). ed25519 + cannot be advertised in the initial handshake: the greeting carries a + 20+1-byte scramble while ed25519 signs a 32-byte challenge. MariaDB itself + always routes ed25519 through an Auth Switch. Flow: + + ``` + greeting (native|sha2) → HandshakeResponse + → AuthSwitchRequest "client_ed25519" + 32-byte scramble + → 64-byte signature → OK / ERR + ``` + +- **Plugin registry**: `plugins[]` grows to 4 with `"client_ed25519"`; new enum + value `AUTH_MYSQL_ED25519 = 3` in `include/MySQL_Protocol.h:36`. `PPHR_3` + recognizes the name; the switch matrix in `process_pkt_handshake_response` + gains the new row. +- **Switch policy** — ProxySQL switches the client to ed25519 when: + 1. the stored credential is `$ED$…` (mandatory — only verifiable scheme), or + 2. the client's HandshakeResponse explicitly requested `client_ed25519` and a + usable credential exists (cleartext or `$ED$`). + + All other combinations keep existing behavior (switch to native, etc.). +- **Verification**: response must be exactly 64 bytes (enforced via the + `auth_response_has` bounds pattern); standard Ed25519 verify of the 32-byte + scramble against the stored or derived public key. +- **Scramble storage**: the switch machinery currently assumes 20-byte + scrambles; the ed25519 path stores its 32-byte challenge on the data stream + alongside the existing `switching_auth_*` state + (`include/MySQL_Data_Stream.h:175`). Generated with OpenSSL `RAND_bytes`. +- **COM_CHANGE_USER**: supported via the existing auth-switch-in-change-user + machinery (`lib/MySQL_Protocol.cpp:1829-1840`) extended with an ed25519 branch + in `verify_user_pass`. The caching_sha2 change-user limitation (#4618) is not + reproduced — ed25519's switch flow has no RSA/TLS sub-protocol. +- **Interactions**: + - TLS not required: the signature never exposes a secret (unlike + clear/sha2-cleartext paths). No `CLIENT_SSL` forcing. + - LDAP `clear_password` selection logic untouched. + - **Passthrough auth is incompatible by construction** (no replay, no + cleartext learning). When a user authenticates via ed25519, passthrough + learning is skipped. Documented. + +## 4. Backend connections + +- Single change: in `deps/mariadb-client-library/plugin_auth_CMakeLists.txt.patch`, + add `client_ed25519` to the DYNAMIC→STATIC flips (joining `caching_sha2_password`, + `sha256_password`, `mysql_clear_password`). The connector then answers a + MariaDB server's ed25519 auth switch transparently using `userinfo->password`. + No `MYSQL_DEFAULT_AUTH` plumbing; no changes to `lib/mysql_connection.cpp`. +- Applies everywhere the connector is used: backend pools, Monitor (an ed25519 + monitor user works with a cleartext `mysql-monitor_password`), cluster sync. +- **`$ED$`-only users on the backend**: the connector would send the literal + `$ED$…` string as the password and fail. On first such failure ProxySQL logs + one explicit warning — "user X has an ed25519 public-key-only credential; + backend authentication requires the cleartext password" — instead of generic + access-denied noise. `$ED$` storage is documented as frontend-verification-only. + +## 5. Admin & observability + +- **No new admin variables.** The feature is driven entirely by credential + format and client plugin choice. +- On `LOAD MYSQL USERS TO RUNTIME`, a `$ED$` password with malformed base64 or + wrong length produces a load-time warning in the error log; the user still + loads, and every auth attempt fails cleanly with access-denied. +- The auth-event JSON dump in `lib/mysql_data_stream.cpp:1936` gains the new + plugin name. + +## 6. Error handling + +- Wrong signature, malformed response length (≠ 64 bytes), or undecodable stored + key → standard `ER_ACCESS_DENIED_ERROR` (1045), byte-identical message to a + wrong password. No information distinguishing "bad key format" from "wrong + password" leaks to the client. +- Derived key material wiped with `OPENSSL_cleanse` following the existing + `cleanse_and_free_password` idiom. + +## 7. Testing & documentation + +- **Unit tests** (`test/tap/tests/unit/`, harness per + `doc/agents/project-conventions.md`): known-answer vectors from the MariaDB + test suite — password → public-key derivation, valid/invalid signature + verification, `$ED$` parsing edge cases (bad base64, wrong length, prefix + case-insensitivity). +- **End-to-end TAP** on `infra-mariadb10` (backend installs `auth_ed25519` via + `INSTALL SONAME`): new `test_ed25519_auth-t.cpp` covering: + - cleartext-stored user: frontend ed25519 + backend ed25519, through to query + execution; + - `$ED$`-stored user: frontend OK, backend fails with the documented warning; + - wrong password / bad signature → 1045; + - additional-password retry; + - `COM_CHANGE_USER` to and from an ed25519 user; + - non-ed25519 client connecting as an `$ED$` user (switched, or denied if the + client lacks the plugin). + + Test binaries link the vendored connector, which after the STATIC patch speaks + `client_ed25519` natively — no external client needed. +- **Docs**: `doc/ed25519_authentication.md` following the structure of + `doc/caching_sha2_password_rsa.md` — formats, migration from MariaDB, backend + behavior, passthrough incompatibility, tier availability. + +## Out of scope (explicit) + +- MariaDB PARSEC (11.6+ default auth scheme). +- Advertising ed25519 in the initial handshake / new values for + `mysql-default_authentication_plugin`. +- PostgreSQL frontend/backend. +- Caching of derived public keys. +- Making `deps/` builds tier-aware. From 21c4fc0b76a52c355389a19e7d0ac0332d1f21a3 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 08:25:20 +0000 Subject: [PATCH 02/23] docs: implementation plan for MariaDB ed25519 authentication Six-task plan executing the approved spec (docs/superpowers/specs/ 2026-08-11-ed25519-authentication-design.md): connector STATIC patch + PROXYSQLED25519 tier cascade, ref10-backed MySQL_Ed25519 wrapper with known-answer unit tests (the 'secret' vector reproduces the MariaDB KB example), frontend Auth Switch protocol flow, COM_CHANGE_USER support and credential validation warnings, end-to-end TAP test on the mariadb10-galera infra, and user documentation. All known-answer vectors were generated during planning by compiling the ref10 sources vendored in deps/ against OpenSSL SHA512. --- .../2026-08-11-ed25519-authentication.md | 1336 +++++++++++++++++ 1 file changed, 1336 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-ed25519-authentication.md diff --git a/docs/superpowers/plans/2026-08-11-ed25519-authentication.md b/docs/superpowers/plans/2026-08-11-ed25519-authentication.md new file mode 100644 index 0000000000..a370a15540 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-ed25519-authentication.md @@ -0,0 +1,1336 @@ +# MariaDB ed25519 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:** Support MariaDB's ed25519 authentication (`client_ed25519`) for frontend client connections (v3.1+ tier) and backend connections (all tiers, via the connector), per the approved spec `docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md`. + +**Architecture:** The vendored MariaDB Connector/C's `client_ed25519` plugin is flipped from DYNAMIC to STATIC, which (a) gives backend connections ed25519 transparently and (b) puts the ref10 crypto symbols (`crypto_sign_keypair`, `crypto_sign_open`) into `libmariadbclient.a` where a thin new wrapper (`lib/MySQL_Ed25519.cpp`) calls them. Frontend auth always runs through an Auth Switch carrying a fresh 32-byte nonce; the client answers with a 64-byte signature verified against a stored `$ED$` public key or a key derived from a stored cleartext password. + +**Tech Stack:** C++17, ref10 Ed25519 (from `deps/`, via `libmariadbclient.a`), OpenSSL (`RAND_bytes`, `EVP_DecodeBlock`), TAP tests, Docker test infra (`mariadb10-galera`). + +## Global Constraints + +- **Tier**: all frontend code behind `#ifdef PROXYSQLED25519`; `PROXYSQL31=1` implies `PROXYSQLED25519=1` (Makefile cascade, same pattern as `PROXYSQLFFTO`). +- **Every build command in this plan uses the tier flag**: `PROXYSQL31=1 make debug -j$(nproc)`. If the tree was last built without it, run `make clean` first (stale-object tier mismatch, see CLAUDE.md). +- **The binary under TAP test must be a DEBUG build** (`PROXYSQL31=1 make debug`). +- Stored credential formats: cleartext (full function) and `$ED$` + 43-char base64 (frontend-verification-only). Prefix match is case-insensitive; total length exactly 47. +- Wire constants: nonce 32 bytes, signature 64 bytes, public key 32 bytes, plugin name `client_ed25519`. +- All auth failures surface as the standard access-denied (1045) — no client-visible distinction between wrong password / bad signature / malformed stored key. +- No new admin variables. +- Never run `./src/proxysql` directly; use the documented isolated test harness. +- Known-answer vectors (generated from the deps ref10 sources; `"secret"` matches the MariaDB KB documented example, which independently validates compatibility): + - `"secret"` → `ZIgUREUg5PVgQ6LskhXmO+eZLS0nC8be6HPjYWR4YJY` + - `"ed25519_pass_1"` → `5TBW79xTAMbhi8QKQtLLVS0V0b2w9mlKnRG6c+2NxTQ` + - `""` → `4LH+dBF+G5W2CKTyId8xR3SyDqZoQjUNUVNxx8aWbG4` + - signature of nonce `0x00..0x1f` under `"ed25519_pass_1"`: + `004a2ab8c18a320bdde27a5fff54ae43f66b4c21373ba3c1852ce0eb9255d073f7b6125fb6ee1a236633da90d0e38b3b58c3295b4ab9eb418402cbfa6f879701` + +--- + +### Task 1: Connector STATIC patch + tier flag plumbing + +**Files:** +- Modify: `deps/mariadb-client-library/plugin_auth_CMakeLists.txt.patch` +- Modify: `Makefile` (~lines 63-72, 106-107, 416-430) +- Modify: `lib/Makefile` (~lines 67-74, 86) +- Modify: `src/Makefile` (~lines 86-90, 104) + +**Interfaces:** +- Consumes: nothing. +- Produces: `libmariadbclient.a` exports `crypto_sign_keypair(unsigned char *pk, unsigned char *pw, unsigned long long pwlen)`, `crypto_sign_open(unsigned char *sm, unsigned long long smlen, const unsigned char *pk)`, `ma_crypto_sign(...)` — Task 2's wrapper links against them. Make variable `PROXYSQLED25519` and compiler define `-DPROXYSQLED25519` active in `lib/` and `src/` whenever `PROXYSQL31=1`. + +- [ ] **Step 1: Add the client_ed25519 hunk to the connector patch** + +The pristine `plugins/auth/CMakeLists.txt` (from `mariadb-connector-c-3.3.8-src.tar.gz`) has the `client_ed25519` `REGISTER_PLUGIN` block at lines 55-64 with `DEFAULT DYNAMIC` at line 58. Insert this hunk as the **first** hunk of `deps/mariadb-client-library/plugin_auth_CMakeLists.txt.patch` (hunks must stay in ascending line order; the existing hunks are at 77, 88, 137). Insert after the `+++ plugins/auth/CMakeLists.txt` header line: + +```diff +@@ -55,7 +55,7 @@ + REGISTER_PLUGIN(TARGET client_ed25519 + TYPE MARIADB_CLIENT_PLUGIN_AUTH + CONFIGURATIONS DYNAMIC STATIC OFF +- DEFAULT DYNAMIC ++ DEFAULT STATIC + SOURCES ${CC_SOURCE_DIR}/plugins/auth/ed25519.c + ${REF10_SOURCES} + ${CRYPT_SOURCE} +``` + +IMPORTANT: the `SOURCES ...ed25519.c ` context line ends with a **trailing space** — copy it exactly (verify against the pristine file, next step). + +- [ ] **Step 2: Validate the patch applies cleanly against the pristine file** + +```bash +cd deps/mariadb-client-library +tar -zxf mariadb-connector-c-3.3.8-src.tar.gz -O mariadb-connector-c-3.3.8-src/plugins/auth/CMakeLists.txt > /tmp/cml_pristine.txt +cp /tmp/cml_pristine.txt /tmp/cml_test.txt +patch /tmp/cml_test.txt < plugin_auth_CMakeLists.txt.patch +grep -A4 "TARGET client_ed25519" /tmp/cml_test.txt | grep "DEFAULT STATIC" +``` +Expected: `patch` reports 4 hunks applied cleanly, and the grep prints ` DEFAULT STATIC`. If a hunk fails, fix whitespace in the new hunk (do not use `--fuzz`). + +- [ ] **Step 3: Top-level Makefile — cascade and export** + +In `Makefile`, inside the existing `ifeq ($(PROXYSQL31),1)` cascade block (~line 71, where `PROXYSQLFFTO := 1` is set), add: + +```make + PROXYSQLED25519 := 1 +``` + +Next to `export PROXYSQLFFTO` (~line 107), add: + +```make +export PROXYSQLED25519 +``` + +Then update the recursive lib/src build lines: `grep -n 'cd lib &&\|cd src &&' Makefile` (4 lines, ~416, 420, 424, 430) and append `PROXYSQLED25519=$(PROXYSQLED25519)` next to the existing `PROXYSQLTSDB=$(PROXYSQLTSDB)` on each. Do NOT touch the `cd deps` or `cd plugins/*` lines (deps are tier-independent; the connector patch applies unconditionally, which is the accepted spec behavior: backend ed25519 works in all tiers). + +Also update the tier documentation comment at ~line 63 to read: `PROXYSQL40=1 implies PROXYSQL31=1 implies PROXYSQLFFTO=1 + PROXYSQLTSDB=1 + PROXYSQLED25519=1`. + +- [ ] **Step 4: lib/Makefile and src/Makefile — translate to -D** + +In `lib/Makefile` after the `PSQLTSDB` block (~line 70-74): + +```make +PSQLED25519 := +ifeq ($(PROXYSQLED25519),1) + PSQLED25519 := -DPROXYSQLED25519 +endif +``` + +and append `$(PSQLED25519)` to the `MYCXXFLAGS :=` line (~line 86, after `$(PSQLTSDB)`). + +In `src/Makefile`, same block after `PSQLFFTO` (~line 90), and append `$(PSQLED25519)` to the `MYCXXFLAGS +=` line (~line 104). + +- [ ] **Step 5: Rebuild the connector and verify the symbols** + +```bash +rm deps/mariadb-client-library/mariadb_client/libmariadb/libmariadbclient.a +PROXYSQL31=1 make build_deps_debug +nm deps/mariadb-client-library/mariadb_client/libmariadb/libmariadbclient.a | grep -E " T (crypto_sign_keypair|crypto_sign_open|ma_crypto_sign)$" +``` +Expected: the recipe re-extracts the tarball, applies all patches (including the new hunk), rebuilds, and `nm` prints all three `T` symbols. If `nm` prints nothing, the STATIC flip did not take — inspect `deps/mariadb-client-library/mariadb_client/plugins/auth/CMakeLists.txt` for `client_ed25519 ... DEFAULT STATIC`. + +- [ ] **Step 6: Full debug build still links** + +```bash +PROXYSQL31=1 make debug -j$(nproc) +``` +Expected: clean build (no source changes yet — this proves the flag plumbing and the fatter `libmariadbclient.a` don't break the link). + +- [ ] **Step 7: Commit** + +```bash +git add deps/mariadb-client-library/plugin_auth_CMakeLists.txt.patch Makefile lib/Makefile src/Makefile +git commit -m "build: statically link client_ed25519 connector plugin, add PROXYSQLED25519 tier flag + +The connector's client_ed25519 plugin (with the full ref10 Ed25519 +implementation) is flipped from DYNAMIC to STATIC in the existing +plugin_auth CMakeLists patch. This transparently enables ed25519 +authentication for backend connections (server-driven auth switch, +no ProxySQL code involved) and exports crypto_sign_keypair / +crypto_sign_open from libmariadbclient.a for the upcoming frontend +verification wrapper. + +PROXYSQLED25519 is a new feature macro implied by PROXYSQL31, +following the PROXYSQLFFTO cascade pattern. deps are intentionally +NOT tier-gated (single connector build serves all tiers, per spec)." +``` + +--- + +### Task 2: MySQL_Ed25519 wrapper + unit test (TDD) + +**Files:** +- Create: `include/MySQL_Ed25519.h` +- Create: `lib/MySQL_Ed25519.cpp` +- Create: `test/tap/tests/unit/ed25519_unit-t.cpp` +- Modify: `lib/Makefile` (`_OBJ_CXX` conditional, ~line 124-138) +- Modify: `test/tap/tests/unit/Makefile` (tier probe ~line 277-292, `UNIT_TESTS` registration ~line 434-436, `OPT` line) +- Modify: `test/tap/groups/groups.json` + +**Interfaces:** +- Consumes: `crypto_sign_keypair`, `crypto_sign_open` from `libmariadbclient.a` (Task 1). +- Produces (used by Tasks 3-4): + - `void proxysql_ed25519_derive_public_key(const char *password, size_t password_len, unsigned char *out_pubkey)` — 32-byte key from arbitrary-length cleartext. + - `bool proxysql_ed25519_verify_signature(const unsigned char *signature, const unsigned char *nonce, const unsigned char *pubkey)` — 64B sig over 32B nonce. + - `bool proxysql_ed25519_is_pubkey_format(const char *password)` — `$ED$` + 43 base64, length 47, prefix case-insensitive; NULL-safe. + - `bool proxysql_ed25519_decode_pubkey(const char *stored, unsigned char *out_pubkey)` — false on malformed input. + - Macros: `ED25519_NONCE_LEN` (32), `ED25519_SIG_LEN` (64), `ED25519_PUBKEY_LEN` (32), `ED25519_PUBKEY_B64_LEN` (43), `ED25519_STORED_PREFIX` (`"$ED$"`), `ED25519_STORED_PREFIX_LEN` (4), `ED25519_STORED_LEN` (47). + +- [ ] **Step 1: Write the failing unit test** + +Create `test/tap/tests/unit/ed25519_unit-t.cpp`: + +```cpp +/** + * @file ed25519_unit-t.cpp + * @brief Known-answer and edge-case tests for the MariaDB-variant Ed25519 + * helpers in lib/MySQL_Ed25519.cpp. + * + * The "secret" vector matches the documented example in the MariaDB KB + * (CREATE USER ... IDENTIFIED VIA ed25519 USING 'ZIgUREUg5...'), which + * independently validates that the ref10 sources vendored in deps/ implement + * the same scheme as the auth_ed25519 server plugin. + */ +#include "tap.h" + +#include "MySQL_Ed25519.h" + +#include +#include +#include + +#include + +struct derivation_kat { const char* password; const char* pubkey_b64; }; + +static const derivation_kat KATS[] = { + { "secret", "ZIgUREUg5PVgQ6LskhXmO+eZLS0nC8be6HPjYWR4YJY" }, + { "ed25519_pass_1", "5TBW79xTAMbhi8QKQtLLVS0V0b2w9mlKnRG6c+2NxTQ" }, + { "", "4LH+dBF+G5W2CKTyId8xR3SyDqZoQjUNUVNxx8aWbG4" }, +}; + +// 64-byte signature of nonce 0x00..0x1f under password "ed25519_pass_1", +// generated with the connector's own ma_crypto_sign() (the scheme is +// deterministic, so this vector is stable). +static const char SIG_HEX[] = + "004a2ab8c18a320bdde27a5fff54ae43f66b4c21373ba3c1852ce0eb9255d073" + "f7b6125fb6ee1a236633da90d0e38b3b58c3295b4ab9eb418402cbfa6f879701"; + +static void unhex(const char* hex, unsigned char* out, size_t outlen) { + for (size_t i = 0; i < outlen; i++) { + unsigned int b = 0; + sscanf(hex + 2 * i, "%2x", &b); + out[i] = static_cast(b); + } +} + +static std::string b64_no_pad(const unsigned char* in, size_t len) { + unsigned char out[64] = { 0 }; + EVP_EncodeBlock(out, in, len); + std::string s(reinterpret_cast(out)); + while (!s.empty() && s.back() == '=') s.pop_back(); + return s; +} + +int main() { + plan( + 3 /* derivation KATs */ + + 3 /* decode round-trips */ + + 7 /* is_pubkey_format edge cases */ + + 2 /* decode_pubkey malformed */ + + 1 /* signature KAT */ + + 3 /* tampered signature / nonce / key */ + ); + + // 1. derivation known-answer tests + for (const derivation_kat& kat : KATS) { + unsigned char pk[ED25519_PUBKEY_LEN]; + proxysql_ed25519_derive_public_key(kat.password, strlen(kat.password), pk); + std::string encoded = b64_no_pad(pk, sizeof(pk)); + ok(encoded == kat.pubkey_b64, + "derive_public_key('%s') = '%s' (expected '%s')", + kat.password, encoded.c_str(), kat.pubkey_b64); + } + + // 2. decode_pubkey round-trips against derivation + for (const derivation_kat& kat : KATS) { + unsigned char derived[ED25519_PUBKEY_LEN]; + unsigned char decoded[ED25519_PUBKEY_LEN]; + proxysql_ed25519_derive_public_key(kat.password, strlen(kat.password), derived); + std::string stored = std::string(ED25519_STORED_PREFIX) + kat.pubkey_b64; + bool rc = proxysql_ed25519_decode_pubkey(stored.c_str(), decoded); + ok(rc && memcmp(derived, decoded, ED25519_PUBKEY_LEN) == 0, + "decode_pubkey('%s') matches derived key", stored.c_str()); + } + + // 3. is_pubkey_format edge cases + { + std::string valid = std::string(ED25519_STORED_PREFIX) + KATS[1].pubkey_b64; + ok(proxysql_ed25519_is_pubkey_format(valid.c_str()) == true, "valid $ED$ string accepted"); + std::string lower = std::string("$ed$") + KATS[1].pubkey_b64; + ok(proxysql_ed25519_is_pubkey_format(lower.c_str()) == true, "prefix match is case-insensitive"); + ok(proxysql_ed25519_is_pubkey_format(KATS[1].pubkey_b64) == false, "bare 43-char base64 rejected (prefix mandatory)"); + ok(proxysql_ed25519_is_pubkey_format("$ED$tooshort") == false, "wrong length rejected"); + std::string toolong = valid + "X"; + ok(proxysql_ed25519_is_pubkey_format(toolong.c_str()) == false, "48-char string rejected"); + ok(proxysql_ed25519_is_pubkey_format(NULL) == false, "NULL rejected"); + ok(proxysql_ed25519_is_pubkey_format("*THISLOOKSLIKEASHA1HASHXXXXXXXXXXXXXXXXX") == false, "SHA1-format password rejected"); + } + + // 4. decode_pubkey malformed input + { + unsigned char pk[ED25519_PUBKEY_LEN]; + // 43 chars but contains characters outside the base64 alphabet + std::string bad = std::string(ED25519_STORED_PREFIX) + "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"; + ok(proxysql_ed25519_decode_pubkey(bad.c_str(), pk) == false, "invalid base64 chars rejected"); + ok(proxysql_ed25519_decode_pubkey("not-ed25519-at-all", pk) == false, "non-$ED$ string rejected"); + } + + // 5. signature known-answer test + unsigned char sig[ED25519_SIG_LEN]; + unsigned char nonce[ED25519_NONCE_LEN]; + unsigned char pk[ED25519_PUBKEY_LEN]; + unhex(SIG_HEX, sig, sizeof(sig)); + for (int i = 0; i < ED25519_NONCE_LEN; i++) nonce[i] = static_cast(i); + proxysql_ed25519_derive_public_key("ed25519_pass_1", strlen("ed25519_pass_1"), pk); + ok(proxysql_ed25519_verify_signature(sig, nonce, pk) == true, "known-answer signature verifies"); + + // 6. negative cases + { + unsigned char tampered_sig[ED25519_SIG_LEN]; + memcpy(tampered_sig, sig, sizeof(sig)); + tampered_sig[10] ^= 0xff; + ok(proxysql_ed25519_verify_signature(tampered_sig, nonce, pk) == false, "tampered signature rejected"); + + unsigned char wrong_nonce[ED25519_NONCE_LEN]; + memcpy(wrong_nonce, nonce, sizeof(nonce)); + wrong_nonce[0] ^= 0x01; + ok(proxysql_ed25519_verify_signature(sig, wrong_nonce, pk) == false, "wrong nonce rejected"); + + unsigned char wrong_pk[ED25519_PUBKEY_LEN]; + proxysql_ed25519_derive_public_key("some_other_password", strlen("some_other_password"), wrong_pk); + ok(proxysql_ed25519_verify_signature(sig, nonce, wrong_pk) == false, "wrong public key rejected"); + } + + return exit_status(); +} +``` + +- [ ] **Step 2: Register the unit test in the unit Makefile and groups.json** + +In `test/tap/tests/unit/Makefile`: + +1. After the `PSQLTSDB` nm-probe block (~line 277-281), add an nm probe (the mangled C++ symbol contains the plain name, so `grep -c` matches): + +```make +PSQLED25519 := +ifneq ($(shell nm $(LIBPROXYSQLAR) 2>/dev/null | grep -c proxysql_ed25519_verify_signature),0) + PROXYSQLED25519 := 1 + PSQLED25519 := -DPROXYSQLED25519 +endif +``` + +2. Append `$(PSQLED25519)` to the `OPT :=` line (the one already containing `$(PSQL31) $(PSQLFFTO) $(PSQLTSDB)`). + +3. Next to the existing `ifeq ($(PROXYSQL31),1) / UNIT_TESTS += caching_sha2_rsa_unit-t / endif` block (~line 434), add: + +```make +ifeq ($(PROXYSQLED25519),1) +UNIT_TESTS += ed25519_unit-t +endif +``` + +In `test/tap/groups/groups.json`, add (keep alphabetical ordering with the surrounding keys): + +```json + "ed25519_unit-t" : [ "unit-tests-g1","@proxysql_min_version:3.1" ], +``` + +- [ ] **Step 3: Verify the test fails to build (header does not exist yet)** + +```bash +cd test/tap/tests/unit && make ed25519_unit-t +``` +Expected: FAIL with `MySQL_Ed25519.h: No such file or directory`. + +- [ ] **Step 4: Write the header** + +Create `include/MySQL_Ed25519.h`: + +```cpp +#ifndef __CLASS_MYSQL_ED25519_H +#define __CLASS_MYSQL_ED25519_H +#ifdef PROXYSQLED25519 + +#include + +/** + * MariaDB-variant Ed25519 helpers for frontend client authentication + * (the client_ed25519 / auth_ed25519 scheme). + * + * MariaDB derives the keypair from SHA512(password) where the password has + * arbitrary length (standard Ed25519 hashes a fixed 32-byte seed), so the + * derivation MUST use the ref10 implementation vendored in + * deps/mariadb-client-library (statically linked into libmariadbclient.a via + * the client_ed25519 plugin). Signature verification is standard Ed25519. + * + * Stored-credential format in mysql_users.password: + * "$ED$" + 43-char unpadded base64 of the 32-byte public key + * (prefix case-insensitive, total length exactly 47). This mirrors MariaDB's + * mysql.user.authentication_string with an explicit marker so it cannot be + * confused with a cleartext password. + */ + +#define ED25519_NONCE_LEN 32 +#define ED25519_SIG_LEN 64 +#define ED25519_PUBKEY_LEN 32 +#define ED25519_PUBKEY_B64_LEN 43 +#define ED25519_STORED_PREFIX "$ED$" +#define ED25519_STORED_PREFIX_LEN 4 +#define ED25519_STORED_LEN (ED25519_STORED_PREFIX_LEN + ED25519_PUBKEY_B64_LEN) + +/** @brief Derive the 32-byte public key from a cleartext password (MariaDB variant). */ +void proxysql_ed25519_derive_public_key(const char* password, size_t password_len, unsigned char* out_pubkey); + +/** @brief Verify a 64-byte signature over a 32-byte nonce against a 32-byte public key. */ +bool proxysql_ed25519_verify_signature(const unsigned char* signature, const unsigned char* nonce, const unsigned char* pubkey); + +/** @brief True when 'password' is a stored ed25519 public key ("$ED$" + 43 base64 chars). NULL-safe. */ +bool proxysql_ed25519_is_pubkey_format(const char* password); + +/** @brief Decode a "$ED$..." stored credential into a 32-byte public key. False on malformed input. */ +bool proxysql_ed25519_decode_pubkey(const char* stored, unsigned char* out_pubkey); + +#endif // PROXYSQLED25519 +#endif // __CLASS_MYSQL_ED25519_H +``` + +- [ ] **Step 5: Write the implementation** + +Create `lib/MySQL_Ed25519.cpp`: + +```cpp +#ifdef PROXYSQLED25519 + +#include "MySQL_Ed25519.h" + +#include +#include + +#include + +// ref10 entry points compiled into libmariadbclient.a by the STATIC +// client_ed25519 plugin registration (deps/mariadb-client-library). +extern "C" { +int crypto_sign_keypair(unsigned char* pk, unsigned char* pw, unsigned long long pwlen); +int crypto_sign_open(unsigned char* sm, unsigned long long smlen, const unsigned char* pk); +} + +void proxysql_ed25519_derive_public_key(const char* password, size_t password_len, unsigned char* out_pubkey) { + // ref10 takes a non-const pw but never modifies it + crypto_sign_keypair(out_pubkey, reinterpret_cast(const_cast(password)), password_len); +} + +bool proxysql_ed25519_verify_signature(const unsigned char* signature, const unsigned char* nonce, const unsigned char* pubkey) { + // crypto_sign_open() expects a mutable "signed message" R||S||M and + // clobbers it during verification, so build a local copy. + unsigned char sm[ED25519_SIG_LEN + ED25519_NONCE_LEN]; + memcpy(sm, signature, ED25519_SIG_LEN); + memcpy(sm + ED25519_SIG_LEN, nonce, ED25519_NONCE_LEN); + return crypto_sign_open(sm, sizeof(sm), pubkey) == 0; +} + +bool proxysql_ed25519_is_pubkey_format(const char* password) { + if (password == NULL) return false; + if (strncasecmp(password, ED25519_STORED_PREFIX, ED25519_STORED_PREFIX_LEN) != 0) return false; + return strlen(password) == ED25519_STORED_LEN; +} + +bool proxysql_ed25519_decode_pubkey(const char* stored, unsigned char* out_pubkey) { + if (proxysql_ed25519_is_pubkey_format(stored) == false) return false; + // 43 base64 chars + '=' forms one complete 44-char group. EVP_DecodeBlock + // emits 33 bytes for it; the 33rd is padding garbage and is discarded. + unsigned char in[ED25519_PUBKEY_B64_LEN + 1]; + memcpy(in, stored + ED25519_STORED_PREFIX_LEN, ED25519_PUBKEY_B64_LEN); + in[ED25519_PUBKEY_B64_LEN] = '='; + unsigned char out[33]; + if (EVP_DecodeBlock(out, in, sizeof(in)) != 33) return false; + memcpy(out_pubkey, out, ED25519_PUBKEY_LEN); + return true; +} + +#endif // PROXYSQLED25519 +``` + +- [ ] **Step 6: Register the object in lib/Makefile** + +In `lib/Makefile`, next to the existing FFTO conditional (~line 134-136), add: + +```make +# ed25519 frontend authentication (MariaDB client_ed25519) +ifeq ($(PROXYSQLED25519),1) +_OBJ_CXX += MySQL_Ed25519.oo +endif +``` + +- [ ] **Step 7: Rebuild libproxysql and run the unit test** + +```bash +PROXYSQL31=1 make debug -j$(nproc) +cd test/tap/tests/unit && make ed25519_unit-t && ./ed25519_unit-t +``` +Expected: builds, prints `1..19` and all `ok` lines, exit status 0. In particular the three derivation KATs must pass — if they fail, the wrapper is NOT calling the connector's ref10 (check `nm` from Task 1 Step 5). + +- [ ] **Step 8: Commit** + +```bash +git add include/MySQL_Ed25519.h lib/MySQL_Ed25519.cpp lib/Makefile \ + test/tap/tests/unit/ed25519_unit-t.cpp test/tap/tests/unit/Makefile test/tap/groups/groups.json +git commit -m "feat: add MariaDB-variant Ed25519 helpers with known-answer unit tests + +proxysql_ed25519_{derive_public_key,verify_signature,is_pubkey_format, +decode_pubkey} wrap the ref10 symbols statically linked into +libmariadbclient.a. Key derivation must use ref10 because MariaDB +hashes an arbitrary-length password (SHA512) where standard Ed25519 +hashes a fixed 32-byte seed; verification is standard Ed25519. + +The 'secret' derivation vector reproduces the documented MariaDB KB +example, independently confirming scheme compatibility." +``` + +--- + +### Task 3: Frontend protocol — initial handshake auth switch and verification + +**Files:** +- Modify: `include/MySQL_Protocol.h` (enum ~line 36-41; PPHR method declarations next to `PPHR_sha2full`) +- Modify: `lib/MySQL_Protocol.cpp` (plugins[] ~91; `generate_pkt_auth_switch_request` ~1131-1219; `PPHR_1` ~2090; `PPHR_3` ~2315-2341; switch matrix ~3563-3619; new `PPHR_ed25519_switch`/`PPHR_ed25519_verify` near `PPHR_sha2full` ~2916; `PPHR_verify_password` insertions ~3429 and ~3445) +- Modify: `lib/mysql_data_stream.cpp` (auth-plugin JSON dump ~1936-1946) + +**Interfaces:** +- Consumes: all `proxysql_ed25519_*` functions and `ED25519_*` macros from Task 2. +- Produces: enum value `AUTH_MYSQL_ED25519` (= 3) in `enum proxysql_auth_plugins`; plugin name string `"client_ed25519"` as `plugins[AUTH_MYSQL_ED25519]`; methods `void MySQL_Protocol::PPHR_ed25519_switch(bool& ret, MyProt_tmp_auth_vars& vars1)` and `void MySQL_Protocol::PPHR_ed25519_verify(bool& ret, MyProt_tmp_auth_vars& vars1)` (Task 4 reuses the same verification flow via the shared state machine). + +**Flow being implemented** (mirrors how MariaDB itself works — ed25519 is never advertised in the greeting; it always runs through an Auth Switch): + +``` +client HandshakeResponse (any plugin) + → PPHR_verify_password stage 0: stored "$ED$" OR client asked client_ed25519 + → PPHR_ed25519_switch(): 32-byte RAND_bytes nonce into scramble_buff, + AuthSwitchRequest "client_ed25519" + nonce, stage=1, auth_in_progress=1 + → client sends 64-byte signature → PPHR_1 (stage 1→2, no NUL-strip) + → PPHR_verify_password dispatch → PPHR_ed25519_verify() +``` + +Known v1 limitation (document, do not fix): if ProxySQL already committed a *native* auth switch before the account lookup (client offered `caching_sha2_password` against a native greeting — `PPHR_4auth0` path), a stored-`$ED$` user cannot be verified because the protocol allows only one switch. Standard clients (libmariadb answering the greeting plugin, or explicitly requesting `client_ed25519`) do not hit this. + +- [ ] **Step 1: enum + method declarations in include/MySQL_Protocol.h** + +Extend the enum at line 36-41: + +```cpp +enum proxysql_auth_plugins { + AUTH_UNKNOWN_PLUGIN = -1, + AUTH_MYSQL_NATIVE_PASSWORD = 0, + AUTH_MYSQL_CLEAR_PASSWORD, + AUTH_MYSQL_CACHING_SHA2_PASSWORD, +#ifdef PROXYSQLED25519 + AUTH_MYSQL_ED25519, // MariaDB client_ed25519 (value 3) +#endif +}; +``` + +Find the `PPHR_sha2full` declaration in the `MySQL_Protocol` class (`grep -n PPHR_sha2full include/MySQL_Protocol.h`) and add next to it: + +```cpp +#ifdef PROXYSQLED25519 + void PPHR_ed25519_switch(bool& ret, MyProt_tmp_auth_vars& vars1); + void PPHR_ed25519_verify(bool& ret, MyProt_tmp_auth_vars& vars1); +#endif +``` + +- [ ] **Step 2: plugins[] and includes in lib/MySQL_Protocol.cpp** + +Replace the array at line 91-95 (drop the explicit `[3]` size): + +```cpp +static const char *plugins[] = { + "mysql_native_password", + "mysql_clear_password", + "caching_sha2_password", +#ifdef PROXYSQLED25519 + "client_ed25519", +#endif +}; +``` + +Near the other includes at the top of the file add: + +```cpp +#ifdef PROXYSQLED25519 +#include "MySQL_Ed25519.h" +#include +#endif +``` + +- [ ] **Step 3: generate_pkt_auth_switch_request — ed25519 case** + +In the first `switch((*myds)->switching_auth_type)` (length computation, ~line 1148-1171) add before `default:`: + +```cpp +#ifdef PROXYSQLED25519 + case AUTH_MYSQL_ED25519: + myhdr.pkt_length=1 // fe + + (strlen(plugins[AUTH_MYSQL_ED25519])+1) + + ED25519_NONCE_LEN; // 32-byte nonce; NO trailing 0x00 (client requires exactly 32 bytes of plugin data) + break; +#endif +``` + +In the second `switch` (packet body, ~line 1181-1204) add before `default:`: + +```cpp +#ifdef PROXYSQLED25519 + case AUTH_MYSQL_ED25519: + memcpy(_ptr+l,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519])); + l+=strlen(plugins[AUTH_MYSQL_ED25519]); + _ptr[l]=0x00; l++; + memcpy(_ptr+l, (*myds)->myconn->scramble_buff, ED25519_NONCE_LEN); l+=ED25519_NONCE_LEN; + break; +#endif +``` + +Guard the unconditional trailing NUL at ~line 1205 — for ed25519, `l` already equals the packet size, so the write would land one byte past the buffer: + +```cpp +#ifdef PROXYSQLED25519 + if ((*myds)->switching_auth_type != AUTH_MYSQL_ED25519) // ed25519 packet ends exactly after the nonce +#endif + _ptr[l]=0x00; //l+=1; //0x00 +``` + +- [ ] **Step 4: PPHR_1 — accept the raw 64-byte signature** + +At ~line 2090, the native branch takes the payload verbatim while every other plugin requires NUL termination. A 64-byte signature is raw binary (it may legitimately contain `0x00` anywhere), so it must take the native-style branch: + +```cpp + if (auth_plugin_id == AUTH_MYSQL_NATIVE_PASSWORD +#ifdef PROXYSQLED25519 + || auth_plugin_id == AUTH_MYSQL_ED25519 // raw 64-byte signature, not NUL-terminated +#endif + ) { + vars1.pass_len = payload_length; + } else { +``` + +- [ ] **Step 5: PPHR_3 — recognize the plugin name** + +In the name-mapping chain at ~line 2323-2338, add a branch before the closing brace of the chain: + +```cpp +#ifdef PROXYSQLED25519 + } else if (strncmp((char *)vars1.auth_plugin,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519]))==0) { + // client explicitly requested client_ed25519; the Auth Switch with a + // 32-byte nonce is driven later by PPHR_verify_password at stage 0 + auth_plugin_id = AUTH_MYSQL_ED25519; +#endif + } +``` + +- [ ] **Step 6: switch matrix — let ed25519 requests through** + +In `process_pkt_handshake_response`, the `sent_auth_plugin_id == AUTH_MYSQL_NATIVE_PASSWORD` switch (~line 3564-3590) has `default: assert(0)`. Add before `default:`: + +```cpp +#ifdef PROXYSQLED25519 + case AUTH_MYSQL_ED25519: + // nothing to do here; PPHR_verify_password() decides the ed25519 + // Auth Switch at stage 0 (after the account lookup) + break; +#endif +``` + +The `sent_auth_plugin_id == AUTH_MYSQL_CACHING_SHA2_PASSWORD` switch (~line 3592-3616) already has `default: break;`, so `AUTH_MYSQL_ED25519` falls through safely — add the same explicit case there anyway, directly above `default:`, for symmetry and greppability. + +- [ ] **Step 7: implement PPHR_ed25519_switch and PPHR_ed25519_verify** + +Place both right after `PPHR_sha2full` (~line 2916) in `lib/MySQL_Protocol.cpp`: + +```cpp +#ifdef PROXYSQLED25519 +/** + * @brief Initiate the client_ed25519 Auth Switch (stage 0 -> 1). + * @details Generates a fresh 32-byte nonce into 'scramble_buff' (40 bytes, so + * it fits) and sends an AuthSwitchRequest naming client_ed25519. The client + * answers with a 64-byte signature that PPHR_1 collects (stage 1 -> 2) and + * PPHR_ed25519_verify() checks. Mirrors the state handling of PPHR_4auth0. + */ +void MySQL_Protocol::PPHR_ed25519_switch(bool& ret, MyProt_tmp_auth_vars& vars1) { + ret = false; + if (RAND_bytes(reinterpret_cast((*myds)->myconn->scramble_buff), ED25519_NONCE_LEN) != 1) { + proxy_error("RAND_bytes() failed generating the ed25519 nonce for user '%s'\n", vars1.user); + return; + } + (*myds)->switching_auth_type = AUTH_MYSQL_ED25519; + (*myds)->switching_auth_stage = 1; + (*myds)->auth_in_progress = 1; + generate_pkt_auth_switch_request(true, NULL, NULL); + (*myds)->myconn->userinfo->set((char *)vars1.user, NULL, vars1.db, NULL); + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, "Session=%p , DS=%p , user='%s' . Sent client_ed25519 Auth Switch\n", + (*myds)->sess, (*myds), vars1.user); +} + +/** + * @brief Verify the 64-byte client_ed25519 signature over the nonce sent by + * PPHR_ed25519_switch() (or by the COM_CHANGE_USER switch path). + * @details The public key comes from a stored "$ED$" credential, or is derived + * from a stored cleartext password (MariaDB variant, ref10). Every failure + * mode -- wrong length, malformed stored key, bad signature -- yields the + * same generic auth failure; nothing distinguishable leaks to the client. + */ +void MySQL_Protocol::PPHR_ed25519_verify(bool& ret, MyProt_tmp_auth_vars& vars1) { + ret = false; + if (vars1.pass_len != ED25519_SIG_LEN) { + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, "Session=%p , DS=%p , user='%s' . Malformed ed25519 signature length %u\n", + (*myds)->sess, (*myds), vars1.user, vars1.pass_len); + return; + } + unsigned char pubkey[ED25519_PUBKEY_LEN]; + if (proxysql_ed25519_is_pubkey_format(vars1.password)) { + if (proxysql_ed25519_decode_pubkey(vars1.password, pubkey) == false) { + proxy_error("mysql_users entry for '%s' has a malformed $ED$ ed25519 credential; denying access\n", vars1.user); + return; + } + } else { + proxysql_ed25519_derive_public_key(vars1.password, strlen(vars1.password), pubkey); + } + if (proxysql_ed25519_verify_signature(vars1.pass, reinterpret_cast((*myds)->myconn->scramble_buff), pubkey)) { + ret = true; + } + OPENSSL_cleanse(pubkey, sizeof(pubkey)); + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, "Session=%p , DS=%p , user='%s' . ed25519 signature verification %s\n", + (*myds)->sess, (*myds), vars1.user, ret ? "succeeded" : "failed"); +} +#endif // PROXYSQLED25519 +``` + +- [ ] **Step 8: PPHR_verify_password — stage-0 gate and verification dispatch** + +Insertion A — the stage-0 gate. Locate the call to `PPHR_5passwordTrue(ret, vars1, reply, account_details);` (~line 3427) and insert **immediately after it**, BEFORE the `if (vars1.pass_len==0 && strlen(vars1.password)==0)` block: + +```cpp +#ifdef PROXYSQLED25519 + // ed25519 gate (stage 0): a client that requested client_ed25519 sends an + // empty auth response in the HandshakeResponse -- it cannot sign before + // receiving the 32-byte nonce -- so this decision MUST precede the + // empty-response checks below. A stored "$ED$" credential forces the + // ed25519 exchange regardless of the plugin the client offered. + // 'switching_auth_sent' guards re-entry: after the switch, the signature + // arrives with stage 0 on the COM_CHANGE_USER path and stage 2 here. + if ((*myds)->switching_auth_stage == 0 && + (*myds)->switching_auth_sent != AUTH_MYSQL_ED25519 && + (*myds)->sess->session_type != PROXYSQL_SESSION_CLICKHOUSE) { + const bool stored_is_ed = proxysql_ed25519_is_pubkey_format(vars1.password); + // a '*SHA1' or '$A$' hash cannot derive an ed25519 key + const bool cred_usable = stored_is_ed || + (vars1.password[0] != '*' && + !(strlen(vars1.password) == 70 && strncasecmp(vars1.password,"$A$0",4)==0)); + if (stored_is_ed || (auth_plugin_id == AUTH_MYSQL_ED25519 && cred_usable)) { + PPHR_ed25519_switch(ret, vars1); + return ret; + } + if (auth_plugin_id == AUTH_MYSQL_ED25519) { + // client insists on ed25519 but the stored hash cannot derive a key + return ret; // ret == false + } + } +#endif +``` + +Insertion B — the verification dispatch. At ~line 3445, the credential-format chain starts with the `$A$` caching_sha2 check. Put the ed25519 dispatch FIRST in that chain: + +```cpp +#ifdef PROXYSQLED25519 + if (auth_plugin_id == AUTH_MYSQL_ED25519 || proxysql_ed25519_is_pubkey_format(vars1.password)) { + // signature collected by PPHR_1 after the Auth Switch; a stored + // "$ED$" key with a non-ed25519 response fails the length check + // inside PPHR_ed25519_verify (generic denial) + PPHR_ed25519_verify(ret, vars1); + } else +#endif + if ( + auth_plugin_id == AUTH_MYSQL_CACHING_SHA2_PASSWORD + && + strlen(vars1.password) == 70 + ... +``` + +(Only add the new branch and the `else`; the existing chain is unchanged.) + +- [ ] **Step 9: data-stream JSON dump** + +In `lib/mysql_data_stream.cpp` (~line 1936-1946), in the `switch (myprot.auth_plugin_id)` add before `default:`: + +```cpp +#ifdef PROXYSQLED25519 + case AUTH_MYSQL_ED25519: + jc1["prot"]["auth_plugin"] = "client_ed25519"; + break; +#endif +``` + +- [ ] **Step 10: Build both tiers** + +```bash +PROXYSQL31=1 make debug -j$(nproc) +``` +Expected: clean build. Then confirm the stable tier still compiles (all new code is `#ifdef`-gated): + +```bash +make clean && make -j$(nproc) && make clean && PROXYSQL31=1 make debug -j$(nproc) +``` +Expected: both builds succeed (the final rebuild restores the debug 3.1 binary for later tasks). + +- [ ] **Step 11: Re-run the unit tests** + +```bash +cd test/tap/tests/unit && make ed25519_unit-t && ./ed25519_unit-t +``` +Expected: still all `ok`. + +- [ ] **Step 12: Commit** + +```bash +git add include/MySQL_Protocol.h lib/MySQL_Protocol.cpp lib/mysql_data_stream.cpp +git commit -m "feat: frontend client_ed25519 authentication via Auth Switch + +Adds AUTH_MYSQL_ED25519 to the frontend plugin registry and implements +the MariaDB flow: ed25519 is never advertised in the greeting (its +challenge is 32 bytes, the greeting scramble is 20); instead +PPHR_verify_password decides at stage 0 -- when the stored credential +is \$ED\$ or the client requested client_ed25519 -- and sends an +AuthSwitchRequest with a fresh RAND_bytes nonce (PPHR_ed25519_switch). +The 64-byte signature returns through PPHR_1 (native-style raw +payload, no NUL terminator) and PPHR_ed25519_verify checks it against +the stored public key or one derived from the stored cleartext +password. All failures collapse into the generic access-denied path. + +Known v1 limitation: a client that triggers the early native switch +(PPHR_4auth0, e.g. caching_sha2 offer against a native greeting) +cannot be re-switched to ed25519 for \$ED\$ users -- the protocol +allows a single switch. Standard libmariadb clients are unaffected." +``` + +--- + +### Task 4: COM_CHANGE_USER support + credential validation warning + +**Files:** +- Modify: `lib/MySQL_Protocol.cpp` (`verify_user_pass` ~1524-1560; `process_pkt_COM_CHANGE_USER` ~1823-1848) +- Modify: `lib/MySQL_Authentication.cpp` (`MySQL_Authentication::add` at line 164) + +**Interfaces:** +- Consumes: `PPHR_ed25519_verify` flow via the shared state machine (the change-user switch response re-enters `process_pkt_handshake_response` → `PPHR_1` → `PPHR_verify_password`; `switching_auth_sent == AUTH_MYSQL_ED25519` set by `generate_pkt_auth_switch_request` prevents the stage-0 gate from re-firing); `proxysql_ed25519_is_pubkey_format`, `proxysql_ed25519_decode_pubkey`, `ED25519_*` macros. +- Produces: COM_CHANGE_USER to an ed25519 user works via Auth Switch; malformed `$ED$` rows warn at load time. + +- [ ] **Step 1: verify_user_pass — recognize the plugin name, reject inline data** + +In the name-mapping chain at ~line 1524-1531 add: + +```cpp +#ifdef PROXYSQLED25519 + } else if (strncmp((char *)auth_plugin,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519]))==0) { + auth_plugin_id = AUTH_MYSQL_ED25519; +#endif + } +``` + +In the cleartext branch (`password[0]!='*'`, ~line 1533-1560) add before the final `else`: + +```cpp +#ifdef PROXYSQLED25519 + } else if (auth_plugin_id == AUTH_MYSQL_ED25519) { + // Inline ed25519 auth data in COM_CHANGE_USER is not part of the + // MariaDB flow: the client cannot sign before receiving a fresh + // nonce. The nonce-based exchange is driven by + // process_pkt_COM_CHANGE_USER via Auth Switch; reject inline data. + ret = false; +#endif +``` + +(The hashed branch, `password[0]=='*'`, needs no change: its `else` leg already fails closed for non-native plugins in MYSQL sessions.) + +- [ ] **Step 2: process_pkt_COM_CHANGE_USER — ed25519 Auth Switch** + +At ~line 1823, the credential dispatch currently reads `if (password==NULL) { ret=false; } else { if (pass_len==0 && strlen(password)==0) ...`. Insert an ed25519 branch as the FIRST check inside the `else`: + +```cpp + if (password==NULL) { + ret=false; + } else { +#ifdef PROXYSQLED25519 + // A stored "$ED$" credential (or an explicit client_ed25519 request) + // can only be verified through a fresh-nonce Auth Switch: any inline + // auth data was computed against the original scramble and is + // meaningless for ed25519. Mirrors the native pass_len==0 switch below + // (issue #3504); the response re-enters process_pkt_handshake_response + // where PPHR_1 picks up switching_auth_type and PPHR_verify_password + // verifies the signature (switching_auth_sent guards its stage-0 gate). + const bool ed25519_switch_needed = + session_type != PROXYSQL_SESSION_CLICKHOUSE && + (proxysql_ed25519_is_pubkey_format(password) || + (client_auth_plugin && strcmp(client_auth_plugin, plugins[AUTH_MYSQL_ED25519]) == 0)); + if (ed25519_switch_needed) { + if (RAND_bytes(reinterpret_cast((*myds)->myconn->scramble_buff), ED25519_NONCE_LEN) != 1) { + proxy_error("RAND_bytes() failed generating the ed25519 nonce for user '%s'\n", user); + ret = false; + } else { + (*myds)->switching_auth_type = AUTH_MYSQL_ED25519; + (*myds)->sess->change_user_auth_switch = true; + generate_pkt_auth_switch_request(true, NULL, NULL); + (*myds)->myconn->userinfo->set((char *)user, NULL, db, NULL); + ret = false; + } + } else +#endif + if (pass_len==0 && strlen(password)==0) { +``` + +(The rest of the chain — the empty-password accept, the native `pass_len==0` switch, and the `verify_user_pass` call — is unchanged and stays attached to the final `else`.) + +- [ ] **Step 3: backend connect warning for $ED$-only credentials** + +Per spec §4, an `$ED$`-stored user cannot authenticate to backends (the +connector would send the literal `$ED$…` string as the password). Emit an +explicit warning where the backend connection is initiated so admins are not +left with generic access-denied noise. In `lib/mysql_connection.cpp`, inside +`MySQL_Connection::connect_start` (~line 998), immediately before the +`mysql_real_connect_start` invocation, add: + +```cpp +#ifdef PROXYSQLED25519 + if (userinfo->password && proxysql_ed25519_is_pubkey_format(userinfo->password)) { + proxy_warning( + "User '%s' has an ed25519 public-key-only ($ED$) credential;" + " backend authentication requires the cleartext password and will fail\n", + userinfo->username); + } +#endif +``` + +and near the top of `lib/mysql_connection.cpp` add: + +```cpp +#ifdef PROXYSQLED25519 +#include "MySQL_Ed25519.h" +#endif +``` + +- [ ] **Step 4: load-time validation warning in MySQL_Authentication::add** + +At the top of `MySQL_Authentication::add` (line 164, before the hashing), add: + +```cpp +#ifdef PROXYSQLED25519 + if (password && strncasecmp(password, ED25519_STORED_PREFIX, ED25519_STORED_PREFIX_LEN) == 0) { + unsigned char tmp_pk[ED25519_PUBKEY_LEN]; + if (proxysql_ed25519_decode_pubkey(password, tmp_pk) == false) { + proxy_warning( + "mysql_users entry for '%s' has a malformed $ED$ ed25519 credential" + " (expected \"$ED$\" followed by exactly 43 base64 characters);" + " every authentication attempt for this user will fail\n", username); + } + } +#endif +``` + +and add near the top of `lib/MySQL_Authentication.cpp`: + +```cpp +#ifdef PROXYSQLED25519 +#include "MySQL_Ed25519.h" +#endif +``` + +- [ ] **Step 5: Build and run unit tests** + +```bash +PROXYSQL31=1 make debug -j$(nproc) +cd test/tap/tests/unit && make ed25519_unit-t && ./ed25519_unit-t +``` +Expected: clean build, unit tests all `ok`. + +- [ ] **Step 6: Commit** + +```bash +git add lib/MySQL_Protocol.cpp lib/MySQL_Authentication.cpp lib/mysql_connection.cpp +git commit -m "feat: COM_CHANGE_USER support for ed25519 users, \$ED\$ load-time validation + +COM_CHANGE_USER targeting a stored-\$ED\$ user (or naming +client_ed25519) now performs the fresh-nonce Auth Switch instead of +failing on unverifiable inline auth data; the signature response rides +the existing change_user_auth_switch rails (#3504) back through +process_pkt_handshake_response. Unlike caching_sha2 (#4618), no +sub-protocol is needed, so change-user works fully. + +MySQL_Authentication::add() warns once at load time when a \$ED\$ +credential is malformed, and connect_start() warns when a backend +connection is attempted with a public-key-only \$ED\$ credential, +instead of leaving admins to puzzle over generic access-denied +errors." +``` + +--- + +### Task 5: End-to-end TAP test on MariaDB infra + +**Files:** +- Create: `test/tap/tests/test_ed25519_auth-t.cpp` +- Modify: `test/tap/groups/groups.json` + +**Interfaces:** +- Consumes: the complete frontend + backend feature; TAP helpers `CommandLine` (`command_line.h`), `mysql_query_t`/`MYSQL_QUERY` (`utils.h`), the `mariadb10-galera` infra. The TAP client binary links the patched vendored connector, so it can itself answer a `client_ed25519` Auth Switch — no external client needed. +- Produces: `test_ed25519_auth-t` registered in `mariadb10-galera-g4`, gated `@proxysql_min_version:3.1`. + +Backend/user fixture (all through ProxySQL as `cl.username`, which routes to the Galera writer): +- password `ed25519_pass_1` ↔ pubkey `5TBW79xTAMbhi8QKQtLLVS0V0b2w9mlKnRG6c+2NxTQ` (Global Constraints vectors). + +- [ ] **Step 1: Write the test** + +Create `test/tap/tests/test_ed25519_auth-t.cpp`: + +```cpp +/** + * @file test_ed25519_auth-t.cpp + * @brief End-to-end MariaDB ed25519 authentication (frontend + backend). + * @details Requires a MariaDB backend (mariadb10-galera infra): installs the + * auth_ed25519 server plugin, creates ed25519 backend users, and exercises: + * 1. cleartext-stored user: frontend ed25519 auth AND backend ed25519 auth + * (query reaches the backend); + * 2. $ED$-stored user: frontend auth succeeds, backend query fails + * (public key cannot drive backend auth -- documented limitation); + * 3. wrong password -> 1045; + * 4. COM_CHANGE_USER into an ed25519 user via Auth Switch; + * 5. additional-password (attributes JSON) retry. + */ +#include +#include +#include + +#include "mysql.h" + +#include "tap.h" +#include "command_line.h" +#include "utils.h" + +const char* ED_PASS = "ed25519_pass_1"; +const char* ED_PUBKEY = "5TBW79xTAMbhi8QKQtLLVS0V0b2w9mlKnRG6c+2NxTQ"; + +int main(int argc, char** argv) { + CommandLine cl; + if (cl.getEnv()) { + diag("Failed to get the required environmental variables."); + return EXIT_FAILURE; + } + + plan(10); + + // ---- fixture: backend plugin + users, via ProxySQL default routing ---- + MYSQL* wr = mysql_init(NULL); + if (!mysql_real_connect(wr, cl.host, cl.username, cl.password, NULL, cl.port, NULL, 0)) { + diag("Failed to connect to ProxySQL: %s", mysql_error(wr)); + return EXIT_FAILURE; + } + // tolerate "already installed" + if (mysql_query(wr, "INSTALL SONAME 'auth_ed25519'")) { + diag("INSTALL SONAME: %s (tolerated if already installed)", mysql_error(wr)); + } + { + MYSQL_RES* res = NULL; + MYSQL_QUERY(wr, "SELECT COUNT(*) FROM information_schema.plugins WHERE plugin_name='ed25519'"); + res = mysql_store_result(wr); + MYSQL_ROW row = mysql_fetch_row(res); + bool plugin_ok = row && strcmp(row[0], "1") == 0; + mysql_free_result(res); + if (!plugin_ok) { + diag("auth_ed25519 server plugin unavailable on this backend"); + return EXIT_FAILURE; + } + } + MYSQL_QUERY(wr, "CREATE DATABASE IF NOT EXISTS test"); + std::string create_user = + std::string("CREATE USER IF NOT EXISTS 'ed_user'@'%' IDENTIFIED VIA ed25519 USING '") + ED_PUBKEY + "'"; + MYSQL_QUERY(wr, create_user.c_str()); + std::string create_user_pk = + std::string("CREATE USER IF NOT EXISTS 'ed_user_pk'@'%' IDENTIFIED VIA ed25519 USING '") + ED_PUBKEY + "'"; + MYSQL_QUERY(wr, create_user_pk.c_str()); + MYSQL_QUERY(wr, "GRANT ALL ON test.* TO 'ed_user'@'%'"); + MYSQL_QUERY(wr, "GRANT ALL ON test.* TO 'ed_user_pk'@'%'"); + + // ---- proxysql users ---- + MYSQL* admin = mysql_init(NULL); + if (!mysql_real_connect(admin, cl.admin_host, cl.admin_username, cl.admin_password, NULL, cl.admin_port, NULL, 0)) { + diag("Failed to connect to ProxySQL admin: %s", mysql_error(admin)); + return EXIT_FAILURE; + } + int def_hg = 0; + { + MYSQL_QUERY(admin, "SELECT MIN(hostgroup_id) FROM runtime_mysql_servers WHERE status='ONLINE'"); + MYSQL_RES* res = mysql_store_result(admin); + MYSQL_ROW row = mysql_fetch_row(res); + if (row && row[0]) { def_hg = atoi(row[0]); } + mysql_free_result(res); + } + std::string q1 = + "INSERT OR REPLACE INTO mysql_users (username,password,active,default_hostgroup,default_schema) VALUES" + " ('ed_user','" + std::string(ED_PASS) + "',1," + std::to_string(def_hg) + ",'test')"; + MYSQL_QUERY(admin, q1.c_str()); + std::string q2 = + "INSERT OR REPLACE INTO mysql_users (username,password,active,default_hostgroup,default_schema) VALUES" + " ('ed_user_pk','$ED$" + std::string(ED_PUBKEY) + "',1," + std::to_string(def_hg) + ",'test')"; + MYSQL_QUERY(admin, q2.c_str()); + MYSQL_QUERY(admin, "LOAD MYSQL USERS TO RUNTIME"); + + // ---- 1-2: cleartext-stored user, full frontend+backend path ---- + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, "ed_user", ED_PASS, "test", cl.port, NULL, 0) != NULL; + ok(conn_ok, "cleartext-stored user connects via ed25519 auth switch (err: %s)", conn_ok ? "-" : mysql_error(c)); + if (conn_ok) { + int rc = mysql_query(c, "SELECT CURRENT_USER()"); + ok(rc == 0, "query reaches the ed25519 backend user (err: %s)", rc ? mysql_error(c) : "-"); + if (rc == 0) { mysql_free_result(mysql_store_result(c)); } + } else { + ok(false, "query skipped: connection failed"); + } + mysql_close(c); + } + + // ---- 3: wrong password -> 1045 ---- + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, "ed_user", "wrong_password", "test", cl.port, NULL, 0) != NULL; + ok(conn_ok == false && mysql_errno(c) == 1045, + "wrong password denied with 1045 (got errno %u)", mysql_errno(c)); + mysql_close(c); + } + + // ---- 4-5: $ED$-stored user: frontend OK, backend query fails ---- + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, "ed_user_pk", ED_PASS, "test", cl.port, NULL, 0) != NULL; + ok(conn_ok, "$ED$-stored user passes frontend verification (err: %s)", conn_ok ? "-" : mysql_error(c)); + if (conn_ok) { + int rc = mysql_query(c, "SELECT 1"); + if (rc == 0) { mysql_free_result(mysql_store_result(c)); } + ok(rc != 0, "backend query fails for public-key-only credential (documented limitation)"); + } else { + ok(false, "backend check skipped: connection failed"); + } + mysql_close(c); + } + + // ---- 6: bad frontend password for $ED$ user ---- + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, "ed_user_pk", "wrong_password", "test", cl.port, NULL, 0) != NULL; + ok(conn_ok == false && mysql_errno(c) == 1045, + "$ED$ user, wrong password denied with 1045 (got errno %u)", mysql_errno(c)); + mysql_close(c); + } + + // ---- 7-8: COM_CHANGE_USER into the ed25519 user ---- + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, cl.username, cl.password, "test", cl.port, NULL, 0) != NULL; + if (!conn_ok) { + ok(false, "base connection for change_user failed: %s", mysql_error(c)); + ok(false, "change_user skipped"); + } else { + int rc = mysql_change_user(c, "ed_user", ED_PASS, "test"); + ok(rc == 0, "COM_CHANGE_USER into ed25519 user succeeds (err: %s)", rc ? mysql_error(c) : "-"); + rc = mysql_query(c, "SELECT 1"); + if (rc == 0) { mysql_free_result(mysql_store_result(c)); } + ok(rc == 0, "query works after change_user (err: %s)", rc ? mysql_error(c) : "-"); + } + mysql_close(c); + } + + // ---- 9-10: additional-password retry (attributes JSON, hex-encoded) ---- + { + // primary password wrong on purpose; additional_password holds the real one + char hexpass[64] = { 0 }; + for (size_t i = 0; i < strlen(ED_PASS); i++) { + sprintf(hexpass + 2 * i, "%02x", (unsigned char)ED_PASS[i]); + } + std::string q = + "UPDATE mysql_users SET password='not_the_real_password'," + " attributes='{\"additional_password\":\"" + std::string(hexpass) + "\"}'" + " WHERE username='ed_user'"; + MYSQL_QUERY(admin, q.c_str()); + MYSQL_QUERY(admin, "LOAD MYSQL USERS TO RUNTIME"); + + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, "ed_user", ED_PASS, "test", cl.port, NULL, 0) != NULL; + ok(conn_ok, "additional-password retry verifies ed25519 signature (err: %s)", conn_ok ? "-" : mysql_error(c)); + if (conn_ok) { + int rc = mysql_query(c, "SELECT 1"); + if (rc == 0) { mysql_free_result(mysql_store_result(c)); } + ok(rc == 0, "query works on additional password (err: %s)", rc ? mysql_error(c) : "-"); + } else { + ok(false, "query skipped: connection failed"); + } + mysql_close(c); + } + + // ---- cleanup ---- + MYSQL_QUERY(admin, "DELETE FROM mysql_users WHERE username IN ('ed_user','ed_user_pk')"); + MYSQL_QUERY(admin, "LOAD MYSQL USERS TO RUNTIME"); + mysql_query(wr, "DROP USER IF EXISTS 'ed_user'@'%'"); + mysql_query(wr, "DROP USER IF EXISTS 'ed_user_pk'@'%'"); + mysql_close(admin); + mysql_close(wr); + + return exit_status(); +} +``` + +NOTE for the implementer: check `command_line.h` for the exact member names (`cl.host`, `cl.port`, `cl.username`, `cl.password`, `cl.admin_host`, `cl.admin_port`, `cl.admin_username`, `cl.admin_password`) and the `MYSQL_QUERY` macro in `utils.h`; mirror whatever `test_auth_methods-t.cpp` uses if any name differs. If `INSERT OR REPLACE INTO mysql_users` conflicts with the runner's fixed config, switch to `DELETE FROM mysql_users WHERE username=...` + `INSERT`. + +- [ ] **Step 2: Register the test in groups.json** + +```json + "test_ed25519_auth-t" : [ "mariadb10-galera-g4","@proxysql_min_version:3.1" ], +``` + +(Only the `mariadb10-galera` infra provides a MariaDB backend; `INSTALL SONAME 'auth_ed25519'` fails on MySQL backends, so do NOT add other groups.) + +- [ ] **Step 3: Build the TAP tests** + +```bash +PROXYSQL31=1 make build_tap_test_debug +``` +Expected: `test_ed25519_auth-t` compiles and links. + +- [ ] **Step 4: Run the test in the isolated harness** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=mariadb10-galera-g4 test/infra/control/ensure-infras.bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=mariadb10-galera-g4 \ + TEST_PY_TAP_INCL="test_ed25519_auth-t" \ + test/infra/control/run-tests-isolated.bash +``` +Expected: `1..11`, all `ok`. On any failure, read the test output AND the proxysql container log; identify the specific failing step before changing anything (per CLAUDE.md failure-reporting rules). Also verify the `$ED$` backend-failure warning appears in the proxysql log during step 5's run (`grep -i "ed25519" `). + +- [ ] **Step 5: Run the full unit-test group to catch regressions** + +```bash +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=unit-tests-g1 test/infra/control/run-tests-isolated.bash +``` +Expected: all tests pass, including `ed25519_unit-t` and pre-existing auth tests. + +- [ ] **Step 6: Commit** + +```bash +git add test/tap/tests/test_ed25519_auth-t.cpp test/tap/groups/groups.json +git commit -m "test: end-to-end MariaDB ed25519 authentication TAP test + +Runs on the mariadb10-galera infra (@proxysql_min_version:3.1): +installs auth_ed25519 on the backend and covers the full matrix -- +cleartext-stored user through to backend query execution, \$ED\$ +public-key-only user (frontend OK, backend fails as documented), +wrong-password 1045 for both formats, COM_CHANGE_USER via Auth +Switch, and additional-password retry. The TAP client itself answers +the client_ed25519 Auth Switch because the vendored connector now +links the plugin statically." +``` + +--- + +### Task 6: Documentation + spec-coverage check + +**Files:** +- Create: `doc/ed25519_authentication.md` + +**Interfaces:** +- Consumes: everything above. +- Produces: user-facing documentation; final verified branch. + +- [ ] **Step 1: Write the documentation** + +Create `doc/ed25519_authentication.md`: + +```markdown +# MariaDB ed25519 Authentication + +ProxySQL supports MariaDB's ed25519 authentication scheme +(`client_ed25519` client plugin / `auth_ed25519` server plugin) on both +sides of the proxy. + +## Availability + +| Side | Tier | Mechanism | +|------|------|-----------| +| Backend (ProxySQL → MariaDB) | all tiers | The bundled MariaDB Connector/C links `client_ed25519` statically and answers the server's auth switch transparently. | +| Frontend (client → ProxySQL) | v3.1+ (`PROXYSQL31`) | ProxySQL verifies `client_ed25519` signatures itself. | + +Oracle MySQL has no ed25519 plugin; this is a MariaDB-ecosystem feature. + +## Credential formats in `mysql_users.password` + +| Format | Example | Frontend auth | Backend auth | +|--------|---------|---------------|--------------| +| cleartext | `my_password` | yes (key derived on the fly) | yes (connector signs with it) | +| `$ED$` + 43-char base64 public key | `$ED$ZIgUREUg5PVgQ6LskhXmO+eZLS0nC8be6HPjYWR4YJY` | yes (signature verified against the key) | **no** — the password is unknown | + +The `$ED$` payload is exactly the value MariaDB stores in +`mysql.user.authentication_string` for an ed25519 user — to migrate, +prefix it with `$ED$`. The prefix is case-insensitive and mandatory: a +bare 43-character string is treated as a cleartext password. + +A malformed `$ED$` value (wrong length or invalid base64) logs a warning +at `LOAD MYSQL USERS TO RUNTIME` time and every authentication attempt +for that user fails with the standard access-denied error. + +## Protocol behavior + +ed25519 is never advertised in the initial handshake (its challenge is +32 bytes; the greeting scramble is 20). ProxySQL sends an +`AuthSwitchRequest` naming `client_ed25519` with a fresh 32-byte nonce +whenever: + +- the stored credential is `$ED$…` (whatever plugin the client offered), or +- the client explicitly requested `client_ed25519` and the stored + credential is cleartext or `$ED$`. + +The client answers with a 64-byte signature. This mirrors MariaDB's own +behavior, so any client able to authenticate against MariaDB ed25519 +works unchanged. `COM_CHANGE_USER` into an ed25519 user is supported via +the same auth-switch mechanism. + +TLS is not required: the exchange never transmits a secret. + +## Limitations + +- `$ED$` (public-key-only) users cannot open backend connections: the + signature scheme is not replayable and the cleartext is unknown. + ProxySQL logs an explicit warning when such a user's backend + connection fails. Store the cleartext password for full functionality. +- Pass-through authentication (`mysql-passthrough_auth_*`) cannot learn + credentials from an ed25519 exchange, by construction. +- If a client triggers an early switch to `mysql_native_password` + (e.g. it offered `caching_sha2_password` against a native greeting), + a stored-`$ED$` user cannot be verified on that connection — the + MySQL protocol allows a single auth switch. Standard MariaDB clients + do not hit this. +- MariaDB PARSEC (11.6+) is not supported. +``` + +- [ ] **Step 2: Spec-coverage check** + +Re-read `docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md` section by section and confirm each maps to a completed task: §1 build/gating → Task 1; §2 storage → Task 2 + Task 3 Step 8; §3 protocol → Task 3 (+ change-user in Task 4); §4 backend → Task 1 (+ `$ED$` connect warning in Task 4 Step 3, exercised by Task 5's log grep); §5 admin/observability → Tasks 3 (JSON dump) and 4 (load warning); §6 errors → Tasks 3-4; §7 tests/docs → Tasks 2, 5, 6. Any gap found here becomes a new task before proceeding. + +- [ ] **Step 3: Final full verification** + +```bash +PROXYSQL31=1 make debug -j$(nproc) +cd test/tap/tests/unit && make ed25519_unit-t && ./ed25519_unit-t && cd - +PROXYSQL31=1 make build_tap_test_debug +WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=mariadb10-galera-g4 \ + TEST_PY_TAP_INCL="test_ed25519_auth-t" \ + test/infra/control/run-tests-isolated.bash +``` +Expected: everything green. + +- [ ] **Step 4: Commit** + +```bash +git add doc/ed25519_authentication.md +git commit -m "docs: MariaDB ed25519 authentication guide + +Formats, MariaDB migration path, protocol behavior, tier +availability, and the documented limitations (\$ED\$ backend +connections, passthrough incompatibility, single-auth-switch edge, +PARSEC out of scope)." +``` From fd345e14e62a4e311447c259e15c752932e66696 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 08:45:50 +0000 Subject: [PATCH 03/23] build: statically link client_ed25519 connector plugin, add PROXYSQLED25519 tier flag The connector's client_ed25519 plugin (with the full ref10 Ed25519 implementation) is flipped from DYNAMIC to STATIC in the existing plugin_auth CMakeLists patch. This transparently enables ed25519 authentication for backend connections (server-driven auth switch, no ProxySQL code involved) and exports crypto_sign_keypair / crypto_sign_open from libmariadbclient.a for the upcoming frontend verification wrapper. PROXYSQLED25519 is a new feature macro implied by PROXYSQL31, following the PROXYSQLFFTO cascade pattern. deps are intentionally NOT tier-gated (single connector build serves all tiers, per spec)." --- Makefile | 12 +++++++----- .../plugin_auth_CMakeLists.txt.patch | 9 +++++++++ lib/Makefile | 7 ++++++- src/Makefile | 7 ++++++- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 493c84dfeb..757193bc73 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ lint-tests: ### * GenAI plugin (built alongside core) ### - Automatically increments the major version (e.g., 3.0.6 -> 4.0.6). ### -### HIERARCHY: `PROXYSQL40=1` implies `PROXYSQL31=1` implies `PROXYSQLFFTO=1` + `PROXYSQLTSDB=1`. +### HIERARCHY: `PROXYSQL40=1` implies `PROXYSQL31=1` implies `PROXYSQLFFTO=1` + `PROXYSQLTSDB=1` + `PROXYSQLED25519=1`. # If PROXYSQL40 is enabled, it automatically enables PROXYSQL31 ifeq ($(PROXYSQL40),1) @@ -71,6 +71,7 @@ endif ifeq ($(PROXYSQL31),1) PROXYSQLFFTO := 1 PROXYSQLTSDB := 1 + PROXYSQLED25519 := 1 endif # Only increment version at the top-level make to avoid double-incrementing in recursive makes @@ -106,6 +107,7 @@ export PROXYSQL40 export PROXYSQL31 export PROXYSQLFFTO export PROXYSQLTSDB +export PROXYSQLED25519 ### NOTES: ### SOURCE_DATE_EPOCH is used for reproducible builds @@ -413,21 +415,21 @@ build_deps_debug_default: .PHONY: build_lib_default build_lib_default: build_deps_default - cd lib && OPTZ="${O2} -ggdb" PROXYSQLCLICKHOUSE=1 PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE} + cd lib && OPTZ="${O2} -ggdb" PROXYSQLCLICKHOUSE=1 PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) PROXYSQLED25519=$(PROXYSQLED25519) CC=${CC} CXX=${CXX} ${MAKE} .PHONY: build_lib_debug_default build_lib_debug_default: build_deps_debug_default - cd lib && OPTZ="${O0} -ggdb -DDEBUG" PROXYSQLCLICKHOUSE=1 PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE} + cd lib && OPTZ="${O0} -ggdb -DDEBUG" PROXYSQLCLICKHOUSE=1 PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) PROXYSQLED25519=$(PROXYSQLED25519) CC=${CC} CXX=${CXX} ${MAKE} .PHONY: build_src_default build_src_default: build_lib_default - cd src && OPTZ="${O2} -ggdb" PROXYSQLCLICKHOUSE=1 PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE} + cd src && OPTZ="${O2} -ggdb" PROXYSQLCLICKHOUSE=1 PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) PROXYSQLED25519=$(PROXYSQLED25519) CC=${CC} CXX=${CXX} ${MAKE} $(if $(filter 1,$(PROXYSQL40)),cd plugins/mysqlx && OPTZ="${O2} -ggdb" PROXYSQL40=$(PROXYSQL40) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE},@echo "[skip] mysqlx plugin (PROXYSQL40 not set)") $(if $(filter 1,$(PROXYSQL40)),cd plugins/genai && OPTZ="${O2} -ggdb" PROXYSQL40=$(PROXYSQL40) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE},@echo "[skip] genai plugin (PROXYSQL40 not set)") .PHONY: build_src_debug_default build_src_debug_default: build_lib_debug_default - cd src && OPTZ="${O0} -ggdb -DDEBUG" PROXYSQLCLICKHOUSE=1 PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE} + cd src && OPTZ="${O0} -ggdb -DDEBUG" PROXYSQLCLICKHOUSE=1 PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) PROXYSQLED25519=$(PROXYSQLED25519) CC=${CC} CXX=${CXX} ${MAKE} $(if $(filter 1,$(PROXYSQL40)),cd plugins/mysqlx && OPTZ="${O0} -ggdb -DDEBUG" PROXYSQL40=$(PROXYSQL40) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE},@echo "[skip] mysqlx plugin (PROXYSQL40 not set)") $(if $(filter 1,$(PROXYSQL40)),cd plugins/genai && OPTZ="${O0} -ggdb -DDEBUG" PROXYSQL40=$(PROXYSQL40) PROXYSQL31=$(PROXYSQL31) PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) CC=${CC} CXX=${CXX} ${MAKE},@echo "[skip] genai plugin (PROXYSQL40 not set)") diff --git a/deps/mariadb-client-library/plugin_auth_CMakeLists.txt.patch b/deps/mariadb-client-library/plugin_auth_CMakeLists.txt.patch index ed4aca87d1..013e3323ac 100644 --- a/deps/mariadb-client-library/plugin_auth_CMakeLists.txt.patch +++ b/deps/mariadb-client-library/plugin_auth_CMakeLists.txt.patch @@ -2,6 +2,15 @@ diff --git plugins/auth/CMakeLists.txt plugins/auth/CMakeLists.txt index 83e324b9..7c4ed019 100644 --- plugins/auth/CMakeLists.txt +++ plugins/auth/CMakeLists.txt +@@ -55,7 +55,7 @@ + REGISTER_PLUGIN(TARGET client_ed25519 + TYPE MARIADB_CLIENT_PLUGIN_AUTH + CONFIGURATIONS DYNAMIC STATIC OFF +- DEFAULT DYNAMIC ++ DEFAULT STATIC + SOURCES ${CC_SOURCE_DIR}/plugins/auth/ed25519.c + ${REF10_SOURCES} + ${CRYPT_SOURCE} @@ -77,7 +77,7 @@ IF(CRYPTO_PLUGIN) REGISTER_PLUGIN(TARGET caching_sha2_password TYPE MARIADB_CLIENT_PLUGIN_AUTH diff --git a/lib/Makefile b/lib/Makefile index f9b3154434..784084ba09 100644 --- a/lib/Makefile +++ b/lib/Makefile @@ -73,6 +73,11 @@ ifeq ($(PROXYSQLTSDB),1) PSQLTSDB := -DPROXYSQLTSDB endif +PSQLED25519 := +ifeq ($(PROXYSQLED25519),1) + PSQLED25519 := -DPROXYSQLED25519 +endif + # 'libhttpserver': Add 'ENABLE_EPOLL' by default for all platforms except # for 'Darwin'. This is required when compiling 'libhttpserver' for avoiding # internal use of 'SELECT' in favor of 'EPOLL'. See #3591. @@ -83,7 +88,7 @@ endif MYCFLAGS := $(IDIRS) $(OPTZ) $(DEBUG) -Wall -DGITVERSION=\"$(GIT_VERSION)\" $(NOJEM) $(WGCOV) $(WASAN) -MYCXXFLAGS := $(STDCPP) $(MYCFLAGS) $(PSQLCH) $(PSQL40) $(PSQL31) $(PSQLFFTO) $(PSQLTSDB) $(ENABLE_EPOLL) +MYCXXFLAGS := $(STDCPP) $(MYCFLAGS) $(PSQLCH) $(PSQL40) $(PSQL31) $(PSQLFFTO) $(PSQLTSDB) $(PSQLED25519) $(ENABLE_EPOLL) default: libproxysql.a .PHONY: default diff --git a/src/Makefile b/src/Makefile index 94d32143b1..5f03691f96 100644 --- a/src/Makefile +++ b/src/Makefile @@ -93,6 +93,11 @@ ifeq ($(PROXYSQLTSDB),1) PSQLTSDB := -DPROXYSQLTSDB endif +PSQLED25519 := +ifeq ($(PROXYSQLED25519),1) + PSQLED25519 := -DPROXYSQLED25519 +endif + MYCXXFLAGS := $(STDCPP) @@ -101,7 +106,7 @@ ifneq ($(UNAME_S),Darwin) MYCXXFLAGS += -fuse-ld=lld endif endif -MYCXXFLAGS += $(IDIRS) $(OPTZ) $(DEBUG) $(PSQLCH) $(PSQL40) $(PSQL31) $(PSQLFFTO) $(PSQLTSDB) -DGITVERSION=\"$(GIT_VERSION)\" $(NOJEM) $(WGCOV) $(WASAN) +MYCXXFLAGS += $(IDIRS) $(OPTZ) $(DEBUG) $(PSQLCH) $(PSQL40) $(PSQL31) $(PSQLFFTO) $(PSQLTSDB) $(PSQLED25519) -DGITVERSION=\"$(GIT_VERSION)\" $(NOJEM) $(WGCOV) $(WASAN) STATICMYLIBS := -Wl,-Bstatic \ From 9aced7ff028ca08b9ea624bff3f2e06070520d0f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 08:51:40 +0000 Subject: [PATCH 04/23] feat: add MariaDB-variant Ed25519 helpers with known-answer unit tests proxysql_ed25519_{derive_public_key,verify_signature,is_pubkey_format, decode_pubkey} wrap the ref10 symbols statically linked into libmariadbclient.a. Key derivation must use ref10 because MariaDB hashes an arbitrary-length password (SHA512) where standard Ed25519 hashes a fixed 32-byte seed; verification is standard Ed25519. The 'secret' derivation vector reproduces the documented MariaDB KB example, independently confirming scheme compatibility. --- include/MySQL_Ed25519.h | 45 +++++++++ lib/Makefile | 5 + lib/MySQL_Ed25519.cpp | 50 ++++++++++ test/tap/groups/groups.json | 1 + test/tap/tests/unit/Makefile | 14 ++- test/tap/tests/unit/ed25519_unit-t.cpp | 133 +++++++++++++++++++++++++ 6 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 include/MySQL_Ed25519.h create mode 100644 lib/MySQL_Ed25519.cpp create mode 100644 test/tap/tests/unit/ed25519_unit-t.cpp diff --git a/include/MySQL_Ed25519.h b/include/MySQL_Ed25519.h new file mode 100644 index 0000000000..230e80365f --- /dev/null +++ b/include/MySQL_Ed25519.h @@ -0,0 +1,45 @@ +#ifndef __CLASS_MYSQL_ED25519_H +#define __CLASS_MYSQL_ED25519_H +#ifdef PROXYSQLED25519 + +#include + +/** + * MariaDB-variant Ed25519 helpers for frontend client authentication + * (the client_ed25519 / auth_ed25519 scheme). + * + * MariaDB derives the keypair from SHA512(password) where the password has + * arbitrary length (standard Ed25519 hashes a fixed 32-byte seed), so the + * derivation MUST use the ref10 implementation vendored in + * deps/mariadb-client-library (statically linked into libmariadbclient.a via + * the client_ed25519 plugin). Signature verification is standard Ed25519. + * + * Stored-credential format in mysql_users.password: + * "$ED$" + 43-char unpadded base64 of the 32-byte public key + * (prefix case-insensitive, total length exactly 47). This mirrors MariaDB's + * mysql.user.authentication_string with an explicit marker so it cannot be + * confused with a cleartext password. + */ + +#define ED25519_NONCE_LEN 32 +#define ED25519_SIG_LEN 64 +#define ED25519_PUBKEY_LEN 32 +#define ED25519_PUBKEY_B64_LEN 43 +#define ED25519_STORED_PREFIX "$ED$" +#define ED25519_STORED_PREFIX_LEN 4 +#define ED25519_STORED_LEN (ED25519_STORED_PREFIX_LEN + ED25519_PUBKEY_B64_LEN) + +/** @brief Derive the 32-byte public key from a cleartext password (MariaDB variant). */ +void proxysql_ed25519_derive_public_key(const char* password, size_t password_len, unsigned char* out_pubkey); + +/** @brief Verify a 64-byte signature over a 32-byte nonce against a 32-byte public key. */ +bool proxysql_ed25519_verify_signature(const unsigned char* signature, const unsigned char* nonce, const unsigned char* pubkey); + +/** @brief True when 'password' is a stored ed25519 public key ("$ED$" + 43 base64 chars). NULL-safe. */ +bool proxysql_ed25519_is_pubkey_format(const char* password); + +/** @brief Decode a "$ED$..." stored credential into a 32-byte public key. False on malformed input. */ +bool proxysql_ed25519_decode_pubkey(const char* stored, unsigned char* out_pubkey); + +#endif // PROXYSQLED25519 +#endif // __CLASS_MYSQL_ED25519_H diff --git a/lib/Makefile b/lib/Makefile index 784084ba09..900459468b 100644 --- a/lib/Makefile +++ b/lib/Makefile @@ -140,6 +140,11 @@ ifeq ($(PROXYSQLFFTO),1) _OBJ_CXX += MySQLFFTO.oo PgSQLFFTO.oo endif +# ed25519 frontend authentication (MariaDB client_ed25519) +ifeq ($(PROXYSQLED25519),1) +_OBJ_CXX += MySQL_Ed25519.oo +endif + OBJ_CXX := $(patsubst %,$(ODIR)/%,$(_OBJ_CXX)) HEADERS := ../include/*.h ../include/*.hpp diff --git a/lib/MySQL_Ed25519.cpp b/lib/MySQL_Ed25519.cpp new file mode 100644 index 0000000000..9f73fa810d --- /dev/null +++ b/lib/MySQL_Ed25519.cpp @@ -0,0 +1,50 @@ +#ifdef PROXYSQLED25519 + +#include "MySQL_Ed25519.h" + +#include +#include + +#include + +// ref10 entry points compiled into libmariadbclient.a by the STATIC +// client_ed25519 plugin registration (deps/mariadb-client-library). +extern "C" { +int crypto_sign_keypair(unsigned char* pk, unsigned char* pw, unsigned long long pwlen); +int crypto_sign_open(unsigned char* sm, unsigned long long smlen, const unsigned char* pk); +} + +void proxysql_ed25519_derive_public_key(const char* password, size_t password_len, unsigned char* out_pubkey) { + // ref10 takes a non-const pw but never modifies it + crypto_sign_keypair(out_pubkey, reinterpret_cast(const_cast(password)), password_len); +} + +bool proxysql_ed25519_verify_signature(const unsigned char* signature, const unsigned char* nonce, const unsigned char* pubkey) { + // crypto_sign_open() expects a mutable "signed message" R||S||M and + // clobbers it during verification, so build a local copy. + unsigned char sm[ED25519_SIG_LEN + ED25519_NONCE_LEN]; + memcpy(sm, signature, ED25519_SIG_LEN); + memcpy(sm + ED25519_SIG_LEN, nonce, ED25519_NONCE_LEN); + return crypto_sign_open(sm, sizeof(sm), pubkey) == 0; +} + +bool proxysql_ed25519_is_pubkey_format(const char* password) { + if (password == NULL) return false; + if (strncasecmp(password, ED25519_STORED_PREFIX, ED25519_STORED_PREFIX_LEN) != 0) return false; + return strlen(password) == ED25519_STORED_LEN; +} + +bool proxysql_ed25519_decode_pubkey(const char* stored, unsigned char* out_pubkey) { + if (proxysql_ed25519_is_pubkey_format(stored) == false) return false; + // 43 base64 chars + '=' forms one complete 44-char group. EVP_DecodeBlock + // emits 33 bytes for it; the 33rd is padding garbage and is discarded. + unsigned char in[ED25519_PUBKEY_B64_LEN + 1]; + memcpy(in, stored + ED25519_STORED_PREFIX_LEN, ED25519_PUBKEY_B64_LEN); + in[ED25519_PUBKEY_B64_LEN] = '='; + unsigned char out[33]; + if (EVP_DecodeBlock(out, in, sizeof(in)) != 33) return false; + memcpy(out_pubkey, out, ED25519_PUBKEY_LEN); + return true; +} + +#endif // PROXYSQLED25519 diff --git a/test/tap/groups/groups.json b/test/tap/groups/groups.json index e39f2f1765..1c27e1b72f 100644 --- a/test/tap/groups/groups.json +++ b/test/tap/groups/groups.json @@ -25,6 +25,7 @@ "connection_pool_unit-t" : [ "unit-tests-g1" ], "connection_unhealthy_unit-t" : [ "unit-tests-g1" ], "deprecate_eof_cache-t" : [ "legacy-g4","mariadb10-galera-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4","mysql84-g4","mysql84-gr-g4","mysql90-g4","mysql95-g4" ], + "ed25519_unit-t" : [ "unit-tests-g1","@proxysql_min_version:3.1" ], "envvars-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" ], "eof_cache_mixed_flags-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4","mysql84-g4","mysql90-g4","mysql95-g4" ], "eof_conn_options_check-t" : [ "legacy-g4","mysql-auto_increment_delay_multiplex=0-g4","mysql-multiplexing=false-g4","mysql-query_digests=0-g4","mysql-query_digests_keep_comment=1-g4","mysql84-g4","mysql90-g4","mysql95-g4" ], diff --git a/test/tap/tests/unit/Makefile b/test/tap/tests/unit/Makefile index f89a7faa14..21879b542c 100644 --- a/test/tap/tests/unit/Makefile +++ b/test/tap/tests/unit/Makefile @@ -280,6 +280,12 @@ ifneq ($(shell nm $(LIBPROXYSQLAR) 2>/dev/null | grep -c init_tsdb_variables),0) PSQLTSDB := -DPROXYSQLTSDB endif +PSQLED25519 := +ifneq ($(shell nm $(LIBPROXYSQLAR) 2>/dev/null | grep -c proxysql_ed25519_verify_signature),0) + PROXYSQLED25519 := 1 + PSQLED25519 := -DPROXYSQLED25519 +endif + # PROXYSQL31 is the parent tier flag for FFTO + TSDB; the codebase has no # #ifdef PROXYSQL31 blocks of its own, so autodetect it as "at least one # of FFTO or TSDB is enabled". This matches the top-level Makefile's @@ -307,12 +313,12 @@ endif # misused (most importantly identity-forgery setters), while tests that include # the plugin headers and link against the plugin sources still see the helpers # they need. -OPT := $(STDCPP) -O0 -ggdb $(PSQLCH) $(PSQLGA) $(PSQL40) $(PSQL31) $(PSQLFFTO) $(PSQLTSDB) $(PSQLDEBUG) \ +OPT := $(STDCPP) -O0 -ggdb $(PSQLCH) $(PSQLGA) $(PSQL40) $(PSQL31) $(PSQLFFTO) $(PSQLTSDB) $(PSQLED25519) $(PSQLDEBUG) \ -DGITVERSION=\"$(GIT_VERSION)\" -DMYSQLX_TEST_BUILD $(NOJEM) $(WGCOV) $(WASAN) \ -Wl,--no-as-needed -Wl,-rpath,$(TAP_LDIR) ifeq ($(UNAME_S),Darwin) - OPT := $(STDCPP) -O0 -ggdb $(PSQLCH) $(PSQLGA) $(PSQL40) $(PSQL31) $(PSQLFFTO) $(PSQLTSDB) $(PSQLDEBUG) \ + OPT := $(STDCPP) -O0 -ggdb $(PSQLCH) $(PSQLGA) $(PSQL40) $(PSQL31) $(PSQLFFTO) $(PSQLTSDB) $(PSQLED25519) $(PSQLDEBUG) \ -DGITVERSION=\"$(GIT_VERSION)\" -DMYSQLX_TEST_BUILD $(NOJEM) $(WGCOV) $(WASAN) endif @@ -435,6 +441,10 @@ ifeq ($(PROXYSQL31),1) UNIT_TESTS += caching_sha2_rsa_unit-t endif +ifeq ($(PROXYSQLED25519),1) +UNIT_TESTS += ed25519_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/ed25519_unit-t.cpp b/test/tap/tests/unit/ed25519_unit-t.cpp new file mode 100644 index 0000000000..2bf5fa0df4 --- /dev/null +++ b/test/tap/tests/unit/ed25519_unit-t.cpp @@ -0,0 +1,133 @@ +/** + * @file ed25519_unit-t.cpp + * @brief Known-answer and edge-case tests for the MariaDB-variant Ed25519 + * helpers in lib/MySQL_Ed25519.cpp. + * + * The "secret" vector matches the documented example in the MariaDB KB + * (CREATE USER ... IDENTIFIED VIA ed25519 USING 'ZIgUREUg5...'), which + * independently validates that the ref10 sources vendored in deps/ implement + * the same scheme as the auth_ed25519 server plugin. + */ +#include "tap.h" + +#include "MySQL_Ed25519.h" + +#include +#include +#include + +#include + +struct derivation_kat { const char* password; const char* pubkey_b64; }; + +static const derivation_kat KATS[] = { + { "secret", "ZIgUREUg5PVgQ6LskhXmO+eZLS0nC8be6HPjYWR4YJY" }, + { "ed25519_pass_1", "5TBW79xTAMbhi8QKQtLLVS0V0b2w9mlKnRG6c+2NxTQ" }, + { "", "4LH+dBF+G5W2CKTyId8xR3SyDqZoQjUNUVNxx8aWbG4" }, +}; + +// 64-byte signature of nonce 0x00..0x1f under password "ed25519_pass_1", +// generated with the connector's own ma_crypto_sign() (the scheme is +// deterministic, so this vector is stable). +static const char SIG_HEX[] = + "004a2ab8c18a320bdde27a5fff54ae43f66b4c21373ba3c1852ce0eb9255d073" + "f7b6125fb6ee1a236633da90d0e38b3b58c3295b4ab9eb418402cbfa6f879701"; + +static void unhex(const char* hex, unsigned char* out, size_t outlen) { + for (size_t i = 0; i < outlen; i++) { + unsigned int b = 0; + sscanf(hex + 2 * i, "%2x", &b); + out[i] = static_cast(b); + } +} + +static std::string b64_no_pad(const unsigned char* in, size_t len) { + unsigned char out[64] = { 0 }; + EVP_EncodeBlock(out, in, len); + std::string s(reinterpret_cast(out)); + while (!s.empty() && s.back() == '=') s.pop_back(); + return s; +} + +int main() { + plan( + 3 /* derivation KATs */ + + 3 /* decode round-trips */ + + 7 /* is_pubkey_format edge cases */ + + 2 /* decode_pubkey malformed */ + + 1 /* signature KAT */ + + 3 /* tampered signature / nonce / key */ + ); + + // 1. derivation known-answer tests + for (const derivation_kat& kat : KATS) { + unsigned char pk[ED25519_PUBKEY_LEN]; + proxysql_ed25519_derive_public_key(kat.password, strlen(kat.password), pk); + std::string encoded = b64_no_pad(pk, sizeof(pk)); + ok(encoded == kat.pubkey_b64, + "derive_public_key('%s') = '%s' (expected '%s')", + kat.password, encoded.c_str(), kat.pubkey_b64); + } + + // 2. decode_pubkey round-trips against derivation + for (const derivation_kat& kat : KATS) { + unsigned char derived[ED25519_PUBKEY_LEN]; + unsigned char decoded[ED25519_PUBKEY_LEN]; + proxysql_ed25519_derive_public_key(kat.password, strlen(kat.password), derived); + std::string stored = std::string(ED25519_STORED_PREFIX) + kat.pubkey_b64; + bool rc = proxysql_ed25519_decode_pubkey(stored.c_str(), decoded); + ok(rc && memcmp(derived, decoded, ED25519_PUBKEY_LEN) == 0, + "decode_pubkey('%s') matches derived key", stored.c_str()); + } + + // 3. is_pubkey_format edge cases + { + std::string valid = std::string(ED25519_STORED_PREFIX) + KATS[1].pubkey_b64; + ok(proxysql_ed25519_is_pubkey_format(valid.c_str()) == true, "valid $ED$ string accepted"); + std::string lower = std::string("$ed$") + KATS[1].pubkey_b64; + ok(proxysql_ed25519_is_pubkey_format(lower.c_str()) == true, "prefix match is case-insensitive"); + ok(proxysql_ed25519_is_pubkey_format(KATS[1].pubkey_b64) == false, "bare 43-char base64 rejected (prefix mandatory)"); + ok(proxysql_ed25519_is_pubkey_format("$ED$tooshort") == false, "wrong length rejected"); + std::string toolong = valid + "X"; + ok(proxysql_ed25519_is_pubkey_format(toolong.c_str()) == false, "48-char string rejected"); + ok(proxysql_ed25519_is_pubkey_format(NULL) == false, "NULL rejected"); + ok(proxysql_ed25519_is_pubkey_format("*THISLOOKSLIKEASHA1HASHXXXXXXXXXXXXXXXXX") == false, "SHA1-format password rejected"); + } + + // 4. decode_pubkey malformed input + { + unsigned char pk[ED25519_PUBKEY_LEN]; + // 43 chars but contains characters outside the base64 alphabet + std::string bad = std::string(ED25519_STORED_PREFIX) + "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"; + ok(proxysql_ed25519_decode_pubkey(bad.c_str(), pk) == false, "invalid base64 chars rejected"); + ok(proxysql_ed25519_decode_pubkey("not-ed25519-at-all", pk) == false, "non-$ED$ string rejected"); + } + + // 5. signature known-answer test + unsigned char sig[ED25519_SIG_LEN]; + unsigned char nonce[ED25519_NONCE_LEN]; + unsigned char pk[ED25519_PUBKEY_LEN]; + unhex(SIG_HEX, sig, sizeof(sig)); + for (int i = 0; i < ED25519_NONCE_LEN; i++) nonce[i] = static_cast(i); + proxysql_ed25519_derive_public_key("ed25519_pass_1", strlen("ed25519_pass_1"), pk); + ok(proxysql_ed25519_verify_signature(sig, nonce, pk) == true, "known-answer signature verifies"); + + // 6. negative cases + { + unsigned char tampered_sig[ED25519_SIG_LEN]; + memcpy(tampered_sig, sig, sizeof(sig)); + tampered_sig[10] ^= 0xff; + ok(proxysql_ed25519_verify_signature(tampered_sig, nonce, pk) == false, "tampered signature rejected"); + + unsigned char wrong_nonce[ED25519_NONCE_LEN]; + memcpy(wrong_nonce, nonce, sizeof(nonce)); + wrong_nonce[0] ^= 0x01; + ok(proxysql_ed25519_verify_signature(sig, wrong_nonce, pk) == false, "wrong nonce rejected"); + + unsigned char wrong_pk[ED25519_PUBKEY_LEN]; + proxysql_ed25519_derive_public_key("some_other_password", strlen("some_other_password"), wrong_pk); + ok(proxysql_ed25519_verify_signature(sig, nonce, wrong_pk) == false, "wrong public key rejected"); + } + + return exit_status(); +} From 3d237de0666db5c373b114b54fdc3cc5de2e9e4d Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 09:11:05 +0000 Subject: [PATCH 05/23] feat: frontend client_ed25519 authentication via Auth Switch Adds AUTH_MYSQL_ED25519 to the frontend plugin registry and implements the MariaDB flow: ed25519 is never advertised in the greeting (its challenge is 32 bytes, the greeting scramble is 20); instead PPHR_verify_password decides at stage 0 -- when the stored credential is \$ED\$ or the client requested client_ed25519 -- and sends an AuthSwitchRequest with a fresh RAND_bytes nonce (PPHR_ed25519_switch). The 64-byte signature returns through PPHR_1 (native-style raw payload, no NUL terminator) and PPHR_ed25519_verify checks it against the stored public key or one derived from the stored cleartext password. All failures collapse into the generic access-denied path. Known v1 limitation: a client that triggers the early native switch (PPHR_4auth0, e.g. caching_sha2 offer against a native greeting) cannot be re-switched to ed25519 for \$ED\$ users -- the protocol allows a single switch. Standard libmariadb clients are unaffected. --- include/MySQL_Protocol.h | 9 ++- lib/MySQL_Protocol.cpp | 145 +++++++++++++++++++++++++++++++++++++- lib/mysql_data_stream.cpp | 5 ++ 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/include/MySQL_Protocol.h b/include/MySQL_Protocol.h index eef70bc5ea..156066d3f4 100644 --- a/include/MySQL_Protocol.h +++ b/include/MySQL_Protocol.h @@ -37,7 +37,10 @@ enum proxysql_auth_plugins { AUTH_UNKNOWN_PLUGIN = -1, AUTH_MYSQL_NATIVE_PASSWORD = 0, AUTH_MYSQL_CLEAR_PASSWORD, - AUTH_MYSQL_CACHING_SHA2_PASSWORD + AUTH_MYSQL_CACHING_SHA2_PASSWORD, +#ifdef PROXYSQLED25519 + AUTH_MYSQL_ED25519, // MariaDB client_ed25519 (value 3) +#endif }; class MySQL_ResultSet { @@ -215,6 +218,10 @@ 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); +#ifdef PROXYSQLED25519 + void PPHR_ed25519_switch(bool& ret, MyProt_tmp_auth_vars& vars1); + void PPHR_ed25519_verify(bool& ret, MyProt_tmp_auth_vars& vars1); +#endif /** * @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 diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index c1b3075a34..2a6ea1be8c 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -18,6 +18,10 @@ using json = nlohmann::json; #include "MySQL_Caching_Sha2_RSA.h" #include #endif +#ifdef PROXYSQLED25519 +#include "MySQL_Ed25519.h" +#include +#endif #include #include @@ -88,10 +92,13 @@ class ScopedStringCleanser { extern "C" char * sha256_crypt_r (const char *key, const char *salt, char *buffer, int buflen); -static const char *plugins[3] = { +static const char *plugins[] = { "mysql_native_password", "mysql_clear_password", "caching_sha2_password", +#ifdef PROXYSQLED25519 + "client_ed25519", +#endif }; #ifdef PROXYSQL31 @@ -1163,6 +1170,13 @@ bool MySQL_Protocol::generate_pkt_auth_switch_request(bool send, void **ptr, uns + 20 // scramble + 1; // 00 break; +#ifdef PROXYSQLED25519 + case AUTH_MYSQL_ED25519: + myhdr.pkt_length=1 // fe + + (strlen(plugins[AUTH_MYSQL_ED25519])+1) + + ED25519_NONCE_LEN; // 32-byte nonce; NO trailing 0x00 (client requires exactly 32 bytes of plugin data) + break; +#endif default: // LCOV_EXCL_START assert(0); @@ -1196,13 +1210,24 @@ bool MySQL_Protocol::generate_pkt_auth_switch_request(bool send, void **ptr, uns _ptr[l]=0x00; l++; memcpy(_ptr+l, (*myds)->myconn->scramble_buff+0, 20); l+=20; break; +#ifdef PROXYSQLED25519 + case AUTH_MYSQL_ED25519: + memcpy(_ptr+l,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519])); + l+=strlen(plugins[AUTH_MYSQL_ED25519]); + _ptr[l]=0x00; l++; + memcpy(_ptr+l, (*myds)->myconn->scramble_buff, ED25519_NONCE_LEN); l+=ED25519_NONCE_LEN; + break; +#endif default: // LCOV_EXCL_START assert(0); // LCOV_EXCL_STOP break; } - _ptr[l]=0x00; //l+=1; //0x00 +#ifdef PROXYSQLED25519 + if ((*myds)->switching_auth_type != AUTH_MYSQL_ED25519) // ed25519 packet ends exactly after the nonce +#endif + _ptr[l]=0x00; //l+=1; //0x00 if (send==true) { (*myds)->PSarrayOUT->add((void *)_ptr,size); (*myds)->DSS=STATE_SERVER_HANDSHAKE; @@ -2087,7 +2112,11 @@ int MySQL_Protocol::PPHR_1(unsigned char *pkt, unsigned int len, bool& ret, MyPr (*myds)->sess, (*myds), vars1.user); return 1; } - if (auth_plugin_id == AUTH_MYSQL_NATIVE_PASSWORD) { + if (auth_plugin_id == AUTH_MYSQL_NATIVE_PASSWORD +#ifdef PROXYSQLED25519 + || auth_plugin_id == AUTH_MYSQL_ED25519 // raw 64-byte signature, not NUL-terminated +#endif + ) { vars1.pass_len = payload_length; } else { const unsigned char* terminator = static_cast( @@ -2336,6 +2365,13 @@ void MySQL_Protocol::PPHR_3(MyProt_tmp_auth_vars& vars1) { // detect plugin id assert(0); } } +#ifdef PROXYSQLED25519 + else if (strncmp((char *)vars1.auth_plugin,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519]))==0) { + // client explicitly requested client_ed25519; the Auth Switch with a + // 32-byte nonce is driven later by PPHR_verify_password at stage 0 + auth_plugin_id = AUTH_MYSQL_ED25519; + } +#endif } proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, "Session=%p , DS=%p , user='%s' , auth_plugin_id=%d\n", (*myds), (*myds)->sess, vars1.user, auth_plugin_id); } @@ -2976,6 +3012,62 @@ bool MySQL_Protocol::PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1) { return false; } +#ifdef PROXYSQLED25519 +/** + * @brief Initiate the client_ed25519 Auth Switch (stage 0 -> 1). + * @details Generates a fresh 32-byte nonce into 'scramble_buff' (40 bytes, so + * it fits) and sends an AuthSwitchRequest naming client_ed25519. The client + * answers with a 64-byte signature that PPHR_1 collects (stage 1 -> 2) and + * PPHR_ed25519_verify() checks. Mirrors the state handling of PPHR_4auth0. + */ +void MySQL_Protocol::PPHR_ed25519_switch(bool& ret, MyProt_tmp_auth_vars& vars1) { + ret = false; + if (RAND_bytes(reinterpret_cast((*myds)->myconn->scramble_buff), ED25519_NONCE_LEN) != 1) { + proxy_error("RAND_bytes() failed generating the ed25519 nonce for user '%s'\n", vars1.user); + return; + } + (*myds)->switching_auth_type = AUTH_MYSQL_ED25519; + (*myds)->switching_auth_stage = 1; + (*myds)->auth_in_progress = 1; + generate_pkt_auth_switch_request(true, NULL, NULL); + (*myds)->myconn->userinfo->set((char *)vars1.user, NULL, vars1.db, NULL); + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, "Session=%p , DS=%p , user='%s' . Sent client_ed25519 Auth Switch\n", + (*myds)->sess, (*myds), vars1.user); +} + +/** + * @brief Verify the 64-byte client_ed25519 signature over the nonce sent by + * PPHR_ed25519_switch() (or by the COM_CHANGE_USER switch path). + * @details The public key comes from a stored "$ED$" credential, or is derived + * from a stored cleartext password (MariaDB variant, ref10). Every failure + * mode -- wrong length, malformed stored key, bad signature -- yields the + * same generic auth failure; nothing distinguishable leaks to the client. + */ +void MySQL_Protocol::PPHR_ed25519_verify(bool& ret, MyProt_tmp_auth_vars& vars1) { + ret = false; + if (vars1.pass_len != ED25519_SIG_LEN) { + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, "Session=%p , DS=%p , user='%s' . Malformed ed25519 signature length %u\n", + (*myds)->sess, (*myds), vars1.user, vars1.pass_len); + return; + } + unsigned char pubkey[ED25519_PUBKEY_LEN]; + if (proxysql_ed25519_is_pubkey_format(vars1.password)) { + if (proxysql_ed25519_decode_pubkey(vars1.password, pubkey) == false) { + proxy_error("mysql_users entry for '%s' has a malformed $ED$ ed25519 credential; denying access\n", vars1.user); + return; + } + } else { + proxysql_ed25519_derive_public_key(vars1.password, strlen(vars1.password), pubkey); + } + if (proxysql_ed25519_verify_signature(vars1.pass, reinterpret_cast((*myds)->myconn->scramble_buff), pubkey)) { + ret = true; + } + OPENSSL_cleanse(pubkey, sizeof(pubkey)); + proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, "Session=%p , DS=%p , user='%s' . ed25519 signature verification %s\n", + (*myds)->sess, (*myds), vars1.user, ret ? "succeeded" : "failed"); +} +#endif // PROXYSQLED25519 + void MySQL_Protocol::PPHR_SetConnAttrs(MyProt_tmp_auth_vars& vars1, account_details_t& attr1) { MySQL_Connection *myconn = NULL; myconn=sess->client_myds->myconn; @@ -3426,6 +3518,33 @@ bool MySQL_Protocol::PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_d // - 'ad::default_schema', 'ad::attributes' PPHR_5passwordTrue(ret, vars1, reply, account_details); +#ifdef PROXYSQLED25519 + // ed25519 gate (stage 0): a client that requested client_ed25519 sends an + // empty auth response in the HandshakeResponse -- it cannot sign before + // receiving the 32-byte nonce -- so this decision MUST precede the + // empty-response checks below. A stored "$ED$" credential forces the + // ed25519 exchange regardless of the plugin the client offered. + // 'switching_auth_sent' guards re-entry: after the switch, the signature + // arrives with stage 0 on the COM_CHANGE_USER path and stage 2 here. + if ((*myds)->switching_auth_stage == 0 && + (*myds)->switching_auth_sent != AUTH_MYSQL_ED25519 && + (*myds)->sess->session_type != PROXYSQL_SESSION_CLICKHOUSE) { + const bool stored_is_ed = proxysql_ed25519_is_pubkey_format(vars1.password); + // a '*SHA1' or '$A$' hash cannot derive an ed25519 key + const bool cred_usable = stored_is_ed || + (vars1.password[0] != '*' && + !(strlen(vars1.password) == 70 && strncasecmp(vars1.password,"$A$0",4)==0)); + if (stored_is_ed || (auth_plugin_id == AUTH_MYSQL_ED25519 && cred_usable)) { + PPHR_ed25519_switch(ret, vars1); + return ret; + } + if (auth_plugin_id == AUTH_MYSQL_ED25519) { + // client insists on ed25519 but the stored hash cannot derive a key + return ret; // ret == false + } + } +#endif + if (vars1.pass_len==0 && strlen(vars1.password)==0) { ret=true; proxy_debug(PROXY_DEBUG_MYSQL_AUTH, 5, "Session=%p , DS=%p , username='%s' , password=''\n", (*myds), (*myds)->sess, vars1.user); @@ -3442,6 +3561,14 @@ bool MySQL_Protocol::PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_d (*myds), (*myds)->sess, vars1.user, get_masked_pass(vars1.password).get(), auth_plugin_id ); #endif // debug +#ifdef PROXYSQLED25519 + if (auth_plugin_id == AUTH_MYSQL_ED25519 || proxysql_ed25519_is_pubkey_format(vars1.password)) { + // signature collected by PPHR_1 after the Auth Switch; a stored + // "$ED$" key with a non-ed25519 response fails the length check + // inside PPHR_ed25519_verify (generic denial) + PPHR_ed25519_verify(ret, vars1); + } else +#endif if ( auth_plugin_id == AUTH_MYSQL_CACHING_SHA2_PASSWORD && @@ -3584,6 +3711,12 @@ bool MySQL_Protocol::process_pkt_handshake_response(unsigned char *pkt, unsigned // if sent_auth_plugin_id == AUTH_MYSQL_NATIVE_PASSWORD assert(0); break; +#ifdef PROXYSQLED25519 + case AUTH_MYSQL_ED25519: + // nothing to do here; PPHR_verify_password() decides the ed25519 + // Auth Switch at stage 0 (after the account lookup) + break; +#endif default: assert(0); break; @@ -3611,6 +3744,12 @@ bool MySQL_Protocol::process_pkt_handshake_response(unsigned char *pkt, unsigned assert(0); } break; +#ifdef PROXYSQLED25519 + case AUTH_MYSQL_ED25519: + // nothing to do here; PPHR_verify_password() decides the ed25519 + // Auth Switch at stage 0 (after the account lookup) + break; +#endif default: break; } diff --git a/lib/mysql_data_stream.cpp b/lib/mysql_data_stream.cpp index 54c1a4b20a..9e1aefc2f5 100644 --- a/lib/mysql_data_stream.cpp +++ b/lib/mysql_data_stream.cpp @@ -1943,6 +1943,11 @@ void MySQL_Data_Stream::get_client_myds_info_json(json& j) { case AUTH_MYSQL_CACHING_SHA2_PASSWORD: jc1["prot"]["auth_plugin"] = "caching_sha2_password"; break; +#ifdef PROXYSQLED25519 + case AUTH_MYSQL_ED25519: + jc1["prot"]["auth_plugin"] = "client_ed25519"; + break; +#endif default: break; } From 3ea8179cb55e7b58f8af879af93498667944ef22 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 09:21:36 +0000 Subject: [PATCH 06/23] fix: NUL-terminate scramble_buff after the ed25519 nonce write PPHR_ed25519_switch() overwrites scramble_buff[0..31] with 32 raw random bytes from RAND_bytes(). That destroys the NUL terminator proxy_create_random_string() had previously written at index 20 for the native 20-byte scramble, and scramble_buff[40] is never zero-initialized by the MySQL_Connection constructor -- so bytes 32-39 are uninitialized stack/heap garbage at this point. The DEBUG-only handshake dump at __exit_process_pkt_handshake_response calls hex(scramble_buff); hex() takes a std::string_view, so the char* argument is implicitly strlen()'d. With ~88% probability RAND_bytes() produces 32 non-zero bytes, so strlen() runs past index 31 into the uninitialized tail (and potentially past the array entirely), printing garbage and risking an ASAN heap-buffer-overflow report under debug builds -- which is what the TAP harness requires (test/infra mandates a DEBUG binary). Fix: write an explicit NUL at index ED25519_NONCE_LEN (32) right after the nonce, bounding the strlen() to the intended 32 bytes. Index 32 is in-bounds for scramble_buff[40], and nothing downstream reads the native-style scramble once a connection has switched to ed25519. Found in code review of commit 3d237de06 (frontend client_ed25519 authentication via Auth Switch). --- lib/MySQL_Protocol.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 2a6ea1be8c..2aecf1a9e3 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -3026,6 +3026,16 @@ void MySQL_Protocol::PPHR_ed25519_switch(bool& ret, MyProt_tmp_auth_vars& vars1) proxy_error("RAND_bytes() failed generating the ed25519 nonce for user '%s'\n", vars1.user); return; } + // The nonce is 32 raw random bytes, not a NUL-terminated string, and + // overwrites the terminator that proxy_create_random_string() left at + // index 20 for the native scramble. scramble_buff[40] is never + // zero-initialized by the MySQL_Connection constructor, so without this + // terminator the DEBUG-only handshake dump (hex(scramble_buff), which + // converts through std::string_view and therefore strlen()'s the + // buffer) would walk into uninitialized bytes 32-39 and potentially + // past the array. Index 32 is safely in bounds of char[40]; nothing + // downstream consumes the native scramble after an ed25519 switch. + (*myds)->myconn->scramble_buff[ED25519_NONCE_LEN] = '\0'; (*myds)->switching_auth_type = AUTH_MYSQL_ED25519; (*myds)->switching_auth_stage = 1; (*myds)->auth_in_progress = 1; From e45d0605d3580fdb54844878a5afbb22633fc8f0 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 09:25:38 +0000 Subject: [PATCH 07/23] feat: COM_CHANGE_USER support for ed25519 users, $ED$ load-time validation COM_CHANGE_USER targeting a stored-$ED$ user (or naming client_ed25519) now performs the fresh-nonce Auth Switch instead of failing on unverifiable inline auth data; the signature response rides the existing change_user_auth_switch rails (#3504) back through process_pkt_handshake_response. Unlike caching_sha2 (#4618), no sub-protocol is needed, so change-user works fully. MySQL_Authentication::add() warns once at load time when a $ED$ credential is malformed, and connect_start() warns when a backend connection is attempted with a public-key-only $ED$ credential, instead of leaving admins to puzzle over generic access-denied errors. --- lib/MySQL_Authentication.cpp | 15 ++++++++++++ lib/MySQL_Protocol.cpp | 47 ++++++++++++++++++++++++++++++++++++ lib/mysql_connection.cpp | 12 +++++++++ 3 files changed, 74 insertions(+) diff --git a/lib/MySQL_Authentication.cpp b/lib/MySQL_Authentication.cpp index 195720eda9..220487259a 100644 --- a/lib/MySQL_Authentication.cpp +++ b/lib/MySQL_Authentication.cpp @@ -16,6 +16,10 @@ #define SPOOKYV2 #endif +#ifdef PROXYSQLED25519 +#include "MySQL_Ed25519.h" +#endif + namespace { #ifdef PROXYSQL31 @@ -162,6 +166,17 @@ creds_group_t& MySQL_Authentication::creds_for(enum cred_username_type usertype) } bool MySQL_Authentication::add(char * username, char * password, enum cred_username_type usertype, bool use_ssl, int default_hostgroup, char *default_schema, bool schema_locked, bool transaction_persistent, bool fast_forward, int max_connections, char* attributes, char *comment) { +#ifdef PROXYSQLED25519 + if (password && strncasecmp(password, ED25519_STORED_PREFIX, ED25519_STORED_PREFIX_LEN) == 0) { + unsigned char tmp_pk[ED25519_PUBKEY_LEN]; + if (proxysql_ed25519_decode_pubkey(password, tmp_pk) == false) { + proxy_warning( + "mysql_users entry for '%s' has a malformed $ED$ ed25519 credential" + " (expected \"$ED$\" followed by exactly 43 base64 characters);" + " every authentication attempt for this user will fail\n", username); + } + } +#endif uint64_t hash1, hash2; SpookyHash myhash; myhash.Init(1,2); diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 2aecf1a9e3..0e8178c3ca 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -1553,6 +1553,10 @@ bool MySQL_Protocol::verify_user_pass( } else if (strncmp((char *)auth_plugin,plugins[2],strlen(plugins[2]))==0) { // caching_sha2_password //auth_plugin_id = 2; // FIXME: this is temporary, because yet not supported auth_plugin_id = AUTH_MYSQL_CACHING_SHA2_PASSWORD; // FIXME: this is temporary, because yet not supported . It must become 3 +#ifdef PROXYSQLED25519 + } else if (strncmp((char *)auth_plugin,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519]))==0) { + auth_plugin_id = AUTH_MYSQL_ED25519; +#endif } if (password[0]!='*') { // clear text password @@ -1580,6 +1584,14 @@ bool MySQL_Protocol::verify_user_pass( // hash, or if we should prepare the state machine for a 'Auth Switch Request'. Progress for this // is tracked in https://github.com/sysown/proxysql/issues/4618. ret = false; +#ifdef PROXYSQLED25519 + } else if (auth_plugin_id == AUTH_MYSQL_ED25519) { + // Inline ed25519 auth data in COM_CHANGE_USER is not part of the + // MariaDB flow: the client cannot sign before receiving a fresh + // nonce. The nonce-based exchange is driven by + // process_pkt_COM_CHANGE_USER via Auth Switch; reject inline data. + ret = false; +#endif } else { ret = false; } @@ -1848,6 +1860,41 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in if (password==NULL) { ret=false; } else { +#ifdef PROXYSQLED25519 + // A stored "$ED$" credential (or an explicit client_ed25519 request) + // can only be verified through a fresh-nonce Auth Switch: any inline + // auth data was computed against the original scramble and is + // meaningless for ed25519. Mirrors the native pass_len==0 switch below + // (issue #3504); the response re-enters process_pkt_handshake_response + // where PPHR_1 picks up switching_auth_type and PPHR_verify_password + // verifies the signature (switching_auth_sent guards its stage-0 gate). + const bool ed25519_switch_needed = + session_type != PROXYSQL_SESSION_CLICKHOUSE && + (proxysql_ed25519_is_pubkey_format(password) || + (client_auth_plugin && strcmp(client_auth_plugin, plugins[AUTH_MYSQL_ED25519]) == 0)); + if (ed25519_switch_needed) { + if (RAND_bytes(reinterpret_cast((*myds)->myconn->scramble_buff), ED25519_NONCE_LEN) != 1) { + proxy_error("RAND_bytes() failed generating the ed25519 nonce for user '%s'\n", user); + ret = false; + } else { + // The nonce is 32 raw random bytes, not a NUL-terminated string, and + // overwrites the terminator that would otherwise sit at index 20 for + // the native scramble. scramble_buff[40] is never zero-initialized by + // the MySQL_Connection constructor, so without this terminator the + // DEBUG-only handshake dump (hex(scramble_buff), which strlen()'s the + // buffer via std::string_view) would walk into uninitialized bytes + // 32-39 and potentially past the array. Index 32 is safely in bounds + // of char[40]; nothing downstream consumes the native scramble after + // an ed25519 switch. + (*myds)->myconn->scramble_buff[ED25519_NONCE_LEN] = '\0'; + (*myds)->switching_auth_type = AUTH_MYSQL_ED25519; + (*myds)->sess->change_user_auth_switch = true; + generate_pkt_auth_switch_request(true, NULL, NULL); + (*myds)->myconn->userinfo->set((char *)user, NULL, db, NULL); + ret = false; + } + } else +#endif if (pass_len==0 && strlen(password)==0) { ret=true; } else { diff --git a/lib/mysql_connection.cpp b/lib/mysql_connection.cpp index 3fe154f8a0..9744711639 100644 --- a/lib/mysql_connection.cpp +++ b/lib/mysql_connection.cpp @@ -16,6 +16,10 @@ using json = nlohmann::json; #include "MySQL_Variables.h" #include +#ifdef PROXYSQLED25519 +#include "MySQL_Ed25519.h" +#endif + // some of the code that follows is from mariadb client library memory allocator typedef int myf; // Type of MyFlags in my_funcs #define MYF(v) (myf) (v) @@ -1021,6 +1025,14 @@ void MySQL_Connection::connect_start() { auth_password=userinfo->password; } } +#ifdef PROXYSQLED25519 + if (userinfo->password && proxysql_ed25519_is_pubkey_format(userinfo->password)) { + proxy_warning( + "User '%s' has an ed25519 public-key-only ($ED$) credential;" + " backend authentication requires the cleartext password and will fail\n", + userinfo->username); + } +#endif if (parent->port) { char* host_ip = connect_start_DNS_lookup(); async_exit_status=mysql_real_connect_start(&ret_mysql, mysql, host_ip, userinfo->username, auth_password, userinfo->schemaname, parent->port, NULL, client_flags); From 5295e30fffc7cdd14c4709894854560946946b80 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 09:36:23 +0000 Subject: [PATCH 08/23] fix: stop the ed25519 nonce from clobbering the connection's native scramble PPHR_ed25519_switch and the COM_CHANGE_USER ed25519 branch both RAND_bytes()'d the 32-byte Auth Switch nonce directly into MySQL_Connection::scramble_buff, aliasing the same 40-byte buffer that holds the native (mysql_native_password) scramble generated once at connection setup. That native scramble is not a one-shot value: it is read again by every later inline-credential COM_CHANGE_USER (verify_user_pass's proxy_scramble/proxy_scramble_sha1 calls) and by the caching_sha2 paths in PPHR_verify_password. Both prior comments claimed "nothing downstream consumes the native scramble after an ed25519 switch" -- that was false. Concretely: client authenticates as native user A (scramble S generated); COM_CHANGE_USER to $ED$ user B overwrites scramble_buff with the ed25519 nonce; COM_CHANGE_USER back to user A with inline auth data computed against S now fails verification against the mutated buffer -- a spurious access-denied on a legitimate change-user back to the original user. Fail-closed, but it breaks the exact change-user workload this feature exists to support. Fix: give the ed25519 nonce its own field, MySQL_Connection:: ed25519_nonce[ED25519_NONCE_LEN], declared next to scramble_buff in include/mysql_connection.h. Updated all four sites that touched the nonce via scramble_buff to use the dedicated field instead: - PPHR_ed25519_switch (RAND_bytes target) - the COM_CHANGE_USER ed25519 branch in process_pkt_COM_CHANGE_USER (RAND_bytes target) - generate_pkt_auth_switch_request's AUTH_MYSQL_ED25519 case (memcpy source when building the AuthSwitchRequest packet) - PPHR_ed25519_verify (nonce argument to proxysql_ed25519_verify_signature) Because the nonce no longer aliases scramble_buff, the NUL-terminator write at scramble_buff[ED25519_NONCE_LEN] that a prior review added to both RAND_bytes sites is now obsolete and has been removed along with its comment: scramble_buff is never written by the ed25519 path anymore, so its terminator (written once by proxy_create_random_string() during the initial handshake) is never disturbed. That terminator-preservation was itself a correctness fix for the DEBUG-only hex(scramble_buff) handshake dump; removing the write it was protecting is safe precisely because the write is gone. --- include/mysql_connection.h | 11 +++++++++++ lib/MySQL_Protocol.cpp | 38 +++++++++++--------------------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/include/mysql_connection.h b/include/mysql_connection.h index 0094ec700f..d65244266c 100644 --- a/include/mysql_connection.h +++ b/include/mysql_connection.h @@ -28,6 +28,10 @@ #include "Servers_SslParams.h" +#ifdef PROXYSQLED25519 +#include "MySQL_Ed25519.h" +#endif + class Variable { public: char *value = (char*)""; @@ -119,6 +123,13 @@ class MySQL_Connection { stmt_execute_metadata_t *stmt_meta; } query; char scramble_buff[40]; +#ifdef PROXYSQLED25519 + // Challenge for the client_ed25519 Auth Switch. Kept separate from + // scramble_buff: the native scramble lives for the whole client + // connection and is consumed by later COM_CHANGE_USER / caching_sha2 + // verifications, so the 32-byte binary nonce must not overwrite it. + unsigned char ed25519_nonce[ED25519_NONCE_LEN]; +#endif unsigned long long creation_time; unsigned long long last_time_used; unsigned long long timeout; diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 0e8178c3ca..2542cb90f9 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -1215,7 +1215,7 @@ bool MySQL_Protocol::generate_pkt_auth_switch_request(bool send, void **ptr, uns memcpy(_ptr+l,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519])); l+=strlen(plugins[AUTH_MYSQL_ED25519]); _ptr[l]=0x00; l++; - memcpy(_ptr+l, (*myds)->myconn->scramble_buff, ED25519_NONCE_LEN); l+=ED25519_NONCE_LEN; + memcpy(_ptr+l, (*myds)->myconn->ed25519_nonce, ED25519_NONCE_LEN); l+=ED25519_NONCE_LEN; break; #endif default: @@ -1873,20 +1873,10 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in (proxysql_ed25519_is_pubkey_format(password) || (client_auth_plugin && strcmp(client_auth_plugin, plugins[AUTH_MYSQL_ED25519]) == 0)); if (ed25519_switch_needed) { - if (RAND_bytes(reinterpret_cast((*myds)->myconn->scramble_buff), ED25519_NONCE_LEN) != 1) { + if (RAND_bytes((*myds)->myconn->ed25519_nonce, ED25519_NONCE_LEN) != 1) { proxy_error("RAND_bytes() failed generating the ed25519 nonce for user '%s'\n", user); ret = false; } else { - // The nonce is 32 raw random bytes, not a NUL-terminated string, and - // overwrites the terminator that would otherwise sit at index 20 for - // the native scramble. scramble_buff[40] is never zero-initialized by - // the MySQL_Connection constructor, so without this terminator the - // DEBUG-only handshake dump (hex(scramble_buff), which strlen()'s the - // buffer via std::string_view) would walk into uninitialized bytes - // 32-39 and potentially past the array. Index 32 is safely in bounds - // of char[40]; nothing downstream consumes the native scramble after - // an ed25519 switch. - (*myds)->myconn->scramble_buff[ED25519_NONCE_LEN] = '\0'; (*myds)->switching_auth_type = AUTH_MYSQL_ED25519; (*myds)->sess->change_user_auth_switch = true; generate_pkt_auth_switch_request(true, NULL, NULL); @@ -3062,27 +3052,21 @@ bool MySQL_Protocol::PPHR_passthrough_init(MyProt_tmp_auth_vars& vars1) { #ifdef PROXYSQLED25519 /** * @brief Initiate the client_ed25519 Auth Switch (stage 0 -> 1). - * @details Generates a fresh 32-byte nonce into 'scramble_buff' (40 bytes, so - * it fits) and sends an AuthSwitchRequest naming client_ed25519. The client - * answers with a 64-byte signature that PPHR_1 collects (stage 1 -> 2) and + * @details Generates a fresh 32-byte nonce into 'ed25519_nonce' and sends an + * AuthSwitchRequest naming client_ed25519. The client answers with a + * 64-byte signature that PPHR_1 collects (stage 1 -> 2) and * PPHR_ed25519_verify() checks. Mirrors the state handling of PPHR_4auth0. + * The nonce is kept in its own field rather than aliasing scramble_buff: + * the native scramble lives for the whole client connection and is read by + * later COM_CHANGE_USER / caching_sha2 verifications, so it must not be + * overwritten by this 32-byte binary (non-NUL-terminated) challenge. */ void MySQL_Protocol::PPHR_ed25519_switch(bool& ret, MyProt_tmp_auth_vars& vars1) { ret = false; - if (RAND_bytes(reinterpret_cast((*myds)->myconn->scramble_buff), ED25519_NONCE_LEN) != 1) { + if (RAND_bytes((*myds)->myconn->ed25519_nonce, ED25519_NONCE_LEN) != 1) { proxy_error("RAND_bytes() failed generating the ed25519 nonce for user '%s'\n", vars1.user); return; } - // The nonce is 32 raw random bytes, not a NUL-terminated string, and - // overwrites the terminator that proxy_create_random_string() left at - // index 20 for the native scramble. scramble_buff[40] is never - // zero-initialized by the MySQL_Connection constructor, so without this - // terminator the DEBUG-only handshake dump (hex(scramble_buff), which - // converts through std::string_view and therefore strlen()'s the - // buffer) would walk into uninitialized bytes 32-39 and potentially - // past the array. Index 32 is safely in bounds of char[40]; nothing - // downstream consumes the native scramble after an ed25519 switch. - (*myds)->myconn->scramble_buff[ED25519_NONCE_LEN] = '\0'; (*myds)->switching_auth_type = AUTH_MYSQL_ED25519; (*myds)->switching_auth_stage = 1; (*myds)->auth_in_progress = 1; @@ -3116,7 +3100,7 @@ void MySQL_Protocol::PPHR_ed25519_verify(bool& ret, MyProt_tmp_auth_vars& vars1) } else { proxysql_ed25519_derive_public_key(vars1.password, strlen(vars1.password), pubkey); } - if (proxysql_ed25519_verify_signature(vars1.pass, reinterpret_cast((*myds)->myconn->scramble_buff), pubkey)) { + if (proxysql_ed25519_verify_signature(vars1.pass, (*myds)->myconn->ed25519_nonce, pubkey)) { ret = true; } OPENSSL_cleanse(pubkey, sizeof(pubkey)); From e032fc2d12f23038e078014877ea1f23c75630cc Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 10:16:32 +0000 Subject: [PATCH 09/23] test: end-to-end MariaDB ed25519 authentication TAP test Runs on the mariadb10-galera infra (@proxysql_min_version:3.1): installs auth_ed25519 on the backend and covers the full matrix -- cleartext-stored user through to backend query execution, $ED$ public-key-only user (frontend OK, backend fails as documented), wrong-password 1045 for both formats, COM_CHANGE_USER via Auth Switch, and additional-password retry. The TAP client itself answers the client_ed25519 Auth Switch because the vendored connector now links the plugin statically. --- test/tap/groups/groups.json | 1 + test/tap/tests/test_ed25519_auth-t.cpp | 198 +++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 test/tap/tests/test_ed25519_auth-t.cpp diff --git a/test/tap/groups/groups.json b/test/tap/groups/groups.json index 1c27e1b72f..07d61573a4 100644 --- a/test/tap/groups/groups.json +++ b/test/tap/groups/groups.json @@ -367,6 +367,7 @@ "test_default_value_transaction_isolation_attr-t" : [ "legacy-g3","mysql-auto_increment_delay_multiplex=0-g3","mysql-multiplexing=false-g3","mysql-query_digests=0-g3","mysql-query_digests_keep_comment=1-g3","mysql84-g3","mysql90-g3","mysql95-g3" ], "test_digest_umap_aux-t" : [ "legacy-g3","mysql-auto_increment_delay_multiplex=0-g3","mysql-multiplexing=false-g3","mysql-query_digests=0-g3","mysql-query_digests_keep_comment=1-g3","mysql84-g3","mysql90-g3","mysql95-g3" ], "test_dns_cache-t" : [ "legacy-g3","mysql-auto_increment_delay_multiplex=0-g3","mysql-multiplexing=false-g3","mysql-query_digests=0-g3","mysql-query_digests_keep_comment=1-g3","mysql84-g3","mysql90-g3","mysql95-g3" ], + "test_ed25519_auth-t" : [ "mariadb10-galera-g4","@proxysql_min_version:3.1" ], "test_empty_query-t" : [ "legacy-g3","mysql-auto_increment_delay_multiplex=0-g3","mysql-multiplexing=false-g3","mysql-query_digests=0-g3","mysql-query_digests_keep_comment=1-g3","mysql84-g3","mysql90-g3","mysql95-g3" ], "test_enforce_autocommit_on_reads-t" : [ "legacy-g3","mysql-auto_increment_delay_multiplex=0-g3","mysql-multiplexing=false-g3","mysql-query_digests=0-g3","mysql-query_digests_keep_comment=1-g3","mysql84-g3","mysql90-g3","mysql95-g3" ], "test_ffto_bypass-t" : [ "legacy-g4","mysql84-g4","mysql90-g4","mysql95-g4","@proxysql_min_version:3.1" ], diff --git a/test/tap/tests/test_ed25519_auth-t.cpp b/test/tap/tests/test_ed25519_auth-t.cpp new file mode 100644 index 0000000000..cd33023061 --- /dev/null +++ b/test/tap/tests/test_ed25519_auth-t.cpp @@ -0,0 +1,198 @@ +/** + * @file test_ed25519_auth-t.cpp + * @brief End-to-end MariaDB ed25519 authentication (frontend + backend). + * @details Requires a MariaDB backend (mariadb10-galera infra): installs the + * auth_ed25519 server plugin, creates ed25519 backend users, and exercises: + * 1. cleartext-stored user: frontend ed25519 auth AND backend ed25519 auth + * (query reaches the backend); + * 2. $ED$-stored user: frontend auth succeeds, backend query fails + * (public key cannot drive backend auth -- documented limitation); + * 3. wrong password -> 1045; + * 4. COM_CHANGE_USER into an ed25519 user via Auth Switch; + * 5. additional-password (attributes JSON) retry. + */ +#include +#include +#include + +#include "mysql.h" + +#include "tap.h" +#include "command_line.h" +#include "utils.h" + +const char* ED_PASS = "ed25519_pass_1"; +const char* ED_PUBKEY = "5TBW79xTAMbhi8QKQtLLVS0V0b2w9mlKnRG6c+2NxTQ"; + +int main(int argc, char** argv) { + CommandLine cl; + if (cl.getEnv()) { + diag("Failed to get the required environmental variables."); + return EXIT_FAILURE; + } + + plan(10); + + // ---- fixture: backend plugin + users, via ProxySQL default routing ---- + MYSQL* wr = mysql_init(NULL); + if (!mysql_real_connect(wr, cl.host, cl.username, cl.password, NULL, cl.port, NULL, 0)) { + diag("Failed to connect to ProxySQL: %s", mysql_error(wr)); + return EXIT_FAILURE; + } + // tolerate "already installed" + if (mysql_query(wr, "INSTALL SONAME 'auth_ed25519'")) { + diag("INSTALL SONAME: %s (tolerated if already installed)", mysql_error(wr)); + } + { + MYSQL_RES* res = NULL; + MYSQL_QUERY(wr, "SELECT COUNT(*) FROM information_schema.plugins WHERE plugin_name='ed25519'"); + res = mysql_store_result(wr); + MYSQL_ROW row = mysql_fetch_row(res); + bool plugin_ok = row && strcmp(row[0], "1") == 0; + mysql_free_result(res); + if (!plugin_ok) { + diag("auth_ed25519 server plugin unavailable on this backend"); + return EXIT_FAILURE; + } + } + // NOTE: fixture is created through ProxySQL as 'testuser' (cl.username), which on the + // mariadb10-galera infra has ALL PRIVILEGES ON *.* but NOT WITH GRANT OPTION (verified via + // `SHOW GRANTS FOR testuser@%` against the running infra), so it cannot execute a GRANT + // statement to hand out privileges on a schema to ed_user/ed_user_pk. Rather than depend on + // a schema-level grant, the ed25519 users below are created with no default database and + // every connection/change_user call in this test passes a NULL db -- this still exercises + // the full frontend+backend authentication path (the thing under test) without requiring + // object-level privileges that the fixture-creating account does not have. + std::string create_user = + std::string("CREATE USER IF NOT EXISTS 'ed_user'@'%' IDENTIFIED VIA ed25519 USING '") + ED_PUBKEY + "'"; + MYSQL_QUERY(wr, create_user.c_str()); + std::string create_user_pk = + std::string("CREATE USER IF NOT EXISTS 'ed_user_pk'@'%' IDENTIFIED VIA ed25519 USING '") + ED_PUBKEY + "'"; + MYSQL_QUERY(wr, create_user_pk.c_str()); + + // ---- proxysql users ---- + MYSQL* admin = mysql_init(NULL); + if (!mysql_real_connect(admin, cl.admin_host, cl.admin_username, cl.admin_password, NULL, cl.admin_port, NULL, 0)) { + diag("Failed to connect to ProxySQL admin: %s", mysql_error(admin)); + return EXIT_FAILURE; + } + int def_hg = 0; + { + MYSQL_QUERY(admin, "SELECT MIN(hostgroup_id) FROM runtime_mysql_servers WHERE status='ONLINE'"); + MYSQL_RES* res = mysql_store_result(admin); + MYSQL_ROW row = mysql_fetch_row(res); + if (row && row[0]) { def_hg = atoi(row[0]); } + mysql_free_result(res); + } + std::string q1 = + "INSERT OR REPLACE INTO mysql_users (username,password,active,default_hostgroup) VALUES" + " ('ed_user','" + std::string(ED_PASS) + "',1," + std::to_string(def_hg) + ")"; + MYSQL_QUERY(admin, q1.c_str()); + std::string q2 = + "INSERT OR REPLACE INTO mysql_users (username,password,active,default_hostgroup) VALUES" + " ('ed_user_pk','$ED$" + std::string(ED_PUBKEY) + "',1," + std::to_string(def_hg) + ")"; + MYSQL_QUERY(admin, q2.c_str()); + MYSQL_QUERY(admin, "LOAD MYSQL USERS TO RUNTIME"); + + // ---- 1-2: cleartext-stored user, full frontend+backend path ---- + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, "ed_user", ED_PASS, NULL, cl.port, NULL, 0) != NULL; + ok(conn_ok, "cleartext-stored user connects via ed25519 auth switch (err: %s)", conn_ok ? "-" : mysql_error(c)); + if (conn_ok) { + int rc = mysql_query(c, "SELECT CURRENT_USER()"); + ok(rc == 0, "query reaches the ed25519 backend user (err: %s)", rc ? mysql_error(c) : "-"); + if (rc == 0) { mysql_free_result(mysql_store_result(c)); } + } else { + ok(false, "query skipped: connection failed"); + } + mysql_close(c); + } + + // ---- 3: wrong password -> 1045 ---- + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, "ed_user", "wrong_password", NULL, cl.port, NULL, 0) != NULL; + ok(conn_ok == false && mysql_errno(c) == 1045, + "wrong password denied with 1045 (got errno %u)", mysql_errno(c)); + mysql_close(c); + } + + // ---- 4-5: $ED$-stored user: frontend OK, backend query fails ---- + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, "ed_user_pk", ED_PASS, NULL, cl.port, NULL, 0) != NULL; + ok(conn_ok, "$ED$-stored user passes frontend verification (err: %s)", conn_ok ? "-" : mysql_error(c)); + if (conn_ok) { + int rc = mysql_query(c, "SELECT 1"); + if (rc == 0) { mysql_free_result(mysql_store_result(c)); } + ok(rc != 0, "backend query fails for public-key-only credential (documented limitation)"); + } else { + ok(false, "backend check skipped: connection failed"); + } + mysql_close(c); + } + + // ---- 6: bad frontend password for $ED$ user ---- + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, "ed_user_pk", "wrong_password", NULL, cl.port, NULL, 0) != NULL; + ok(conn_ok == false && mysql_errno(c) == 1045, + "$ED$ user, wrong password denied with 1045 (got errno %u)", mysql_errno(c)); + mysql_close(c); + } + + // ---- 7-8: COM_CHANGE_USER into the ed25519 user ---- + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, cl.username, cl.password, NULL, cl.port, NULL, 0) != NULL; + if (!conn_ok) { + ok(false, "base connection for change_user failed: %s", mysql_error(c)); + ok(false, "change_user skipped"); + } else { + int rc = mysql_change_user(c, "ed_user", ED_PASS, NULL); + ok(rc == 0, "COM_CHANGE_USER into ed25519 user succeeds (err: %s)", rc ? mysql_error(c) : "-"); + rc = mysql_query(c, "SELECT 1"); + if (rc == 0) { mysql_free_result(mysql_store_result(c)); } + ok(rc == 0, "query works after change_user (err: %s)", rc ? mysql_error(c) : "-"); + } + mysql_close(c); + } + + // ---- 9-10: additional-password retry (attributes JSON, hex-encoded) ---- + { + // primary password wrong on purpose; additional_password holds the real one + char hexpass[64] = { 0 }; + for (size_t i = 0; i < strlen(ED_PASS); i++) { + sprintf(hexpass + 2 * i, "%02x", (unsigned char)ED_PASS[i]); + } + std::string q = + "UPDATE mysql_users SET password='not_the_real_password'," + " attributes='{\"additional_password\":\"" + std::string(hexpass) + "\"}'" + " WHERE username='ed_user'"; + MYSQL_QUERY(admin, q.c_str()); + MYSQL_QUERY(admin, "LOAD MYSQL USERS TO RUNTIME"); + + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, "ed_user", ED_PASS, NULL, cl.port, NULL, 0) != NULL; + ok(conn_ok, "additional-password retry verifies ed25519 signature (err: %s)", conn_ok ? "-" : mysql_error(c)); + if (conn_ok) { + int rc = mysql_query(c, "SELECT 1"); + if (rc == 0) { mysql_free_result(mysql_store_result(c)); } + ok(rc == 0, "query works on additional password (err: %s)", rc ? mysql_error(c) : "-"); + } else { + ok(false, "query skipped: connection failed"); + } + mysql_close(c); + } + + // ---- cleanup ---- + MYSQL_QUERY(admin, "DELETE FROM mysql_users WHERE username IN ('ed_user','ed_user_pk')"); + MYSQL_QUERY(admin, "LOAD MYSQL USERS TO RUNTIME"); + mysql_query(wr, "DROP USER IF EXISTS 'ed_user'@'%'"); + mysql_query(wr, "DROP USER IF EXISTS 'ed_user_pk'@'%'"); + mysql_close(admin); + mysql_close(wr); + + return exit_status(); +} From b93f01d3fb8e86b8088866bc5210c6de29ece06a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 10:26:37 +0000 Subject: [PATCH 10/23] test: assert specific errno/message for $ED$ backend-auth failure The $ED$ (public-key-only) backend-query assertion in test_ed25519_auth-t.cpp only checked that the query failed (rc != 0), which a Galera blip, backend outage, or unrelated regression would also satisfy, making the row unable to prove the documented public-key-only limitation. Tighten it to assert the specific failure: errno 1045 with an 'Access denied' message, confirmed empirically against the mariadb10-galera infra (ProxySQL forwards the backend's own native 1045 access-denied response verbatim to the client when the backend connection retry fails for a $ED$ user). Also diag() the observed errno/error unconditionally so future failures are diagnosable. --- test/tap/tests/test_ed25519_auth-t.cpp | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/test/tap/tests/test_ed25519_auth-t.cpp b/test/tap/tests/test_ed25519_auth-t.cpp index cd33023061..263b5e385e 100644 --- a/test/tap/tests/test_ed25519_auth-t.cpp +++ b/test/tap/tests/test_ed25519_auth-t.cpp @@ -5,8 +5,10 @@ * auth_ed25519 server plugin, creates ed25519 backend users, and exercises: * 1. cleartext-stored user: frontend ed25519 auth AND backend ed25519 auth * (query reaches the backend); - * 2. $ED$-stored user: frontend auth succeeds, backend query fails - * (public key cannot drive backend auth -- documented limitation); + * 2. $ED$-stored user: frontend auth succeeds, backend query fails with the + * backend's own 1045 "Access denied" (public key cannot drive backend auth -- + * documented limitation; asserted on the specific errno/message, not just + * "the query failed", so a Galera blip or backend outage cannot pass this check); * 3. wrong password -> 1045; * 4. COM_CHANGE_USER into an ed25519 user via Auth Switch; * 5. additional-password (attributes JSON) retry. @@ -23,6 +25,13 @@ const char* ED_PASS = "ed25519_pass_1"; const char* ED_PUBKEY = "5TBW79xTAMbhi8QKQtLLVS0V0b2w9mlKnRG6c+2NxTQ"; +// Confirmed empirically against the mariadb10-galera infra (see task-5-report.md): +// when ProxySQL retries the backend connection for a $ED$ (public-key-only) user, the +// backend itself rejects the retry with its native 1045 access-denied error, and +// ProxySQL propagates that same errno/message straight through to the client -- this +// is not a ProxySQL-specific connect-timeout code, it is the backend's own "Access +// denied for user ..." response, forwarded verbatim. +#define ED25519_PK_BACKEND_ERRNO 1045 int main(int argc, char** argv) { CommandLine cl; @@ -126,7 +135,18 @@ int main(int argc, char** argv) { if (conn_ok) { int rc = mysql_query(c, "SELECT 1"); if (rc == 0) { mysql_free_result(mysql_store_result(c)); } - ok(rc != 0, "backend query fails for public-key-only credential (documented limitation)"); + unsigned int eno = mysql_errno(c); + const char* emsg = mysql_error(c); + diag("$ED$ backend query result: rc=%d errno=%u error='%s'", rc, eno, emsg ? emsg : ""); + // Distinguish the intended "backend rejects the public key" failure from generic + // connectivity loss (Galera blip, backend outage, unrelated regression): both the + // specific errno AND a stable substring of the backend's own access-denied text + // must match, not just "some error occurred". + bool is_access_denied = emsg && strstr(emsg, "Access denied") != NULL; + ok(rc != 0 && eno == ED25519_PK_BACKEND_ERRNO && is_access_denied, + "backend query fails for public-key-only credential with errno %d and 'Access denied' " + "(documented limitation; got errno %u, error '%s')", + ED25519_PK_BACKEND_ERRNO, eno, emsg ? emsg : ""); } else { ok(false, "backend check skipped: connection failed"); } From c217f7dc6ef1cd8b5e5a8cf1dffe5e60f3c203c8 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 10:30:34 +0000 Subject: [PATCH 11/23] docs: MariaDB ed25519 authentication guide Formats, MariaDB migration path, protocol behavior, tier availability, and the documented limitations ($ED$ backend connections, passthrough incompatibility, single-auth-switch edge, PARSEC out of scope). --- doc/ed25519_authentication.md | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 doc/ed25519_authentication.md diff --git a/doc/ed25519_authentication.md b/doc/ed25519_authentication.md new file mode 100644 index 0000000000..46eabb2b0b --- /dev/null +++ b/doc/ed25519_authentication.md @@ -0,0 +1,63 @@ +# MariaDB ed25519 Authentication + +ProxySQL supports MariaDB's ed25519 authentication scheme +(`client_ed25519` client plugin / `auth_ed25519` server plugin) on both +sides of the proxy. + +## Availability + +| Side | Tier | Mechanism | +|------|------|-----------| +| Backend (ProxySQL → MariaDB) | all tiers | The bundled MariaDB Connector/C links `client_ed25519` statically and answers the server's auth switch transparently. | +| Frontend (client → ProxySQL) | v3.1+ (`PROXYSQL31`) | ProxySQL verifies `client_ed25519` signatures itself. | + +Oracle MySQL has no ed25519 plugin; this is a MariaDB-ecosystem feature. + +## Credential formats in `mysql_users.password` + +| Format | Example | Frontend auth | Backend auth | +|--------|---------|---------------|--------------| +| cleartext | `my_password` | yes (key derived on the fly) | yes (connector signs with it) | +| `$ED$` + 43-char base64 public key | `$ED$ZIgUREUg5PVgQ6LskhXmO+eZLS0nC8be6HPjYWR4YJY` | yes (signature verified against the key) | **no** — the password is unknown | + +The `$ED$` payload is exactly the value MariaDB stores in +`mysql.user.authentication_string` for an ed25519 user — to migrate, +prefix it with `$ED$`. The prefix is case-insensitive and mandatory: a +bare 43-character string is treated as a cleartext password. + +A malformed `$ED$` value (wrong length or invalid base64) logs a warning +at `LOAD MYSQL USERS TO RUNTIME` time and every authentication attempt +for that user fails with the standard access-denied error. + +## Protocol behavior + +ed25519 is never advertised in the initial handshake (its challenge is +32 bytes; the greeting scramble is 20). ProxySQL sends an +`AuthSwitchRequest` naming `client_ed25519` with a fresh 32-byte nonce +whenever: + +- the stored credential is `$ED$…` (whatever plugin the client offered), or +- the client explicitly requested `client_ed25519` and the stored + credential is cleartext or `$ED$`. + +The client answers with a 64-byte signature. This mirrors MariaDB's own +behavior, so any client able to authenticate against MariaDB ed25519 +works unchanged. `COM_CHANGE_USER` into an ed25519 user is supported via +the same auth-switch mechanism. + +TLS is not required: the exchange never transmits a secret. + +## Limitations + +- `$ED$` (public-key-only) users cannot open backend connections: the + signature scheme is not replayable and the cleartext is unknown. + ProxySQL logs an explicit warning when such a user's backend + connection fails. Store the cleartext password for full functionality. +- Pass-through authentication (`mysql-passthrough_auth_*`) cannot learn + credentials from an ed25519 exchange, by construction. +- If a client triggers an early switch to `mysql_native_password` + (e.g. it offered `caching_sha2_password` against a native greeting), + a stored-`$ED$` user cannot be verified on that connection — the + MySQL protocol allows a single auth switch. Standard MariaDB clients + do not hit this. +- MariaDB PARSEC (11.6+) is not supported. From 2b85860ddc2737877d62c1d286d62cbc26cdddb5 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 11:11:45 +0000 Subject: [PATCH 12/23] fix: deny malformed $ED$-prefixed credentials, never fall back to cleartext Security gap found during a review of the ed25519 documentation task: proxysql_ed25519_is_pubkey_format() requires an exact 47-char "$ED$" + 43-base64 credential. A stored mysql_users.password that begins with "$ED$" but has the WRONG length (e.g. "$ED$short") is not pubkey-format, so every routing decision that gated on is_pubkey_format() fell through to ordinary cleartext/native-password verification -- a client that typed the literal stored string "$ED$short" as its password would authenticate successfully. The load-time warning added in an earlier task already fires for any "$ED$"-prefixed value and claims "every authentication attempt for this user will fail", which was false for exactly this subcase. Human ruling: fix the code, fail-closed. ANY password beginning with the "$ED$" prefix (case-insensitive), valid or not, is a reserved marker for ed25519 credentials and must never be treated as a cleartext password. A malformed "$ED$..." row denies authentication with the standard access-denied, same as any other credential mismatch -- it does not get a distinguishable error. The existing warning wording needed no change under this ruling, since it is now accurate. Fix: - New proxysql_ed25519_has_prefix() (include/MySQL_Ed25519.h, lib/MySQL_Ed25519.cpp): NULL-safe "$ED$" marker test, independent of length/validity. proxysql_ed25519_is_pubkey_format() is reimplemented on top of it (has_prefix() + exact-length check) to stay DRY; its own contract (require full 47-char validity) is unchanged, and proxysql_ed25519_decode_pubkey() -- which calls is_pubkey_format() internally -- is also unchanged, so decoding still requires a valid key. - Every routing decision in lib/MySQL_Protocol.cpp that used is_pubkey_format() to decide *which auth method to run* now routes on has_prefix() instead, so a malformed "$ED$..." value is steered into the ed25519 verification path (which then denies it generically via decode_pubkey() failure) rather than falling through to cleartext comparison: - PPHR_verify_password's stage-0 gate (stored_is_ed) - PPHR_verify_password's stage-2 credential-format dispatch - PPHR_ed25519_verify's internal decode-vs-derive selector (this one matters most: without it, a "$ED$short" row would hit the MariaDB-variant "derive key from cleartext password" branch -- deriving from the reserved marker string itself, still a form of treating it as cleartext) - process_pkt_COM_CHANGE_USER's ed25519_switch_needed verify_user_pass()'s cleartext branch (the COM_CHANGE_USER inline-data path) now also denies outright on has_prefix() before considering any auth_plugin_id, as defense in depth independent of caller-side gating. mysql_connection.cpp's backend-connect warning switched from is_pubkey_format() to has_prefix() for the same reason: a "$ED$short" user is just as unusable against a real backend as a valid one. process_pkt_auth_swich_response() (lib/MySQL_Protocol.cpp ~1460) has the same unguarded cleartext branch but is dead code -- grepped for call sites, found only its own declaration/definition, so it was left untouched rather than edited for no reachable effect. Edge cost, called out per the ruling: a legitimate cleartext password that happens to start with the literal 4 characters "$ED$" is no longer usable for that user -- the prefix is now a fully reserved marker. Tests: - test/tap/tests/unit/ed25519_unit-t.cpp: 5 new assertions for proxysql_ed25519_has_prefix (valid 47-char credential, malformed "$ED$short", case-insensitive "$ed$...", bare base64 without marker, NULL), plan() 19 -> 24. - test/tap/tests/test_ed25519_auth-t.cpp: new user 'ed_user_bad' with mysql_users.password literally '$ED$short' (no backend user needed -- frontend denial happens before any backend connection); connecting with password '$ED$short' now fails with 1045 instead of succeeding. plan() 10 -> 11. Verification: - PROXYSQL31=1 make debug -j$(nproc): exit 0. - ed25519_unit-t: 24/24 ok. - PROXYSQL31=1 make build_tap_test_debug: exit 0. - Isolated harness, WORKSPACE= INFRA_ID=ed25519 TAP_GROUP=mariadb10-galera-g4 TEST_PY_TAP_INCL=test_ed25519_auth-t: 1..11, all ok, including "ok 7 - malformed $ED$ credential ('$ED$short') denied with 1045, not accepted as cleartext". SUMMARY: PASS 1/407 FAIL 0/407, ret_rc = [0]. --- include/MySQL_Ed25519.h | 3 ++ lib/MySQL_Ed25519.cpp | 8 +++- lib/MySQL_Protocol.cpp | 63 ++++++++++++++++++++------ lib/mysql_connection.cpp | 6 ++- test/tap/tests/test_ed25519_auth-t.cpp | 33 ++++++++++++-- test/tap/tests/unit/ed25519_unit-t.cpp | 13 ++++++ 6 files changed, 107 insertions(+), 19 deletions(-) diff --git a/include/MySQL_Ed25519.h b/include/MySQL_Ed25519.h index 230e80365f..154a887f00 100644 --- a/include/MySQL_Ed25519.h +++ b/include/MySQL_Ed25519.h @@ -38,6 +38,9 @@ bool proxysql_ed25519_verify_signature(const unsigned char* signature, const uns /** @brief True when 'password' is a stored ed25519 public key ("$ED$" + 43 base64 chars). NULL-safe. */ bool proxysql_ed25519_is_pubkey_format(const char* password); +/** @brief True when 'password' begins with the "$ED$" marker (case-insensitive), regardless of validity. Any such value is reserved for ed25519 credentials and never treated as a cleartext password. NULL-safe. */ +bool proxysql_ed25519_has_prefix(const char* password); + /** @brief Decode a "$ED$..." stored credential into a 32-byte public key. False on malformed input. */ bool proxysql_ed25519_decode_pubkey(const char* stored, unsigned char* out_pubkey); diff --git a/lib/MySQL_Ed25519.cpp b/lib/MySQL_Ed25519.cpp index 9f73fa810d..8e77d07648 100644 --- a/lib/MySQL_Ed25519.cpp +++ b/lib/MySQL_Ed25519.cpp @@ -28,9 +28,13 @@ bool proxysql_ed25519_verify_signature(const unsigned char* signature, const uns return crypto_sign_open(sm, sizeof(sm), pubkey) == 0; } -bool proxysql_ed25519_is_pubkey_format(const char* password) { +bool proxysql_ed25519_has_prefix(const char* password) { if (password == NULL) return false; - if (strncasecmp(password, ED25519_STORED_PREFIX, ED25519_STORED_PREFIX_LEN) != 0) return false; + return strncasecmp(password, ED25519_STORED_PREFIX, ED25519_STORED_PREFIX_LEN) == 0; +} + +bool proxysql_ed25519_is_pubkey_format(const char* password) { + if (proxysql_ed25519_has_prefix(password) == false) return false; return strlen(password) == ED25519_STORED_LEN; } diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 2542cb90f9..fe51fa3abb 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -1560,6 +1560,18 @@ bool MySQL_Protocol::verify_user_pass( } if (password[0]!='*') { // clear text password +#ifdef PROXYSQLED25519 + // Defense in depth: a "$ED$"-prefixed stored password is reserved for + // ed25519 credentials and must never be compared as a literal + // cleartext/native password, even when malformed (wrong length). + // process_pkt_COM_CHANGE_USER's ed25519_switch_needed gate already + // routes such users through the nonce-based Auth Switch before + // reaching this function, but verify_user_pass() fails closed on its + // own regardless of caller-side gating. + if (proxysql_ed25519_has_prefix(password)) { + ret = false; + } else +#endif if (auth_plugin_id == 0) { // mysql_native_password proxy_scramble(reply, (*myds)->myconn->scramble_buff, password); if (auth_response_has(pass_len, SHA_DIGEST_LENGTH) && @@ -1861,16 +1873,24 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in ret=false; } else { #ifdef PROXYSQLED25519 - // A stored "$ED$" credential (or an explicit client_ed25519 request) - // can only be verified through a fresh-nonce Auth Switch: any inline - // auth data was computed against the original scramble and is - // meaningless for ed25519. Mirrors the native pass_len==0 switch below - // (issue #3504); the response re-enters process_pkt_handshake_response - // where PPHR_1 picks up switching_auth_type and PPHR_verify_password - // verifies the signature (switching_auth_sent guards its stage-0 gate). + // A stored "$ED$"-prefixed credential (or an explicit client_ed25519 + // request) can only be verified through a fresh-nonce Auth Switch: + // any inline auth data was computed against the original scramble + // and is meaningless for ed25519. Mirrors the native pass_len==0 + // switch below (issue #3504); the response re-enters + // process_pkt_handshake_response where PPHR_1 picks up + // switching_auth_type and PPHR_verify_password verifies the + // signature (switching_auth_sent guards its stage-0 gate). + // Routing on the prefix alone (not full pubkey validity) is + // deliberate and fail-closed: a "$ED$..." value of the wrong length + // must never reach verify_user_pass()'s inline-credential branches + // below and be compared as a literal cleartext/hashed password -- + // it is routed here instead, and the client's inline data (if any) + // is discarded in favor of the nonce-based exchange, which denies + // it generically via PPHR_ed25519_verify(). const bool ed25519_switch_needed = session_type != PROXYSQL_SESSION_CLICKHOUSE && - (proxysql_ed25519_is_pubkey_format(password) || + (proxysql_ed25519_has_prefix(password) || (client_auth_plugin && strcmp(client_auth_plugin, plugins[AUTH_MYSQL_ED25519]) == 0)); if (ed25519_switch_needed) { if (RAND_bytes((*myds)->myconn->ed25519_nonce, ED25519_NONCE_LEN) != 1) { @@ -3092,7 +3112,14 @@ void MySQL_Protocol::PPHR_ed25519_verify(bool& ret, MyProt_tmp_auth_vars& vars1) return; } unsigned char pubkey[ED25519_PUBKEY_LEN]; - if (proxysql_ed25519_is_pubkey_format(vars1.password)) { + // Route on the "$ED$" prefix, not on full validity: any prefixed value is + // reserved for ed25519 credentials and must never fall into the + // cleartext-derivation branch below, even when malformed (wrong length). + // proxysql_ed25519_decode_pubkey() re-checks strict pubkey format + // internally and fails closed (generic denial) for a "$ED$"-prefixed but + // invalid credential -- it is never treated as a MariaDB-variant + // cleartext password. + if (proxysql_ed25519_has_prefix(vars1.password)) { if (proxysql_ed25519_decode_pubkey(vars1.password, pubkey) == false) { proxy_error("mysql_users entry for '%s' has a malformed $ED$ ed25519 credential; denying access\n", vars1.user); return; @@ -3563,14 +3590,20 @@ bool MySQL_Protocol::PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_d // ed25519 gate (stage 0): a client that requested client_ed25519 sends an // empty auth response in the HandshakeResponse -- it cannot sign before // receiving the 32-byte nonce -- so this decision MUST precede the - // empty-response checks below. A stored "$ED$" credential forces the - // ed25519 exchange regardless of the plugin the client offered. + // empty-response checks below. A stored "$ED$"-prefixed credential + // forces the ed25519 exchange regardless of the plugin the client + // offered. Routing on the prefix alone (not full pubkey validity) is + // deliberate and fail-closed: a malformed "$ED$..." value (wrong + // length) must never fall through to plain cleartext comparison + // below -- it is routed into the ed25519 flow, where + // PPHR_ed25519_verify()/proxysql_ed25519_decode_pubkey() denies it + // with the generic auth failure. // 'switching_auth_sent' guards re-entry: after the switch, the signature // arrives with stage 0 on the COM_CHANGE_USER path and stage 2 here. if ((*myds)->switching_auth_stage == 0 && (*myds)->switching_auth_sent != AUTH_MYSQL_ED25519 && (*myds)->sess->session_type != PROXYSQL_SESSION_CLICKHOUSE) { - const bool stored_is_ed = proxysql_ed25519_is_pubkey_format(vars1.password); + const bool stored_is_ed = proxysql_ed25519_has_prefix(vars1.password); // a '*SHA1' or '$A$' hash cannot derive an ed25519 key const bool cred_usable = stored_is_ed || (vars1.password[0] != '*' && @@ -3603,7 +3636,11 @@ bool MySQL_Protocol::PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_d ); #endif // debug #ifdef PROXYSQLED25519 - if (auth_plugin_id == AUTH_MYSQL_ED25519 || proxysql_ed25519_is_pubkey_format(vars1.password)) { + // Route on the "$ED$" prefix, not full pubkey validity: a + // malformed "$ED$..." stored credential must still be denied via + // PPHR_ed25519_verify()'s generic failure, never treated as a + // cleartext/native password comparison below. + if (auth_plugin_id == AUTH_MYSQL_ED25519 || proxysql_ed25519_has_prefix(vars1.password)) { // signature collected by PPHR_1 after the Auth Switch; a stored // "$ED$" key with a non-ed25519 response fails the length check // inside PPHR_ed25519_verify (generic denial) diff --git a/lib/mysql_connection.cpp b/lib/mysql_connection.cpp index 9744711639..a1346e4162 100644 --- a/lib/mysql_connection.cpp +++ b/lib/mysql_connection.cpp @@ -1026,7 +1026,11 @@ void MySQL_Connection::connect_start() { } } #ifdef PROXYSQLED25519 - if (userinfo->password && proxysql_ed25519_is_pubkey_format(userinfo->password)) { + // Prefix match, not full-validity match: a "$ED$"-prefixed value of the + // wrong length is just as unusable as cleartext against a backend as a + // well-formed one -- the "$ED$" marker is reserved and never a real + // cleartext password either way. + if (userinfo->password && proxysql_ed25519_has_prefix(userinfo->password)) { proxy_warning( "User '%s' has an ed25519 public-key-only ($ED$) credential;" " backend authentication requires the cleartext password and will fail\n", diff --git a/test/tap/tests/test_ed25519_auth-t.cpp b/test/tap/tests/test_ed25519_auth-t.cpp index 263b5e385e..9656317e1b 100644 --- a/test/tap/tests/test_ed25519_auth-t.cpp +++ b/test/tap/tests/test_ed25519_auth-t.cpp @@ -11,7 +11,11 @@ * "the query failed", so a Galera blip or backend outage cannot pass this check); * 3. wrong password -> 1045; * 4. COM_CHANGE_USER into an ed25519 user via Auth Switch; - * 5. additional-password (attributes JSON) retry. + * 5. additional-password (attributes JSON) retry; + * 6. malformed "$ED$"-prefixed stored credential ("$ED$short", wrong + * length): connecting with the literal stored string as the password + * must be denied with 1045, never accepted as a cleartext match + * (regression coverage for the fail-closed prefix-routing fix). */ #include #include @@ -40,7 +44,7 @@ int main(int argc, char** argv) { return EXIT_FAILURE; } - plan(10); + plan(11); // ---- fixture: backend plugin + users, via ProxySQL default routing ---- MYSQL* wr = mysql_init(NULL); @@ -101,6 +105,14 @@ int main(int argc, char** argv) { "INSERT OR REPLACE INTO mysql_users (username,password,active,default_hostgroup) VALUES" " ('ed_user_pk','$ED$" + std::string(ED_PUBKEY) + "',1," + std::to_string(def_hg) + ")"; MYSQL_QUERY(admin, q2.c_str()); + // Malformed "$ED$"-prefixed credential: wrong length, not a valid 47-char + // public key. No backend user is needed -- the fail-closed fix denies + // this at the frontend, before any backend connection is attempted. + const char* ED_BAD_STORED = "$ED$short"; + std::string q3 = + "INSERT OR REPLACE INTO mysql_users (username,password,active,default_hostgroup) VALUES" + " ('ed_user_bad','" + std::string(ED_BAD_STORED) + "',1," + std::to_string(def_hg) + ")"; + MYSQL_QUERY(admin, q3.c_str()); MYSQL_QUERY(admin, "LOAD MYSQL USERS TO RUNTIME"); // ---- 1-2: cleartext-stored user, full frontend+backend path ---- @@ -162,6 +174,21 @@ int main(int argc, char** argv) { mysql_close(c); } + // ---- 6b: malformed "$ED$"-prefixed stored credential is never treated + // as cleartext. Before the fail-closed fix, is_pubkey_format() rejected + // "$ED$short" as not-a-valid-key, so it fell through to plain cleartext + // comparison and a client sending the literal stored string as its + // password would authenticate successfully. It must now be denied with + // the standard 1045, exactly like any other credential mismatch. + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, "ed_user_bad", ED_BAD_STORED, NULL, cl.port, NULL, 0) != NULL; + ok(conn_ok == false && mysql_errno(c) == 1045, + "malformed $ED$ credential ('%s') denied with 1045, not accepted as cleartext (got errno %u)", + ED_BAD_STORED, mysql_errno(c)); + mysql_close(c); + } + // ---- 7-8: COM_CHANGE_USER into the ed25519 user ---- { MYSQL* c = mysql_init(NULL); @@ -207,7 +234,7 @@ int main(int argc, char** argv) { } // ---- cleanup ---- - MYSQL_QUERY(admin, "DELETE FROM mysql_users WHERE username IN ('ed_user','ed_user_pk')"); + MYSQL_QUERY(admin, "DELETE FROM mysql_users WHERE username IN ('ed_user','ed_user_pk','ed_user_bad')"); MYSQL_QUERY(admin, "LOAD MYSQL USERS TO RUNTIME"); mysql_query(wr, "DROP USER IF EXISTS 'ed_user'@'%'"); mysql_query(wr, "DROP USER IF EXISTS 'ed_user_pk'@'%'"); diff --git a/test/tap/tests/unit/ed25519_unit-t.cpp b/test/tap/tests/unit/ed25519_unit-t.cpp index 2bf5fa0df4..d1d1462843 100644 --- a/test/tap/tests/unit/ed25519_unit-t.cpp +++ b/test/tap/tests/unit/ed25519_unit-t.cpp @@ -54,6 +54,7 @@ int main() { 3 /* derivation KATs */ + 3 /* decode round-trips */ + 7 /* is_pubkey_format edge cases */ + + 5 /* has_prefix edge cases */ + 2 /* decode_pubkey malformed */ + 1 /* signature KAT */ + 3 /* tampered signature / nonce / key */ @@ -94,6 +95,18 @@ int main() { ok(proxysql_ed25519_is_pubkey_format("*THISLOOKSLIKEASHA1HASHXXXXXXXXXXXXXXXXX") == false, "SHA1-format password rejected"); } + // 3b. has_prefix edge cases -- routes on the marker alone, independent of + // validity, per the fail-closed rule: any "$ED$"-prefixed value (valid + // or malformed) must be recognized so it is never treated as cleartext. + { + std::string valid = std::string(ED25519_STORED_PREFIX) + KATS[1].pubkey_b64; + ok(proxysql_ed25519_has_prefix(valid.c_str()) == true, "has_prefix: valid 47-char credential accepted"); + ok(proxysql_ed25519_has_prefix("$ED$short") == true, "has_prefix: malformed wrong-length value still recognized"); + ok(proxysql_ed25519_has_prefix("$ed$short") == true, "has_prefix: case-insensitive marker match"); + ok(proxysql_ed25519_has_prefix(KATS[1].pubkey_b64) == false, "has_prefix: bare base64 without marker rejected"); + ok(proxysql_ed25519_has_prefix(NULL) == false, "has_prefix: NULL rejected"); + } + // 4. decode_pubkey malformed input { unsigned char pk[ED25519_PUBKEY_LEN]; From 5780da236f15483a40b2dcc6df2a0c1224e38bc1 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 11:42:24 +0000 Subject: [PATCH 13/23] fix: scope ed25519 auth gates to MySQL sessions; brace NUL guard; zero nonce Final-review fix wave, code findings: - lib/MySQL_Protocol.cpp: the ed25519 gates were written as "!= CLICKHOUSE" rather than "== MYSQL", so under PROXYSQL31 they also applied to PROXYSQL_SESSION_ADMIN and PROXYSQL_SESSION_STATS, which authenticate through this same verify_user_pass()/PPHR_verify_password()/ process_pkt_COM_CHANGE_USER() code. A $ED$-prefixed admin/stats password would therefore have been routed into the ed25519 switch (an admin-port connection cannot complete a MySQL-protocol Auth Switch, so this would have been an unrecoverable admin-port lockout) or denied outright by the has_prefix guard in verify_user_pass(). All three gates now check `session_type == PROXYSQL_SESSION_MYSQL` explicitly: - the stage-0 gate in PPHR_verify_password (~L3603) - `ed25519_switch_needed` in process_pkt_COM_CHANGE_USER (~L1891) - the has_prefix denial in verify_user_pass (~L1571) With the gate scoped to MYSQL, a $ED$-prefixed admin password now falls through to the pre-existing cleartext comparison unchanged from before this feature -- the intended conservative behavior for non-MySQL sessions, since the feature simply doesn't apply there. - lib/MySQL_Protocol.cpp (~L1227-1230): braced the previously-unbraced `#ifdef`-wrapped `if` guarding the trailing-NUL write in generate_pkt_auth_switch_request(). The guard suppresses the NUL byte for the ed25519 packet (which must end exactly after the 32-byte nonce, with no extra byte), and was a single unbraced statement -- a future line inserted directly below it would have silently escaped the guard and corrupted that one-byte heap write. No behavior change. - lib/mysql_connection.cpp: zero-initialize `ed25519_nonce` in the MySQL_Connection constructor. The field was previously left uninitialized between construction and the first RAND_bytes() fill in PPHR_ed25519_switch(); harmless in practice (nothing reads it before that fill), but leaves ASAN/MSAN runs clean on the theoretical uninitialized-read path. --- lib/MySQL_Protocol.cpp | 31 +++++++++++++++++++++++++++---- lib/mysql_connection.cpp | 6 ++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index fe51fa3abb..5433b13c71 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -1227,7 +1227,9 @@ bool MySQL_Protocol::generate_pkt_auth_switch_request(bool send, void **ptr, uns #ifdef PROXYSQLED25519 if ((*myds)->switching_auth_type != AUTH_MYSQL_ED25519) // ed25519 packet ends exactly after the nonce #endif - _ptr[l]=0x00; //l+=1; //0x00 + { + _ptr[l]=0x00; //l+=1; //0x00 + } if (send==true) { (*myds)->PSarrayOUT->add((void *)_ptr,size); (*myds)->DSS=STATE_SERVER_HANDSHAKE; @@ -1568,7 +1570,12 @@ bool MySQL_Protocol::verify_user_pass( // routes such users through the nonce-based Auth Switch before // reaching this function, but verify_user_pass() fails closed on its // own regardless of caller-side gating. - if (proxysql_ed25519_has_prefix(password)) { + // Scoped to MYSQL sessions only: admin/stats credentials flow through + // this same verify path under PROXYSQL31, and a $ED$-prefixed admin + // password must not be denied here -- it falls through to the + // pre-existing cleartext comparison below, unchanged from before this + // feature (the feature simply doesn't apply to non-MySQL sessions). + if (session_type == PROXYSQL_SESSION_MYSQL && proxysql_ed25519_has_prefix(password)) { ret = false; } else #endif @@ -1888,8 +1895,17 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in // it is routed here instead, and the client's inline data (if any) // is discarded in favor of the nonce-based exchange, which denies // it generically via PPHR_ed25519_verify(). + // Scoped to MYSQL sessions only (not just "not CLICKHOUSE"): under + // PROXYSQL31 admin/stats credentials are verified through this same + // process_pkt_COM_CHANGE_USER path, and a $ED$-prefixed admin + // password must not force an ed25519 Auth Switch that the admin + // protocol cannot complete -- that would be an unrecoverable + // admin-port lockout. Scoping to MYSQL leaves such a password to + // fall through to the pre-existing cleartext comparison, which is + // the intended conservative behavior for non-MySQL sessions (the + // feature simply doesn't apply there). const bool ed25519_switch_needed = - session_type != PROXYSQL_SESSION_CLICKHOUSE && + session_type == PROXYSQL_SESSION_MYSQL && (proxysql_ed25519_has_prefix(password) || (client_auth_plugin && strcmp(client_auth_plugin, plugins[AUTH_MYSQL_ED25519]) == 0)); if (ed25519_switch_needed) { @@ -3600,9 +3616,16 @@ bool MySQL_Protocol::PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_d // with the generic auth failure. // 'switching_auth_sent' guards re-entry: after the switch, the signature // arrives with stage 0 on the COM_CHANGE_USER path and stage 2 here. + // Scoped to MYSQL sessions only: admin/stats credentials are verified + // through this same PPHR_verify_password path under PROXYSQL31, and a + // $ED$-prefixed admin password must not be routed into the ed25519 + // switch here -- that would be an unrecoverable admin-port lockout. + // With the gate scoped to MYSQL, such a password falls through to the + // pre-existing cleartext comparison instead, which is the intended + // conservative behavior for non-MySQL sessions. if ((*myds)->switching_auth_stage == 0 && (*myds)->switching_auth_sent != AUTH_MYSQL_ED25519 && - (*myds)->sess->session_type != PROXYSQL_SESSION_CLICKHOUSE) { + (*myds)->sess->session_type == PROXYSQL_SESSION_MYSQL) { const bool stored_is_ed = proxysql_ed25519_has_prefix(vars1.password); // a '*SHA1' or '$A$' hash cannot derive an ed25519 key const bool cred_usable = stored_is_ed || diff --git a/lib/mysql_connection.cpp b/lib/mysql_connection.cpp index a1346e4162..3950321063 100644 --- a/lib/mysql_connection.cpp +++ b/lib/mysql_connection.cpp @@ -503,6 +503,12 @@ MySQL_Connection::MySQL_Connection() { statuses.myconnpoll_put = 0; memset(gtid_uuid,0,sizeof(gtid_uuid)); memset(&connected_host_details, 0, sizeof(connected_host_details)); +#ifdef PROXYSQLED25519 + // Zero-initialize so an ASAN/MSAN run never reads an uninitialized nonce + // on the (unreachable in practice) path where it would be read before + // PPHR_ed25519_switch() first populates it via RAND_bytes(). + memset(ed25519_nonce, 0, sizeof(ed25519_nonce)); +#endif }; MySQL_Connection::~MySQL_Connection() { From 019d410a874c57d9e464bbb800afb92258fd6b95 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 11:42:40 +0000 Subject: [PATCH 14/23] test: exercise the ed25519 client-switch and forced change-user paths Final-review fix wave, test-coverage finding. The e2e test's headline assertions didn't exercise what their names claimed. The vendored MariaDB connector only sends client_ed25519 in the handshake response if MYSQL_DEFAULT_AUTH requests it; 'ed_user' has a plain cleartext ProxySQL credential (not $ED$-prefixed), so with no client-side plugin request every one of its connections silently ran over plain mysql_native_password. Assertions 1 ("connects via ed25519 auth switch"), 8-9 (COM_CHANGE_USER "into ed25519 user"), and 10-11 ("additional-password retry verifies ed25519 signature") never touched the ed25519 code path at all -- mislabeled coverage that would not have caught a regression in the client-requested-switch branch (`ed25519_switch_needed`'s `client_auth_plugin` check) or in signature verification driven by that path. Fixes, three parts: 1. Set `mysql_options(c, MYSQL_DEFAULT_AUTH, "client_ed25519")` before mysql_real_connect() on the two 'ed_user' cleartext-credential connections (initial connect, and the additional-password retry). This makes the client actually request client_ed25519, exercising switch-policy rule 2 (client request + usable cleartext credential -> derive key from the stored cleartext and verify the signature). 2. Added a new COM_CHANGE_USER case using 'ed_user_pk' (the $ED$-stored user): `mysql_change_user(c, "ed_user_pk", ED_PASS, NULL)`. A $ED$ stored credential forces the ed25519 change-user switch unconditionally (process_pkt_COM_CHANGE_USER's ed25519_switch_needed fires on the stored-credential prefix alone), so this needs no MYSQL_DEFAULT_AUTH option and gives coverage independent of client plugin negotiation. The change_user call itself is asserted ok (frontend signature verification succeeded); the following query is asserted to fail with the same 1045/"Access denied" backend limitation as the initial-connect $ED$ case, since the backend still can't complete auth without the cleartext password. 3. Renamed every affected assertion description to state the path it actually proves: the block-1 connect is now "connects via ed25519 auth switch (client-requested, key derived from cleartext)"; the pre-existing COM_CHANGE_USER-into-ed_user block is now explicitly labeled "(native auth path, cleartext credential)" since it still doesn't request client_ed25519 and was left as ordinary native-auth coverage (the ed25519 change-user path is now covered by the new block instead); the additional-password block is labeled "(client-requested switch)". plan() raised from 11 to 13 to match the two new assertions (change_user success + post-change_user query failure) from part 2. Verified against mariadb10-galera-g4 with the corresponding lib/ fix commit: 13/13 assertions pass, and the ProxySQL debug log confirms actual routing through the derivation path for 'ed_user': MySQL_Protocol.cpp:3111:PPHR_ed25519_switch(): ... user='ed_user' . Sent client_ed25519 Auth Switch MySQL_Protocol.cpp:3150:PPHR_ed25519_verify(): ... user='ed_user' . ed25519 signature verification succeeded --- test/tap/tests/test_ed25519_auth-t.cpp | 90 ++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/test/tap/tests/test_ed25519_auth-t.cpp b/test/tap/tests/test_ed25519_auth-t.cpp index 9656317e1b..94997116f5 100644 --- a/test/tap/tests/test_ed25519_auth-t.cpp +++ b/test/tap/tests/test_ed25519_auth-t.cpp @@ -3,15 +3,26 @@ * @brief End-to-end MariaDB ed25519 authentication (frontend + backend). * @details Requires a MariaDB backend (mariadb10-galera infra): installs the * auth_ed25519 server plugin, creates ed25519 backend users, and exercises: - * 1. cleartext-stored user: frontend ed25519 auth AND backend ed25519 auth - * (query reaches the backend); - * 2. $ED$-stored user: frontend auth succeeds, backend query fails with the - * backend's own 1045 "Access denied" (public key cannot drive backend auth -- + * 1. cleartext-stored user, client-requested switch: the client sets + * MYSQL_DEFAULT_AUTH=client_ed25519, so the server offers ed25519 in + * the greeting, the client requests it, and ProxySQL derives the key + * from the stored cleartext credential to verify the signature (spec + * switch-policy rule 2). Query then reaches the backend over the + * derived-key ed25519 backend connection. + * 2. $ED$-stored user: the stored "$ED$" credential forces the + * ed25519 switch unconditionally (independent of client plugin); + * frontend auth succeeds, backend query fails with the backend's own + * 1045 "Access denied" (public key cannot drive backend auth -- * documented limitation; asserted on the specific errno/message, not just * "the query failed", so a Galera blip or backend outage cannot pass this check); * 3. wrong password -> 1045; - * 4. COM_CHANGE_USER into an ed25519 user via Auth Switch; - * 5. additional-password (attributes JSON) retry; + * 4. COM_CHANGE_USER: (a) into a cleartext-stored ed25519-backed user via + * plain native auth (no client-side ed25519 request, so this exercises + * the ordinary change-user path, not the ed25519 switch -- labeled + * accordingly); (b) into the $ED$-stored user, which forces the + * ed25519 change-user switch regardless of client plugin; + * 5. additional-password (attributes JSON) retry, also with a + * client-requested ed25519 switch; * 6. malformed "$ED$"-prefixed stored credential ("$ED$short", wrong * length): connecting with the literal stored string as the password * must be denied with 1045, never accepted as a cleartext match @@ -44,7 +55,7 @@ int main(int argc, char** argv) { return EXIT_FAILURE; } - plan(11); + plan(13); // ---- fixture: backend plugin + users, via ProxySQL default routing ---- MYSQL* wr = mysql_init(NULL); @@ -115,11 +126,18 @@ int main(int argc, char** argv) { MYSQL_QUERY(admin, q3.c_str()); MYSQL_QUERY(admin, "LOAD MYSQL USERS TO RUNTIME"); - // ---- 1-2: cleartext-stored user, full frontend+backend path ---- + // ---- 1-2: cleartext-stored user, client-requested switch, full frontend+backend path ---- { MYSQL* c = mysql_init(NULL); + // Force the client to request client_ed25519 in the handshake response + // (the vendored connector otherwise defaults to mysql_native_password + // and the greeting's ed25519 offer is never taken up) -- this is what + // actually drives the switch-policy "client request + usable cleartext + // credential -> derive key" path (spec switch-policy rule 2), rather + // than silently falling through to plain native auth. + mysql_options(c, MYSQL_DEFAULT_AUTH, "client_ed25519"); bool conn_ok = mysql_real_connect(c, cl.host, "ed_user", ED_PASS, NULL, cl.port, NULL, 0) != NULL; - ok(conn_ok, "cleartext-stored user connects via ed25519 auth switch (err: %s)", conn_ok ? "-" : mysql_error(c)); + ok(conn_ok, "connects via ed25519 auth switch (client-requested, key derived from cleartext) (err: %s)", conn_ok ? "-" : mysql_error(c)); if (conn_ok) { int rc = mysql_query(c, "SELECT CURRENT_USER()"); ok(rc == 0, "query reaches the ed25519 backend user (err: %s)", rc ? mysql_error(c) : "-"); @@ -189,7 +207,13 @@ int main(int argc, char** argv) { mysql_close(c); } - // ---- 7-8: COM_CHANGE_USER into the ed25519 user ---- + // ---- 7-8: COM_CHANGE_USER into a cleartext-stored ed25519-backed user, native auth path ---- + // The connector's COM_CHANGE_USER request here does not carry a client_ed25519 + // plugin request (no MYSQL_DEFAULT_AUTH set on this connection), and 'ed_user's + // stored ProxySQL credential is plain cleartext (not "$ED$"-prefixed), so this + // exercises the ordinary native-auth change-user path, not the ed25519 switch -- + // labeled accordingly. See the block below for change-user via the forced + // ed25519 switch. { MYSQL* c = mysql_init(NULL); bool conn_ok = mysql_real_connect(c, cl.host, cl.username, cl.password, NULL, cl.port, NULL, 0) != NULL; @@ -198,15 +222,48 @@ int main(int argc, char** argv) { ok(false, "change_user skipped"); } else { int rc = mysql_change_user(c, "ed_user", ED_PASS, NULL); - ok(rc == 0, "COM_CHANGE_USER into ed25519 user succeeds (err: %s)", rc ? mysql_error(c) : "-"); + ok(rc == 0, "COM_CHANGE_USER into ed25519-backed user succeeds (native auth path, cleartext credential) (err: %s)", rc ? mysql_error(c) : "-"); rc = mysql_query(c, "SELECT 1"); if (rc == 0) { mysql_free_result(mysql_store_result(c)); } - ok(rc == 0, "query works after change_user (err: %s)", rc ? mysql_error(c) : "-"); + ok(rc == 0, "query works after change_user, native auth path (err: %s)", rc ? mysql_error(c) : "-"); } mysql_close(c); } - // ---- 9-10: additional-password retry (attributes JSON, hex-encoded) ---- + // ---- 8b-8c: COM_CHANGE_USER into the $ED$-stored user forces the ed25519 + // change-user switch, regardless of client plugin (unlike the block above, + // no MYSQL_DEFAULT_AUTH is needed here -- the stored "$ED$" credential alone + // makes process_pkt_COM_CHANGE_USER's ed25519_switch_needed gate fire). + // The signature-based frontend auth for change_user must succeed; a + // following query is expected to fail with the same public-key-only + // backend limitation as the initial-connect $ED$ case above. + { + MYSQL* c = mysql_init(NULL); + bool conn_ok = mysql_real_connect(c, cl.host, cl.username, cl.password, NULL, cl.port, NULL, 0) != NULL; + if (!conn_ok) { + ok(false, "base connection for $ED$ change_user failed: %s", mysql_error(c)); + ok(false, "$ED$ change_user query check skipped"); + } else { + int rc = mysql_change_user(c, "ed_user_pk", ED_PASS, NULL); + ok(rc == 0, "COM_CHANGE_USER into $ED$-stored user succeeds (forced ed25519 switch, signature verified) (err: %s)", rc ? mysql_error(c) : "-"); + if (rc == 0) { + int qrc = mysql_query(c, "SELECT 1"); + if (qrc == 0) { mysql_free_result(mysql_store_result(c)); } + unsigned int eno = mysql_errno(c); + const char* emsg = mysql_error(c); + bool is_access_denied = emsg && strstr(emsg, "Access denied") != NULL; + ok(qrc != 0 && eno == ED25519_PK_BACKEND_ERRNO && is_access_denied, + "query after $ED$ change_user fails with errno %d and 'Access denied' " + "(documented public-key-only backend limitation; got errno %u, error '%s')", + ED25519_PK_BACKEND_ERRNO, eno, emsg ? emsg : ""); + } else { + ok(false, "query check skipped: change_user failed"); + } + } + mysql_close(c); + } + + // ---- 9-10: additional-password retry (attributes JSON, hex-encoded), client-requested switch ---- { // primary password wrong on purpose; additional_password holds the real one char hexpass[64] = { 0 }; @@ -221,8 +278,13 @@ int main(int argc, char** argv) { MYSQL_QUERY(admin, "LOAD MYSQL USERS TO RUNTIME"); MYSQL* c = mysql_init(NULL); + // Same client-requested-switch rationale as the block 1-2 connection: + // without this, the vendored connector never offers client_ed25519 and + // the additional-password retry would silently run over plain native + // auth instead of the ed25519 signature-verification path. + mysql_options(c, MYSQL_DEFAULT_AUTH, "client_ed25519"); bool conn_ok = mysql_real_connect(c, cl.host, "ed_user", ED_PASS, NULL, cl.port, NULL, 0) != NULL; - ok(conn_ok, "additional-password retry verifies ed25519 signature (err: %s)", conn_ok ? "-" : mysql_error(c)); + ok(conn_ok, "additional-password retry verifies ed25519 signature (client-requested switch) (err: %s)", conn_ok ? "-" : mysql_error(c)); if (conn_ok) { int rc = mysql_query(c, "SELECT 1"); if (rc == 0) { mysql_free_result(mysql_store_result(c)); } From 500d815517353b7dc506a5e372c49422e8955994 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 11:42:47 +0000 Subject: [PATCH 15/23] docs: clarify default_authentication_plugin requirement and 3.0 upgrade note Final-review fix wave, doc findings. - Protocol behavior section: state plainly that frontend ed25519 requires mysql-default_authentication_plugin=mysql_native_password (ProxySQL's built-in default, so unaffected deployments need no change). If it's set to caching_sha2_password instead, ProxySQL advertises caching_sha2_password in the greeting and an ordinary client -- one that hasn't explicitly requested client_ed25519 -- switches early to caching_sha2_password before ProxySQL can route it into the ed25519 exchange; on that path a $ED$-stored user is denied unconditionally without ever reaching ed25519 verification. This was previously only implicit in the "single auth switch" limitation bullet. - New "Upgrading from 3.0" section: the $ED$ prefix becomes reserved as of this feature. A pre-existing 3.0 deployment with a cleartext password that coincidentally starts with the literal "$ED$" stops authenticating after upgrade, since that value is now parsed as an ed25519 credential and never compared as cleartext. This is intentional fail-closed behavior (human-approved during design): a parse failure denies access rather than silently falling back to cleartext comparison of an unparseable "$ED$..." value. ProxySQL logs a warning for the affected account on each connection attempt (MySQL_Protocol.cpp's PPHR_ed25519_verify(), "has a malformed $ED$ ed25519 credential; denying access"). --- doc/ed25519_authentication.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/doc/ed25519_authentication.md b/doc/ed25519_authentication.md index 46eabb2b0b..4eb82e3268 100644 --- a/doc/ed25519_authentication.md +++ b/doc/ed25519_authentication.md @@ -47,6 +47,31 @@ the same auth-switch mechanism. TLS is not required: the exchange never transmits a secret. +**Frontend ed25519 requires `mysql-default_authentication_plugin=mysql_native_password`** +(this is ProxySQL's built-in default, so no change is needed unless it was +overridden). If it is set to `caching_sha2_password` instead, ProxySQL +advertises `caching_sha2_password` in its initial handshake greeting, and an +ordinary client — one that has not explicitly requested `client_ed25519` — +switches early to `caching_sha2_password` before ProxySQL has a chance to +route it into the ed25519 exchange. On that early-switch path, a `$ED$` +stored user is denied unconditionally (see Limitations below): it never +reaches the ed25519 verification code at all. + +## Upgrading from 3.0 + +The `$ED$` prefix becomes reserved as of this feature: any stored +`mysql_users.password` value that literally begins with `$ED$` is now +parsed as an ed25519 credential, never compared as cleartext. If an +existing 3.0 deployment happens to have a cleartext password that starts +with the literal four characters `$ED$` (coincidental, but possible), +that account stops authenticating after the upgrade — this is fail-closed +by design (human-approved: silently falling back to cleartext comparison +for an unparseable "$ED$..." value was judged more dangerous than a hard +failure). ProxySQL logs a warning for the affected account on each +connection attempt. Fix by renaming the credential to not start with +`$ED$`, or by re-issuing it as a proper `$ED$` ed25519 +credential if that was the intent. + ## Limitations - `$ED$` (public-key-only) users cannot open backend connections: the From 38ce7b1feafe0ef236ada192559d47db3d9b4d13 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 11:57:07 +0000 Subject: [PATCH 16/23] fix: extend $ED$ fail-closed denial to SQLite3-server sessions Scoped re-review residual on the ed25519 final-fix wave. Human ruling on the finding, implemented exactly: The previous commit (5780da236) scoped all three ed25519-related session checks in lib/MySQL_Protocol.cpp down to `== PROXYSQL_SESSION_MYSQL` to fix an admin/stats lockout risk. That was correct for the two gates that control the ed25519 nonce-based Auth Switch EXCHANGE (the stage-0 gate in PPHR_verify_password and ed25519_switch_needed in process_pkt_COM_CHANGE_USER) -- only a MySQL-wire client can complete a MySQL Auth Switch, so scoping the exchange to MYSQL is a protocol necessity, not a policy choice. But it over-narrowed the fail-closed $ED$-prefix DENIAL in verify_user_pass's cleartext-password branch, which is a different kind of check: a trust decision, not a protocol capability. That denial's purpose is to guarantee a "$ED$"-prefixed stored credential (including a malformed one, e.g. "$ED$short") is NEVER compared as a literal cleartext password. Narrowing it to MYSQL-only reopened exactly the cleartext fall-through the original fail-closed fix (2b85860dd) was written to close, but now on PROXYSQL_SESSION_SQLITE: ProxySQL's SQLite3-server frontend (its own admin-clone SQL port) authenticates against the same mysql_users rows via the same USERNAME_FRONTEND credential scope in GloMyAuth. A stored "$ED$short" value would again be compared as a literal password on that port, and the stored string itself would authenticate. Ruling: the fail-closed $ED$ reservation applies to both PROXYSQL_SESSION_MYSQL and PROXYSQL_SESSION_SQLITE (both frontend consumers of the same credential rows); the ed25519 auth exchange itself remains MYSQL-only, unchanged. Change: verify_user_pass's has_prefix denial condition changed from `session_type == PROXYSQL_SESSION_MYSQL` to `(session_type == PROXYSQL_SESSION_MYSQL || session_type == PROXYSQL_SESSION_SQLITE)`, with an updated comment explaining the two-different-scopes distinction (reservation vs. exchange) so this isn't re-collapsed to one scope again in a future edit. The stage-0 gate and process_pkt_COM_CHANGE_USER's ed25519_switch_needed gate are untouched -- both stay MYSQL-only per the ruling. Verified: the handshake-path dispatch that also denies $ED$-prefixed credentials -- `if (auth_plugin_id == AUTH_MYSQL_ED25519 || proxysql_ed25519_has_prefix(vars1.password))` in PPHR_verify_password (~MySQL_Protocol.cpp:3678, routing into PPHR_ed25519_verify) -- already carries no session-type condition at all, so SQLite3-server handshake auth for a $ED$-stored user was already routed to PPHR_ed25519_verify and denied (that path never needed this fix); the residual was isolated to verify_user_pass's cleartext-password branch, used by the COM_CHANGE_USER / secondary verification path. Testing: - PROXYSQL31=1 make debug: exit 0 - test/tap/tests/unit/ed25519_unit-t: 24/24 (unchanged, no unit test touches this scoping) - e2e test_ed25519_auth-t against mariadb10-galera-g4: 13/13 (unchanged from before this commit -- confirms MySQL-session behavior, which is all that test exercises, is untouched by widening the SQLite3 denial) --- lib/MySQL_Protocol.cpp | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 5433b13c71..34d47a0a8f 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -1570,12 +1570,24 @@ bool MySQL_Protocol::verify_user_pass( // routes such users through the nonce-based Auth Switch before // reaching this function, but verify_user_pass() fails closed on its // own regardless of caller-side gating. - // Scoped to MYSQL sessions only: admin/stats credentials flow through - // this same verify path under PROXYSQL31, and a $ED$-prefixed admin - // password must not be denied here -- it falls through to the - // pre-existing cleartext comparison below, unchanged from before this - // feature (the feature simply doesn't apply to non-MySQL sessions). - if (session_type == PROXYSQL_SESSION_MYSQL && proxysql_ed25519_has_prefix(password)) { + // Scope note -- the fail-closed $ED$ RESERVATION and the ed25519 + // EXCHANGE are deliberately two different scopes: + // - Reservation (this denial): applies to every session type that + // consumes GloMyAuth's USERNAME_FRONTEND credential scope, i.e. + // PROXYSQL_SESSION_MYSQL *and* PROXYSQL_SESSION_SQLITE -- the + // SQLite3-server frontend (ProxySQL's own admin-clone SQL port) + // reads the same mysql_users rows. Narrowing this to MYSQL-only + // reopened a cleartext fall-through on the SQLite3 port: a stored + // "$ED$short" value would again be compared literally and the + // stored string itself would authenticate there -- exactly what + // this fail-closed fix exists to prevent, regardless of which + // frontend port is asking. + // - Exchange (the stage-0 gate in PPHR_verify_password and + // ed25519_switch_needed in process_pkt_COM_CHANGE_USER): stays + // MYSQL-only. That is a protocol capability -- only a MySQL-wire + // client can complete a MySQL Auth Switch -- not a trust + // decision, so it is scoped independently of this reservation. + if ((session_type == PROXYSQL_SESSION_MYSQL || session_type == PROXYSQL_SESSION_SQLITE) && proxysql_ed25519_has_prefix(password)) { ret = false; } else #endif From 33bdf5f6d6130e2fe18335a0790a682165ffbbc1 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 12:55:00 +0000 Subject: [PATCH 17/23] fix: address bot-review findings on the ed25519 auth paths Four review findings from PR #6033 (Codex, CodeRabbit, gitar): - Scope the $ED$ credential dispatch in PPHR_verify_password to MYSQL||SQLITE sessions (Codex P1). The dispatch was session-unscoped, so under PROXYSQL31 a $ED$-prefixed admin/stats password routed the 20-byte native response into the 64-byte signature check -- an unrecoverable admin-port lockout on upgrade. ADMIN/STATS now fall through to the pre-existing cleartext comparison; SQLITE keeps the fail-closed reservation, MYSQL keeps the exchange. - Mirror the cred_usable check in the COM_CHANGE_USER gate (CodeRabbit): an explicit client_ed25519 request against a '*SHA1'/'$A$' credential no longer wastes an Auth Switch round trip on a doomed exchange. - Strict canonical base64 in proxysql_ed25519_decode_pubkey (CodeRabbit): EVP_DecodeBlock alone treats '=' anywhere as six zero bits and accepts non-canonical trailing bits, silently decoding a corrupted credential to a DIFFERENT key; a re-encode round-trip now rejects embedded '=', trailing '=', and non-canonical final symbols. Unit tests added for all three (24 -> 27 assertions). - Rate-limit the $ED$ backend-connect warning to once per user (CodeRabbit Major + gitar): connect_start is a hot path and the pool retries failed connects, so a misconfigured user could flood the log. Also NULL-safe on username. Additionally: convert the ED25519_* macros to inline constexpr (SonarCloud S5028) and forward PROXYSQLED25519 to the unit-test Makefile's recursive libproxysql.a rebuild (CodeRabbit) so a header-triggered rebuild cannot produce a MySQL_Connection layout mismatch between the archive and unit-test objects. --- include/MySQL_Ed25519.h | 14 +++++++------- lib/MySQL_Ed25519.cpp | 10 ++++++++++ lib/MySQL_Protocol.cpp | 26 +++++++++++++++++++++++--- lib/mysql_connection.cpp | 25 +++++++++++++++++++++---- test/tap/tests/unit/Makefile | 1 + test/tap/tests/unit/ed25519_unit-t.cpp | 26 +++++++++++++++++++++++--- 6 files changed, 85 insertions(+), 17 deletions(-) diff --git a/include/MySQL_Ed25519.h b/include/MySQL_Ed25519.h index 154a887f00..24cf5383c3 100644 --- a/include/MySQL_Ed25519.h +++ b/include/MySQL_Ed25519.h @@ -21,13 +21,13 @@ * confused with a cleartext password. */ -#define ED25519_NONCE_LEN 32 -#define ED25519_SIG_LEN 64 -#define ED25519_PUBKEY_LEN 32 -#define ED25519_PUBKEY_B64_LEN 43 -#define ED25519_STORED_PREFIX "$ED$" -#define ED25519_STORED_PREFIX_LEN 4 -#define ED25519_STORED_LEN (ED25519_STORED_PREFIX_LEN + ED25519_PUBKEY_B64_LEN) +inline constexpr size_t ED25519_NONCE_LEN = 32; +inline constexpr size_t ED25519_SIG_LEN = 64; +inline constexpr size_t ED25519_PUBKEY_LEN = 32; +inline constexpr size_t ED25519_PUBKEY_B64_LEN = 43; +inline constexpr char ED25519_STORED_PREFIX[] = "$ED$"; +inline constexpr size_t ED25519_STORED_PREFIX_LEN = sizeof(ED25519_STORED_PREFIX) - 1; +inline constexpr size_t ED25519_STORED_LEN = ED25519_STORED_PREFIX_LEN + ED25519_PUBKEY_B64_LEN; /** @brief Derive the 32-byte public key from a cleartext password (MariaDB variant). */ void proxysql_ed25519_derive_public_key(const char* password, size_t password_len, unsigned char* out_pubkey); diff --git a/lib/MySQL_Ed25519.cpp b/lib/MySQL_Ed25519.cpp index 8e77d07648..f4134c17ab 100644 --- a/lib/MySQL_Ed25519.cpp +++ b/lib/MySQL_Ed25519.cpp @@ -47,6 +47,16 @@ bool proxysql_ed25519_decode_pubkey(const char* stored, unsigned char* out_pubke in[ED25519_PUBKEY_B64_LEN] = '='; unsigned char out[33]; if (EVP_DecodeBlock(out, in, sizeof(in)) != 33) return false; + // EVP_DecodeBlock() alone is too lenient: it treats '=' anywhere in the + // payload as six zero bits and silently accepts non-canonical trailing + // bits in the final symbol, so a corrupted credential could decode to a + // DIFFERENT key than the operator intended instead of being rejected. + // Round-trip check: re-encode the decoded key (44 chars incl. canonical + // padding + NUL) and require the first 43 characters to match the stored + // payload exactly. + unsigned char reencoded[45]; + EVP_EncodeBlock(reencoded, out, ED25519_PUBKEY_LEN); + if (memcmp(reencoded, in, ED25519_PUBKEY_B64_LEN) != 0) return false; memcpy(out_pubkey, out, ED25519_PUBKEY_LEN); return true; } diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 34d47a0a8f..c6dc28b6e9 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -1916,10 +1916,19 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in // fall through to the pre-existing cleartext comparison, which is // the intended conservative behavior for non-MySQL sessions (the // feature simply doesn't apply there). + const bool cu_stored_is_ed = proxysql_ed25519_has_prefix(password); + // a '*SHA1' or '$A$' hash cannot derive an ed25519 key; refuse the + // switch for an explicit client_ed25519 request against such a + // credential instead of wasting a round trip on a doomed exchange + // (mirrors the cred_usable check in the PPHR_verify_password gate) + const bool cu_cred_usable = cu_stored_is_ed || + (password[0] != '*' && + !(strlen(password) == 70 && strncasecmp(password, "$A$0", 4) == 0)); const bool ed25519_switch_needed = session_type == PROXYSQL_SESSION_MYSQL && - (proxysql_ed25519_has_prefix(password) || - (client_auth_plugin && strcmp(client_auth_plugin, plugins[AUTH_MYSQL_ED25519]) == 0)); + (cu_stored_is_ed || + (cu_cred_usable && client_auth_plugin && + strcmp(client_auth_plugin, plugins[AUTH_MYSQL_ED25519]) == 0)); if (ed25519_switch_needed) { if (RAND_bytes((*myds)->myconn->ed25519_nonce, ED25519_NONCE_LEN) != 1) { proxy_error("RAND_bytes() failed generating the ed25519 nonce for user '%s'\n", user); @@ -3675,7 +3684,18 @@ bool MySQL_Protocol::PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_d // malformed "$ED$..." stored credential must still be denied via // PPHR_ed25519_verify()'s generic failure, never treated as a // cleartext/native password comparison below. - if (auth_plugin_id == AUTH_MYSQL_ED25519 || proxysql_ed25519_has_prefix(vars1.password)) { + // Session scope matches the $ED$ reservation rule: MYSQL (the + // only session type the ed25519 exchange is offered on) and + // SQLITE (shares the USERNAME_FRONTEND credential rows, so the + // fail-closed reservation must hold there too). ADMIN/STATS are + // deliberately excluded: their credentials flow through this + // same function under PROXYSQL31, and routing a $ED$-prefixed + // admin password into the 64-byte signature check would be an + // unrecoverable admin-port lockout -- they fall through to the + // pre-existing cleartext comparison instead. + if (((*myds)->sess->session_type == PROXYSQL_SESSION_MYSQL || + (*myds)->sess->session_type == PROXYSQL_SESSION_SQLITE) && + (auth_plugin_id == AUTH_MYSQL_ED25519 || proxysql_ed25519_has_prefix(vars1.password))) { // signature collected by PPHR_1 after the Auth Switch; a stored // "$ED$" key with a non-ed25519 response fails the length check // inside PPHR_ed25519_verify (generic denial) diff --git a/lib/mysql_connection.cpp b/lib/mysql_connection.cpp index 3950321063..5d5861e460 100644 --- a/lib/mysql_connection.cpp +++ b/lib/mysql_connection.cpp @@ -15,6 +15,8 @@ using json = nlohmann::json; #include "MySQL_Query_Processor.h" #include "MySQL_Variables.h" #include +#include +#include #ifdef PROXYSQLED25519 #include "MySQL_Ed25519.h" @@ -1037,10 +1039,25 @@ void MySQL_Connection::connect_start() { // well-formed one -- the "$ED$" marker is reserved and never a real // cleartext password either way. if (userinfo->password && proxysql_ed25519_has_prefix(userinfo->password)) { - proxy_warning( - "User '%s' has an ed25519 public-key-only ($ED$) credential;" - " backend authentication requires the cleartext password and will fail\n", - userinfo->username); + // connect_start() runs for every backend connect attempt and the pool + // retries failed connects, so warn once per user rather than flooding + // the error log; MySQL_Authentication::add() already flags the row at + // load time. The set is bounded by the number of distinct usernames, + // and this branch is only entered for misconfigured $ED$ users. + static std::mutex ed25519_warned_mutex; + static std::set ed25519_warned_users; + const std::string ed25519_uname { userinfo->username ? userinfo->username : "" }; + bool ed25519_first_warning = false; + { + std::lock_guard lock(ed25519_warned_mutex); + ed25519_first_warning = ed25519_warned_users.insert(ed25519_uname).second; + } + if (ed25519_first_warning) { + proxy_warning( + "User '%s' has an ed25519 public-key-only ($ED$) credential;" + " backend authentication requires the cleartext password and will fail\n", + ed25519_uname.c_str()); + } } #endif if (parent->port) { diff --git a/test/tap/tests/unit/Makefile b/test/tap/tests/unit/Makefile index 21879b542c..737a93b037 100644 --- a/test/tap/tests/unit/Makefile +++ b/test/tap/tests/unit/Makefile @@ -383,6 +383,7 @@ $(LIBPROXYSQLAR): FORCE $(MAKE) -C $(PROXYSQL_PATH)/lib libproxysql.a \ PROXYSQLCLICKHOUSE=1 \ PROXYSQLFFTO=$(PROXYSQLFFTO) PROXYSQLTSDB=$(PROXYSQLTSDB) \ + PROXYSQLED25519=$(PROXYSQLED25519) \ PROXYSQL40=$(PROXYSQL40) PROXYSQL31=$(PROXYSQL31) CC=$(CC) CXX=$(CXX) diff --git a/test/tap/tests/unit/ed25519_unit-t.cpp b/test/tap/tests/unit/ed25519_unit-t.cpp index d1d1462843..970d879812 100644 --- a/test/tap/tests/unit/ed25519_unit-t.cpp +++ b/test/tap/tests/unit/ed25519_unit-t.cpp @@ -55,7 +55,7 @@ int main() { 3 /* decode round-trips */ + 7 /* is_pubkey_format edge cases */ + 5 /* has_prefix edge cases */ + - 2 /* decode_pubkey malformed */ + + 5 /* decode_pubkey malformed / non-canonical */ + 1 /* signature KAT */ + 3 /* tampered signature / nonce / key */ ); @@ -107,13 +107,33 @@ int main() { ok(proxysql_ed25519_has_prefix(NULL) == false, "has_prefix: NULL rejected"); } - // 4. decode_pubkey malformed input + // 4. decode_pubkey malformed / non-canonical input. + // EVP_DecodeBlock() alone treats '=' anywhere as six zero bits and ignores + // non-canonical trailing bits, so decode_pubkey adds a re-encode round-trip; + // these cases must all be rejected rather than silently decoding to a + // different key than the operator stored. { unsigned char pk[ED25519_PUBKEY_LEN]; // 43 chars but contains characters outside the base64 alphabet std::string bad = std::string(ED25519_STORED_PREFIX) + "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"; ok(proxysql_ed25519_decode_pubkey(bad.c_str(), pk) == false, "invalid base64 chars rejected"); ok(proxysql_ed25519_decode_pubkey("not-ed25519-at-all", pk) == false, "non-$ED$ string rejected"); + + std::string valid = std::string(ED25519_STORED_PREFIX) + KATS[1].pubkey_b64; + std::string embedded_eq = valid; + embedded_eq[ED25519_STORED_PREFIX_LEN + 20] = '='; + ok(proxysql_ed25519_decode_pubkey(embedded_eq.c_str(), pk) == false, + "embedded '=' in payload rejected (would decode as zero bits)"); + std::string trailing_eq = valid; + trailing_eq[trailing_eq.size() - 1] = '='; + ok(proxysql_ed25519_decode_pubkey(trailing_eq.c_str(), pk) == false, + "'=' as 43rd payload char rejected"); + // KATS[1] ends in 'Q' (0b010000, canonical: final 2 slack bits zero); + // 'R' (0b010001) decodes to the same 32 bytes but is non-canonical + std::string noncanon = valid; + noncanon[noncanon.size() - 1] = 'R'; + ok(proxysql_ed25519_decode_pubkey(noncanon.c_str(), pk) == false, + "non-canonical final symbol rejected (trailing bits not zero)"); } // 5. signature known-answer test @@ -121,7 +141,7 @@ int main() { unsigned char nonce[ED25519_NONCE_LEN]; unsigned char pk[ED25519_PUBKEY_LEN]; unhex(SIG_HEX, sig, sizeof(sig)); - for (int i = 0; i < ED25519_NONCE_LEN; i++) nonce[i] = static_cast(i); + for (size_t i = 0; i < ED25519_NONCE_LEN; i++) nonce[i] = static_cast(i); proxysql_ed25519_derive_public_key("ed25519_pass_1", strlen("ed25519_pass_1"), pk); ok(proxysql_ed25519_verify_signature(sig, nonce, pk) == true, "known-answer signature verifies"); From 9df1b97327621bdc73e9254a5e652428a18a3b4f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 12:55:00 +0000 Subject: [PATCH 18/23] test: harden the ed25519 e2e fixture and satisfy the Sonar security gate - sprintf -> snprintf in the hex-encoding loop (SonarCloud S6069, the finding that failed the PR quality gate's security rating). - Drop-and-recreate the backend fixture accounts instead of CREATE USER IF NOT EXISTS (CodeRabbit): a stale account left by a previous run with a different authentication string would fail the ED_PASS assertions for the wrong reason. - ED25519_PK_BACKEND_ERRNO macro -> constexpr (SonarCloud S5028). Verified: 13/13 e2e assertions on mariadb10-galera-g4 against the rebuilt binary; 27/27 unit assertions. --- test/tap/tests/test_ed25519_auth-t.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/tap/tests/test_ed25519_auth-t.cpp b/test/tap/tests/test_ed25519_auth-t.cpp index 94997116f5..6c1a4b5480 100644 --- a/test/tap/tests/test_ed25519_auth-t.cpp +++ b/test/tap/tests/test_ed25519_auth-t.cpp @@ -46,7 +46,7 @@ const char* ED_PUBKEY = "5TBW79xTAMbhi8QKQtLLVS0V0b2w9mlKnRG6c+2NxTQ"; // ProxySQL propagates that same errno/message straight through to the client -- this // is not a ProxySQL-specific connect-timeout code, it is the backend's own "Access // denied for user ..." response, forwarded verbatim. -#define ED25519_PK_BACKEND_ERRNO 1045 +constexpr unsigned int ED25519_PK_BACKEND_ERRNO = 1045; int main(int argc, char** argv) { CommandLine cl; @@ -87,11 +87,16 @@ int main(int argc, char** argv) { // every connection/change_user call in this test passes a NULL db -- this still exercises // the full frontend+backend authentication path (the thing under test) without requiring // object-level privileges that the fixture-creating account does not have. + // drop-and-recreate: a previous run (or an unrelated test) may have left + // these accounts behind with a different authentication string, which + // would make the ED_PASS assertions fail for the wrong reason + MYSQL_QUERY(wr, "DROP USER IF EXISTS 'ed_user'@'%'"); std::string create_user = - std::string("CREATE USER IF NOT EXISTS 'ed_user'@'%' IDENTIFIED VIA ed25519 USING '") + ED_PUBKEY + "'"; + std::string("CREATE USER 'ed_user'@'%' IDENTIFIED VIA ed25519 USING '") + ED_PUBKEY + "'"; MYSQL_QUERY(wr, create_user.c_str()); + MYSQL_QUERY(wr, "DROP USER IF EXISTS 'ed_user_pk'@'%'"); std::string create_user_pk = - std::string("CREATE USER IF NOT EXISTS 'ed_user_pk'@'%' IDENTIFIED VIA ed25519 USING '") + ED_PUBKEY + "'"; + std::string("CREATE USER 'ed_user_pk'@'%' IDENTIFIED VIA ed25519 USING '") + ED_PUBKEY + "'"; MYSQL_QUERY(wr, create_user_pk.c_str()); // ---- proxysql users ---- @@ -268,7 +273,7 @@ int main(int argc, char** argv) { // primary password wrong on purpose; additional_password holds the real one char hexpass[64] = { 0 }; for (size_t i = 0; i < strlen(ED_PASS); i++) { - sprintf(hexpass + 2 * i, "%02x", (unsigned char)ED_PASS[i]); + snprintf(hexpass + 2 * i, sizeof(hexpass) - 2 * i, "%02x", (unsigned char)ED_PASS[i]); } std::string q = "UPDATE mysql_users SET password='not_the_real_password'," From 88a91c03a62edd53dbb84527b8df62f1f02a3470 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 12:55:00 +0000 Subject: [PATCH 19/23] docs: document the $ED$-additional-credential limitation, markdown polish - doc/ed25519_authentication.md: a $ED$ public key stored as the ADDITIONAL password requires the client to explicitly request client_ed25519 (Codex P2). Deliberately documented rather than auto-switching on the additional credential's format: forcing every client of such an account through ed25519 would break clients without the plugin whose primary credential is perfectly valid. - Add language identifiers to protocol-flow fences (markdownlint MD040). - Mark the implementation plan as a historical artifact: review-driven fix rounds amended assertion counts and internals after it was written; the shipped code and tests are authoritative (CodeRabbit flagged the stale embedded counts). --- doc/ed25519_authentication.md | 9 +++++++++ .../plans/2026-08-11-ed25519-authentication.md | 8 +++++++- .../specs/2026-08-11-ed25519-authentication-design.md | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/doc/ed25519_authentication.md b/doc/ed25519_authentication.md index 4eb82e3268..811fed6d6f 100644 --- a/doc/ed25519_authentication.md +++ b/doc/ed25519_authentication.md @@ -85,4 +85,13 @@ credential if that was the intent. a stored-`$ED$` user cannot be verified on that connection — the MySQL protocol allows a single auth switch. Standard MariaDB clients do not hit this. +- A `$ED$` public key stored as the *additional* password (the + `additional_password` attribute) while the primary credential is an + ordinary password requires the client to explicitly request + `client_ed25519` (e.g. `--default-auth=client_ed25519`). ProxySQL + decides the auth switch from the primary credential and the client's + requested plugin; it deliberately does not force every client of such + an account through ed25519, because that would break clients without + the `client_ed25519` plugin whose primary credential is perfectly + valid. - MariaDB PARSEC (11.6+) is not supported. diff --git a/docs/superpowers/plans/2026-08-11-ed25519-authentication.md b/docs/superpowers/plans/2026-08-11-ed25519-authentication.md index a370a15540..a69a4c29ec 100644 --- a/docs/superpowers/plans/2026-08-11-ed25519-authentication.md +++ b/docs/superpowers/plans/2026-08-11-ed25519-authentication.md @@ -2,6 +2,12 @@ > **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. +> **Historical artifact.** This plan reflects plan-time expectations; review-driven +> fix rounds amended the implementation afterwards (e.g. the unit test grew from 19 +> to a larger assertion count, the e2e test from 10, and the nonce moved to a +> dedicated `ed25519_nonce` member). The shipped code and tests are authoritative; +> embedded expected outputs here are not updated retroactively. + **Goal:** Support MariaDB's ed25519 authentication (`client_ed25519`) for frontend client connections (v3.1+ tier) and backend connections (all tiers, via the connector), per the approved spec `docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md`. **Architecture:** The vendored MariaDB Connector/C's `client_ed25519` plugin is flipped from DYNAMIC to STATIC, which (a) gives backend connections ed25519 transparently and (b) puts the ref10 crypto symbols (`crypto_sign_keypair`, `crypto_sign_open`) into `libmariadbclient.a` where a thin new wrapper (`lib/MySQL_Ed25519.cpp`) calls them. Frontend auth always runs through an Auth Switch carrying a fresh 32-byte nonce; the client answers with a 64-byte signature verified against a stored `$ED$` public key or a key derived from a stored cleartext password. @@ -494,7 +500,7 @@ example, independently confirming scheme compatibility." **Flow being implemented** (mirrors how MariaDB itself works — ed25519 is never advertised in the greeting; it always runs through an Auth Switch): -``` +```text client HandshakeResponse (any plugin) → PPHR_verify_password stage 0: stored "$ED$" OR client asked client_ed25519 → PPHR_ed25519_switch(): 32-byte RAND_bytes nonce into scramble_buff, diff --git a/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md b/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md index 58c11de5a4..15fa1dfa69 100644 --- a/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md +++ b/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md @@ -98,7 +98,7 @@ native, `$A$0…` length 70 → caching_sha2): 20+1-byte scramble while ed25519 signs a 32-byte challenge. MariaDB itself always routes ed25519 through an Auth Switch. Flow: - ``` + ```text greeting (native|sha2) → HandshakeResponse → AuthSwitchRequest "client_ed25519" + 32-byte scramble → 64-byte signature → OK / ERR From 8bb188023adec1a43f9dcc42b05f280bc15dc797 Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 15:14:20 +0000 Subject: [PATCH 20/23] style: drop unnecessary const-removing cast in verify_user_pass ed25519 arm auth_plugin is already const char*; strncmp takes const char* directly. Clears the SonarCloud M23_090 critical flagged on this new-code line (the identical pre-existing casts on the adjacent legacy arms are deliberately left untouched). --- lib/MySQL_Protocol.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 8a92430501..5855fa157b 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -1556,7 +1556,7 @@ bool MySQL_Protocol::verify_user_pass( //auth_plugin_id = 2; // FIXME: this is temporary, because yet not supported auth_plugin_id = AUTH_MYSQL_CACHING_SHA2_PASSWORD; // FIXME: this is temporary, because yet not supported . It must become 3 #ifdef PROXYSQLED25519 - } else if (strncmp((char *)auth_plugin,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519]))==0) { + } else if (strncmp(auth_plugin,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519]))==0) { auth_plugin_id = AUTH_MYSQL_ED25519; #endif } From 293c0868a3a05d6c814f8f8fbbb47d6d5612accd Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 15:34:51 +0000 Subject: [PATCH 21/23] style: resolve remaining SonarCloud new-code findings Fix where the fix is a real improvement, NOSONAR with a stated reason where the flagged pattern is deliberate: - New ed25519_cred_usable() helper shared by the initial-handshake and COM_CHANGE_USER gates: removes the duplicated inline '$A$0' format test (a deferred finding from the branch's final review) and uses a bounded strnlen scan (S5813 x2). - Compile-time ED25519_PLUGIN_NAME_LEN replaces five strlen(plugins[AUTH_MYSQL_ED25519]) calls on the fixed plugin name (S5813 x5). - proxysql_ed25519_is_pubkey_format uses a bounded strnlen (S5813). - ED_PASS in the e2e test becomes a constexpr array; the hex loop bounds on sizeof (S5813). - NOSONAR with justification on: the arbitrary-length password strlen feeding key derivation (MariaDB hashes the whole password, no bound exists); the ref10-mandated const_cast (upstream C API takes non-const pw it never modifies); the KAT-literal strlen calls in the unit test; and the TAP-convention linear main in the e2e test (S3776). Verified: 27/27 unit, 13/13 e2e on mariadb10-galera against the rebuilt binary. --- lib/MySQL_Ed25519.cpp | 5 +-- lib/MySQL_Protocol.cpp | 44 ++++++++++++++++---------- test/tap/tests/test_ed25519_auth-t.cpp | 6 ++-- test/tap/tests/unit/ed25519_unit-t.cpp | 4 +-- 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/lib/MySQL_Ed25519.cpp b/lib/MySQL_Ed25519.cpp index f4134c17ab..0938a3e0ae 100644 --- a/lib/MySQL_Ed25519.cpp +++ b/lib/MySQL_Ed25519.cpp @@ -16,7 +16,7 @@ int crypto_sign_open(unsigned char* sm, unsigned long long smlen, const unsigned void proxysql_ed25519_derive_public_key(const char* password, size_t password_len, unsigned char* out_pubkey) { // ref10 takes a non-const pw but never modifies it - crypto_sign_keypair(out_pubkey, reinterpret_cast(const_cast(password)), password_len); + crypto_sign_keypair(out_pubkey, reinterpret_cast(const_cast(password)), password_len); // NOSONAR: the vendored ref10 C API declares pw non-const but only reads it; changing the API would fork the upstream sources } bool proxysql_ed25519_verify_signature(const unsigned char* signature, const unsigned char* nonce, const unsigned char* pubkey) { @@ -35,7 +35,8 @@ bool proxysql_ed25519_has_prefix(const char* password) { bool proxysql_ed25519_is_pubkey_format(const char* password) { if (proxysql_ed25519_has_prefix(password) == false) return false; - return strlen(password) == ED25519_STORED_LEN; + // bounded scan: only "exactly ED25519_STORED_LEN chars" matters + return strnlen(password, ED25519_STORED_LEN + 1) == ED25519_STORED_LEN; } bool proxysql_ed25519_decode_pubkey(const char* stored, unsigned char* out_pubkey) { diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 5855fa157b..b9331e57ca 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -101,6 +101,22 @@ static const char *plugins[] = { #endif }; +#ifdef PROXYSQLED25519 +// compile-time length of plugins[AUTH_MYSQL_ED25519] ("client_ed25519") +static constexpr size_t ED25519_PLUGIN_NAME_LEN = sizeof("client_ed25519") - 1; + +// A stored credential can seed an ed25519 keypair only when it is cleartext +// (or already a "$ED$" public key): '*SHA1' and '$A$0' caching_sha2 hashes +// cannot derive a key. Shared by the initial-handshake gate and the +// COM_CHANGE_USER gate so the two policies cannot drift. The scan is bounded: +// the $A$ format test only needs to know whether the length is exactly 70. +static bool ed25519_cred_usable(const char* password) { + if (proxysql_ed25519_has_prefix(password)) return true; + if (password[0] == '*') return false; + return !(strnlen(password, 71) == 70 && strncasecmp(password, "$A$0", 4) == 0); +} +#endif + #ifdef PROXYSQL31 enum class frontend_auth_context : uint8_t { INITIAL_HANDSHAKE, @@ -1173,7 +1189,7 @@ bool MySQL_Protocol::generate_pkt_auth_switch_request(bool send, void **ptr, uns #ifdef PROXYSQLED25519 case AUTH_MYSQL_ED25519: myhdr.pkt_length=1 // fe - + (strlen(plugins[AUTH_MYSQL_ED25519])+1) + + (ED25519_PLUGIN_NAME_LEN+1) + ED25519_NONCE_LEN; // 32-byte nonce; NO trailing 0x00 (client requires exactly 32 bytes of plugin data) break; #endif @@ -1212,8 +1228,8 @@ bool MySQL_Protocol::generate_pkt_auth_switch_request(bool send, void **ptr, uns break; #ifdef PROXYSQLED25519 case AUTH_MYSQL_ED25519: - memcpy(_ptr+l,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519])); - l+=strlen(plugins[AUTH_MYSQL_ED25519]); + memcpy(_ptr+l,plugins[AUTH_MYSQL_ED25519],ED25519_PLUGIN_NAME_LEN); + l+=ED25519_PLUGIN_NAME_LEN; _ptr[l]=0x00; l++; memcpy(_ptr+l, (*myds)->myconn->ed25519_nonce, ED25519_NONCE_LEN); l+=ED25519_NONCE_LEN; break; @@ -1556,7 +1572,7 @@ bool MySQL_Protocol::verify_user_pass( //auth_plugin_id = 2; // FIXME: this is temporary, because yet not supported auth_plugin_id = AUTH_MYSQL_CACHING_SHA2_PASSWORD; // FIXME: this is temporary, because yet not supported . It must become 3 #ifdef PROXYSQLED25519 - } else if (strncmp(auth_plugin,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519]))==0) { + } else if (strncmp(auth_plugin,plugins[AUTH_MYSQL_ED25519],ED25519_PLUGIN_NAME_LEN)==0) { auth_plugin_id = AUTH_MYSQL_ED25519; #endif } @@ -1917,13 +1933,10 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in // the intended conservative behavior for non-MySQL sessions (the // feature simply doesn't apply there). const bool cu_stored_is_ed = proxysql_ed25519_has_prefix(password); - // a '*SHA1' or '$A$' hash cannot derive an ed25519 key; refuse the - // switch for an explicit client_ed25519 request against such a - // credential instead of wasting a round trip on a doomed exchange - // (mirrors the cred_usable check in the PPHR_verify_password gate) - const bool cu_cred_usable = cu_stored_is_ed || - (password[0] != '*' && - !(strlen(password) == 70 && strncasecmp(password, "$A$0", 4) == 0)); + // refuse the switch for an explicit client_ed25519 request against a + // hashed credential instead of wasting a round trip on a doomed + // exchange (same policy as the PPHR_verify_password gate) + const bool cu_cred_usable = ed25519_cred_usable(password); const bool ed25519_switch_needed = session_type == PROXYSQL_SESSION_MYSQL && (cu_stored_is_ed || @@ -2487,7 +2500,7 @@ void MySQL_Protocol::PPHR_3(MyProt_tmp_auth_vars& vars1) { // detect plugin id } } #ifdef PROXYSQLED25519 - else if (strncmp((char *)vars1.auth_plugin,plugins[AUTH_MYSQL_ED25519],strlen(plugins[AUTH_MYSQL_ED25519]))==0) { + else if (strncmp((char *)vars1.auth_plugin,plugins[AUTH_MYSQL_ED25519],ED25519_PLUGIN_NAME_LEN)==0) { // client explicitly requested client_ed25519; the Auth Switch with a // 32-byte nonce is driven later by PPHR_verify_password at stage 0 auth_plugin_id = AUTH_MYSQL_ED25519; @@ -3201,7 +3214,7 @@ void MySQL_Protocol::PPHR_ed25519_verify(bool& ret, MyProt_tmp_auth_vars& vars1) return; } } else { - proxysql_ed25519_derive_public_key(vars1.password, strlen(vars1.password), pubkey); + proxysql_ed25519_derive_public_key(vars1.password, strlen(vars1.password), pubkey); // NOSONAR: stored credential is a NUL-terminated string from mysql_users; arbitrary length by design (MariaDB hashes the whole password) } if (proxysql_ed25519_verify_signature(vars1.pass, (*myds)->myconn->ed25519_nonce, pubkey)) { ret = true; @@ -3687,10 +3700,7 @@ bool MySQL_Protocol::PPHR_verify_password(MyProt_tmp_auth_vars& vars1, account_d (*myds)->switching_auth_sent != AUTH_MYSQL_ED25519 && (*myds)->sess->session_type == PROXYSQL_SESSION_MYSQL) { const bool stored_is_ed = proxysql_ed25519_has_prefix(vars1.password); - // a '*SHA1' or '$A$' hash cannot derive an ed25519 key - const bool cred_usable = stored_is_ed || - (vars1.password[0] != '*' && - !(strlen(vars1.password) == 70 && strncasecmp(vars1.password,"$A$0",4)==0)); + const bool cred_usable = ed25519_cred_usable(vars1.password); if (stored_is_ed || (auth_plugin_id == AUTH_MYSQL_ED25519 && cred_usable)) { PPHR_ed25519_switch(ret, vars1); return ret; diff --git a/test/tap/tests/test_ed25519_auth-t.cpp b/test/tap/tests/test_ed25519_auth-t.cpp index 6c1a4b5480..61d8b4601b 100644 --- a/test/tap/tests/test_ed25519_auth-t.cpp +++ b/test/tap/tests/test_ed25519_auth-t.cpp @@ -38,7 +38,7 @@ #include "command_line.h" #include "utils.h" -const char* ED_PASS = "ed25519_pass_1"; +constexpr char ED_PASS[] = "ed25519_pass_1"; const char* ED_PUBKEY = "5TBW79xTAMbhi8QKQtLLVS0V0b2w9mlKnRG6c+2NxTQ"; // Confirmed empirically against the mariadb10-galera infra (see task-5-report.md): // when ProxySQL retries the backend connection for a $ED$ (public-key-only) user, the @@ -48,7 +48,7 @@ const char* ED_PUBKEY = "5TBW79xTAMbhi8QKQtLLVS0V0b2w9mlKnRG6c+2NxTQ"; // denied for user ..." response, forwarded verbatim. constexpr unsigned int ED25519_PK_BACKEND_ERRNO = 1045; -int main(int argc, char** argv) { +int main(int argc, char** argv) { // NOSONAR: TAP scenario tests follow the suite convention of linear assertion blocks in main; splitting would obscure the scenario ordering the test depends on CommandLine cl; if (cl.getEnv()) { diag("Failed to get the required environmental variables."); @@ -272,7 +272,7 @@ int main(int argc, char** argv) { { // primary password wrong on purpose; additional_password holds the real one char hexpass[64] = { 0 }; - for (size_t i = 0; i < strlen(ED_PASS); i++) { + for (size_t i = 0; i + 1 < sizeof(ED_PASS); i++) { snprintf(hexpass + 2 * i, sizeof(hexpass) - 2 * i, "%02x", (unsigned char)ED_PASS[i]); } std::string q = diff --git a/test/tap/tests/unit/ed25519_unit-t.cpp b/test/tap/tests/unit/ed25519_unit-t.cpp index 970d879812..defba46f75 100644 --- a/test/tap/tests/unit/ed25519_unit-t.cpp +++ b/test/tap/tests/unit/ed25519_unit-t.cpp @@ -63,7 +63,7 @@ int main() { // 1. derivation known-answer tests for (const derivation_kat& kat : KATS) { unsigned char pk[ED25519_PUBKEY_LEN]; - proxysql_ed25519_derive_public_key(kat.password, strlen(kat.password), pk); + proxysql_ed25519_derive_public_key(kat.password, strlen(kat.password), pk); // NOSONAR: KAT table entries are string literals std::string encoded = b64_no_pad(pk, sizeof(pk)); ok(encoded == kat.pubkey_b64, "derive_public_key('%s') = '%s' (expected '%s')", @@ -74,7 +74,7 @@ int main() { for (const derivation_kat& kat : KATS) { unsigned char derived[ED25519_PUBKEY_LEN]; unsigned char decoded[ED25519_PUBKEY_LEN]; - proxysql_ed25519_derive_public_key(kat.password, strlen(kat.password), derived); + proxysql_ed25519_derive_public_key(kat.password, strlen(kat.password), derived); // NOSONAR: KAT table entries are string literals std::string stored = std::string(ED25519_STORED_PREFIX) + kat.pubkey_b64; bool rc = proxysql_ed25519_decode_pubkey(stored.c_str(), decoded); ok(rc && memcmp(derived, decoded, ED25519_PUBKEY_LEN) == 0, From 0a593d09af3c7f08476f97e268210b401929896c Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 17:29:49 +0000 Subject: [PATCH 22/23] ci: retrigger full check matrix A manual job-level rerun of the transient mariadb10-galera-g6 runner failure (the run-level rerun API correctly refused; the job-level API did not) superseded the run's attempt-1 results with a startup_failure attempt, hiding the ~70 green checks from the PR view. The workflow_run trigger chain (doc/GH-Actions/README.md) cannot restart mid-tree, so a fresh head SHA is the supported way to regenerate the complete matrix. No code changes. The previously green results remain visible in the attempt-1 run history; g6's single test (prepare_statement_err3024-t) was additionally verified locally against this branch (PASS 1/408). From 7b1e068f571c4d311bea02287c9d28630a61077b Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 17:52:07 +0000 Subject: [PATCH 23/23] fix: exact-match ed25519 plugin-name negotiation; doc accuracy fixes From the cubic review round on PR #6033: - Plugin-name negotiation for ed25519 now uses exact strcmp in PPHR_3 and verify_user_pass (safe: both parsers reject unterminated names). The previous strncmp prefix match would have negotiated a non-standard name like 'client_ed25519_x' into the ed25519 flow; fail-closed, but sloppy, and inconsistent with the COM_CHANGE_USER gate which already matched exactly. - Makefile tier-cascade comment now names PROXYSQLED25519. - Upgrade note states the $ED$ reservation is case-insensitive and reflects the rate-limited warning behavior (once at load, once per user at backend connect - not per attempt). - Design spec gains a post-implementation note: the shipped nonce lives in MySQL_Connection::ed25519_nonce, not the data-stream location the spec described; plus the other post-review deviations. Verified: build clean, 27/27 unit, 13/13 e2e on mariadb10-galera. --- Makefile | 2 +- doc/ed25519_authentication.md | 22 +++++++++++-------- ...026-08-11-ed25519-authentication-design.md | 13 ++++++++++- lib/MySQL_Protocol.cpp | 9 ++++++-- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index 757193bc73..efd71b177a 100644 --- a/Makefile +++ b/Makefile @@ -67,7 +67,7 @@ ifeq ($(PROXYSQL40),1) PROXYSQL31 := 1 endif -# If PROXYSQL31 is enabled, it automatically enables FFTO and TSDB +# If PROXYSQL31 is enabled, it automatically enables FFTO, TSDB and ED25519 ifeq ($(PROXYSQL31),1) PROXYSQLFFTO := 1 PROXYSQLTSDB := 1 diff --git a/doc/ed25519_authentication.md b/doc/ed25519_authentication.md index 811fed6d6f..12054f3705 100644 --- a/doc/ed25519_authentication.md +++ b/doc/ed25519_authentication.md @@ -61,16 +61,20 @@ reaches the ed25519 verification code at all. The `$ED$` prefix becomes reserved as of this feature: any stored `mysql_users.password` value that literally begins with `$ED$` is now -parsed as an ed25519 credential, never compared as cleartext. If an +parsed as an ed25519 credential, never compared as cleartext. The +reservation is **case-insensitive** — `$ed$`, `$Ed$` and `$eD$` count too +(matching how ProxySQL already detects the `$A$0` caching_sha2 format +case-insensitively). If an existing 3.0 deployment happens to have a cleartext password that starts -with the literal four characters `$ED$` (coincidental, but possible), -that account stops authenticating after the upgrade — this is fail-closed -by design (human-approved: silently falling back to cleartext comparison -for an unparseable "$ED$..." value was judged more dangerous than a hard -failure). ProxySQL logs a warning for the affected account on each -connection attempt. Fix by renaming the credential to not start with -`$ED$`, or by re-issuing it as a proper `$ED$` ed25519 -credential if that was the intent. +with those four characters in any case combination (coincidental, but +possible), that account stops authenticating after the upgrade — this is +fail-closed by design (human-approved: silently falling back to cleartext +comparison for an unparseable "$ED$..." value was judged more dangerous +than a hard failure). ProxySQL warns once at `LOAD MYSQL USERS TO +RUNTIME` time for a malformed `$ED$` value, and once per user when a +backend connection is attempted with a `$ED$` credential. Fix by renaming +the credential to not start with `$ED$`, or by re-issuing it as a proper +`$ED$` ed25519 credential if that was the intent. ## Limitations diff --git a/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md b/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md index 15fa1dfa69..b234f7d7b9 100644 --- a/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md +++ b/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md @@ -1,9 +1,20 @@ # Ed25519 Authentication for MySQL Client Connections — Design **Date:** 2026-08-11 -**Status:** Approved +**Status:** Approved (historical design artifact — see note) **Tier:** v3.1+ (`PROXYSQL31`) +> **Post-implementation note.** This document reflects the approved design at +> planning time; review-driven fixes changed some internals afterwards, and +> the shipped code is authoritative. Known deviations: the 32-byte challenge +> lives in a dedicated `MySQL_Connection::ed25519_nonce` buffer, NOT on the +> data stream nor in `scramble_buff` (the native scramble must survive for +> later COM_CHANGE_USER/caching_sha2 verification); any `$ED$`-prefixed +> password is fail-closed rather than falling back to cleartext (human +> ruling), with the ed25519 exchange offered on MySQL client sessions only; +> and the test suites grew beyond the counts listed in §7. See +> `doc/ed25519_authentication.md` for the user-facing contract. + ## Goal Support MariaDB's ed25519 authentication scheme (`client_ed25519` client plugin / diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index b9331e57ca..f6a6ecf9bb 100644 --- a/lib/MySQL_Protocol.cpp +++ b/lib/MySQL_Protocol.cpp @@ -1572,7 +1572,9 @@ bool MySQL_Protocol::verify_user_pass( //auth_plugin_id = 2; // FIXME: this is temporary, because yet not supported auth_plugin_id = AUTH_MYSQL_CACHING_SHA2_PASSWORD; // FIXME: this is temporary, because yet not supported . It must become 3 #ifdef PROXYSQLED25519 - } else if (strncmp(auth_plugin,plugins[AUTH_MYSQL_ED25519],ED25519_PLUGIN_NAME_LEN)==0) { + } else if (strcmp(auth_plugin,plugins[AUTH_MYSQL_ED25519])==0) { + // exact match (the parser guarantees NUL termination): a non-standard + // name like "client_ed25519_x" must not be negotiated as ed25519 auth_plugin_id = AUTH_MYSQL_ED25519; #endif } @@ -2500,7 +2502,10 @@ void MySQL_Protocol::PPHR_3(MyProt_tmp_auth_vars& vars1) { // detect plugin id } } #ifdef PROXYSQLED25519 - else if (strncmp((char *)vars1.auth_plugin,plugins[AUTH_MYSQL_ED25519],ED25519_PLUGIN_NAME_LEN)==0) { + // exact match (PPHR_2 rejects unterminated plugin names): a + // non-standard name like "client_ed25519_x" must not be negotiated + // as ed25519 + else if (strcmp((char *)vars1.auth_plugin,plugins[AUTH_MYSQL_ED25519])==0) { // client explicitly requested client_ed25519; the Auth Switch with a // 32-byte nonce is driven later by PPHR_verify_password at stage 0 auth_plugin_id = AUTH_MYSQL_ED25519;