Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

* Add the MariaDB `parsec` authentication plugin.

* Add a pluggable authentication system with the `AuthPlugin` interface,
`AuthContext`, and `RegisterAuthPlugin`. Custom plugins receive the connection
context and an immutable snapshot of the selected credentials, established
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,11 +667,14 @@ The driver implements a pluggable authentication system that supports various au
* `mysql_old_password` - Old MySQL authentication (requires `allowOldPasswords=true`)
* `sha256_password` - SHA256 authentication
* `client_ed25519` - MariaDB Ed25519 authentication
* `parsec` - MariaDB parsec authentication

Custom plugins implement the two methods of `AuthPlugin`, `InitAuth` and `ContinuationAuth`, and are registered with `RegisterAuthPlugin(name, factory)`. Registration does not call the factory; the driver calls it to create an independent instance for each authentication exchange. Factories may run concurrently for different connections. All built-in plugins use the same registry: registering a built-in name replaces its factory for subsequent authentication exchanges, including the initial handshake and authentication switches. Authentication-method permissions such as `allowCleartextPasswords` still apply to that name. Built-in plugin implementations are internal to the driver.

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
Expand Down
3 changes: 0 additions & 3 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion auth_caching_sha2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions auth_parsec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Go MySQL Driver - A MySQL-Driver for Go's database/sql package
//
// Copyright 2026 The Go-MySQL-Driver Authors. All rights reserved.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at http://mozilla.org/MPL/2.0/.

package mysql

import (
"context"
"crypto/ed25519"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha512"
"fmt"
)

// parsecMaxIterationFactor bounds the PBKDF2 cost a server may request.
// The number of rounds is 1024 << factor, so 10 allows up to 1,048,576 rounds.
const parsecMaxIterationFactor = 10

// parsecPlugin implements the MariaDB parsec authentication.
//
// The server sends a 32-byte nonce. The client requests the extended salt
// with an empty packet, derives an Ed25519 key from the password with
// PBKDF2-HMAC-SHA512, and signs the server nonce followed by a client nonce.
//
// See: https://mariadb.com/kb/en/connection/#parsec-plugin
type parsecPlugin struct {
saltRequested bool
}

func init() {
RegisterAuthPlugin("parsec", func() AuthPlugin { return &parsecPlugin{} })
}

func (p *parsecPlugin) InitAuth(ctx context.Context, authData []byte, auth *AuthContext) ([]byte, error) {
if len(authData) != 32 {
return nil, ErrMalformPkt
}
p.saltRequested = true
// An empty packet requests the extended salt.
return []byte{}, nil
}

// ContinuationAuth processes the extended salt:
//
// string<1> 'P' (KDF algorithm = PBKDF2)
// int<1> iteration factor, rounds = 1024 << factor
// byte<EOF> salt
//
// and responds with the 32-byte client nonce followed by the 64-byte signature.
func (p *parsecPlugin) ContinuationAuth(ctx context.Context, packet, authData []byte, auth *AuthContext) ([]byte, error) {
if !p.saltRequested {
return nil, ErrMalformPkt
}
p.saltRequested = false

if len(packet) < 3 || packet[0] != 'P' {
return nil, fmt.Errorf("%w: unexpected parsec extended salt", ErrMalformPkt)
}
factor := int(packet[1])
if factor > parsecMaxIterationFactor {
return nil, fmt.Errorf("parsec: server requested iteration factor %d, maximum is %d", factor, parsecMaxIterationFactor)
}
salt := packet[2:]

seed, err := pbkdf2.Key(sha512.New, auth.Password(), salt, 1024<<factor, ed25519.SeedSize)
if err != nil {
return nil, err
}
priv := ed25519.NewKeyFromSeed(seed)

resp := make([]byte, 32+ed25519.SignatureSize)
if _, err := rand.Read(resp[:32]); err != nil {
return nil, err
}
// sign server nonce + client nonce
msg := make([]byte, 0, len(authData)+32)
msg = append(msg, authData...)
msg = append(msg, resp[:32]...)
copy(resp[32:], ed25519.Sign(priv, msg))
return resp, nil
}
134 changes: 134 additions & 0 deletions auth_parsec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Go MySQL Driver - A MySQL-Driver for Go's database/sql package
//
// Copyright 2026 The Go-MySQL-Driver Authors. All rights reserved.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at http://mozilla.org/MPL/2.0/.

package mysql

import (
"bytes"
"context"
"crypto/ed25519"
"crypto/pbkdf2"
"crypto/sha512"
"errors"
"testing"
)

func TestParsecAuth(t *testing.T) {
conn, mc := newRWMockConn(2)
mc.cfg.Passwd = "secret"

// auth switch request: parsec with a 32-byte nonce ending with 0x00
serverNonce := append(bytes.Repeat([]byte{'n'}, 31), 0)
payload := append([]byte{iEOF}, "parsec"...)
payload = append(payload, 0)
payload = append(payload, serverNonce...)
conn.data = makePacket(2, payload)

// extended salt (prefixed with 0x01), then OK
salt := []byte("0123456789abcdefgh")
extSalt := append([]byte{iAuthMoreData, 'P', 2}, salt...)
conn.queuedReplies = [][]byte{
makePacket(4, extSalt),
{7, 0, 0, 6, 0, 0, 0, 2, 0, 0, 0},
}
conn.maxReads = 3

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)
}

// empty packet requesting the extended salt, then client nonce + signature
if len(conn.written) != 4+4+96 || !bytes.Equal(conn.written[:8], []byte{0, 0, 0, 3, 96, 0, 0, 5}) {
t.Fatalf("unexpected packets written: %v", conn.written)
}
resp := conn.written[8:]
clientNonce, signature := resp[:32], resp[32:]

seed, err := pbkdf2.Key(sha512.New, "secret", salt, 1024<<2, ed25519.SeedSize)
if err != nil {
t.Fatal(err)
}
pub := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
msg := append(append([]byte{}, serverNonce...), clientNonce...)
if !ed25519.Verify(pub, msg, signature) {
t.Error("signature does not verify against the PBKDF2-derived key")
}
}

func TestParsecAuthRejectsMalformed(t *testing.T) {
cfg := NewConfig()
cfg.Passwd = "secret"
auth := newAuthContext(cfg, cfg.Passwd, false)
ctx := context.Background()
nonce := bytes.Repeat([]byte{'n'}, 32)

if _, err := (&parsecPlugin{}).InitAuth(ctx, nonce[:31], auth); !errors.Is(err, ErrMalformPkt) {
t.Errorf("31-byte nonce: got %v, want ErrMalformPkt", err)
}
if _, err := (&parsecPlugin{}).ContinuationAuth(ctx, []byte{'P', 0, 's'}, nonce, auth); !errors.Is(err, ErrMalformPkt) {
t.Errorf("continuation before init: got %v, want ErrMalformPkt", err)
}

for _, tc := range []struct {
name string
packet []byte
}{
{"wrong KDF", []byte{'Q', 0, 's', 'a', 'l', 't'}},
{"no salt", []byte{'P', 0}},
{"empty", nil},
} {
p := &parsecPlugin{}
if _, err := p.InitAuth(ctx, nonce, auth); err != nil {
t.Fatal(err)
}
if _, err := p.ContinuationAuth(ctx, tc.packet, nonce, auth); !errors.Is(err, ErrMalformPkt) {
t.Errorf("%s: got %v, want ErrMalformPkt", tc.name, err)
}
}

// iteration factor above the limit is refused before any hashing
p := &parsecPlugin{}
if _, err := p.InitAuth(ctx, nonce, auth); err != nil {
t.Fatal(err)
}
_, err := p.ContinuationAuth(ctx, []byte{'P', parsecMaxIterationFactor + 1, 's', 'a', 'l', 't'}, nonce, auth)
if err == nil || errors.Is(err, ErrMalformPkt) {
t.Errorf("factor %d: got %v, want iteration factor error", parsecMaxIterationFactor+1, err)
}

// a second continuation after the response is unexpected
p = &parsecPlugin{}
if _, err := p.InitAuth(ctx, nonce, auth); err != nil {
t.Fatal(err)
}
if _, err := p.ContinuationAuth(ctx, []byte{'P', 0, 's', 'a', 'l', 't'}, nonce, auth); err != nil {
t.Fatal(err)
}
if _, err := p.ContinuationAuth(ctx, []byte{'P', 0, 's', 'a', 'l', 't'}, nonce, auth); !errors.Is(err, ErrMalformPkt) {
t.Errorf("second continuation: got %v, want ErrMalformPkt", err)
}
}

func TestParsecAuthMaxIterationFactor(t *testing.T) {
if testing.Short() {
t.Skip("skipping PBKDF2 at the maximum iteration factor in short mode")
}
cfg := NewConfig()
cfg.Passwd = "secret"
auth := newAuthContext(cfg, cfg.Passwd, false)
nonce := bytes.Repeat([]byte{'n'}, 32)
p := &parsecPlugin{}
if _, err := p.InitAuth(context.Background(), nonce, auth); err != nil {
t.Fatal(err)
}
resp, err := p.ContinuationAuth(context.Background(), []byte{'P', parsecMaxIterationFactor, 's', 'a', 'l', 't'}, nonce, auth)
if err != nil || len(resp) != 96 {
t.Fatalf("factor %d: got %d bytes, %v", parsecMaxIterationFactor, len(resp), err)
}
}
5 changes: 3 additions & 2 deletions auth_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions auth_plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion auth_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions auth_sha256.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,18 @@ 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.
func encryptPassword(password string, seed []byte, pub *rsa.PublicKey) ([]byte, error) {
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)
Expand Down
Loading
Loading