Skip ALTER ROLE when the stored SCRAM verifier already matches the password (#3171)

* Skip ALTER ROLE when the stored SCRAM verifier already matches the password

With password_encryption = scram-sha-256, syncSecrets compared the stored
rolpassword with a freshly generated verifier. SCRAM verifiers embed a
random salt, so the strings never match and every sync cycle re-issued
ALTER ROLE ... PASSWORD for every managed role, re-salting the verifier
each time. Besides the WAL and audit noise, this invalidates SCRAM
pass-through credentials cached by connection poolers (e.g. pgbouncer
behind auth_query), causing a short window of 'password authentication
failed' server logins after every sync.

Verify the stored hash against the desired password instead: for SCRAM
verifiers the salt and iteration count are taken from the stored value
and the derived keys are compared. Hashes whose type does not match the
configured password_encryption are still reported as outdated, so
switching between md5 and scram-sha-256 keeps re-hashing roles as
before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update pkg/util/util.go

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Ida Novindasari <idanovinda@gmail.com>
Co-authored-by: Felix Kunde <felix-kunde@gmx.de>
This commit is contained in:
g2px1
2026-08-20 19:28:36 +02:00
committed by Felix Kunde
co-authored by Claude Fable 5 Ida Novindasari Felix Kunde
parent 6138bc4a69
commit 6e65436624
3 changed files with 138 additions and 6 deletions
+97 -2
View File
@@ -13,6 +13,7 @@ import (
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -94,14 +95,21 @@ func NewEncryptor(encryption string) *Encryptor {
}
func (e *Encryptor) PGUserPassword(user spec.PgUser) string {
if (len(user.Password) == md5.Size*2+len(md5prefix) && user.Password[:3] == md5prefix) ||
(len(user.Password) > len(scramsha256prefix) && user.Password[:len(scramsha256prefix)] == scramsha256prefix) || user.Password == "" {
if isMD5Hash(user.Password) || isScramHash(user.Password) || user.Password == "" {
// Avoid processing already encrypted or empty passwords
return user.Password
}
return e.encrypt(user)
}
func isMD5Hash(password string) bool {
return len(password) == md5.Size*2+len(md5prefix) && password[:3] == md5prefix
}
func isScramHash(password string) bool {
return len(password) > len(scramsha256prefix) && password[:len(scramsha256prefix)] == scramsha256prefix
}
func (e *Encryptor) PGUserPasswordMD5(user spec.PgUser) string {
s := md5.Sum([]byte(user.Password + user.Name)) // #nosec, using md5 since PostgreSQL uses it for hashing passwords.
return md5prefix + hex.EncodeToString(s[:])
@@ -127,6 +135,93 @@ func (e *Encryptor) PGUserPasswordScramSHA256(user spec.PgUser) string {
return pass
}
// PGUserPasswordUpToDate reports whether the password hash stored in the
// database already corresponds to the user's desired password and the
// configured password encryption, i.e. whether ALTER ROLE ... PASSWORD can
// be skipped during role sync.
//
// A SCRAM-SHA-256 verifier embeds a random salt, so regenerating one from
// the plaintext and comparing strings never matches. Instead, the salt and
// iteration count are taken from the stored verifier and the derived keys
// are compared. A stored hash whose type differs from the configured
// encryption is reported as outdated so that changing password_encryption
// still re-hashes the roles.
func PGUserPasswordUpToDate(user spec.PgUser, storedPassword, encryption string) bool {
// Empty and pre-hashed desired passwords can only be compared verbatim,
// mirroring the early return in PGUserPassword.
if user.Password == "" || isMD5Hash(user.Password) || isScramHash(user.Password) {
return user.Password == storedPassword
}
switch {
case isMD5Hash(storedPassword):
if encryption != "md5" {
return false
}
return NewEncryptor(encryption).PGUserPassword(user) == storedPassword
case isScramHash(storedPassword):
if encryption == "md5" {
return false
}
return scramVerifierMatches(user.Password, storedPassword)
}
return false
}
// scramVerifierMatches verifies a plaintext password against a stored
// SCRAM-SHA-256 verifier of the form
// SCRAM-SHA-256$<iterations>:<salt>$<storedKey>:<serverKey>
// by re-deriving the keys with the stored salt and iteration count.
func scramVerifierMatches(password, verifier string) bool {
rest := strings.TrimPrefix(verifier, scramsha256prefix+"$")
if rest == verifier {
return false
}
saltedParams, keys, found := strings.Cut(rest, "$")
if !found {
return false
}
iterationsPart, saltPart, found := strings.Cut(saltedParams, ":")
if !found {
return false
}
storedKeyPart, serverKeyPart, found := strings.Cut(keys, ":")
if !found {
return false
}
iterationCount, err := strconv.Atoi(iterationsPart)
if err != nil || iterationCount < 1 {
return false
}
salt, err := base64.StdEncoding.DecodeString(saltPart)
if err != nil {
return false
}
storedKey, err := base64.StdEncoding.DecodeString(storedKeyPart)
if err != nil {
return false
}
serverKey, err := base64.StdEncoding.DecodeString(serverKeyPart)
if err != nil {
return false
}
key := pbkdf2.Key([]byte(password), salt, iterationCount, 32, sha256.New)
serverMAC := hmac.New(sha256.New, key)
serverMAC.Write([]byte("Server Key"))
derivedServerKey := serverMAC.Sum(nil)
clientMAC := hmac.New(sha256.New, key)
clientMAC.Write([]byte("Client Key"))
derivedStoredKey := sha256.Sum256(clientMAC.Sum(nil))
return hmac.Equal(derivedServerKey, serverKey) && hmac.Equal(derivedStoredKey[:], storedKey)
}
// Diff returns diffs between 2 objects
func Diff(a, b interface{}) []string {
return pretty.Diff(a, b)