VAULT-13614 Support SCRAM-SHA-256 encrypted passwords for PostgreSQL (#19616)

This commit is contained in:
Raymond Ho 2023-03-21 12:12:53 -07:00 committed by GitHub
parent 427b4dbd49
commit 96e966e9ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 307 additions and 15 deletions

View File

@ -11,5 +11,6 @@ project {
"builtin/credential/aws/pkcs7/**",
"ui/node_modules/**",
"enos/modules/k8s_deploy_vault/raft-config.hcl",
"plugins/database/postgresql/scram/**"
]
}

View File

@ -246,6 +246,7 @@ test_packages[13]+=" $base/command/server"
test_packages[13]+=" $base/physical/aerospike"
test_packages[13]+=" $base/physical/cockroachdb"
test_packages[13]+=" $base/plugins/database/postgresql"
test_packages[13]+=" $base/plugins/database/postgresql/scram"
if [ "${ENTERPRISE:+x}" == "x" ] ; then
test_packages[13]+=" $base/vault/external_tests/filteredpathsext"
fi

3
changelog/19616.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
secrets/postgresql: Add configuration to scram-sha-256 encrypt passwords on Vault before sending them to PostgreSQL
```

View File

@ -0,0 +1,25 @@
package postgresql
import "fmt"
// passwordAuthentication determines whether to send passwords in plaintext (password) or hashed (scram-sha-256).
type passwordAuthentication string
var (
// passwordAuthenticationPassword is the default. If set, passwords will be sent to PostgreSQL in plain text.
passwordAuthenticationPassword passwordAuthentication = "password"
passwordAuthenticationSCRAMSHA256 passwordAuthentication = "scram-sha-256"
)
var passwordAuthentications = map[passwordAuthentication]struct{}{
passwordAuthenticationSCRAMSHA256: {},
passwordAuthenticationPassword: {},
}
func parsePasswordAuthentication(s string) (passwordAuthentication, error) {
if _, ok := passwordAuthentications[passwordAuthentication(s)]; !ok {
return "", fmt.Errorf("'%s' is not a valid password authentication type", s)
}
return passwordAuthentication(s), nil
}

View File

@ -12,6 +12,7 @@ import (
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/go-secure-stdlib/strutil"
"github.com/hashicorp/vault/plugins/database/postgresql/scram"
"github.com/hashicorp/vault/sdk/database/dbplugin/v5"
"github.com/hashicorp/vault/sdk/database/helper/connutil"
"github.com/hashicorp/vault/sdk/database/helper/dbutil"
@ -68,7 +69,8 @@ func new() *PostgreSQL {
connProducer.Type = postgreSQLTypeName
db := &PostgreSQL{
SQLConnectionProducer: connProducer,
SQLConnectionProducer: connProducer,
passwordAuthentication: passwordAuthenticationPassword,
}
return db
@ -77,7 +79,8 @@ func new() *PostgreSQL {
type PostgreSQL struct {
*connutil.SQLConnectionProducer
usernameProducer template.StringTemplate
usernameProducer template.StringTemplate
passwordAuthentication passwordAuthentication
}
func (p *PostgreSQL) Initialize(ctx context.Context, req dbplugin.InitializeRequest) (dbplugin.InitializeResponse, error) {
@ -105,6 +108,20 @@ func (p *PostgreSQL) Initialize(ctx context.Context, req dbplugin.InitializeRequ
return dbplugin.InitializeResponse{}, fmt.Errorf("invalid username template: %w", err)
}
passwordAuthenticationRaw, err := strutil.GetString(req.Config, "password_authentication")
if err != nil {
return dbplugin.InitializeResponse{}, fmt.Errorf("failed to retrieve password_authentication: %w", err)
}
if passwordAuthenticationRaw != "" {
pwAuthentication, err := parsePasswordAuthentication(passwordAuthenticationRaw)
if err != nil {
return dbplugin.InitializeResponse{}, err
}
p.passwordAuthentication = pwAuthentication
}
resp := dbplugin.InitializeResponse{
Config: newConf,
}
@ -188,6 +205,15 @@ func (p *PostgreSQL) changeUserPassword(ctx context.Context, username string, ch
"username": username,
"password": password,
}
if p.passwordAuthentication == passwordAuthenticationSCRAMSHA256 {
hashedPassword, err := scram.Hash(password)
if err != nil {
return fmt.Errorf("unable to scram-sha256 password: %w", err)
}
m["password"] = hashedPassword
}
if err := dbtxn.ExecuteTxQueryDirect(ctx, tx, m, query); err != nil {
return fmt.Errorf("failed to execute query: %w", err)
}
@ -272,15 +298,24 @@ func (p *PostgreSQL) NewUser(ctx context.Context, req dbplugin.NewUserRequest) (
}
defer tx.Rollback()
m := map[string]string{
"name": username,
"username": username,
"password": req.Password,
"expiration": expirationStr,
}
if p.passwordAuthentication == passwordAuthenticationSCRAMSHA256 {
hashedPassword, err := scram.Hash(req.Password)
if err != nil {
return dbplugin.NewUserResponse{}, fmt.Errorf("unable to scram-sha256 password: %w", err)
}
m["password"] = hashedPassword
}
for _, stmt := range req.Statements.Commands {
if containsMultilineStatement(stmt) {
// Execute it as-is.
m := map[string]string{
"name": username,
"username": username,
"password": req.Password,
"expiration": expirationStr,
}
if err := dbtxn.ExecuteTxQueryDirect(ctx, tx, m, stmt); err != nil {
return dbplugin.NewUserResponse{}, fmt.Errorf("failed to execute query: %w", err)
}
@ -293,12 +328,6 @@ func (p *PostgreSQL) NewUser(ctx context.Context, req dbplugin.NewUserRequest) (
continue
}
m := map[string]string{
"name": username,
"username": username,
"password": req.Password,
"expiration": expirationStr,
}
if err := dbtxn.ExecuteTxQueryDirect(ctx, tx, m, query); err != nil {
return dbplugin.NewUserResponse{}, fmt.Errorf("failed to execute query: %w", err)
}

View File

@ -18,6 +18,7 @@ import (
dbtesting "github.com/hashicorp/vault/sdk/database/dbplugin/v5/testing"
"github.com/hashicorp/vault/sdk/database/helper/dbutil"
"github.com/hashicorp/vault/sdk/helper/template"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -93,6 +94,97 @@ func TestPostgreSQL_Initialize_ConnURLWithDSNFormat(t *testing.T) {
}
}
// TestPostgreSQL_PasswordAuthentication tests that the default "password_authentication" is "none", and that
// an error is returned if an invalid "password_authentication" is provided.
func TestPostgreSQL_PasswordAuthentication(t *testing.T) {
cleanup, connURL := postgresql.PrepareTestContainer(t, "13.4-buster")
defer cleanup()
dsnConnURL, err := dbutil.ParseURL(connURL)
assert.NoError(t, err)
db := new()
ctx := context.Background()
t.Run("invalid-password-authentication", func(t *testing.T) {
connectionDetails := map[string]interface{}{
"connection_url": dsnConnURL,
"password_authentication": "invalid-password-authentication",
}
req := dbplugin.InitializeRequest{
Config: connectionDetails,
VerifyConnection: true,
}
_, err := db.Initialize(ctx, req)
assert.EqualError(t, err, "'invalid-password-authentication' is not a valid password authentication type")
})
t.Run("default-is-none", func(t *testing.T) {
connectionDetails := map[string]interface{}{
"connection_url": dsnConnURL,
}
req := dbplugin.InitializeRequest{
Config: connectionDetails,
VerifyConnection: true,
}
_ = dbtesting.AssertInitialize(t, db, req)
assert.Equal(t, passwordAuthenticationPassword, db.passwordAuthentication)
})
}
// TestPostgreSQL_PasswordAuthentication_SCRAMSHA256 tests that password_authentication works when set to scram-sha-256.
// When sending an encrypted password, the raw password should still successfully authenticate the user.
func TestPostgreSQL_PasswordAuthentication_SCRAMSHA256(t *testing.T) {
cleanup, connURL := postgresql.PrepareTestContainer(t, "13.4-buster")
defer cleanup()
dsnConnURL, err := dbutil.ParseURL(connURL)
if err != nil {
t.Fatal(err)
}
connectionDetails := map[string]interface{}{
"connection_url": dsnConnURL,
"password_authentication": string(passwordAuthenticationSCRAMSHA256),
}
req := dbplugin.InitializeRequest{
Config: connectionDetails,
VerifyConnection: true,
}
db := new()
resp := dbtesting.AssertInitialize(t, db, req)
assert.Equal(t, string(passwordAuthenticationSCRAMSHA256), resp.Config["password_authentication"])
if !db.Initialized {
t.Fatal("Database should be initialized")
}
ctx := context.Background()
newUserRequest := dbplugin.NewUserRequest{
Statements: dbplugin.Statements{
Commands: []string{
`
CREATE ROLE "{{name}}" WITH
LOGIN
PASSWORD '{{password}}'
VALID UNTIL '{{expiration}}';
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "{{name}}";`,
},
},
Password: "somesecurepassword",
Expiration: time.Now().Add(1 * time.Minute),
}
newUserResponse, err := db.NewUser(ctx, newUserRequest)
assertCredsExist(t, db.ConnectionURL, newUserResponse.Username, newUserRequest.Password)
}
func TestPostgreSQL_NewUser(t *testing.T) {
type testCase struct {
req dbplugin.NewUserRequest

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Taishi Kasuga
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,86 @@
package scram
//
// @see https://github.com/postgres/postgres/blob/c30f54ad732ca5c8762bb68bbe0f51de9137dd72/src/interfaces/libpq/fe-auth.c#L1167-L1285
// @see https://github.com/postgres/postgres/blob/e6bdfd9700ebfc7df811c97c2fc46d7e94e329a2/src/interfaces/libpq/fe-auth-scram.c#L868-L905
// @see https://github.com/postgres/postgres/blob/c30f54ad732ca5c8762bb68bbe0f51de9137dd72/src/port/pg_strong_random.c#L66-L96
// @see https://github.com/postgres/postgres/blob/e6bdfd9700ebfc7df811c97c2fc46d7e94e329a2/src/common/scram-common.c#L160-L274
// @see https://github.com/postgres/postgres/blob/e6bdfd9700ebfc7df811c97c2fc46d7e94e329a2/src/common/scram-common.c#L27-L85
// Implementation from https://github.com/supercaracal/scram-sha-256/blob/d3c05cd927770a11c6e12de3e3a99c3446a1f78d/main.go
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"golang.org/x/crypto/pbkdf2"
)
const (
// @see https://github.com/postgres/postgres/blob/e6bdfd9700ebfc7df811c97c2fc46d7e94e329a2/src/include/common/scram-common.h#L36-L41
saltSize = 16
// @see https://github.com/postgres/postgres/blob/c30f54ad732ca5c8762bb68bbe0f51de9137dd72/src/include/common/sha2.h#L22
digestLen = 32
// @see https://github.com/postgres/postgres/blob/e6bdfd9700ebfc7df811c97c2fc46d7e94e329a2/src/include/common/scram-common.h#L43-L47
iterationCnt = 4096
)
var (
clientRawKey = []byte("Client Key")
serverRawKey = []byte("Server Key")
)
func genSalt(size int) ([]byte, error) {
salt := make([]byte, size)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, err
}
return salt, nil
}
func encodeB64(src []byte) (dst []byte) {
dst = make([]byte, base64.StdEncoding.EncodedLen(len(src)))
base64.StdEncoding.Encode(dst, src)
return
}
func getHMACSum(key, msg []byte) []byte {
h := hmac.New(sha256.New, key)
_, _ = h.Write(msg)
return h.Sum(nil)
}
func getSHA256Sum(key []byte) []byte {
h := sha256.New()
_, _ = h.Write(key)
return h.Sum(nil)
}
func hashPassword(rawPassword, salt []byte, iter, keyLen int) string {
digestKey := pbkdf2.Key(rawPassword, salt, iter, keyLen, sha256.New)
clientKey := getHMACSum(digestKey, clientRawKey)
storedKey := getSHA256Sum(clientKey)
serverKey := getHMACSum(digestKey, serverRawKey)
return fmt.Sprintf("SCRAM-SHA-256$%d:%s$%s:%s",
iter,
string(encodeB64(salt)),
string(encodeB64(storedKey)),
string(encodeB64(serverKey)),
)
}
func Hash(password string) (string, error) {
salt, err := genSalt(saltSize)
if err != nil {
return "", err
}
hashedPassword := hashPassword([]byte(password), salt, iterationCnt, digestLen)
return hashedPassword, nil
}

View File

@ -0,0 +1,27 @@
package scram
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
// TestScram tests the Hash method. The hashed password string should have a SCRAM-SHA-256 prefix.
func TestScram(t *testing.T) {
tcs := map[string]struct {
Password string
}{
"empty-password": {Password: ""},
"simple-password": {Password: "password"},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
got, err := Hash(tc.Password)
assert.NoError(t, err)
assert.True(t, strings.HasPrefix(got, "SCRAM-SHA-256$4096:"))
assert.Len(t, got, 133)
})
}
}

View File

@ -55,6 +55,12 @@ has a number of parameters to further configure a connection.
and password fields. See the [databases secrets engine docs](/vault/docs/secrets/databases#disable-character-escaping)
for more information. Defaults to `false`.
- `password_authentication` `(string: "password")` - When set to "scram-sha-256", passwords will be hashed by Vault and stored as-is by PostgreSQL.
Using "scram-sha-256" requires a minimum version of PostgreSQL 10. Available options are "scram-sha-256" and "password". The default is "password".
When set to "password", passwords will be sent to PostgresSQL in plaintext format and may appear in PostgreSQL logs as-is.
For more information, please refer to the [https://www.postgresql.org/docs/current/sql-createrole.html#password](PostgreSQL documentation).
<details>
<summary><b>Default Username Template</b></summary>

View File

@ -48,7 +48,8 @@ options, including SSL options, can be found in the [pgx][pgxlib] and
allowed_roles="my-role" \
connection_url="postgresql://{{username}}:{{password}}@localhost:5432/database-name" \
username="vaultuser" \
password="vaultpass"
password="vaultpass" \
password_authentication="scram-sha-256"
```
1. Configure a role that maps a name in Vault to an SQL statement to execute to