Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ services:
PGX_TEST_UNIX_SOCKET_CONN_STRING: "host=/var/run/postgresql port=5432 user=postgres dbname=pgx_test"
PGX_TEST_TCP_CONN_STRING: "host=127.0.0.1 port=5432 user=pgx_md5 password=secret dbname=pgx_test"
PGX_TEST_MD5_PASSWORD_CONN_STRING: "host=127.0.0.1 port=5432 user=pgx_md5 password=secret dbname=pgx_test"
PGX_TEST_SCRAM_PASSWORD_CONN_STRING: "host=127.0.0.1 port=5432 user=pgx_scram password=secret dbname=pgx_test"
PGX_TEST_SCRAM_PASSWORD_CONN_STRING: "host=localhost port=5432 user=pgx_scram password=secret dbname=pgx_test channel_binding=disable"
PGX_TEST_SCRAM_PLUS_CONN_STRING: "host=127.0.0.1 port=5432 user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=require"
PGX_TEST_PLAIN_PASSWORD_CONN_STRING: "host=127.0.0.1 port=5432 user=pgx_pw password=secret dbname=pgx_test"
PGX_TEST_TLS_CONN_STRING: "host=localhost port=5432 user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test"
PGX_TEST_TLS_CONN_STRING: "host=localhost port=5432 user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=disable"
PGX_TEST_TLS_CLIENT_CONN_STRING: "host=localhost port=5432 user=pgx_sslcert sslmode=verify-full sslrootcert=/tmp/ca.pem sslcert=/tmp/pgx_sslcert.crt sslkey=/tmp/pgx_sslcert.key dbname=pgx_test"
PGX_SSL_PASSWORD: certpw

Expand Down
26 changes: 16 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,50 +20,55 @@ jobs:
pgx-test-database: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-unix-socket-conn-string: "host=/var/run/postgresql dbname=pgx_test"
pgx-test-tcp-conn-string: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-scram-password-conn-string: "host=127.0.0.1 user=pgx_scram password=secret dbname=pgx_test"
pgx-test-scram-password-conn-string: "host=127.0.0.1 user=pgx_scram password=secret dbname=pgx_test channel_binding=disable"
pgx-test-scram-plus-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=require"
pgx-test-md5-password-conn-string: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-plain-password-conn-string: "host=127.0.0.1 user=pgx_pw password=secret dbname=pgx_test"
pgx-test-tls-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test"
pgx-test-tls-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=disable"
pgx-ssl-password: certpw
pgx-test-tls-client-conn-string: "host=localhost user=pgx_sslcert sslmode=verify-full sslrootcert=/tmp/ca.pem sslcert=/tmp/pgx_sslcert.crt sslkey=/tmp/pgx_sslcert.key dbname=pgx_test"
- pg-version: 15
pgx-test-database: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-unix-socket-conn-string: "host=/var/run/postgresql dbname=pgx_test"
pgx-test-tcp-conn-string: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-scram-password-conn-string: "host=127.0.0.1 user=pgx_scram password=secret dbname=pgx_test"
pgx-test-scram-password-conn-string: "host=127.0.0.1 user=pgx_scram password=secret dbname=pgx_test channel_binding=disable"
pgx-test-scram-plus-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=require"
pgx-test-md5-password-conn-string: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-plain-password-conn-string: "host=127.0.0.1 user=pgx_pw password=secret dbname=pgx_test"
pgx-test-tls-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test"
pgx-test-tls-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=disable"
pgx-ssl-password: certpw
pgx-test-tls-client-conn-string: "host=localhost user=pgx_sslcert sslmode=verify-full sslrootcert=/tmp/ca.pem sslcert=/tmp/pgx_sslcert.crt sslkey=/tmp/pgx_sslcert.key dbname=pgx_test"
- pg-version: 16
pgx-test-database: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-unix-socket-conn-string: "host=/var/run/postgresql dbname=pgx_test"
pgx-test-tcp-conn-string: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-scram-password-conn-string: "host=127.0.0.1 user=pgx_scram password=secret dbname=pgx_test"
pgx-test-scram-password-conn-string: "host=127.0.0.1 user=pgx_scram password=secret dbname=pgx_test channel_binding=disable"
pgx-test-scram-plus-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=require"
pgx-test-md5-password-conn-string: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-plain-password-conn-string: "host=127.0.0.1 user=pgx_pw password=secret dbname=pgx_test"
pgx-test-tls-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test"
pgx-test-tls-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=disable"
pgx-ssl-password: certpw
pgx-test-tls-client-conn-string: "host=localhost user=pgx_sslcert sslmode=verify-full sslrootcert=/tmp/ca.pem sslcert=/tmp/pgx_sslcert.crt sslkey=/tmp/pgx_sslcert.key dbname=pgx_test"
- pg-version: 17
pgx-test-database: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-unix-socket-conn-string: "host=/var/run/postgresql dbname=pgx_test"
pgx-test-tcp-conn-string: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-scram-password-conn-string: "host=127.0.0.1 user=pgx_scram password=secret dbname=pgx_test"
pgx-test-scram-password-conn-string: "host=127.0.0.1 user=pgx_scram password=secret dbname=pgx_test channel_binding=disable"
pgx-test-scram-plus-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=require"
pgx-test-md5-password-conn-string: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-plain-password-conn-string: "host=127.0.0.1 user=pgx_pw password=secret dbname=pgx_test"
pgx-test-tls-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test"
pgx-test-tls-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=disable"
pgx-ssl-password: certpw
pgx-test-tls-client-conn-string: "host=localhost user=pgx_sslcert sslmode=verify-full sslrootcert=/tmp/ca.pem sslcert=/tmp/pgx_sslcert.crt sslkey=/tmp/pgx_sslcert.key dbname=pgx_test"
- pg-version: 18
pgx-test-database: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-unix-socket-conn-string: "host=/var/run/postgresql dbname=pgx_test"
pgx-test-tcp-conn-string: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-scram-password-conn-string: "host=127.0.0.1 user=pgx_scram password=secret dbname=pgx_test"
pgx-test-scram-password-conn-string: "host=127.0.0.1 user=pgx_scram password=secret dbname=pgx_test channel_binding=disable"
pgx-test-scram-plus-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=require"
pgx-test-md5-password-conn-string: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
pgx-test-plain-password-conn-string: "host=127.0.0.1 user=pgx_pw password=secret dbname=pgx_test"
pgx-test-tls-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test"
pgx-test-tls-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test channel_binding=disable"
pgx-test-oauth: "true"
pgx-ssl-password: certpw
pgx-test-tls-client-conn-string: "host=localhost user=pgx_sslcert sslmode=verify-full sslrootcert=/tmp/ca.pem sslcert=/tmp/pgx_sslcert.crt sslkey=/tmp/pgx_sslcert.key dbname=pgx_test"
Expand Down Expand Up @@ -119,6 +124,7 @@ jobs:
PGX_TEST_OAUTH: ${{ matrix.pgx-test-oauth }}
# TestConnectTLS fails. However, it succeeds if I connect to the CI server with upterm and run it. Give up on that test for now.
# PGX_TEST_TLS_CONN_STRING: ${{ matrix.pgx-test-tls-conn-string }}
# PGX_TEST_SCRAM_PLUS_CONN_STRING: ${{ matrix.pgx-test-scram-plus-conn-string }}
PGX_SSL_PASSWORD: ${{ matrix.pgx-ssl-password }}
PGX_TEST_TLS_CLIENT_CONN_STRING: ${{ matrix.pgx-test-tls-client-conn-string }}

Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,11 @@ export POSTGRESQL_DATA_DIR=postgresql
export PGX_TEST_DATABASE="host=127.0.0.1 database=pgx_test user=pgx_md5 password=secret"
export PGX_TEST_UNIX_SOCKET_CONN_STRING="host=/private/tmp database=pgx_test"
export PGX_TEST_TCP_CONN_STRING="host=127.0.0.1 database=pgx_test user=pgx_md5 password=secret"
export PGX_TEST_SCRAM_PASSWORD_CONN_STRING="host=127.0.0.1 user=pgx_scram password=secret database=pgx_test"
export PGX_TEST_SCRAM_PASSWORD_CONN_STRING="host=127.0.0.1 user=pgx_scram password=secret database=pgx_test channel_binding=disable"
export PGX_TEST_SCRAM_PLUS_CONN_STRING="host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=`pwd`/.testdb/ca.pem database=pgx_test channel_binding=require"
export PGX_TEST_MD5_PASSWORD_CONN_STRING="host=127.0.0.1 database=pgx_test user=pgx_md5 password=secret"
export PGX_TEST_PLAIN_PASSWORD_CONN_STRING="host=127.0.0.1 user=pgx_pw password=secret"
export PGX_TEST_TLS_CONN_STRING="host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=`pwd`/.testdb/ca.pem"
export PGX_TEST_TLS_CONN_STRING="host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=`pwd`/.testdb/ca.pem channel_binding=disable"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicitly set channel_binding to disable to preserve the original test cases that utilize this conn string. Left unset would default to prefer and that would automatically upgrade the connection auth mechanism given that pg_hba.conf has pgx_ssl setup to auth with scram. Instead, we copy this conn string to PGX_TEST_SCRAM_PLUS_CONN_STRING above and set the channel binding to require in order to explicitly exercise that path.

export PGX_SSL_PASSWORD=certpw
export PGX_TEST_TLS_CLIENT_CONN_STRING="host=localhost user=pgx_sslcert sslmode=verify-full sslrootcert=`pwd`/.testdb/ca.pem database=pgx_test sslcert=`pwd`/.testdb/pgx_sslcert.crt sslkey=`pwd`/.testdb/pgx_sslcert.key"
```
Expand Down
144 changes: 136 additions & 8 deletions pgconn/auth_scram.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// SCRAM-SHA-256 authentication
// SCRAM-SHA-256 and SCRAM-SHA-256-PLUS authentication
//
// Resources:
// https://tools.ietf.org/html/rfc5802
// https://tools.ietf.org/html/rfc5929
// https://tools.ietf.org/html/rfc8265
// https://www.postgresql.org/docs/current/sasl-authentication.html
//
Expand All @@ -18,17 +19,25 @@ import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"hash"
"slices"
"strconv"

"github.com/jackc/pgx/v5/pgproto3"
"golang.org/x/text/secure/precis"
)

const clientNonceLen = 18
const (
clientNonceLen = 18
scramSHA256Name = "SCRAM-SHA-256"
scramSHA256PlusName = "SCRAM-SHA-256-PLUS"
)

// Perform SCRAM authentication.
func (c *PgConn) scramAuth(serverAuthMechanisms []string) error {
Expand All @@ -37,9 +46,35 @@ func (c *PgConn) scramAuth(serverAuthMechanisms []string) error {
return err
}

serverHasPlus := slices.Contains(sc.serverAuthMechanisms, scramSHA256PlusName)
if c.config.ChannelBinding == "require" && !serverHasPlus {
return errors.New("channel binding required but server does not support SCRAM-SHA-256-PLUS")
}

// If we have a TLS connection and channel binding is not disabled, attempt to
// extract the server certificate hash for tls-server-end-point channel binding.
if tlsConn, ok := c.conn.(*tls.Conn); ok && c.config.ChannelBinding != "disable" {
certHash, err := getTLSCertificateHash(tlsConn)
if err != nil && c.config.ChannelBinding == "require" {
return fmt.Errorf("channel binding required but failed to get server certificate hash: %w", err)
}

// Upgrade to SCRAM-SHA-256-PLUS if we have binding data and the server supports it.
if certHash != nil && serverHasPlus {
sc.authMechanism = scramSHA256PlusName
}

sc.channelBindingData = certHash
sc.hasTLS = true
}

if c.config.ChannelBinding == "require" && sc.channelBindingData == nil {
return errors.New("channel binding required but channel binding data is not available")
}

// Send client-first-message in a SASLInitialResponse
saslInitialResponse := &pgproto3.SASLInitialResponse{
AuthMechanism: "SCRAM-SHA-256",
AuthMechanism: sc.authMechanism,
Data: sc.clientFirstMessage(),
}
c.frontend.Send(saslInitialResponse)
Expand Down Expand Up @@ -111,7 +146,28 @@ type scramClient struct {
password string
clientNonce []byte

// authMechanism is the selected SASL mechanism for the client. Must be
// either SCRAM-SHA-256 (default) or SCRAM-SHA-256-PLUS.
//
// Upgraded to SCRAM-SHA-256-PLUS during authentication when channel binding
// is not disabled, channel binding data is available (TLS connection with
// an obtainable server certificate hash) and the server advertises
// SCRAM-SHA-256-PLUS.
authMechanism string

// hasTLS indicates whether the connection is using TLS. This is
// needed because the GS2 header must distinguish between a client that
// supports channel binding but the server does not ("y,,") versus one
// that does not support it at all ("n,,").
hasTLS bool

// channelBindingData is the hash of the server's TLS certificate, computed
// per the tls-server-end-point channel binding type (RFC 5929). Used as
// the binding input in SCRAM-SHA-256-PLUS. nil when not in use.
channelBindingData []byte

clientFirstMessageBare []byte
clientGS2Header []byte

serverFirstMessage []byte
clientAndServerNonce []byte
Expand All @@ -125,11 +181,14 @@ type scramClient struct {
func newScramClient(serverAuthMechanisms []string, password string) (*scramClient, error) {
sc := &scramClient{
serverAuthMechanisms: serverAuthMechanisms,
authMechanism: scramSHA256Name,
}

// Ensure server supports SCRAM-SHA-256
hasScramSHA256 := slices.Contains(sc.serverAuthMechanisms, "SCRAM-SHA-256")
if !hasScramSHA256 {
// Ensure the server supports SCRAM-SHA-256. SCRAM-SHA-256-PLUS is the
// channel binding variant and is only advertised when the server supports
// SSL. PostgreSQL always advertises the base SCRAM-SHA-256 mechanism
// regardless of SSL.
if !slices.Contains(sc.serverAuthMechanisms, scramSHA256Name) {
return nil, errors.New("server does not support SCRAM-SHA-256")
}

Expand All @@ -153,8 +212,32 @@ func newScramClient(serverAuthMechanisms []string, password string) (*scramClien
}

func (sc *scramClient) clientFirstMessage() []byte {
// The client-first-message is the GS2 header concatenated with the bare
// message (username + client nonce). The GS2 header communicates the
// client's channel binding capability to the server:
//
// "n,," - client is not using TLS (channel binding not possible)
// "y,," - client is using TLS but channel binding is not
// in use (e.g., server did not advertise SCRAM-SHA-256-PLUS
// or the server certificate hash was not obtainable)
// "p=tls-server-end-point,," - channel binding is active via SCRAM-SHA-256-PLUS
//
// See:
// https://www.rfc-editor.org/rfc/rfc5802#section-6
// https://www.rfc-editor.org/rfc/rfc5929#section-4
// https://www.postgresql.org/docs/current/sasl-authentication.html#SASL-SCRAM-SHA-256

sc.clientFirstMessageBare = fmt.Appendf(nil, "n=,r=%s", sc.clientNonce)
return fmt.Appendf(nil, "n,,%s", sc.clientFirstMessageBare)

if sc.authMechanism == scramSHA256PlusName {
sc.clientGS2Header = []byte("p=tls-server-end-point,,")
} else if sc.hasTLS {
sc.clientGS2Header = []byte("y,,")
} else {
sc.clientGS2Header = []byte("n,,")
}

return append(sc.clientGS2Header, sc.clientFirstMessageBare...)
}

func (sc *scramClient) recvServerFirstMessage(serverFirstMessage []byte) error {
Expand Down Expand Up @@ -213,7 +296,19 @@ func (sc *scramClient) recvServerFirstMessage(serverFirstMessage []byte) error {
}

func (sc *scramClient) clientFinalMessage() string {
clientFinalMessageWithoutProof := fmt.Appendf(nil, "c=biws,r=%s", sc.clientAndServerNonce)
// The c= attribute carries the base64-encoded channel binding input.
//
// Without channel binding this is just the GS2 header alone ("biws" for
// "n,," or "eSws" for "y,,").
//
// With channel binding, this is the GS2 header with the channel binding data
// (certificate hash) appended.
channelBindInput := sc.clientGS2Header
if sc.authMechanism == scramSHA256PlusName {
channelBindInput = slices.Concat(sc.clientGS2Header, sc.channelBindingData)
}
channelBindingEncoded := base64.StdEncoding.EncodeToString(channelBindInput)
clientFinalMessageWithoutProof := fmt.Appendf(nil, "c=%s,r=%s", channelBindingEncoded, sc.clientAndServerNonce)

var err error
sc.saltedPassword, err = pbkdf2.Key(sha256.New, sc.password, sc.salt, sc.iterations, 32)
Expand Down Expand Up @@ -269,3 +364,36 @@ func computeServerSignature(saltedPassword, authMessage []byte) []byte {
base64.StdEncoding.Encode(buf, serverSignature)
return buf
}

// Get the server certificate hash for SCRAM channel binding type
// tls-server-end-point.
func getTLSCertificateHash(conn *tls.Conn) ([]byte, error) {
state := conn.ConnectionState()
if len(state.PeerCertificates) == 0 {
return nil, errors.New("no peer certificates for channel binding")
}

cert := state.PeerCertificates[0]

// Per RFC 5929 section 4.1: If the certificate's signatureAlgorithm uses
// MD5 or SHA-1, use SHA-256. Otherwise use the hash from the signature
// algorithm.
//
// See: https://www.rfc-editor.org/rfc/rfc5929.html#section-4.1
var h hash.Hash
switch cert.SignatureAlgorithm {
case x509.MD5WithRSA, x509.SHA1WithRSA, x509.ECDSAWithSHA1:
h = sha256.New()
case x509.SHA256WithRSA, x509.SHA256WithRSAPSS, x509.ECDSAWithSHA256:
h = sha256.New()
case x509.SHA384WithRSA, x509.SHA384WithRSAPSS, x509.ECDSAWithSHA384:
h = sha512.New384()
case x509.SHA512WithRSA, x509.SHA512WithRSAPSS, x509.ECDSAWithSHA512:
h = sha512.New()
default:
return nil, fmt.Errorf("tls-server-end-point channel binding is undefined for certificate signature algorithm %v", cert.SignatureAlgorithm)
}

h.Write(cert.Raw)
return h.Sum(nil), nil
}
Loading