diff --git a/.copywrite.hcl b/.copywrite.hcl index df52f5c5a299..ab9fa58f3411 100644 --- a/.copywrite.hcl +++ b/.copywrite.hcl @@ -11,5 +11,6 @@ project { "builtin/credential/aws/pkcs7/**", "ui/node_modules/**", "enos/modules/k8s_deploy_vault/raft-config.hcl", + "plugins/database/postgresql/scram/**" ] } diff --git a/.github/scripts/generate-test-package-lists.sh b/.github/scripts/generate-test-package-lists.sh index 493a92c8c87c..f2f5e3e695bd 100755 --- a/.github/scripts/generate-test-package-lists.sh +++ b/.github/scripts/generate-test-package-lists.sh @@ -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 diff --git a/changelog/19616.txt b/changelog/19616.txt new file mode 100644 index 000000000000..3afcc608d19a --- /dev/null +++ b/changelog/19616.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/postgresql: Add configuration to scram-sha-256 encrypt passwords on Vault before sending them to PostgreSQL +``` \ No newline at end of file diff --git a/plugins/database/postgresql/passwordauthentication.go b/plugins/database/postgresql/passwordauthentication.go new file mode 100644 index 000000000000..ec94bafba118 --- /dev/null +++ b/plugins/database/postgresql/passwordauthentication.go @@ -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 +} diff --git a/plugins/database/postgresql/postgresql.go b/plugins/database/postgresql/postgresql.go index 44f4844a2230..66c44cc34a08 100644 --- a/plugins/database/postgresql/postgresql.go +++ b/plugins/database/postgresql/postgresql.go @@ -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) } diff --git a/plugins/database/postgresql/postgresql_test.go b/plugins/database/postgresql/postgresql_test.go index a268f3712072..80acb79700bf 100644 --- a/plugins/database/postgresql/postgresql_test.go +++ b/plugins/database/postgresql/postgresql_test.go @@ -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 diff --git a/plugins/database/postgresql/scram/LICENSE b/plugins/database/postgresql/scram/LICENSE new file mode 100644 index 000000000000..cc36995f299f --- /dev/null +++ b/plugins/database/postgresql/scram/LICENSE @@ -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. \ No newline at end of file diff --git a/plugins/database/postgresql/scram/scram.go b/plugins/database/postgresql/scram/scram.go new file mode 100644 index 000000000000..f5c6923cef66 --- /dev/null +++ b/plugins/database/postgresql/scram/scram.go @@ -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 +} diff --git a/plugins/database/postgresql/scram/scram_test.go b/plugins/database/postgresql/scram/scram_test.go new file mode 100644 index 000000000000..d2933ebbca40 --- /dev/null +++ b/plugins/database/postgresql/scram/scram_test.go @@ -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) + }) + } +} diff --git a/website/content/api-docs/secret/databases/postgresql.mdx b/website/content/api-docs/secret/databases/postgresql.mdx index 68667d313e74..d7918ca079c5 100644 --- a/website/content/api-docs/secret/databases/postgresql.mdx +++ b/website/content/api-docs/secret/databases/postgresql.mdx @@ -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). + +
Default Username Template diff --git a/website/content/docs/secrets/databases/postgresql.mdx b/website/content/docs/secrets/databases/postgresql.mdx index 8704ce188f26..691f7b8e1b69 100644 --- a/website/content/docs/secrets/databases/postgresql.mdx +++ b/website/content/docs/secrets/databases/postgresql.mdx @@ -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