mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(coderd): connect dbcrypt package implementation (#9523)
See also: https://github.com/coder/coder/pull/9522 - Adds commands `server dbcrypt {rotate,decrypt,delete}` to re-encrypt, decrypt, or delete encrypted data, respectively. - Plumbs through dbcrypt in enterprise/coderd (including unit tests). - Adds documentation in admin/encryption.md. This enables dbcrypt by default, but the feature is soft-enforced on supplying external token encryption keys. Without specifying any keys, encryption/decryption is a no-op.
This commit is contained in:
@@ -5,6 +5,7 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/coder/coder/v2/enterprise/audit/backends"
|
||||
"github.com/coder/coder/v2/enterprise/coderd"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/dormancy"
|
||||
"github.com/coder/coder/v2/enterprise/dbcrypt"
|
||||
"github.com/coder/coder/v2/enterprise/trialer"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
|
||||
@@ -74,11 +76,31 @@ func (r *RootCmd) Server(_ func()) *clibase.Cmd {
|
||||
CheckInactiveUsersCancelFunc: dormancy.CheckInactiveUsers(ctx, options.Logger, options.Database),
|
||||
}
|
||||
|
||||
if encKeys := options.DeploymentValues.ExternalTokenEncryptionKeys.Value(); len(encKeys) != 0 {
|
||||
keys := make([][]byte, 0, len(encKeys))
|
||||
for idx, ek := range encKeys {
|
||||
dk, err := base64.StdEncoding.DecodeString(ek)
|
||||
if err != nil {
|
||||
return nil, nil, xerrors.Errorf("decode external-token-encryption-key %d: %w", idx, err)
|
||||
}
|
||||
keys = append(keys, dk)
|
||||
}
|
||||
cs, err := dbcrypt.NewCiphers(keys...)
|
||||
if err != nil {
|
||||
return nil, nil, xerrors.Errorf("initialize encryption: %w", err)
|
||||
}
|
||||
o.ExternalTokenEncryption = cs
|
||||
}
|
||||
|
||||
api, err := coderd.New(ctx, o)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return api.AGPL, api, nil
|
||||
})
|
||||
|
||||
cmd.AddSubcommands(
|
||||
r.dbcryptCmd(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/cli"
|
||||
"github.com/coder/coder/v2/cli/clibase"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/enterprise/dbcrypt"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func (r *RootCmd) dbcryptCmd() *clibase.Cmd {
|
||||
dbcryptCmd := &clibase.Cmd{
|
||||
Use: "dbcrypt",
|
||||
Short: "Manage database encryption.",
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
}
|
||||
dbcryptCmd.AddSubcommands(
|
||||
r.dbcryptDecryptCmd(),
|
||||
r.dbcryptDeleteCmd(),
|
||||
r.dbcryptRotateCmd(),
|
||||
)
|
||||
return dbcryptCmd
|
||||
}
|
||||
|
||||
func (*RootCmd) dbcryptRotateCmd() *clibase.Cmd {
|
||||
var flags rotateFlags
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "rotate",
|
||||
Short: "Rotate database encryption keys.",
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stdout))
|
||||
if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
if err := flags.valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ks := [][]byte{}
|
||||
dk, err := base64.StdEncoding.DecodeString(flags.New)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decode new key: %w", err)
|
||||
}
|
||||
ks = append(ks, dk)
|
||||
|
||||
for _, k := range flags.Old {
|
||||
dk, err := base64.StdEncoding.DecodeString(k)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decode old key: %w", err)
|
||||
}
|
||||
ks = append(ks, dk)
|
||||
}
|
||||
|
||||
ciphers, err := dbcrypt.NewCiphers(ks...)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create ciphers: %w", err)
|
||||
}
|
||||
|
||||
var act string
|
||||
switch len(flags.Old) {
|
||||
case 0:
|
||||
act = "Data will be encrypted with the new key."
|
||||
default:
|
||||
act = "Data will be decrypted with all available keys and re-encrypted with new key."
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%s\n\n- New key: %s\n- Old keys: %s\n\nRotate external token encryption keys?\n",
|
||||
act,
|
||||
flags.New,
|
||||
strings.Join(flags.Old, ", "),
|
||||
)
|
||||
if _, err := cliui.Prompt(inv, cliui.PromptOptions{Text: msg, IsConfirm: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", flags.PostgresURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("connect to postgres: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
logger.Info(ctx, "connected to postgres")
|
||||
if err := dbcrypt.Rotate(ctx, logger, sqlDB, ciphers); err != nil {
|
||||
return xerrors.Errorf("rotate ciphers: %w", err)
|
||||
}
|
||||
logger.Info(ctx, "operation completed successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
flags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (*RootCmd) dbcryptDecryptCmd() *clibase.Cmd {
|
||||
var flags decryptFlags
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "decrypt",
|
||||
Short: "Decrypt a previously encrypted database.",
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stdout))
|
||||
if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
if err := flags.valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ks := make([][]byte, 0, len(flags.Keys))
|
||||
for _, k := range flags.Keys {
|
||||
dk, err := base64.StdEncoding.DecodeString(k)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decode key: %w", err)
|
||||
}
|
||||
ks = append(ks, dk)
|
||||
}
|
||||
|
||||
ciphers, err := dbcrypt.NewCiphers(ks...)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create ciphers: %w", err)
|
||||
}
|
||||
|
||||
if _, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "This will decrypt all encrypted data in the database. Are you sure you want to continue?",
|
||||
IsConfirm: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", flags.PostgresURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("connect to postgres: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
logger.Info(ctx, "connected to postgres")
|
||||
if err := dbcrypt.Decrypt(ctx, logger, sqlDB, ciphers); err != nil {
|
||||
return xerrors.Errorf("rotate ciphers: %w", err)
|
||||
}
|
||||
logger.Info(ctx, "operation completed successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
flags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (*RootCmd) dbcryptDeleteCmd() *clibase.Cmd {
|
||||
var flags deleteFlags
|
||||
cmd := &clibase.Cmd{
|
||||
Use: "delete",
|
||||
Short: "Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION.",
|
||||
Handler: func(inv *clibase.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stdout))
|
||||
if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
if err := flags.valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := `All encrypted data will be deleted from the database:
|
||||
- Encrypted user OAuth access and refresh tokens
|
||||
- Encrypted user Git authentication access and refresh tokens
|
||||
|
||||
Are you sure you want to continue?`
|
||||
if _, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: msg,
|
||||
IsConfirm: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", flags.PostgresURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("connect to postgres: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = sqlDB.Close()
|
||||
}()
|
||||
logger.Info(ctx, "connected to postgres")
|
||||
if err := dbcrypt.Delete(ctx, logger, sqlDB); err != nil {
|
||||
return xerrors.Errorf("delete encrypted data: %w", err)
|
||||
}
|
||||
logger.Info(ctx, "operation completed successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
flags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
type rotateFlags struct {
|
||||
PostgresURL string
|
||||
New string
|
||||
Old []string
|
||||
}
|
||||
|
||||
func (f *rotateFlags) attach(opts *clibase.OptionSet) {
|
||||
*opts = append(
|
||||
*opts,
|
||||
clibase.Option{
|
||||
Flag: "postgres-url",
|
||||
Env: "CODER_PG_CONNECTION_URL",
|
||||
Description: "The connection URL for the Postgres database.",
|
||||
Value: clibase.StringOf(&f.PostgresURL),
|
||||
},
|
||||
clibase.Option{
|
||||
Flag: "new-key",
|
||||
Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_NEW_KEY",
|
||||
Description: "The new external token encryption key. Must be base64-encoded.",
|
||||
Value: clibase.StringOf(&f.New),
|
||||
},
|
||||
clibase.Option{
|
||||
Flag: "old-keys",
|
||||
Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_OLD_KEYS",
|
||||
Description: "The old external token encryption keys. Must be a comma-separated list of base64-encoded keys.",
|
||||
Value: clibase.StringArrayOf(&f.Old),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *rotateFlags) valid() error {
|
||||
if f.PostgresURL == "" {
|
||||
return xerrors.Errorf("no database configured")
|
||||
}
|
||||
|
||||
if f.New == "" {
|
||||
return xerrors.Errorf("no new key provided")
|
||||
}
|
||||
|
||||
if val, err := base64.StdEncoding.DecodeString(f.New); err != nil {
|
||||
return xerrors.Errorf("new key must be base64-encoded")
|
||||
} else if len(val) != 32 {
|
||||
return xerrors.Errorf("new key must be exactly 32 bytes in length")
|
||||
}
|
||||
|
||||
for i, k := range f.Old {
|
||||
if val, err := base64.StdEncoding.DecodeString(k); err != nil {
|
||||
return xerrors.Errorf("old key at index %d must be base64-encoded", i)
|
||||
} else if len(val) != 32 {
|
||||
return xerrors.Errorf("old key at index %d must be exactly 32 bytes in length", i)
|
||||
}
|
||||
|
||||
// Pedantic, but typos here will ruin your day.
|
||||
if k == f.New {
|
||||
return xerrors.Errorf("old key at index %d is the same as the new key", i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type decryptFlags struct {
|
||||
PostgresURL string
|
||||
Keys []string
|
||||
}
|
||||
|
||||
func (f *decryptFlags) attach(opts *clibase.OptionSet) {
|
||||
*opts = append(
|
||||
*opts,
|
||||
clibase.Option{
|
||||
Flag: "postgres-url",
|
||||
Env: "CODER_PG_CONNECTION_URL",
|
||||
Description: "The connection URL for the Postgres database.",
|
||||
Value: clibase.StringOf(&f.PostgresURL),
|
||||
},
|
||||
clibase.Option{
|
||||
Flag: "keys",
|
||||
Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_DECRYPT_KEYS",
|
||||
Description: "Keys required to decrypt existing data. Must be a comma-separated list of base64-encoded keys.",
|
||||
Value: clibase.StringArrayOf(&f.Keys),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *decryptFlags) valid() error {
|
||||
if f.PostgresURL == "" {
|
||||
return xerrors.Errorf("no database configured")
|
||||
}
|
||||
|
||||
if len(f.Keys) == 0 {
|
||||
return xerrors.Errorf("no keys provided")
|
||||
}
|
||||
|
||||
for i, k := range f.Keys {
|
||||
if val, err := base64.StdEncoding.DecodeString(k); err != nil {
|
||||
return xerrors.Errorf("key at index %d must be base64-encoded", i)
|
||||
} else if len(val) != 32 {
|
||||
return xerrors.Errorf("key at index %d must be exactly 32 bytes in length", i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type deleteFlags struct {
|
||||
PostgresURL string
|
||||
Confirm bool
|
||||
}
|
||||
|
||||
func (f *deleteFlags) attach(opts *clibase.OptionSet) {
|
||||
*opts = append(
|
||||
*opts,
|
||||
clibase.Option{
|
||||
Flag: "postgres-url",
|
||||
Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_POSTGRES_URL",
|
||||
Description: "The connection URL for the Postgres database.",
|
||||
Value: clibase.StringOf(&f.PostgresURL),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *deleteFlags) valid() error {
|
||||
if f.PostgresURL == "" {
|
||||
return xerrors.Errorf("no database configured")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/postgres"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
"github.com/coder/coder/v2/enterprise/dbcrypt"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
)
|
||||
|
||||
// nolint: paralleltest // use of t.Setenv
|
||||
func TestServerDBCrypt(t *testing.T) {
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test requires a postgres instance")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// Setup a postgres database.
|
||||
connectionURL, closePg, err := postgres.Open()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(closePg)
|
||||
|
||||
sqlDB, err := sql.Open("postgres", connectionURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
db := database.New(sqlDB)
|
||||
|
||||
// Populate the database with some unencrypted data.
|
||||
users := genData(t, db, 10)
|
||||
|
||||
// Setup an initial cipher
|
||||
keyA := mustString(t, 32)
|
||||
cipherA, err := dbcrypt.NewCiphers([]byte(keyA))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Encrypt all the data with the initial cipher.
|
||||
inv, _ := newCLI(t, "server", "dbcrypt", "rotate",
|
||||
"--postgres-url", connectionURL,
|
||||
"--new-key", base64.StdEncoding.EncodeToString([]byte(keyA)),
|
||||
"--yes",
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate that all existing data has been encrypted with cipher A.
|
||||
for _, usr := range users {
|
||||
requireEncryptedWithCipher(ctx, t, db, cipherA[0], usr.ID)
|
||||
}
|
||||
|
||||
// Create an encrypted database
|
||||
cryptdb, err := dbcrypt.New(ctx, db, cipherA...)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Populate the database with some encrypted data using cipher A.
|
||||
users = append(users, genData(t, cryptdb, 10)...)
|
||||
|
||||
// Re-encrypt all existing data with a new cipher.
|
||||
keyB := mustString(t, 32)
|
||||
cipherBA, err := dbcrypt.NewCiphers([]byte(keyB), []byte(keyA))
|
||||
require.NoError(t, err)
|
||||
|
||||
inv, _ = newCLI(t, "server", "dbcrypt", "rotate",
|
||||
"--postgres-url", connectionURL,
|
||||
"--new-key", base64.StdEncoding.EncodeToString([]byte(keyB)),
|
||||
"--old-keys", base64.StdEncoding.EncodeToString([]byte(keyA)),
|
||||
"--yes",
|
||||
)
|
||||
pty = ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate that all data has been re-encrypted with cipher B.
|
||||
for _, usr := range users {
|
||||
requireEncryptedWithCipher(ctx, t, db, cipherBA[0], usr.ID)
|
||||
}
|
||||
|
||||
// Assert that we can revoke the old key.
|
||||
err = db.RevokeDBCryptKey(ctx, cipherA[0].HexDigest())
|
||||
require.NoError(t, err, "failed to revoke old key")
|
||||
|
||||
// Assert that the key has been revoked in the database.
|
||||
keys, err := db.GetDBCryptKeys(ctx)
|
||||
oldKey := keys[0] // ORDER BY number ASC;
|
||||
newKey := keys[1]
|
||||
require.NoError(t, err, "failed to get db crypt keys")
|
||||
require.Len(t, keys, 2, "expected exactly 2 keys")
|
||||
require.Equal(t, cipherBA[0].HexDigest(), newKey.ActiveKeyDigest.String, "expected the new key to be the active key")
|
||||
require.Empty(t, newKey.RevokedKeyDigest.String, "expected the new key to not be revoked")
|
||||
require.Equal(t, cipherBA[1].HexDigest(), oldKey.RevokedKeyDigest.String, "expected the old key to be revoked")
|
||||
require.Empty(t, oldKey.ActiveKeyDigest.String, "expected the old key to not be active")
|
||||
|
||||
// Revoking the new key should fail.
|
||||
err = db.RevokeDBCryptKey(ctx, cipherBA[0].HexDigest())
|
||||
require.Error(t, err, "expected to fail to revoke the new key")
|
||||
var pgErr *pq.Error
|
||||
require.True(t, xerrors.As(err, &pgErr), "expected a pg error")
|
||||
require.EqualValues(t, "23503", pgErr.Code, "expected a foreign key constraint violation error")
|
||||
|
||||
// Decrypt the data using only cipher B. This should result in the key being revoked.
|
||||
inv, _ = newCLI(t, "server", "dbcrypt", "decrypt",
|
||||
"--postgres-url", connectionURL,
|
||||
"--keys", base64.StdEncoding.EncodeToString([]byte(keyB)),
|
||||
"--yes",
|
||||
)
|
||||
pty = ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate that both keys have been revoked.
|
||||
keys, err = db.GetDBCryptKeys(ctx)
|
||||
require.NoError(t, err, "failed to get db crypt keys")
|
||||
require.Len(t, keys, 2, "expected exactly 2 keys")
|
||||
for _, key := range keys {
|
||||
require.Empty(t, key.ActiveKeyDigest.String, "expected the new key to not be active")
|
||||
}
|
||||
|
||||
// Validate that all data has been decrypted.
|
||||
for _, usr := range users {
|
||||
requireEncryptedWithCipher(ctx, t, db, &nullCipher{}, usr.ID)
|
||||
}
|
||||
|
||||
// Re-encrypt all existing data with a new cipher.
|
||||
keyC := mustString(t, 32)
|
||||
cipherC, err := dbcrypt.NewCiphers([]byte(keyC))
|
||||
require.NoError(t, err)
|
||||
|
||||
inv, _ = newCLI(t, "server", "dbcrypt", "rotate",
|
||||
"--postgres-url", connectionURL,
|
||||
"--new-key", base64.StdEncoding.EncodeToString([]byte(keyC)),
|
||||
"--yes",
|
||||
)
|
||||
|
||||
pty = ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate that all data has been re-encrypted with cipher C.
|
||||
for _, usr := range users {
|
||||
requireEncryptedWithCipher(ctx, t, db, cipherC[0], usr.ID)
|
||||
}
|
||||
|
||||
// Now delete all the encrypted data.
|
||||
inv, _ = newCLI(t, "server", "dbcrypt", "delete",
|
||||
"--postgres-url", connectionURL,
|
||||
"--external-token-encryption-keys", base64.StdEncoding.EncodeToString([]byte(keyC)),
|
||||
"--yes",
|
||||
)
|
||||
pty = ptytest.New(t)
|
||||
inv.Stdout = pty.Output()
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Assert that no user links remain.
|
||||
for _, usr := range users {
|
||||
userLinks, err := db.GetUserLinksByUserID(ctx, usr.ID)
|
||||
require.NoError(t, err, "failed to get user links for user %s", usr.ID)
|
||||
require.Empty(t, userLinks)
|
||||
gitAuthLinks, err := db.GetGitAuthLinksByUserID(ctx, usr.ID)
|
||||
require.NoError(t, err, "failed to get git auth links for user %s", usr.ID)
|
||||
require.Empty(t, gitAuthLinks)
|
||||
}
|
||||
|
||||
// Validate that the key has been revoked in the database.
|
||||
keys, err = db.GetDBCryptKeys(ctx)
|
||||
require.NoError(t, err, "failed to get db crypt keys")
|
||||
require.Len(t, keys, 3, "expected exactly 3 keys")
|
||||
for _, k := range keys {
|
||||
require.Empty(t, k.ActiveKeyDigest.String, "expected the key to not be active")
|
||||
require.NotEmpty(t, k.RevokedKeyDigest.String, "expected the key to be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func genData(t *testing.T, db database.Store, n int) []database.User {
|
||||
t.Helper()
|
||||
var users []database.User
|
||||
for i := 0; i < n; i++ {
|
||||
usr := dbgen.User(t, db, database.User{
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
_ = dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: usr.ID,
|
||||
LoginType: usr.LoginType,
|
||||
OAuthAccessToken: "access-" + usr.ID.String(),
|
||||
OAuthRefreshToken: "refresh-" + usr.ID.String(),
|
||||
})
|
||||
_ = dbgen.GitAuthLink(t, db, database.GitAuthLink{
|
||||
UserID: usr.ID,
|
||||
ProviderID: "fake",
|
||||
OAuthAccessToken: "access-" + usr.ID.String(),
|
||||
OAuthRefreshToken: "refresh-" + usr.ID.String(),
|
||||
})
|
||||
users = append(users, usr)
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func mustString(t *testing.T, n int) string {
|
||||
t.Helper()
|
||||
s, err := cryptorand.String(n)
|
||||
require.NoError(t, err)
|
||||
return s
|
||||
}
|
||||
|
||||
func requireEncryptedEquals(t *testing.T, c dbcrypt.Cipher, expected, actual string) {
|
||||
t.Helper()
|
||||
var decodedVal []byte
|
||||
var err error
|
||||
if _, ok := c.(*nullCipher); !ok {
|
||||
decodedVal, err = base64.StdEncoding.DecodeString(actual)
|
||||
require.NoError(t, err, "failed to decode base64 string")
|
||||
} else {
|
||||
// If a nullCipher is being used, we expect the value not to be encrypted.
|
||||
decodedVal = []byte(actual)
|
||||
}
|
||||
val, err := c.Decrypt(decodedVal)
|
||||
require.NoError(t, err, "failed to decrypt value")
|
||||
require.Equal(t, expected, string(val))
|
||||
}
|
||||
|
||||
func requireEncryptedWithCipher(ctx context.Context, t *testing.T, db database.Store, c dbcrypt.Cipher, userID uuid.UUID) {
|
||||
t.Helper()
|
||||
userLinks, err := db.GetUserLinksByUserID(ctx, userID)
|
||||
require.NoError(t, err, "failed to get user links for user %s", userID)
|
||||
for _, ul := range userLinks {
|
||||
requireEncryptedEquals(t, c, "access-"+userID.String(), ul.OAuthAccessToken)
|
||||
requireEncryptedEquals(t, c, "refresh-"+userID.String(), ul.OAuthRefreshToken)
|
||||
require.Equal(t, c.HexDigest(), ul.OAuthAccessTokenKeyID.String)
|
||||
require.Equal(t, c.HexDigest(), ul.OAuthRefreshTokenKeyID.String)
|
||||
}
|
||||
gitAuthLinks, err := db.GetGitAuthLinksByUserID(ctx, userID)
|
||||
require.NoError(t, err, "failed to get git auth links for user %s", userID)
|
||||
for _, gal := range gitAuthLinks {
|
||||
requireEncryptedEquals(t, c, "access-"+userID.String(), gal.OAuthAccessToken)
|
||||
requireEncryptedEquals(t, c, "refresh-"+userID.String(), gal.OAuthRefreshToken)
|
||||
require.Equal(t, c.HexDigest(), gal.OAuthAccessTokenKeyID.String)
|
||||
require.Equal(t, c.HexDigest(), gal.OAuthRefreshTokenKeyID.String)
|
||||
}
|
||||
}
|
||||
|
||||
// nullCipher is a dbcrypt.Cipher that does not encrypt or decrypt.
|
||||
// used for testing
|
||||
type nullCipher struct{}
|
||||
|
||||
func (*nullCipher) Encrypt(b []byte) ([]byte, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (*nullCipher) Decrypt(b []byte) ([]byte, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (*nullCipher) HexDigest() string {
|
||||
return "" // This co-incidentally happens to be the value of sql.NullString{}.String...
|
||||
}
|
||||
|
||||
var _ dbcrypt.Cipher = (*nullCipher)(nil)
|
||||
@@ -6,6 +6,7 @@ Start a Coder server
|
||||
create-admin-user Create a new admin user with the given username,
|
||||
email and password and adds it to every
|
||||
organization.
|
||||
dbcrypt Manage database encryption.
|
||||
postgres-builtin-serve Run the built-in PostgreSQL deployment.
|
||||
postgres-builtin-url Output the connection URL for the built-in
|
||||
PostgreSQL deployment.
|
||||
@@ -458,6 +459,16 @@ These options are only available in the Enterprise Edition.
|
||||
An HTTP URL that is accessible by other replicas to relay DERP
|
||||
traffic. Required for high availability.
|
||||
|
||||
--external-token-encryption-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS
|
||||
Encrypt OIDC and Git authentication tokens with AES-256-GCM in the
|
||||
database. The value must be a comma-separated list of base64-encoded
|
||||
keys. Each key, when base64-decoded, must be exactly 32 bytes in
|
||||
length. The first key will be used to encrypt new values. Subsequent
|
||||
keys will be used as a fallback when decrypting. During normal
|
||||
operation it is recommended to only set one key unless you are in the
|
||||
process of rotating keys with the `coder server dbcrypt rotate`
|
||||
command.
|
||||
|
||||
--scim-auth-header string, $CODER_SCIM_AUTH_HEADER
|
||||
Enables SCIM and sets the authentication header for the built-in SCIM
|
||||
server. New users are automatically created with OIDC authentication.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
Usage: coder server dbcrypt
|
||||
|
||||
Manage database encryption.
|
||||
|
||||
[1mSubcommands[0m
|
||||
decrypt Decrypt a previously encrypted database.
|
||||
delete Delete all encrypted data from the database. THIS IS A
|
||||
DESTRUCTIVE OPERATION.
|
||||
rotate Rotate database encryption keys.
|
||||
|
||||
---
|
||||
Run `coder --help` for a list of global options.
|
||||
@@ -0,0 +1,17 @@
|
||||
Usage: coder server dbcrypt decrypt [flags]
|
||||
|
||||
Decrypt a previously encrypted database.
|
||||
|
||||
[1mOptions[0m
|
||||
--keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_DECRYPT_KEYS
|
||||
Keys required to decrypt existing data. Must be a comma-separated list
|
||||
of base64-encoded keys.
|
||||
|
||||
--postgres-url string, $CODER_PG_CONNECTION_URL
|
||||
The connection URL for the Postgres database.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
|
||||
---
|
||||
Run `coder --help` for a list of global options.
|
||||
@@ -0,0 +1,15 @@
|
||||
Usage: coder server dbcrypt delete [flags]
|
||||
|
||||
Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION.
|
||||
|
||||
Aliases: rm
|
||||
|
||||
[1mOptions[0m
|
||||
--postgres-url string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_POSTGRES_URL
|
||||
The connection URL for the Postgres database.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
|
||||
---
|
||||
Run `coder --help` for a list of global options.
|
||||
@@ -0,0 +1,20 @@
|
||||
Usage: coder server dbcrypt rotate [flags]
|
||||
|
||||
Rotate database encryption keys.
|
||||
|
||||
[1mOptions[0m
|
||||
--new-key string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_NEW_KEY
|
||||
The new external token encryption key. Must be base64-encoded.
|
||||
|
||||
--old-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_OLD_KEYS
|
||||
The old external token encryption keys. Must be a comma-separated list
|
||||
of base64-encoded keys.
|
||||
|
||||
--postgres-url string, $CODER_PG_CONNECTION_URL
|
||||
The connection URL for the Postgres database.
|
||||
|
||||
-y, --yes bool
|
||||
Bypass prompts.
|
||||
|
||||
---
|
||||
Run `coder --help` for a list of global options.
|
||||
Reference in New Issue
Block a user