diff --git a/Makefile b/Makefile index 493c84dfeb..efd71b177a 100644 --- a/Makefile +++ b/Makefile @@ -60,17 +60,18 @@ 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) 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 + 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/doc/ed25519_authentication.md b/doc/ed25519_authentication.md new file mode 100644 index 0000000000..12054f3705 --- /dev/null +++ b/doc/ed25519_authentication.md @@ -0,0 +1,101 @@ +# 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. + +**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. 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 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 + +- `$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. +- 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 new file mode 100644 index 0000000000..a69a4c29ec --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-ed25519-authentication.md @@ -0,0 +1,1342 @@ +# 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. + +> **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. + +**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): + +```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, + 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)." +``` 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..b234f7d7b9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-ed25519-authentication-design.md @@ -0,0 +1,212 @@ +# Ed25519 Authentication for MySQL Client Connections — Design + +**Date:** 2026-08-11 +**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 / +`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: + + ```text + 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. diff --git a/include/MySQL_Ed25519.h b/include/MySQL_Ed25519.h new file mode 100644 index 0000000000..24cf5383c3 --- /dev/null +++ b/include/MySQL_Ed25519.h @@ -0,0 +1,48 @@ +#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. + */ + +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); + +/** @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 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); + +#endif // PROXYSQLED25519 +#endif // __CLASS_MYSQL_ED25519_H diff --git a/include/MySQL_Protocol.h b/include/MySQL_Protocol.h index 83ebb8d098..94085eddc2 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/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/Makefile b/lib/Makefile index f9b3154434..900459468b 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 @@ -135,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_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_Ed25519.cpp b/lib/MySQL_Ed25519.cpp new file mode 100644 index 0000000000..0938a3e0ae --- /dev/null +++ b/lib/MySQL_Ed25519.cpp @@ -0,0 +1,65 @@ +#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); // 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) { + // 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_has_prefix(const char* password) { + if (password == NULL) 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; + // 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) { + 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; + // 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; +} + +#endif // PROXYSQLED25519 diff --git a/lib/MySQL_Protocol.cpp b/lib/MySQL_Protocol.cpp index 16888393ff..f6a6ecf9bb 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,12 +92,31 @@ 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 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, @@ -1163,6 +1186,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 + + (ED25519_PLUGIN_NAME_LEN+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 +1226,26 @@ 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],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; +#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; @@ -1528,9 +1571,44 @@ 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 (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 } 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. + // 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 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) && @@ -1555,6 +1633,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; } @@ -1823,6 +1909,54 @@ bool MySQL_Protocol::process_pkt_COM_CHANGE_USER(unsigned char *pkt, unsigned in if (password==NULL) { ret=false; } else { +#ifdef PROXYSQLED25519 + // 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(). + // 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 cu_stored_is_ed = proxysql_ed25519_has_prefix(password); + // 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 || + (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); + 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) { ret=true; } else { @@ -2114,7 +2248,11 @@ int MySQL_Protocol::PPHR_1(unsigned char *pkt, unsigned int len, bool& ret, MyPr return 1; #endif } - 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( @@ -2363,6 +2501,16 @@ void MySQL_Protocol::PPHR_3(MyProt_tmp_auth_vars& vars1) { // detect plugin id assert(0); } } +#ifdef PROXYSQLED25519 + // 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; + } +#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); } @@ -3015,6 +3163,73 @@ 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 '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((*myds)->myconn->ed25519_nonce, 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]; + // 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; + } + } else { + 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; + } + 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; @@ -3465,6 +3680,43 @@ 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$"-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. + // 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_MYSQL) { + const bool stored_is_ed = proxysql_ed25519_has_prefix(vars1.password); + 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; + } + 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); @@ -3481,6 +3733,29 @@ 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 + // 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. + // 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) + PPHR_ed25519_verify(ret, vars1); + } else +#endif if ( auth_plugin_id == AUTH_MYSQL_CACHING_SHA2_PASSWORD && @@ -3623,6 +3898,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; @@ -3650,6 +3931,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_connection.cpp b/lib/mysql_connection.cpp index 3fe154f8a0..5d5861e460 100644 --- a/lib/mysql_connection.cpp +++ b/lib/mysql_connection.cpp @@ -15,6 +15,12 @@ using json = nlohmann::json; #include "MySQL_Query_Processor.h" #include "MySQL_Variables.h" #include +#include +#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 @@ -499,6 +505,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() { @@ -1021,6 +1033,33 @@ void MySQL_Connection::connect_start() { auth_password=userinfo->password; } } +#ifdef PROXYSQLED25519 + // 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)) { + // 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) { 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); 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; } 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 \ diff --git a/test/tap/groups/groups.json b/test/tap/groups/groups.json index b4cf5ae466..cb972d6154 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" ], @@ -367,6 +368,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..61d8b4601b --- /dev/null +++ b/test/tap/tests/test_ed25519_auth-t.cpp @@ -0,0 +1,312 @@ +/** + * @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, 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: (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 + * (regression coverage for the fail-closed prefix-routing fix). + */ +#include +#include +#include + +#include "mysql.h" + +#include "tap.h" +#include "command_line.h" +#include "utils.h" + +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 +// 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. +constexpr unsigned int ED25519_PK_BACKEND_ERRNO = 1045; + +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."); + return EXIT_FAILURE; + } + + plan(13); + + // ---- 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. + // 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 '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 '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()); + // 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, 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, "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) : "-"); + 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)); } + 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"); + } + 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); + } + + // ---- 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 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; + 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-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, native auth path (err: %s)", rc ? mysql_error(c) : "-"); + } + mysql_close(c); + } + + // ---- 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 }; + 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 = + "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); + // 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 (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)); } + 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','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'@'%'"); + mysql_close(admin); + mysql_close(wr); + + return exit_status(); +} diff --git a/test/tap/tests/unit/Makefile b/test/tap/tests/unit/Makefile index f89a7faa14..737a93b037 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 @@ -377,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) @@ -435,6 +442,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..defba46f75 --- /dev/null +++ b/test/tap/tests/unit/ed25519_unit-t.cpp @@ -0,0 +1,166 @@ +/** + * @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 */ + + 5 /* has_prefix edge cases */ + + 5 /* decode_pubkey malformed / non-canonical */ + + 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); // 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')", + 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); // 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, + "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"); + } + + // 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 / 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 + 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 (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"); + + // 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(); +}