Files
five/hbrtl/pgserver/auth.go
CharlesKWON 90eafcfc06 feat(pgserver): Phase 5 — password + MD5 authentication
Trust mode (v1.0 default) accepts anyone; that's fine for embedded
demo but unshipping a multi-client database without credentials
would be irresponsible. This commit adds two of libpq's three
standard auth flows. SCRAM-SHA-256 is Phase 5.1 — pgx/psql both
fall back to MD5 cleanly when the server advertises only md5, so
v1.0's functional coverage is complete with the pair landed here.

Auth subsystem
--------------

`hbrtl/pgserver/auth.go` adds:

* An in-memory role registry: `roleMap map[string]*role` guarded by
  sync.RWMutex. Reads (lookupRole) are hot-path during connection
  startup so the RWMutex lets multiple sessions auth in parallel
  without serialising through a plain Mutex.

* `AddRole(name, password)` / `RemoveRole(name)` Go API consumed
  by the new HB_FUNCs `PG_ADD_ROLE` / `PG_REMOVE_ROLE` (see
  register.go). Bootstrap PRG idiom:

      PG_ADD_ROLE("alice", "swordfish")
      PG_ADD_ROLE("bob",   "hunter2")
      PG_SERVER_START(":5432", "md5")

* `authPassword()` — cleartext PasswordMessage exchange. The wire
  payload is plain so intended for TLS-protected links only;
  Phase 6 ties the warning to actual TLS detection on the session.

* `authMD5()` — libpq's md5 challenge:

      server → AuthenticationMD5Password{salt: 4 random bytes}
      client → "md5" || md5_hex( md5_hex(password || user) || salt )

  We recompute the canonical hash from the stored plaintext and
  compare. md5Challenge() is exported for pinning by a Go unit
  test (vector cross-checked against libpq's fe-auth-md5.c).

Salt is sourced from crypto/rand on every challenge so replay
attacks against a captured wire trace can't reuse a prior hash.

Dispatch matrix (Config.AuthMode → flow):
  "" / "trust" → AuthenticationOk immediately, no lookup
  "password"   → authPassword()
  "md5"        → authMD5()
  anything else→ 28000 + connection close

Tests
-----

Unit (hbrtl/pgserver/pgserver_test.go):
  PASS  TestMD5Challenge           (vector + determinism + diff)
  PASS  TestRoleRegistry           (add/replace/remove/lookup)

Integration (tests/pgserver/run.sh):
  PASS  Simple Query: SELECT 1, 'hello'
  PASS  Multi-statement Simple Query
  PASS  Transaction control: BEGIN/COMMIT round-trip
  PASS  MD5 auth: wrong password rejected
  PASS  MD5 auth: correct password accepted

End-to-end matrix with real psql:
  wrong password   → "ERROR: md5 authentication failed for user 'alice'"
  correct password → SELECT returns row
  unknown user     → "ERROR: md5 authentication failed for user 'eve'"
  password mode    → cleartext exchange works equivalently

All six release gates green:
  go test ./...               ✓
  FiveSql2 SQL:1999 43/43     ✓
  Harbour compat 56/56        ✓
  std.ch 17/17                ✓
  FRB 7/7                     ✓
  pgserver integration 5/5    ✓ (up from 3/3 in Phase 4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:01:30 +09:00

169 lines
5.1 KiB
Go

// 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 (
"crypto/md5"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"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:
s.sendError("28000",
"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
func (e sentinelError) Error() string { return string(e) }
const errAuthRejected = sentinelError("pgserver: authentication rejected")