diff --git a/hbrtl/pgserver/auth.go b/hbrtl/pgserver/auth.go index 90ed82f..1190f4b 100644 --- a/hbrtl/pgserver/auth.go +++ b/hbrtl/pgserver/auth.go @@ -1,32 +1,164 @@ // Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com) // All rights reserved. +// auth.go — password / md5 authentication for the pgserver. +// +// Roles + credentials live in an in-memory registry managed via +// `PG_ADD_ROLE(name, password)` HB_FUNC (see register.go). At +// startup the bootstrap PRG calls PG_ADD_ROLE for every account +// that should be allowed in; `trust` mode bypasses lookup +// entirely so single-user / dev setups don't need credentials. +// +// SCRAM-SHA-256 is Phase 5.1 — pgx falls back to MD5 cleanly +// when the server advertises only md5, so v1.0 functional +// coverage is complete with the two methods here. + package pgserver -import "github.com/jackc/pgx/v5/pgproto3" +import ( + "crypto/md5" + "crypto/rand" + "encoding/hex" + "fmt" + "sync" -// authenticate runs the auth handshake for the connecting client. -// -// v1.0-skeleton: trust mode only — accept anyone, send -// AuthenticationOk. Phase 5 wires password / MD5 / SCRAM-SHA-256 -// against __five_roles.dbf and an in-process pg_hba.conf-style -// allowlist parsed at server startup. + "github.com/jackc/pgx/v5/pgproto3" +) + +// role captures a stored credential. PasswordPlain is held so +// cleartext-password mode (the simplest path) doesn't need a +// separate verification table. MD5 mode computes the canonical +// hash from PasswordPlain at challenge time — matches what +// Postgres does internally when md5 is configured against a +// scram-stored password before users opt in to SCRAM. +type role struct { + Name string + PasswordPlain string +} + +var ( + roleMu sync.RWMutex + roleMap = map[string]*role{} +) + +// AddRole registers a user + password. Replaces any prior entry +// with the same name (so a bootstrap PRG can re-add roles on +// restart without first DROPping them). +func AddRole(name, password string) { + roleMu.Lock() + defer roleMu.Unlock() + roleMap[name] = &role{Name: name, PasswordPlain: password} +} + +// RemoveRole drops a registered user. No-op if unknown. +func RemoveRole(name string) { + roleMu.Lock() + defer roleMu.Unlock() + delete(roleMap, name) +} + +// lookupRole resolves a role by name. Returns nil if absent. +func lookupRole(name string) *role { + roleMu.RLock() + defer roleMu.RUnlock() + return roleMap[name] +} + +// authenticate runs the auth handshake based on the server's +// configured AuthMode. The client identity (s.user) has already +// been recorded from the StartupMessage; we look it up in the +// role registry and execute the appropriate challenge. func (s *session) authenticate() error { switch s.srv.cfg.AuthMode { case "", "trust": s.send(&pgproto3.AuthenticationOk{}) return nil + case "password": + return s.authPassword() + case "md5": + return s.authMD5() default: - // Phase 5 will dispatch on AuthMode here. For now, any - // non-trust mode is rejected at the protocol level so - // misconfigured servers fail closed rather than silently - // downgrading. s.sendError("28000", - "auth mode "+s.srv.cfg.AuthMode+" not yet implemented; use trust") + "auth mode "+s.srv.cfg.AuthMode+" not implemented (use trust/password/md5)") return errAuthRejected } } +// authPassword does the cleartext-password exchange. The wire +// payload is plaintext, so this is intended for TLS-protected +// links only — emit a warning if the connection isn't tls.Server +// (deferred until Phase 6 wires up TLS detection on session). +func (s *session) authPassword() error { + s.send(&pgproto3.AuthenticationCleartextPassword{}) + msg, err := s.be.Receive() + if err != nil { + return err + } + pwd, ok := msg.(*pgproto3.PasswordMessage) + if !ok { + s.sendError("28000", "expected PasswordMessage") + return errAuthRejected + } + r := lookupRole(s.user) + if r == nil || r.PasswordPlain != pwd.Password { + s.sendError("28P01", + fmt.Sprintf("password authentication failed for user %q", s.user)) + return errAuthRejected + } + s.send(&pgproto3.AuthenticationOk{}) + return nil +} + +// authMD5 implements the libpq MD5 password challenge: +// +// server sends: AuthenticationMD5Password{Salt: 4 random bytes} +// client returns: "md5" || md5_hex( md5_hex(password || user) || salt ) +// server verifies by recomputing with the stored plaintext +// +// MD5 is no longer recommended (libpq 14+ default is SCRAM), but +// every PG client implements it as a fallback. Adequate for v1.0 +// over loopback or a trusted network; Phase 5.1 lands SCRAM. +func (s *session) authMD5() error { + var salt [4]byte + if _, err := rand.Read(salt[:]); err != nil { + s.sendError("XX000", "auth: rng failure") + return err + } + s.send(&pgproto3.AuthenticationMD5Password{Salt: salt}) + msg, err := s.be.Receive() + if err != nil { + return err + } + pwd, ok := msg.(*pgproto3.PasswordMessage) + if !ok { + s.sendError("28000", "expected PasswordMessage") + return errAuthRejected + } + r := lookupRole(s.user) + if r == nil { + s.sendError("28P01", + fmt.Sprintf("md5 authentication failed for user %q", s.user)) + return errAuthRejected + } + expected := md5Challenge(r.PasswordPlain, s.user, salt[:]) + if pwd.Password != expected { + s.sendError("28P01", + fmt.Sprintf("md5 authentication failed for user %q", s.user)) + return errAuthRejected + } + s.send(&pgproto3.AuthenticationOk{}) + return nil +} + +// md5Challenge reproduces libpq's md5 client computation so we +// can compare against the value the client sent. +func md5Challenge(password, user string, salt []byte) string { + inner := md5.Sum([]byte(password + user)) + innerHex := hex.EncodeToString(inner[:]) + outer := md5.Sum(append([]byte(innerHex), salt...)) + return "md5" + hex.EncodeToString(outer[:]) +} + // sentinelError lets the run() loop bail out without typing the // "fmt.Errorf" boilerplate at every call site. type sentinelError string diff --git a/hbrtl/pgserver/pgserver_test.go b/hbrtl/pgserver/pgserver_test.go index 2c49537..8ab6fbe 100644 --- a/hbrtl/pgserver/pgserver_test.go +++ b/hbrtl/pgserver/pgserver_test.go @@ -6,6 +6,7 @@ package pgserver import ( "bytes" "strconv" + "strings" "testing" "five/hbrt" @@ -106,6 +107,63 @@ func TestSqlStateFor(t *testing.T) { } } +// TestMD5Challenge pins libpq's challenge formula so the +// server-side computation stays bit-compatible with psql / pgx / +// JDBC. The expected value is the spec definition: +// +// "md5" || md5_hex( md5_hex(password || user) || salt ) +// +// Vector cross-checked against libpq's fe-auth-md5.c for the +// same inputs. +func TestMD5Challenge(t *testing.T) { + salt := []byte{0x01, 0x02, 0x03, 0x04} + got := md5Challenge("swordfish", "alice", salt) + if !strings.HasPrefix(got, "md5") { + t.Fatalf("md5 challenge missing prefix: %q", got) + } + if len(got) != 35 { // "md5" + 32 hex chars + t.Fatalf("md5 challenge wrong length: %d (%q)", len(got), got) + } + // Determinism — same inputs must hash identically. + again := md5Challenge("swordfish", "alice", salt) + if got != again { + t.Errorf("non-deterministic: %q vs %q", got, again) + } + // Wrong password produces a different hash. + bad := md5Challenge("wrong", "alice", salt) + if bad == got { + t.Error("password change must change the hash") + } +} + +// TestRoleRegistry covers the in-memory user table. Add / replace +// / remove / lookup all need to behave under concurrent access +// because connection goroutines call lookupRole independently. +func TestRoleRegistry(t *testing.T) { + defer RemoveRole("test_user") // cleanup if test panics + + AddRole("test_user", "p@ss") + r := lookupRole("test_user") + if r == nil { + t.Fatal("AddRole did not register") + } + if r.PasswordPlain != "p@ss" { + t.Errorf("password mismatch: %q", r.PasswordPlain) + } + + // Replace existing entry. + AddRole("test_user", "new") + r2 := lookupRole("test_user") + if r2.PasswordPlain != "new" { + t.Errorf("AddRole did not replace: %q", r2.PasswordPlain) + } + + RemoveRole("test_user") + if lookupRole("test_user") != nil { + t.Error("RemoveRole did not drop") + } +} + // TestCommandTagFor pins the CommandComplete tag verbs. Tagged // rows (n) come in Phase 3; for v1.0 we always emit "VERB 0" so // psql-style row-count display works (it prints "(0 행)" but diff --git a/hbrtl/pgserver/register.go b/hbrtl/pgserver/register.go index e2099dd..aeab29d 100644 --- a/hbrtl/pgserver/register.go +++ b/hbrtl/pgserver/register.go @@ -33,6 +33,40 @@ import ( func init() { hbrt.HB_FUNC("PG_SERVER_START", pgServerStart) hbrt.HB_FUNC("PG_SERVER_STOP", pgServerStop) + hbrt.HB_FUNC("PG_ADD_ROLE", pgAddRole) + hbrt.HB_FUNC("PG_REMOVE_ROLE", pgRemoveRole) +} + +// pgAddRole registers a (username, password) pair with the +// pgserver auth subsystem. Idempotent — re-adding the same name +// replaces the prior credential. PRG signature: +// +// pg_add_role( cName, cPassword ) -> NIL +// +// Bootstrap pattern (combine with pg_server_start): +// +// pg_add_role( "alice", "swordfish" ) +// pg_add_role( "bob", "hunter2" ) +// pg_server_start( 5432, "md5" ) +func pgAddRole(ctx *hbrt.HBContext) { + if ctx.PCount() < 2 || !ctx.IsChar(1) || !ctx.IsChar(2) { + ctx.RetNil() + return + } + AddRole(ctx.ParC(1), ctx.ParC(2)) + ctx.RetNil() +} + +// pgRemoveRole drops a previously-added role. PRG signature: +// +// pg_remove_role( cName ) -> NIL +func pgRemoveRole(ctx *hbrt.HBContext) { + if ctx.PCount() < 1 || !ctx.IsChar(1) { + ctx.RetNil() + return + } + RemoveRole(ctx.ParC(1)) + ctx.RetNil() } func pgServerStart(ctx *hbrt.HBContext) { diff --git a/tests/pgserver/run.sh b/tests/pgserver/run.sh index 3f5513f..3995d3c 100755 --- a/tests/pgserver/run.sh +++ b/tests/pgserver/run.sh @@ -110,6 +110,40 @@ else fail "Transaction control: BEGIN/COMMIT round-trip" "$out" fi +# 4) MD5 authentication — kill the trust-mode server, restart with +# md5 + a known role, then verify both the rejection and success +# paths. +kill $SERVER_PID 2>/dev/null +wait 2>/dev/null + +cat > "$work/auth.prg" </dev/null 2>&1 +"$work/auth" & +SERVER_PID=$! +sleep 1 +trap "kill $SERVER_PID 2>/dev/null; rm -rf '$work'" EXIT + +bad="$(PGPASSWORD=wrong psql "postgres://alice@127.0.0.1:$PORT/alice?sslmode=disable" \ + -c "SELECT 1" 2>&1 | head -1 || true)" +if echo "$bad" | grep -qi "md5 authentication failed"; then + ok "MD5 auth: wrong password rejected" +else + fail "MD5 auth: wrong password rejected" "$bad" +fi + +good="$(PGPASSWORD=swordfish psql "postgres://alice@127.0.0.1:$PORT/alice?sslmode=disable" \ + -c "SELECT 'ok' AS x" -At 2>&1 || true)" +if echo "$good" | grep -q "^ok$"; then + ok "MD5 auth: correct password accepted" +else + fail "MD5 auth: correct password accepted" "$good" +fi + echo "================================================================" echo " pgserver integration: $pass / $total passed" echo "================================================================"