Documentation
¶
Overview ¶
Package scram implements SCRAM-SHA-256 authentication for PostgreSQL protocol connections.
Overview ¶
This package provides SCRAM-SHA-256 authentication for both server and client roles, enabling Multigres to verify client credentials and extract SCRAM keys for passthrough authentication to backend PostgreSQL servers. This eliminates the need to store plaintext passwords while maintaining compatibility with PostgreSQL's native authentication.
SCRAM-SHA-256 Protocol ¶
SCRAM (Salted Challenge Response Authentication Mechanism) is defined in RFC 5802: https://datatracker.ietf.org/doc/html/rfc5802
PostgreSQL's SCRAM-SHA-256 implementation is documented at: https://www.postgresql.org/docs/current/sasl-authentication.html
The protocol involves a three-message exchange:
- Client → Server: client-first-message (username, nonce)
- Server → Client: server-first-message (combined nonce, salt, iterations)
- Client → Server: client-final-message (proof)
- Server → Client: server-final-message (server signature for mutual auth)
Why Not Use an Existing Library? ¶
Several Go SCRAM libraries exist (xdg-go/scram, lib/pq, jackc/pgx), but none support our critical requirement: ClientKey extraction for passthrough authentication.
Existing libraries:
- xdg-go/scram: Most comprehensive, but lacks ClientKey extraction and context.Context support
- lib/pq: Maintenance mode, client-side only
- jackc/pgx: Client library, no server-side SCRAM
Our implementation adds:
- ExtractAndVerifyClientProof: Recovers ClientKey from client's proof for passthrough auth
- context.Context support: Allows timeout/cancellation during credential lookup
- Minimum security thresholds: Enforces 4096+ iterations and 8+ byte salts
These features enable Multigres to verify clients and reuse extracted keys to authenticate to backend PostgreSQL servers without storing plaintext passwords.
Architecture ¶
The package is organized into several components:
- ScramAuthenticator: Stateful server-side authenticator handling the protocol exchange
- SCRAMClient: Client-side authenticator supporting password and passthrough modes
- PasswordHashProvider: Interface for retrieving SCRAM password hashes from storage
- Cryptographic functions: RFC 5802 compliant key derivation and verification
- Protocol parsers/generators: Message construction and parsing (unexported)
Usage Example ¶
// Server-side authentication
provider := NewMyPasswordProvider()
auth := scram.NewScramAuthenticator(provider, "mydb")
// Start SASL negotiation
mechanisms := auth.StartAuthentication()
// Send AuthenticationSASL with mechanisms...
// Handle client-first-message
serverFirst, err := auth.HandleClientFirst(ctx, clientFirstMsg)
// Send AuthenticationSASLContinue with serverFirst...
// Handle client-final-message
serverFinal, err := auth.HandleClientFinal(clientFinalMsg)
if auth.IsAuthenticated() {
// Authentication successful
clientKey, serverKey := auth.ExtractedKeys()
// Use keys for passthrough authentication...
}
Key Passthrough Authentication ¶
A critical feature of this implementation is extracting the ClientKey from the client's proof during authentication. This enables SCRAM passthrough:
- Client authenticates to Multigres multigateway
- Multigateway extracts ClientKey from the authentication proof
- Multigateway uses ClientKey to authenticate to PostgreSQL as that user
- No plaintext password needed at any stage
This is possible because SCRAM proofs reveal the ClientKey through XOR:
ClientKey = ClientProof XOR ClientSignature
The extracted ClientKey can then be used with the stored ServerKey to perform subsequent SCRAM authentications to backend PostgreSQL servers without knowing the original password.
Password Hash Storage ¶
This package expects password hashes in PostgreSQL's SCRAM-SHA-256 format:
SCRAM-SHA-256$<iterations>:<salt>$<StoredKey>:<ServerKey>
The PasswordHashProvider interface abstracts the storage mechanism:
type PasswordHashProvider interface {
GetPasswordHash(ctx context.Context, username, database string) (*ScramHash, error)
}
Implementations can:
- Query PostgreSQL's pg_authid directly
- Use a credential cache with TTL and invalidation
- Fetch from a centralized credential service
- Combine multiple sources with fallback logic
Future Directions ¶
Current implementation:
- Core SCRAM protocol and cryptography
- Server-side authentication (ScramAuthenticator)
- Client-side authentication with passthrough support (SCRAMClient)
- No credential caching
- No integration with multigateway/multipooler
Planned enhancements:
- Caching password hashes in multigateway to save a round-trip to multipooler on connect
- Integration with multigateway/multipooler for end-to-end passthrough
Credential Cache Design Considerations ¶
When implementing credential caching:
TTL: Balance security (shorter) vs performance (longer)
PgBouncer: No cache, runs auth_query on every connection See src/client.c start_auth_query() https://github.com/pgbouncer/pgbouncer/tree/master/src
Supavisor: 24-hour cache with 15-second background refresh + refresh on auth failure See lib/supavisor/secret_cache.ex @default_secrets_ttl, fetch_validation_secrets/3 See lib/supavisor/secret_checker.ex @interval, check_secrets/2 (background polling) See lib/supavisor/client_handler/auth.ex check_and_update_secrets/7 (refresh on auth failure) https://github.com/supabase/supavisor/tree/main/lib/supavisor
Invalidation: Consider cache busting on password changes Option 1: PostgreSQL triggers + notification channel Option 2: Periodic refresh on access Option 3: External invalidation API
Security Considerations ¶
- All password hash comparisons use constant-time algorithms (crypto/subtle)
- Nonce validation prevents replay attacks
- State machine prevents protocol violations
- No plaintext passwords stored or logged
- ClientKey extraction requires successful authentication
Password Normalization (SASLprep) ¶
This implementation includes SASLprep password normalization (RFC 4013) for full PostgreSQL compatibility. SASLprep applies NFKC Unicode normalization and character mapping to passwords before hashing.
Key behaviors:
- Non-ASCII spaces normalized to ASCII space (U+0020)
- Soft hyphens and zero-width characters removed
- Unicode combining characters normalized
- Fallback to raw password on normalization failure (prohibited chars, bidi violations)
This matches PostgreSQL's lenient approach: passwords that fail SASLprep validation (invalid UTF-8, prohibited characters, bidirectional check failures) are accepted using their raw byte representation.
Compatibility ¶
This implementation is compatible with:
- PostgreSQL 10+ SCRAM-SHA-256 authentication
- Standard PostgreSQL client libraries (psql, libpq, pgx, etc.)
- PostgreSQL's pg_authid password hash format
- PostgreSQL's SASLprep implementation (RFC 4013)
Not currently supported:
- SCRAM-SHA-1 (deprecated, not used by PostgreSQL)
- Channel binding (SCRAM-SHA-256-PLUS)
- Custom iteration counts (uses hash's iteration count)
References ¶
- RFC 5802 (SCRAM): https://datatracker.ietf.org/doc/html/rfc5802
- PostgreSQL SASL: https://www.postgresql.org/docs/current/sasl-authentication.html
- PgBouncer auth: https://www.pgbouncer.org/config.html#authentication-settings
- Supavisor: https://github.com/supabase/supavisor
Index ¶
- Constants
- Variables
- func ComputeClientKey(saltedPassword []byte) []byte
- func ComputeClientSignature(storedKey []byte, authMessage string) []byte
- func ComputeSaltedPassword(password string, salt []byte, iterations int) []byte
- func ComputeServerKey(saltedPassword []byte) []byte
- func ComputeServerSignature(serverKey []byte, authMessage string) []byte
- func ComputeStoredKey(clientKey []byte) []byte
- func ExtractAndVerifyClientProof(storedKey []byte, authMessage string, clientProof []byte) ([]byte, error)
- func IsScramSHA256Hash(hash string) bool
- type PasswordHashProvider
- type SCRAMClient
- type ScramAuthenticator
- func (a *ScramAuthenticator) AuthenticatedUser() string
- func (a *ScramAuthenticator) ExtractedKeys() (clientKey, serverKey []byte)
- func (a *ScramAuthenticator) HandleClientFinal(clientFinalMessage string) (string, error)
- func (a *ScramAuthenticator) HandleClientFirst(ctx context.Context, clientFirstMessage, startupMessageUsername string) (string, error)
- func (a *ScramAuthenticator) IsAuthenticated() bool
- func (a *ScramAuthenticator) Reset()
- func (a *ScramAuthenticator) StartAuthentication() []string
- type ScramHash
Constants ¶
const ( // ScramSHA256Prefix is the prefix for SCRAM-SHA-256 password hashes in PostgreSQL. ScramSHA256Prefix = "SCRAM-SHA-256" // MinIterationCount is the minimum PBKDF2 iteration count accepted for security. // RFC 5802 recommends a minimum of 4096 iterations to make brute-force attacks harder. MinIterationCount = 4096 // MinSaltLength is the minimum salt length in bytes accepted for security. MinSaltLength = 8 )
const (
// ScramSHA256Mechanism is the SASL mechanism name for SCRAM-SHA-256.
ScramSHA256Mechanism = "SCRAM-SHA-256"
)
Variables ¶
var ( // ErrUserNotFound indicates the user does not exist. ErrUserNotFound = errors.New("user not found") // ErrAuthenticationFailed indicates the password proof was invalid. ErrAuthenticationFailed = errors.New("authentication failed") )
Sentinel errors for SCRAM authentication.
Functions ¶
func ComputeClientKey ¶
ComputeClientKey computes ClientKey = HMAC(SaltedPassword, "Client Key").
func ComputeClientSignature ¶
ComputeClientSignature computes ClientSignature = HMAC(StoredKey, AuthMessage).
func ComputeSaltedPassword ¶
ComputeSaltedPassword computes the SCRAM SaltedPassword using PBKDF2. SaltedPassword = Hi(Normalize(password), salt, iterations) Where Hi is PBKDF2 with HMAC-SHA-256.
Passwords are normalized using SASLprep (RFC 4013), which applies stringprep with NFKC normalization and character mapping. If normalization fails (invalid UTF-8, prohibited characters, bidirectional check failures), the raw password is used unchanged, matching PostgreSQL's lenient behavior.
func ComputeServerKey ¶
ComputeServerKey computes ServerKey = HMAC(SaltedPassword, "Server Key").
func ComputeServerSignature ¶
ComputeServerSignature computes ServerSignature = HMAC(ServerKey, AuthMessage).
func ComputeStoredKey ¶
ComputeStoredKey computes StoredKey = H(ClientKey) where H is SHA-256.
func ExtractAndVerifyClientProof ¶
func ExtractAndVerifyClientProof(storedKey []byte, authMessage string, clientProof []byte) ([]byte, error)
ExtractAndVerifyClientProof verifies the client's proof and extracts the ClientKey. This is used for SCRAM key passthrough: after verifying a client, we can use the extracted ClientKey to authenticate as that user to PostgreSQL.
The verification process: 1. Compute ClientSignature = HMAC(StoredKey, AuthMessage) 2. Recover ClientKey = ClientProof XOR ClientSignature 3. Compute RecoveredStoredKey = H(ClientKey) 4. Verify RecoveredStoredKey == StoredKey
Returns the extracted ClientKey on successful verification (error == nil). Returns ErrAuthenticationFailed if the proof is invalid (wrong password). Returns other errors for unexpected conditions (malformed proof, length mismatches). Uses constant-time comparison to prevent timing attacks.
func IsScramSHA256Hash ¶
IsScramSHA256Hash returns true if the hash string appears to be a SCRAM-SHA-256 hash. This is a quick check based on the prefix; it does not validate the entire format.
Types ¶
type PasswordHashProvider ¶
type PasswordHashProvider interface {
// GetPasswordHash retrieves the SCRAM-SHA-256 hash for a user in a database.
// Returns ErrUserNotFound if the user does not exist.
GetPasswordHash(ctx context.Context, username, database string) (*ScramHash, error)
}
PasswordHashProvider is an interface for retrieving password hashes. This abstraction allows the authenticator to be used with different storage backends (PostgreSQL, cache, etc.).
type SCRAMClient ¶
type SCRAMClient struct {
// contains filtered or unexported fields
}
SCRAMClient implements client-side SCRAM-SHA-256 authentication. It supports two modes: 1. Password mode: authenticate using a plaintext password 2. Passthrough mode: authenticate using pre-extracted ClientKey/ServerKey
Passthrough mode enables SCRAM key passthrough: after a proxy verifies a client, it can extract the ClientKey and use it to authenticate to the backend database without knowing the plaintext password.
func NewSCRAMClientWithKeys ¶
func NewSCRAMClientWithKeys(username string, clientKey, serverKey []byte) *SCRAMClient
NewSCRAMClientWithKeys creates a SCRAM client that authenticates with extracted SCRAM keys. This enables SCRAM passthrough: a proxy can verify a client, extract the ClientKey, and use it to authenticate to PostgreSQL without the plaintext password.
The clientKey and serverKey should be extracted from a previous SCRAM authentication using ExtractAndVerifyClientProof and the hash's ServerKey.
func NewSCRAMClientWithPassword ¶
func NewSCRAMClientWithPassword(username, password string) *SCRAMClient
NewSCRAMClientWithPassword creates a SCRAM client that authenticates with a password. This is the standard mode where the password is used to derive SCRAM keys.
func (*SCRAMClient) ClientFirstMessage ¶
func (c *SCRAMClient) ClientFirstMessage() (string, error)
ClientFirstMessage generates the client-first-message to send to the server. This starts the SCRAM authentication handshake. Returns the full message including the GS2 header.
func (*SCRAMClient) ProcessServerFirst ¶
func (c *SCRAMClient) ProcessServerFirst(serverFirst string) (string, error)
ProcessServerFirst processes the server-first-message and generates the client-final-message. The serverFirst parameter is the server's response to the client-first-message. Returns the client-final-message to send to the server.
func (*SCRAMClient) VerifyServerFinal ¶
func (c *SCRAMClient) VerifyServerFinal(serverFinal string) error
VerifyServerFinal verifies the server-final-message for mutual authentication. The serverFinal parameter is the server's response to the client-final-message. Returns nil if the server signature is valid, or an error if verification fails.
type ScramAuthenticator ¶
type ScramAuthenticator struct {
// contains filtered or unexported fields
}
ScramAuthenticator handles SCRAM-SHA-256 authentication. It implements the server side of the SCRAM protocol as defined in RFC 5802.
Usage:
- Call StartAuthentication() to get the list of supported mechanisms.
- After receiving client-first-message, call HandleClientFirst().
- After receiving client-final-message, call HandleClientFinal().
- Check IsAuthenticated() to see if auth succeeded.
- Call ExtractedKeys() to get SCRAM keys for passthrough authentication.
The authenticator maintains state between calls and enforces valid state transitions to prevent protocol errors.
Thread Safety: ScramAuthenticator is NOT thread-safe. Each connection must use its own authenticator instance. Do not share authenticators across goroutines or reuse them for multiple concurrent authentication attempts. The Reset() method allows reusing an authenticator sequentially, but only after the previous authentication has completed.
func NewScramAuthenticator ¶
func NewScramAuthenticator(provider PasswordHashProvider, database string) *ScramAuthenticator
NewScramAuthenticator creates a new SCRAM authenticator with the given password hash provider and database name for credential lookup.
Panics if provider is nil.
func (*ScramAuthenticator) AuthenticatedUser ¶
func (a *ScramAuthenticator) AuthenticatedUser() string
AuthenticatedUser returns the username that was successfully authenticated. Returns an empty string if authentication has not completed successfully.
func (*ScramAuthenticator) ExtractedKeys ¶
func (a *ScramAuthenticator) ExtractedKeys() (clientKey, serverKey []byte)
ExtractedKeys returns the SCRAM keys extracted during authentication. These can be used for passthrough authentication to backend PostgreSQL servers.
ClientKey is recovered from the client's proof (ClientKey = ClientProof XOR ClientSignature). ServerKey comes from the stored password hash.
Returns nil, nil if authentication has not completed successfully.
func (*ScramAuthenticator) HandleClientFinal ¶
func (a *ScramAuthenticator) HandleClientFinal(clientFinalMessage string) (string, error)
HandleClientFinal processes the client-final-message from the client. Returns the server-final-message to send back on success.
The client-final-message contains the channel binding, combined nonce, and client proof. This method verifies the proof and, if valid, returns the server signature for mutual authentication.
Returns ErrAuthenticationFailed if the proof is invalid.
func (*ScramAuthenticator) HandleClientFirst ¶
func (a *ScramAuthenticator) HandleClientFirst(ctx context.Context, clientFirstMessage, startupMessageUsername string) (string, error)
HandleClientFirst processes the client-first-message from the client. Returns the server-first-message to send back.
The client-first-message comes from SASLInitialResponse and contains the GS2 header and client-first-message-bare (username, client nonce).
The startupMessageUsername is used when the client sends an empty username in the client-first-message. PostgreSQL allows this and uses the username from the startup message. This parameter should be the username from the startup message.
This method looks up the user's password hash and generates the server-first-message containing the combined nonce, salt, and iteration count.
func (*ScramAuthenticator) IsAuthenticated ¶
func (a *ScramAuthenticator) IsAuthenticated() bool
IsAuthenticated returns true if the authentication completed successfully.
func (*ScramAuthenticator) Reset ¶
func (a *ScramAuthenticator) Reset()
Reset clears the authenticator state, allowing it to be reused for another authentication attempt.
func (*ScramAuthenticator) StartAuthentication ¶
func (a *ScramAuthenticator) StartAuthentication() []string
StartAuthentication begins the SCRAM authentication process. Returns the list of supported SASL mechanisms.
This corresponds to sending AuthenticationSASL (auth type 10) to the client.
type ScramHash ¶
type ScramHash struct {
// Iterations is the PBKDF2 iteration count used to derive the salted password.
Iterations int
// Salt is the random salt used in PBKDF2 key derivation.
Salt []byte
// StoredKey is H(ClientKey) where H is SHA-256 and ClientKey = HMAC(SaltedPassword, "Client Key").
// Used to verify the client's proof.
StoredKey []byte
// ServerKey is HMAC(SaltedPassword, "Server Key").
// Used to generate the server's signature for mutual authentication.
ServerKey []byte
}
ScramHash contains the parsed components of a PostgreSQL SCRAM-SHA-256 password hash. The hash format is: SCRAM-SHA-256$<iterations>:<salt>$<StoredKey>:<ServerKey> where salt, StoredKey, and ServerKey are base64-encoded.
func ParseScramSHA256Hash ¶
ParseScramSHA256Hash parses a PostgreSQL SCRAM-SHA-256 password hash string. The expected format is: SCRAM-SHA-256$<iterations>:<salt>$<StoredKey>:<ServerKey>
Example: SCRAM-SHA-256$4096:W22ZaJ0SNY7soEsUEjb6gQ==$WG5d8oPm3OtcPnkdi4Oln6rNiYzlYY42lUpMtdJ7U90=:HKZfkuYXDxJboM9DFNR0yFNHpRx/rbdVdNOTk/V0v0Q=