From f78050cb88cf805f547b54a02a44f5a78c79d049 Mon Sep 17 00:00:00 2001 From: rusher Date: Tue, 13 Oct 2026 14:07:18 +0200 Subject: [PATCH 1/2] auth: pass auth switch data to plugins unmodified --- README.md | 2 ++ auth.go | 3 --- auth_caching_sha2.go | 5 ++++- auth_plugin.go | 5 +++-- auth_plugin_test.go | 2 +- auth_security_test.go | 2 +- auth_sha256.go | 7 ++++--- 7 files changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8caba7d76..154e00ee4 100644 --- a/README.md +++ b/README.md @@ -672,6 +672,8 @@ Custom plugins implement the two methods of `AuthPlugin`, `InitAuth` and `Contin The driver passes the connection's `context.Context` for cancellation and an `AuthContext` snapshot containing the selected credentials, established transport information, and configured RSA public key. Plugins do not receive or modify the connection's `Config`; the driver checks authentication-method permissions before starting a plugin. Custom plugins that require a particular transport must check it in `InitAuth` before returning credentials. +`InitAuth` receives the server challenge exactly as sent, including any trailing NUL byte; plugins interpret it themselves. + `AuthContext.TLS()` reports a completed MySQL TLS handshake, not merely a TLS configuration or an external tunnel. A continuation returning `nil, nil` waits for another server packet without sending; a non-nil empty slice sends an empty packet. Authentication succeeds only when the driver receives the server's OK packet. ### `LOAD DATA LOCAL INFILE` support diff --git a/auth.go b/auth.go index 1a0e2754c..a1bcde9e6 100644 --- a/auth.go +++ b/auth.go @@ -244,9 +244,6 @@ func (mc *mysqlConn) parseAuthSwitchData(data []byte, initialSeed []byte) (strin plugin := string(data[1:pluginEndIndex]) authData := data[pluginEndIndex+1:] - if len(authData) > 0 && authData[len(authData)-1] == 0 { - authData = authData[:len(authData)-1] - } savedAuthData := make([]byte, len(authData)) copy(savedAuthData, authData) diff --git a/auth_caching_sha2.go b/auth_caching_sha2.go index 2ef2aa837..cc7075e74 100644 --- a/auth_caching_sha2.go +++ b/auth_caching_sha2.go @@ -54,7 +54,10 @@ func init() { // 3. XOR(SHA256(password), SHA256(SHA256(SHA256(password)), scramble)) func (p *cachingSha2PasswordPlugin) InitAuth(ctx context.Context, authData []byte, auth *AuthContext) ([]byte, error) { p.state = cachingSha2Initial - return scrambleSHA256Password(authData, auth.Password()), nil + if len(authData) < 20 { + return nil, ErrMalformPkt + } + return scrambleSHA256Password(authData[:20], auth.Password()), nil } // ContinuationAuth processes the server's response to our authentication attempt. diff --git a/auth_plugin.go b/auth_plugin.go index 5bd436c34..9a6c369f1 100644 --- a/auth_plugin.go +++ b/auth_plugin.go @@ -19,8 +19,9 @@ import ( // while a method is running. Plugins should use it to cancel blocking work. type AuthPlugin interface { // InitAuth initializes the authentication process and returns the initial response. - // authData is the challenge data from the server. It must not be modified - // and must be copied if retained after the call. + // authData is the challenge data exactly as sent by the server, including + // any trailing NUL. It must not be modified and must be copied if retained + // after the call. // auth is the snapshot for this exchange, shared with ContinuationAuth. // A nil or empty response sends an empty authentication response. // Plugins must check any transport requirements here before returning credentials. diff --git a/auth_plugin_test.go b/auth_plugin_test.go index 0013bef94..3c6ed456d 100644 --- a/auth_plugin_test.go +++ b/auth_plugin_test.go @@ -290,7 +290,7 @@ func TestAuthSwitchContextSnapshot(t *testing.T) { }) payload := append([]byte{iEOF}, name...) payload = append(payload, 0) - payload = append(payload, "challenge\x00"...) + payload = append(payload, "challenge"...) conn.data = makePacket(2, payload) conn.queuedReplies = [][]byte{append( makePacket(4, []byte{iAuthMoreData, 7}), diff --git a/auth_security_test.go b/auth_security_test.go index f1c872c31..3606d2ae9 100644 --- a/auth_security_test.go +++ b/auth_security_test.go @@ -134,7 +134,7 @@ func TestAuthSwitchRejectsShortChallenge(t *testing.T) { payload := append([]byte{iEOF}, tc.plugin...) payload = append(payload, 0) payload = append(payload, bytes.Repeat([]byte{'x'}, tc.length)...) - conn.data = makePacket(2, append(payload, 0)) + conn.data = makePacket(2, payload) conn.maxReads = 1 err := mc.handleAuthResult(t.Context(), []byte("0123456789abcdefghij"), &nativePasswordPlugin{}, newAuthContext(mc.cfg, mc.cfg.Passwd, false)) if !errors.Is(err, ErrMalformPkt) || conn.writes != 0 { diff --git a/auth_sha256.go b/auth_sha256.go index d0ec97747..2deee7be6 100644 --- a/auth_sha256.go +++ b/auth_sha256.go @@ -117,7 +117,7 @@ func (p *sha256PasswordPlugin) ContinuationAuth(ctx context.Context, packet, aut // encryptPassword encrypts the password using RSA-OAEP with SHA1 hash. // // The process: -// 1. XORs the password with the auth seed to prevent replay attacks +// 1. XORs the password with the 20-byte scramble to prevent replay attacks // 2. Encrypts the XORed password using RSA-OAEP with SHA1 // // The encryption uses OAEP padding which is more secure than PKCS#1 v1.5 padding. @@ -125,9 +125,10 @@ func encryptPassword(password string, seed []byte, pub *rsa.PublicKey) ([]byte, if pub == nil { return nil, fmt.Errorf("public key is nil") } - if len(seed) == 0 { - return nil, fmt.Errorf("%w: empty auth seed", ErrMalformPkt) + if len(seed) < 20 { + return nil, ErrMalformPkt } + seed = seed[:20] // Create the plaintext by XORing password with seed plain := make([]byte, len(password)+1) From bf9a134a9205872ba0f52be1badcbb7df8a35193 Mon Sep 17 00:00:00 2001 From: rusher Date: Tue, 13 Oct 2026 14:38:39 +0200 Subject: [PATCH 2/2] auth: test the auth switch path with NUL-terminated and raw challenges --- auth_plugin_test.go | 6 +-- auth_test.go | 101 +++++++++++++++++++++++++------------------- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/auth_plugin_test.go b/auth_plugin_test.go index 3c6ed456d..4bc9e38f8 100644 --- a/auth_plugin_test.go +++ b/auth_plugin_test.go @@ -273,14 +273,14 @@ func TestAuthSwitchContextSnapshot(t *testing.T) { registerTestAuthPlugin(t, name, func() AuthPlugin { return &authTestPlugin{ init: func(gotCtx context.Context, seed []byte, gotAuth *AuthContext) ([]byte, error) { - if gotCtx != ctx || gotAuth == auth || gotAuth.User() != "user" || gotAuth.Password() != "selected-password" || !gotAuth.TLS() || string(seed) != "challenge" { + if gotCtx != ctx || gotAuth == auth || gotAuth.User() != "user" || gotAuth.Password() != "selected-password" || !gotAuth.TLS() || string(seed) != "challenge\x00" { return nil, errors.New("auth switch did not preserve the authentication snapshot") } switchedAuth = gotAuth return []byte{42}, nil }, next: func(gotCtx context.Context, packet, seed []byte, gotAuth *AuthContext) ([]byte, error) { - if gotCtx != ctx || gotAuth != switchedAuth || string(seed) != "challenge" { + if gotCtx != ctx || gotAuth != switchedAuth || string(seed) != "challenge\x00" { return nil, errors.New("continuation did not retain the switched context") } continued = true @@ -290,7 +290,7 @@ func TestAuthSwitchContextSnapshot(t *testing.T) { }) payload := append([]byte{iEOF}, name...) payload = append(payload, 0) - payload = append(payload, "challenge"...) + payload = append(payload, "challenge\x00"...) conn.data = makePacket(2, payload) conn.queuedReplies = [][]byte{append( makePacket(4, []byte{iAuthMoreData, 7}), diff --git a/auth_test.go b/auth_test.go index f9e0923de..be2c4fa95 100644 --- a/auth_test.go +++ b/auth_test.go @@ -1321,51 +1321,64 @@ func TestAuthSwitchSHA256PasswordSecure(t *testing.T) { } } -// Derived from https://github.com/MariaDB/server/blob/6b2287fff23fbdc362499501c562f01d0d2db52e/plugin/auth_ed25519/ed25519-t.c +// The first vector is derived from +// https://github.com/MariaDB/server/blob/6b2287fff23fbdc362499501c562f01d0d2db52e/plugin/auth_ed25519/ed25519-t.c +// The second one uses a nonce ending with 0x00, which the auth switch parser +// must not strip. func TestEd25519Auth(t *testing.T) { - conn, mc := newRWMockConn(1) - mc.cfg.User = "root" - mc.cfg.Passwd = "foobar" - - authData := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") - plugin := "client_ed25519" - - // Send Client Authentication Packet - auth := newAuthContext(mc.cfg, mc.cfg.Passwd, false) - authPlugin, _ := globalPluginRegistry.GetPlugin(plugin) - authResp, err := authPlugin.InitAuth(context.Background(), authData, auth) - if err != nil { - t.Fatal(err) - } - err = mc.writeHandshakeResponsePacket(authResp, plugin) - if err != nil { - t.Fatal(err) - } - - expectedAuthResp := []byte{ - 232, 61, 201, 63, 67, 63, 51, 53, 86, 73, 238, 35, 170, 117, 146, - 214, 26, 17, 35, 9, 8, 132, 245, 141, 48, 99, 66, 58, 36, 228, 48, - 84, 115, 254, 187, 168, 88, 162, 249, 57, 35, 85, 79, 238, 167, 106, - 68, 117, 56, 135, 171, 47, 20, 14, 133, 79, 15, 229, 124, 160, 176, - 100, 138, 14, - } - if len(authResp) != 64 { - t.Fatalf("expected 64 bytes from client, got %d", len(authResp)) - } - if !bytes.Equal(authResp, expectedAuthResp) { - t.Fatalf("auth response did not match expected value:\n%v\n%v", authResp, expectedAuthResp) - } - conn.written = nil - - // auth response - conn.data = []byte{ - 7, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, // OK - } - conn.maxReads = 1 - - // Handle response to auth packet - if err = mc.handleAuthResult(context.Background(), []byte{}, authPlugin, auth); err != nil { - t.Errorf("got error: %v", err) + for _, tc := range []struct { + name string + nonce []byte + signature []byte + }{ + { + name: "nonce", + nonce: []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + signature: []byte{ + 232, 61, 201, 63, 67, 63, 51, 53, 86, 73, 238, 35, 170, 117, 146, + 214, 26, 17, 35, 9, 8, 132, 245, 141, 48, 99, 66, 58, 36, 228, 48, + 84, 115, 254, 187, 168, 88, 162, 249, 57, 35, 85, 79, 238, 167, 106, + 68, 117, 56, 135, 171, 47, 20, 14, 133, 79, 15, 229, 124, 160, 176, + 100, 138, 14, + }, + }, + { + name: "nonce ending with 0x00", + nonce: append([]byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), 0), + signature: []byte{ + 96, 74, 190, 202, 77, 225, 185, 115, 213, 135, 190, 172, 250, 219, + 249, 233, 156, 237, 180, 231, 14, 9, 197, 137, 203, 189, 212, 102, + 147, 147, 132, 170, 249, 40, 142, 200, 35, 232, 195, 99, 207, 92, 96, + 223, 252, 165, 109, 83, 127, 33, 227, 29, 169, 192, 249, 171, 239, + 206, 228, 32, 153, 209, 214, 7, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + conn, mc := newRWMockConn(2) + mc.cfg.User = "root" + mc.cfg.Passwd = "foobar" + + // auth switch request: client_ed25519 with a 32-byte nonce + payload := append([]byte{iEOF}, "client_ed25519"...) + payload = append(payload, 0) + payload = append(payload, tc.nonce...) + conn.data = makePacket(2, payload) + + // auth response + conn.queuedReplies = [][]byte{{7, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0}} + conn.maxReads = 2 + + auth := newAuthContext(mc.cfg, mc.cfg.Passwd, false) + if err := mc.handleAuthResult(context.Background(), nil, &nativePasswordPlugin{}, auth); err != nil { + t.Fatalf("got error: %v", err) + } + + expectedReply := append([]byte{64, 0, 0, 3}, tc.signature...) + if !bytes.Equal(conn.written, expectedReply) { + t.Fatalf("auth response did not match expected value:\n%v\n%v", conn.written, expectedReply) + } + }) } }