Skip to content

Commit 3bc943f

Browse files
EDsCODEclaude
andcommitted
Add MD5 authentication support
Implements PostgreSQL MD5 password authentication as an alternative to cleartext (both protected by TLS): - New AuthMethod config field ("cleartext" default, "md5") - writeAuthMD5Password sends 4-byte random salt to client - verifyMD5Password validates md5(md5(password + username) + salt) - DUCKGRES_AUTH_METHOD env var and auth_method YAML config support - Cleartext remains default for backwards compatibility The MD5 auth flow: 1. Server generates random salt, sends AuthenticationMD5Password 2. Client computes "md5" + md5(md5(password+username) + salt) 3. Server verifies the hash matches Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 08e8094 commit 3bc943f

4 files changed

Lines changed: 93 additions & 12 deletions

File tree

main.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type FileConfig struct {
2323
DataDir string `yaml:"data_dir"`
2424
TLS TLSConfig `yaml:"tls"`
2525
Users map[string]string `yaml:"users"`
26+
AuthMethod string `yaml:"auth_method"` // "cleartext" (default) or "md5"
2627
RateLimit RateLimitFileConfig `yaml:"rate_limit"`
2728
Extensions []string `yaml:"extensions"`
2829
DuckLake DuckLakeFileConfig `yaml:"ducklake"`
@@ -109,12 +110,13 @@ func main() {
109110
fmt.Fprintf(os.Stderr, "Options:\n")
110111
flag.PrintDefaults()
111112
fmt.Fprintf(os.Stderr, "\nEnvironment variables:\n")
112-
fmt.Fprintf(os.Stderr, " DUCKGRES_CONFIG Path to YAML config file\n")
113-
fmt.Fprintf(os.Stderr, " DUCKGRES_HOST Host to bind to (default: 0.0.0.0)\n")
114-
fmt.Fprintf(os.Stderr, " DUCKGRES_PORT Port to listen on (default: 5432)\n")
115-
fmt.Fprintf(os.Stderr, " DUCKGRES_DATA_DIR Directory for DuckDB files (default: ./data)\n")
116-
fmt.Fprintf(os.Stderr, " DUCKGRES_CERT TLS certificate file (default: ./certs/server.crt)\n")
117-
fmt.Fprintf(os.Stderr, " DUCKGRES_KEY TLS private key file (default: ./certs/server.key)\n")
113+
fmt.Fprintf(os.Stderr, " DUCKGRES_CONFIG Path to YAML config file\n")
114+
fmt.Fprintf(os.Stderr, " DUCKGRES_HOST Host to bind to (default: 0.0.0.0)\n")
115+
fmt.Fprintf(os.Stderr, " DUCKGRES_PORT Port to listen on (default: 5432)\n")
116+
fmt.Fprintf(os.Stderr, " DUCKGRES_DATA_DIR Directory for DuckDB files (default: ./data)\n")
117+
fmt.Fprintf(os.Stderr, " DUCKGRES_CERT TLS certificate file (default: ./certs/server.crt)\n")
118+
fmt.Fprintf(os.Stderr, " DUCKGRES_KEY TLS private key file (default: ./certs/server.key)\n")
119+
fmt.Fprintf(os.Stderr, " DUCKGRES_AUTH_METHOD Auth method: cleartext (default) or md5\n")
118120
fmt.Fprintf(os.Stderr, "\nPrecedence: CLI flags > environment variables > config file > defaults\n")
119121
}
120122

@@ -166,6 +168,9 @@ func main() {
166168
if len(fileCfg.Users) > 0 {
167169
cfg.Users = fileCfg.Users
168170
}
171+
if fileCfg.AuthMethod != "" {
172+
cfg.AuthMethod = server.AuthMethod(fileCfg.AuthMethod)
173+
}
169174

170175
// Apply rate limit config
171176
if fileCfg.RateLimit.MaxFailedAttempts > 0 {
@@ -249,6 +254,9 @@ func main() {
249254
if v := os.Getenv("DUCKGRES_KEY"); v != "" {
250255
cfg.TLSKeyFile = v
251256
}
257+
if v := os.Getenv("DUCKGRES_AUTH_METHOD"); v != "" {
258+
cfg.AuthMethod = server.AuthMethod(v)
259+
}
252260
if v := os.Getenv("DUCKGRES_DUCKLAKE_METADATA_STORE"); v != "" {
253261
cfg.DuckLake.MetadataStore = v
254262
}

server/conn.go

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ package server
33
import (
44
"bufio"
55
"bytes"
6+
"crypto/md5"
7+
"crypto/rand"
68
"crypto/tls"
79
"database/sql"
810
"encoding/binary"
911
"encoding/csv"
12+
"encoding/hex"
1013
"fmt"
1114
"io"
1215
"log/slog"
@@ -482,9 +485,29 @@ func (c *clientConn) handleStartup() error {
482485
break
483486
}
484487

485-
// Request password
486-
if err := writeAuthCleartextPassword(c.writer); err != nil {
487-
return err
488+
// Get the expected password for this user
489+
expectedPassword, userExists := c.server.cfg.Users[c.username]
490+
491+
// Determine auth method (default to cleartext for backwards compatibility)
492+
authMethod := c.server.cfg.AuthMethod
493+
if authMethod == "" {
494+
authMethod = AuthCleartext
495+
}
496+
497+
var salt [4]byte
498+
if authMethod == AuthMD5 {
499+
// Generate random salt for MD5 auth
500+
if _, err := rand.Read(salt[:]); err != nil {
501+
return fmt.Errorf("failed to generate salt: %w", err)
502+
}
503+
if err := writeAuthMD5Password(c.writer, salt); err != nil {
504+
return err
505+
}
506+
} else {
507+
// Request cleartext password
508+
if err := writeAuthCleartextPassword(c.writer); err != nil {
509+
return err
510+
}
488511
}
489512
if err := c.writer.Flush(); err != nil {
490513
return fmt.Errorf("failed to flush writer: %w", err)
@@ -504,9 +527,17 @@ func (c *clientConn) handleStartup() error {
504527
// Password is null-terminated
505528
password := string(bytes.TrimRight(body, "\x00"))
506529

507-
// Validate password
508-
expectedPassword, ok := c.server.cfg.Users[c.username]
509-
if !ok || expectedPassword != password {
530+
// Validate password based on auth method
531+
var authValid bool
532+
if !userExists {
533+
authValid = false
534+
} else if authMethod == AuthMD5 {
535+
authValid = verifyMD5Password(password, expectedPassword, c.username, salt)
536+
} else {
537+
authValid = password == expectedPassword
538+
}
539+
540+
if !authValid {
510541
// Record failed authentication attempt
511542
banned := c.server.rateLimiter.RecordFailedAuth(c.conn.RemoteAddr())
512543
if banned {
@@ -528,6 +559,26 @@ func (c *clientConn) handleStartup() error {
528559
return nil
529560
}
530561

562+
// verifyMD5Password verifies an MD5-hashed password response.
563+
// The client computes: "md5" + md5(md5(password + username) + salt)
564+
// where salt is the 4-byte random salt sent by the server.
565+
func verifyMD5Password(clientResponse, password, username string, salt [4]byte) bool {
566+
// Client response should start with "md5" followed by 32 hex chars
567+
if len(clientResponse) != 35 || clientResponse[:3] != "md5" {
568+
return false
569+
}
570+
571+
// Compute expected hash: md5(md5(password + username) + salt)
572+
inner := md5.Sum([]byte(password + username))
573+
innerHex := hex.EncodeToString(inner[:])
574+
575+
outer := md5.Sum(append([]byte(innerHex), salt[:]...))
576+
outerHex := hex.EncodeToString(outer[:])
577+
578+
expected := "md5" + outerHex
579+
return clientResponse == expected
580+
}
581+
531582
func (c *clientConn) sendInitialParams() {
532583
params := map[string]string{
533584
"server_version": "15.0 (Duckgres)",

server/protocol.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,14 @@ func writeAuthCleartextPassword(w io.Writer) error {
176176
return writeMessage(w, msgAuth, data)
177177
}
178178

179+
// writeAuthMD5Password requests MD5-hashed password with a 4-byte salt
180+
func writeAuthMD5Password(w io.Writer, salt [4]byte) error {
181+
data := make([]byte, 8)
182+
binary.BigEndian.PutUint32(data, authMD5Pwd)
183+
copy(data[4:], salt[:])
184+
return writeMessage(w, msgAuth, data)
185+
}
186+
179187
// writeParameterStatus sends a parameter status message
180188
func writeParameterStatus(w io.Writer, name, value string) error {
181189
data := []byte(name)

server/server.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,26 @@ func redactConnectionString(connStr string) string {
5555
return passwordPattern.ReplaceAllString(connStr, "${1}[REDACTED]")
5656
}
5757

58+
// AuthMethod represents the authentication method to use
59+
type AuthMethod string
60+
61+
const (
62+
// AuthCleartext uses cleartext password (default, protected by TLS)
63+
AuthCleartext AuthMethod = "cleartext"
64+
// AuthMD5 uses MD5 hashed password (PostgreSQL standard)
65+
AuthMD5 AuthMethod = "md5"
66+
)
67+
5868
type Config struct {
5969
Host string
6070
Port int
6171
DataDir string
6272
Users map[string]string // username -> password
6373

74+
// AuthMethod specifies the authentication method.
75+
// Supported values: "cleartext" (default), "md5".
76+
AuthMethod AuthMethod
77+
6478
// TLS configuration (required)
6579
TLSCertFile string // Path to TLS certificate file
6680
TLSKeyFile string // Path to TLS private key file

0 commit comments

Comments
 (0)