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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user