From 6651fe1bd2735e406fe9fc2c97c516374fa54e1d Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 30 May 2023 16:26:29 +0000 Subject: [PATCH 01/52] feat: encrypt oidc and git auth tokens in the database --- coderd/authorize.go | 2 +- coderd/database/dbcrypt/dbcrypt.go | 164 +++++++++++++++ coderd/database/dbcrypt/dbcrypt_test.go | 196 ++++++++++++++++++ coderd/database/dbgen/generator.go | 4 +- codersdk/deployment.go | 12 +- cryptorand/cipher.go | 48 +++++ cryptorand/cipher_test.go | 30 +++ enterprise/cli/server.go | 12 ++ enterprise/coderd/coderd.go | 38 +++- .../coderd/coderdenttest/coderdenttest.go | 2 +- enterprise/coderd/licenses.go | 4 +- 11 files changed, 497 insertions(+), 15 deletions(-) create mode 100644 coderd/database/dbcrypt/dbcrypt.go create mode 100644 coderd/database/dbcrypt/dbcrypt_test.go create mode 100644 cryptorand/cipher.go create mode 100644 cryptorand/cipher_test.go diff --git a/coderd/authorize.go b/coderd/authorize.go index 9dcc7e411298e..ab0042603fac8 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -75,7 +75,7 @@ func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object r } // Log information for debugging. This will be very helpful // in the early days - logger.Warn(r.Context(), "unauthorized", + logger.Debug(r.Context(), "unauthorized", slog.F("roles", roles.Actor.SafeRoleNames()), slog.F("actor_id", roles.Actor.ID), slog.F("actor_name", roles.ActorName), diff --git a/coderd/database/dbcrypt/dbcrypt.go b/coderd/database/dbcrypt/dbcrypt.go new file mode 100644 index 0000000000000..e579903288b87 --- /dev/null +++ b/coderd/database/dbcrypt/dbcrypt.go @@ -0,0 +1,164 @@ +package dbcrypt + +import ( + "context" + "database/sql" + "strings" + "sync/atomic" + + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/cryptorand" +) + +// MagicPrefix is prepended to all encrypted values in the database. +// This is used to determine if a value is encrypted or not. +// If it is encrypted but a key is not provided, an error is returned. +const MagicPrefix = "dbcrypt-" + +// ErrInvalidCipher is returned when an invalid cipher is provided +// for the encrypted data. +var ErrInvalidCipher = xerrors.New("an invalid encryption cipher was provided for the encrypted data") + +type Options struct { + // ExternalTokenCipher is an optional cipher that is used + // to encrypt/decrypt user link and git auth link tokens. If this is nil, + // then no encryption/decryption will be performed. + ExternalTokenCipher *atomic.Pointer[cryptorand.Cipher] +} + +// New creates a database.Store wrapper that encrypts/decrypts values +// stored at rest in the database. +func New(db database.Store, options *Options) database.Store { + return &dbCrypt{ + Options: options, + Store: db, + } +} + +type dbCrypt struct { + *Options + database.Store +} + +func (db *dbCrypt) InTx(function func(database.Store) error, txOpts *sql.TxOptions) error { + return db.Store.InTx(func(s database.Store) error { + return function(&dbCrypt{ + Options: db.Options, + Store: s, + }) + }, txOpts) +} + +func (db *dbCrypt) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (database.UserLink, error) { + link, err := db.Store.GetUserLinkByLinkedID(ctx, linkedID) + if err != nil { + return database.UserLink{}, err + } + return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) +} + +func (db *dbCrypt) GetUserLinkByUserIDLoginType(ctx context.Context, params database.GetUserLinkByUserIDLoginTypeParams) (database.UserLink, error) { + link, err := db.Store.GetUserLinkByUserIDLoginType(ctx, params) + if err != nil { + return database.UserLink{}, err + } + return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) +} + +func (db *dbCrypt) InsertUserLink(ctx context.Context, params database.InsertUserLinkParams) (database.UserLink, error) { + err := db.encryptFields(¶ms.OAuthAccessToken, ¶ms.OAuthRefreshToken) + if err != nil { + return database.UserLink{}, err + } + return db.Store.InsertUserLink(ctx, params) +} + +func (db *dbCrypt) UpdateUserLink(ctx context.Context, params database.UpdateUserLinkParams) (database.UserLink, error) { + err := db.encryptFields(¶ms.OAuthAccessToken, ¶ms.OAuthRefreshToken) + if err != nil { + return database.UserLink{}, err + } + return db.Store.UpdateUserLink(ctx, params) +} + +func (db *dbCrypt) InsertGitAuthLink(ctx context.Context, params database.InsertGitAuthLinkParams) (database.GitAuthLink, error) { + err := db.encryptFields(¶ms.OAuthAccessToken, ¶ms.OAuthRefreshToken) + if err != nil { + return database.GitAuthLink{}, err + } + return db.Store.InsertGitAuthLink(ctx, params) +} + +func (db *dbCrypt) GetGitAuthLink(ctx context.Context, params database.GetGitAuthLinkParams) (database.GitAuthLink, error) { + link, err := db.Store.GetGitAuthLink(ctx, params) + if err != nil { + return database.GitAuthLink{}, err + } + return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) +} + +func (db *dbCrypt) UpdateGitAuthLink(ctx context.Context, params database.UpdateGitAuthLinkParams) (database.GitAuthLink, error) { + err := db.encryptFields(¶ms.OAuthAccessToken, ¶ms.OAuthRefreshToken) + if err != nil { + return database.GitAuthLink{}, err + } + return db.Store.UpdateGitAuthLink(ctx, params) +} + +func (db *dbCrypt) encryptFields(fields ...*string) error { + cipherPtr := db.ExternalTokenCipher.Load() + // If no cipher is loaded, then we don't need to encrypt or decrypt anything! + if cipherPtr == nil { + return nil + } + cipher := *cipherPtr + for _, field := range fields { + if field == nil { + continue + } + + encrypted, err := cipher.Encrypt([]byte(*field)) + if err != nil { + return err + } + *field = MagicPrefix + string(encrypted) + } + return nil +} + +// decryptFields decrypts the given fields in place. +// If the value fails to decrypt, sql.ErrNoRows will be returned. +func (db *dbCrypt) decryptFields(fields ...*string) error { + cipherPtr := db.ExternalTokenCipher.Load() + // If no cipher is loaded, then we don't need to encrypt or decrypt anything! + if cipherPtr == nil { + for _, field := range fields { + if field == nil { + continue + } + if strings.HasPrefix(*field, MagicPrefix) { + return ErrInvalidCipher + } + } + return nil + } + + cipher := *cipherPtr + for _, field := range fields { + if field == nil { + continue + } + if len(*field) < len(MagicPrefix) || !strings.HasPrefix(*field, MagicPrefix) { + continue + } + + decrypted, err := cipher.Decrypt([]byte((*field)[len(MagicPrefix):])) + if err != nil { + return xerrors.Errorf("%w: %s", ErrInvalidCipher, err) + } + *field = string(decrypted) + } + return nil +} diff --git a/coderd/database/dbcrypt/dbcrypt_test.go b/coderd/database/dbcrypt/dbcrypt_test.go new file mode 100644 index 0000000000000..8529f457bb1cb --- /dev/null +++ b/coderd/database/dbcrypt/dbcrypt_test.go @@ -0,0 +1,196 @@ +package dbcrypt_test + +import ( + "context" + "crypto/rand" + "io" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbcrypt" + "github.com/coder/coder/coderd/database/dbfake" + "github.com/coder/coder/coderd/database/dbgen" + "github.com/coder/coder/cryptorand" +) + +func TestUserLinks(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("InsertUserLink", func(t *testing.T) { + t.Parallel() + db, crypt, cipher := setup(t) + initCipher(t, cipher) + link := dbgen.UserLink(t, crypt, database.UserLink{ + OAuthAccessToken: "access", + OAuthRefreshToken: "refresh", + }) + link, err := db.GetUserLinkByLinkedID(ctx, link.LinkedID) + require.NoError(t, err) + requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") + requireEncryptedEquals(t, cipher, link.OAuthRefreshToken, "refresh") + }) + + t.Run("UpdateUserLink", func(t *testing.T) { + t.Parallel() + db, crypt, cipher := setup(t) + initCipher(t, cipher) + link := dbgen.UserLink(t, crypt, database.UserLink{}) + _, err := crypt.UpdateUserLink(ctx, database.UpdateUserLinkParams{ + OAuthAccessToken: "access", + OAuthRefreshToken: "refresh", + UserID: link.UserID, + LoginType: link.LoginType, + }) + require.NoError(t, err) + link, err = db.GetUserLinkByLinkedID(ctx, link.LinkedID) + require.NoError(t, err) + requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") + requireEncryptedEquals(t, cipher, link.OAuthRefreshToken, "refresh") + }) + + t.Run("GetUserLinkByLinkedID", func(t *testing.T) { + t.Parallel() + db, crypt, cipher := setup(t) + initCipher(t, cipher) + link := dbgen.UserLink(t, crypt, database.UserLink{ + OAuthAccessToken: "access", + OAuthRefreshToken: "refresh", + }) + link, err := db.GetUserLinkByLinkedID(ctx, link.LinkedID) + require.NoError(t, err) + requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") + requireEncryptedEquals(t, cipher, link.OAuthRefreshToken, "refresh") + + // Reset the key and empty values should be returned! + initCipher(t, cipher) + + link, err = crypt.GetUserLinkByLinkedID(ctx, link.LinkedID) + require.ErrorIs(t, err, dbcrypt.ErrInvalidCipher) + }) + + t.Run("GetUserLinkByUserIDLoginType", func(t *testing.T) { + t.Parallel() + db, crypt, cipher := setup(t) + initCipher(t, cipher) + link := dbgen.UserLink(t, crypt, database.UserLink{ + OAuthAccessToken: "access", + OAuthRefreshToken: "refresh", + }) + link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: link.UserID, + LoginType: link.LoginType, + }) + require.NoError(t, err) + requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") + requireEncryptedEquals(t, cipher, link.OAuthRefreshToken, "refresh") + + // Reset the key and empty values should be returned! + initCipher(t, cipher) + + link, err = crypt.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: link.UserID, + LoginType: link.LoginType, + }) + require.ErrorIs(t, err, dbcrypt.ErrInvalidCipher) + }) +} + +func TestGitAuthLinks(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("InsertGitAuthLink", func(t *testing.T) { + t.Parallel() + db, crypt, cipher := setup(t) + initCipher(t, cipher) + link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{ + OAuthAccessToken: "access", + OAuthRefreshToken: "refresh", + }) + link, err := db.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: link.ProviderID, + UserID: link.UserID, + }) + require.NoError(t, err) + requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") + requireEncryptedEquals(t, cipher, link.OAuthRefreshToken, "refresh") + }) + + t.Run("UpdateGitAuthLink", func(t *testing.T) { + t.Parallel() + db, crypt, cipher := setup(t) + initCipher(t, cipher) + link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{}) + _, err := crypt.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ + ProviderID: link.ProviderID, + UserID: link.UserID, + OAuthAccessToken: "access", + OAuthRefreshToken: "refresh", + }) + require.NoError(t, err) + link, err = db.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: link.ProviderID, + UserID: link.UserID, + }) + require.NoError(t, err) + requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") + requireEncryptedEquals(t, cipher, link.OAuthRefreshToken, "refresh") + }) + + t.Run("GetGitAuthLink", func(t *testing.T) { + t.Parallel() + db, crypt, cipher := setup(t) + initCipher(t, cipher) + link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{ + OAuthAccessToken: "access", + OAuthRefreshToken: "refresh", + }) + link, err := db.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + UserID: link.UserID, + ProviderID: link.ProviderID, + }) + require.NoError(t, err) + requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") + requireEncryptedEquals(t, cipher, link.OAuthRefreshToken, "refresh") + + // Reset the key and empty values should be returned! + initCipher(t, cipher) + + link, err = crypt.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + UserID: link.UserID, + ProviderID: link.ProviderID, + }) + require.ErrorIs(t, err, dbcrypt.ErrInvalidCipher) + }) +} + +func requireEncryptedEquals(t *testing.T, cipher *atomic.Pointer[cryptorand.Cipher], value, expected string) { + t.Helper() + c := (*cipher.Load()) + got, err := c.Decrypt([]byte(value[len(dbcrypt.MagicPrefix):])) + require.NoError(t, err) + require.Equal(t, expected, string(got)) +} + +func initCipher(t *testing.T, cipher *atomic.Pointer[cryptorand.Cipher]) { + t.Helper() + key := make([]byte, 32) // AES-256 key size is 32 bytes + _, err := io.ReadFull(rand.Reader, key) + require.NoError(t, err) + c, err := cryptorand.CipherAES256(key) + require.NoError(t, err) + cipher.Store(&c) +} + +func setup(t *testing.T) (db, cryptodb database.Store, cipher *atomic.Pointer[cryptorand.Cipher]) { + t.Helper() + rawDB := dbfake.New() + cipher = &atomic.Pointer[cryptorand.Cipher]{} + return rawDB, dbcrypt.New(rawDB, &dbcrypt.Options{ + ExternalTokenCipher: cipher, + }), cipher +} diff --git a/coderd/database/dbgen/generator.go b/coderd/database/dbgen/generator.go index b53a749aa0f49..9d735698f3889 100644 --- a/coderd/database/dbgen/generator.go +++ b/coderd/database/dbgen/generator.go @@ -385,7 +385,7 @@ func UserLink(t testing.TB, db database.Store, orig database.UserLink) database. LoginType: takeFirst(orig.LoginType, database.LoginTypeGithub), LinkedID: takeFirst(orig.LinkedID), OAuthAccessToken: takeFirst(orig.OAuthAccessToken, uuid.NewString()), - OAuthRefreshToken: takeFirst(orig.OAuthAccessToken, uuid.NewString()), + OAuthRefreshToken: takeFirst(orig.OAuthRefreshToken, uuid.NewString()), OAuthExpiry: takeFirst(orig.OAuthExpiry, database.Now().Add(time.Hour*24)), }) @@ -398,7 +398,7 @@ func GitAuthLink(t testing.TB, db database.Store, orig database.GitAuthLink) dat ProviderID: takeFirst(orig.ProviderID, uuid.New().String()), UserID: takeFirst(orig.UserID, uuid.New()), OAuthAccessToken: takeFirst(orig.OAuthAccessToken, uuid.NewString()), - OAuthRefreshToken: takeFirst(orig.OAuthAccessToken, uuid.NewString()), + OAuthRefreshToken: takeFirst(orig.OAuthRefreshToken, uuid.NewString()), OAuthExpiry: takeFirst(orig.OAuthExpiry, database.Now().Add(time.Hour*24)), CreatedAt: takeFirst(orig.CreatedAt, database.Now()), UpdatedAt: takeFirst(orig.UpdatedAt, database.Now()), diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 84c295aefee7f..b9b7e527cd49e 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -46,6 +46,7 @@ const ( FeatureAppearance FeatureName = "appearance" FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling" FeatureWorkspaceProxy FeatureName = "workspace_proxy" + FeatureExternalTokenEncryption FeatureName = "external_token_encryption" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -61,6 +62,7 @@ var FeatureNames = []FeatureName{ FeatureAppearance, FeatureAdvancedTemplateScheduling, FeatureWorkspaceProxy, + FeatureExternalTokenEncryption, } // Humanize returns the feature name in a human-readable format. @@ -146,6 +148,7 @@ type DeploymentValues struct { AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"` SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"` + ExternalTokenEncryptionKey clibase.String `json:"external_token_encryption_key"` Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"` @@ -1374,7 +1377,14 @@ when required by your organization's security policy.`, Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"), Value: &c.SCIMAPIKey, }, - + { + Name: "External Token Encryption Key", + Description: "Encrypt OIDC and Git authentication tokens with AES-256-GCM in the database. The value must be a base64-encoded key.", + Flag: "external-token-encryption-key", + Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_KEY", + Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"), + Value: &c.ExternalTokenEncryptionKey, + }, { Name: "Disable Path Apps", Description: "Disable workspace apps that are not served from subdomains. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured.", diff --git a/cryptorand/cipher.go b/cryptorand/cipher.go new file mode 100644 index 0000000000000..4be6c1917d0d3 --- /dev/null +++ b/cryptorand/cipher.go @@ -0,0 +1,48 @@ +package cryptorand + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "io" + + "golang.org/x/xerrors" +) + +type Cipher interface { + Encrypt([]byte) ([]byte, error) + Decrypt([]byte) ([]byte, error) +} + +// CipherAES256 returns a new AES-256 cipher. +func CipherAES256(key []byte) (Cipher, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &aes256{aead}, nil +} + +type aes256 struct { + aead cipher.AEAD +} + +func (a *aes256) Encrypt(plaintext []byte) ([]byte, error) { + nonce := make([]byte, a.aead.NonceSize()) + _, err := io.ReadFull(rand.Reader, nonce) + if err != nil { + return nil, err + } + return a.aead.Seal(nonce, nonce, plaintext, nil), nil +} + +func (a *aes256) Decrypt(ciphertext []byte) ([]byte, error) { + if len(ciphertext) < a.aead.NonceSize() { + return nil, xerrors.Errorf("ciphertext too short") + } + return a.aead.Open(nil, ciphertext[:a.aead.NonceSize()], ciphertext[a.aead.NonceSize():], nil) +} diff --git a/cryptorand/cipher_test.go b/cryptorand/cipher_test.go new file mode 100644 index 0000000000000..ff0943dc4f7e7 --- /dev/null +++ b/cryptorand/cipher_test.go @@ -0,0 +1,30 @@ +package cryptorand_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cryptorand" +) + +func TestCipherAES256(t *testing.T) { + t.Parallel() + key := bytes.Repeat([]byte{'a'}, 32) + cipher, err := cryptorand.CipherAES256(key) + require.NoError(t, err) + + output, err := cipher.Encrypt([]byte("hello world")) + require.NoError(t, err) + + response, err := cipher.Decrypt(output) + require.NoError(t, err) + require.Equal(t, "hello world", string(response)) + + t.Run("InvalidInput", func(t *testing.T) { + t.Parallel() + _, err := cipher.Decrypt(bytes.Repeat([]byte{'a'}, 100)) + require.NoError(t, err) + }) +} diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index d26f9e1b64618..5ccb29c1c02e3 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -5,6 +5,7 @@ package cli import ( "context" "database/sql" + "encoding/base64" "errors" "io" "net/url" @@ -66,6 +67,17 @@ func (r *RootCmd) server() *clibase.Cmd { Options: options, } + if options.DeploymentValues.ExternalTokenEncryptionKey.Value() != "" { + key, err := base64.StdEncoding.DecodeString(options.DeploymentValues.ExternalTokenEncryptionKey.String()) + if err != nil { + return nil, nil, xerrors.Errorf("decode external-token-encryption-key: %w", err) + } + o.ExternalTokenEncryption, err = cryptorand.CipherAES256(key) + if err != nil { + return nil, nil, xerrors.Errorf("create external-token-encryption-key cipher: %w", err) + } + } + api, err := coderd.New(ctx, o) if err != nil { return nil, nil, err diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index ff44aed60c676..93301bdb4a550 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "net/http" "sync" + "sync/atomic" "time" "golang.org/x/xerrors" @@ -18,11 +19,13 @@ import ( "cdr.dev/slog" "github.com/coder/coder/coderd" agplaudit "github.com/coder/coder/coderd/audit" + "github.com/coder/coder/coderd/database/dbcrypt" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/codersdk" + "github.com/coder/coder/cryptorand" "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/enterprise/coderd/proxyhealth" "github.com/coder/coder/enterprise/derpmesh" @@ -39,8 +42,8 @@ func New(ctx context.Context, options *Options) (*API, error) { if options.EntitlementsUpdateInterval == 0 { options.EntitlementsUpdateInterval = 10 * time.Minute } - if options.Keys == nil { - options.Keys = Keys + if options.LicenseKeys == nil { + options.LicenseKeys = Keys } if options.Options == nil { options.Options = &coderd.Options{} @@ -52,9 +55,16 @@ func New(ctx context.Context, options *Options) (*API, error) { options.Options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry) } ctx, cancelFunc := context.WithCancel(ctx) + + externalTokenCipher := &atomic.Pointer[cryptorand.Cipher]{} + options.Database = dbcrypt.New(options.Database, &dbcrypt.Options{ + ExternalTokenCipher: externalTokenCipher, + }) + api := &API{ - ctx: ctx, - cancel: cancelFunc, + ctx: ctx, + cancel: cancelFunc, + externalTokenCipher: externalTokenCipher, AGPL: coderd.New(options.Options), Options: options, @@ -274,8 +284,9 @@ type Options struct { RBAC bool AuditLogging bool // Whether to block non-browser connections. - BrowserOnly bool - SCIMAPIKey []byte + BrowserOnly bool + SCIMAPIKey []byte + ExternalTokenEncryption cryptorand.Cipher // Used for high availability. DERPServerRelayAddress string @@ -283,7 +294,7 @@ type Options struct { EntitlementsUpdateInterval time.Duration ProxyHealthInterval time.Duration - Keys map[string]ed25519.PublicKey + LicenseKeys map[string]ed25519.PublicKey } type API struct { @@ -295,6 +306,8 @@ type API struct { ctx context.Context cancel context.CancelFunc + externalTokenCipher *atomic.Pointer[cryptorand.Cipher] + // Detects multiple Coder replicas running at the same time. replicaManager *replicasync.Manager // Meshes DERP connections from multiple replicas. @@ -319,13 +332,14 @@ func (api *API) updateEntitlements(ctx context.Context) error { entitlements, err := license.Entitlements( ctx, api.Database, - api.Logger, len(api.replicaManager.All()), len(api.GitAuthConfigs), api.Keys, map[codersdk.FeatureName]bool{ + api.Logger, len(api.replicaManager.All()), len(api.GitAuthConfigs), api.LicenseKeys, map[codersdk.FeatureName]bool{ codersdk.FeatureAuditLog: api.AuditLogging, codersdk.FeatureBrowserOnly: api.BrowserOnly, codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "", codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1, codersdk.FeatureTemplateRBAC: api.RBAC, + codersdk.FeatureExternalTokenEncryption: api.ExternalTokenEncryption != nil, codersdk.FeatureExternalProvisionerDaemons: true, codersdk.FeatureAdvancedTemplateScheduling: true, codersdk.FeatureWorkspaceProxy: true, @@ -396,6 +410,14 @@ func (api *API) updateEntitlements(ctx context.Context) error { } } + if changed, enabled := featureChanged(codersdk.FeatureExternalTokenEncryption); changed { + if enabled { + api.externalTokenCipher.Store(&api.ExternalTokenEncryption) + } else { + api.externalTokenCipher.Store(nil) + } + } + if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed { coordinator := agpltailnet.NewCoordinator(api.Logger) if enabled { diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 40f819ea67507..3026230507b99 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -74,7 +74,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c DERPServerRegionID: oop.DERPMap.RegionIDs()[0], Options: oop, EntitlementsUpdateInterval: options.EntitlementsUpdateInterval, - Keys: Keys, + LicenseKeys: Keys, ProxyHealthInterval: options.ProxyHealthInterval, }) assert.NoError(t, err) diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index 24085ee9a7bea..7ca65667cfc2a 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -82,7 +82,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { return } - rawClaims, err := license.ParseRaw(addLicense.License, api.Keys) + rawClaims, err := license.ParseRaw(addLicense.License, api.LicenseKeys) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid license", @@ -100,7 +100,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { } expTime := time.Unix(int64(exp), 0) - claims, err := license.ParseClaims(addLicense.License, api.Keys) + claims, err := license.ParseClaims(addLicense.License, api.LicenseKeys) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid license", From b9251fd68acf00c2aca3426a927f08514f50d829 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 11 Jun 2023 17:41:45 +0000 Subject: [PATCH 02/52] Fix dbcrypt --- coderd/database/dbauthz/querier.go | 13 +++++++++ coderd/database/dbcrypt/dbcrypt.go | 36 ++++++++++++++++++------- coderd/database/dbcrypt/dbcrypt_test.go | 7 ++--- coderd/database/dbfake/databasefake.go | 33 +++++++++++++++++++++++ coderd/database/dbmock/store.go | 28 +++++++++++++++++++ coderd/database/querier.go | 2 ++ coderd/database/queries.sql.go | 26 ++++++++++++++++++ coderd/database/queries/gitauth.sql | 3 +++ coderd/database/queries/user_links.sql | 6 +++++ site/src/api/typesGenerated.ts | 3 +++ 10 files changed, 144 insertions(+), 13 deletions(-) diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 1f3a5b81663b9..1d869c1eb31ab 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -1113,6 +1113,10 @@ func (q *querier) InsertUserLink(ctx context.Context, arg database.InsertUserLin return q.db.InsertUserLink(ctx, arg) } +func (q *querier) DeleteUserLinkByLinkedID(ctx context.Context, linkedID string) error { + return deleteQ(q.log, q.auth, q.db.GetUserLinkByLinkedID, q.db.DeleteUserLinkByLinkedID)(ctx, linkedID) +} + func (q *querier) SoftDeleteUserByID(ctx context.Context, id uuid.UUID) error { deleteF := func(ctx context.Context, id uuid.UUID) error { return q.db.UpdateUserDeletedByID(ctx, database.UpdateUserDeletedByIDParams{ @@ -1201,6 +1205,15 @@ func (q *querier) GetGitAuthLink(ctx context.Context, arg database.GetGitAuthLin return fetch(q.log, q.auth, q.db.GetGitAuthLink)(ctx, arg) } +func (q *querier) DeleteGitAuthLink(ctx context.Context, arg database.DeleteGitAuthLinkParams) error { + return deleteQ(q.log, q.auth, func(ctx context.Context, arg database.DeleteGitAuthLinkParams) (database.GitAuthLink, error) { + return q.db.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + ProviderID: arg.ProviderID, + UserID: arg.UserID, + }) + }, q.db.DeleteGitAuthLink)(ctx, arg) +} + func (q *querier) InsertGitAuthLink(ctx context.Context, arg database.InsertGitAuthLinkParams) (database.GitAuthLink, error) { return insert(q.log, q.auth, rbac.ResourceUserData.WithOwner(arg.UserID.String()).WithID(arg.UserID), q.db.InsertGitAuthLink)(ctx, arg) } diff --git a/coderd/database/dbcrypt/dbcrypt.go b/coderd/database/dbcrypt/dbcrypt.go index e579903288b87..ffdca87c2b8b9 100644 --- a/coderd/database/dbcrypt/dbcrypt.go +++ b/coderd/database/dbcrypt/dbcrypt.go @@ -17,10 +17,6 @@ import ( // If it is encrypted but a key is not provided, an error is returned. const MagicPrefix = "dbcrypt-" -// ErrInvalidCipher is returned when an invalid cipher is provided -// for the encrypted data. -var ErrInvalidCipher = xerrors.New("an invalid encryption cipher was provided for the encrypted data") - type Options struct { // ExternalTokenCipher is an optional cipher that is used // to encrypt/decrypt user link and git auth link tokens. If this is nil, @@ -56,7 +52,9 @@ func (db *dbCrypt) GetUserLinkByLinkedID(ctx context.Context, linkedID string) ( if err != nil { return database.UserLink{}, err } - return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) + return link, db.decryptFields(func() error { + return db.Store.DeleteUserLinkByLinkedID(ctx, linkedID) + }, &link.OAuthAccessToken, &link.OAuthRefreshToken) } func (db *dbCrypt) GetUserLinkByUserIDLoginType(ctx context.Context, params database.GetUserLinkByUserIDLoginTypeParams) (database.UserLink, error) { @@ -64,7 +62,9 @@ func (db *dbCrypt) GetUserLinkByUserIDLoginType(ctx context.Context, params data if err != nil { return database.UserLink{}, err } - return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) + return link, db.decryptFields(func() error { + return db.Store.DeleteUserLinkByLinkedID(ctx, link.LinkedID) + }, &link.OAuthAccessToken, &link.OAuthRefreshToken) } func (db *dbCrypt) InsertUserLink(ctx context.Context, params database.InsertUserLinkParams) (database.UserLink, error) { @@ -96,7 +96,12 @@ func (db *dbCrypt) GetGitAuthLink(ctx context.Context, params database.GetGitAut if err != nil { return database.GitAuthLink{}, err } - return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) + return link, db.decryptFields(func() error { + return db.Store.DeleteGitAuthLink(ctx, database.DeleteGitAuthLinkParams{ + ProviderID: params.ProviderID, + UserID: params.UserID, + }) + }, &link.OAuthAccessToken, &link.OAuthRefreshToken) } func (db *dbCrypt) UpdateGitAuthLink(ctx context.Context, params database.UpdateGitAuthLinkParams) (database.GitAuthLink, error) { @@ -130,7 +135,15 @@ func (db *dbCrypt) encryptFields(fields ...*string) error { // decryptFields decrypts the given fields in place. // If the value fails to decrypt, sql.ErrNoRows will be returned. -func (db *dbCrypt) decryptFields(fields ...*string) error { +func (db *dbCrypt) decryptFields(deleteFn func() error, fields ...*string) error { + delete := func() error { + err := deleteFn() + if err != nil { + return xerrors.Errorf("delete encrypted row: %w", err) + } + return sql.ErrNoRows + } + cipherPtr := db.ExternalTokenCipher.Load() // If no cipher is loaded, then we don't need to encrypt or decrypt anything! if cipherPtr == nil { @@ -139,7 +152,9 @@ func (db *dbCrypt) decryptFields(fields ...*string) error { continue } if strings.HasPrefix(*field, MagicPrefix) { - return ErrInvalidCipher + // If we have a magic prefix but encryption is disabled, + // we should delete the row. + return delete() } } return nil @@ -156,7 +171,8 @@ func (db *dbCrypt) decryptFields(fields ...*string) error { decrypted, err := cipher.Decrypt([]byte((*field)[len(MagicPrefix):])) if err != nil { - return xerrors.Errorf("%w: %s", ErrInvalidCipher, err) + // If the encryption key changed, we should delete the row. + return delete() } *field = string(decrypted) } diff --git a/coderd/database/dbcrypt/dbcrypt_test.go b/coderd/database/dbcrypt/dbcrypt_test.go index 8529f457bb1cb..fe7aaef7270f9 100644 --- a/coderd/database/dbcrypt/dbcrypt_test.go +++ b/coderd/database/dbcrypt/dbcrypt_test.go @@ -3,6 +3,7 @@ package dbcrypt_test import ( "context" "crypto/rand" + "database/sql" "io" "sync/atomic" "testing" @@ -69,7 +70,7 @@ func TestUserLinks(t *testing.T) { initCipher(t, cipher) link, err = crypt.GetUserLinkByLinkedID(ctx, link.LinkedID) - require.ErrorIs(t, err, dbcrypt.ErrInvalidCipher) + require.ErrorIs(t, err, sql.ErrNoRows) }) t.Run("GetUserLinkByUserIDLoginType", func(t *testing.T) { @@ -95,7 +96,7 @@ func TestUserLinks(t *testing.T) { UserID: link.UserID, LoginType: link.LoginType, }) - require.ErrorIs(t, err, dbcrypt.ErrInvalidCipher) + require.ErrorIs(t, err, sql.ErrNoRows) }) } @@ -164,7 +165,7 @@ func TestGitAuthLinks(t *testing.T) { UserID: link.UserID, ProviderID: link.ProviderID, }) - require.ErrorIs(t, err, dbcrypt.ErrInvalidCipher) + require.ErrorIs(t, err, sql.ErrNoRows) }) } diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 41800dbcd9e74..d7689d5030b24 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -4380,6 +4380,24 @@ func (q *fakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGrou return database.Group{}, sql.ErrNoRows } +func (q *fakeQuerier) DeleteGitAuthLink(_ context.Context, arg database.DeleteGitAuthLinkParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, link := range q.gitAuthLinks { + if link.ProviderID != arg.ProviderID { + continue + } + if link.UserID != arg.UserID { + continue + } + q.gitAuthLinks[index] = q.gitAuthLinks[len(q.gitAuthLinks)-1] + q.gitAuthLinks = q.gitAuthLinks[:len(q.gitAuthLinks)-1] + return nil + } + return sql.ErrNoRows +} + func (q *fakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -4911,6 +4929,21 @@ func (q *fakeQuerier) DeleteReplicasUpdatedBefore(_ context.Context, before time return nil } +func (q *fakeQuerier) DeleteUserLinkByLinkedID(ctx context.Context, linkedID string) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, link := range q.userLinks { + if link.LinkedID != linkedID { + continue + } + q.userLinks[index] = q.userLinks[len(q.userLinks)-1] + q.userLinks = q.userLinks[:len(q.userLinks)-1] + return nil + } + return sql.ErrNoRows +} + func (q *fakeQuerier) InsertReplica(_ context.Context, arg database.InsertReplicaParams) (database.Replica, error) { if err := validateDatabaseType(arg); err != nil { return database.Replica{}, err diff --git a/coderd/database/dbmock/store.go b/coderd/database/dbmock/store.go index 5fe83e6f514d6..09e672f9d911c 100644 --- a/coderd/database/dbmock/store.go +++ b/coderd/database/dbmock/store.go @@ -110,6 +110,20 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(arg0, a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), arg0, arg1) } +// DeleteGitAuthLink mocks base method. +func (m *MockStore) DeleteGitAuthLink(arg0 context.Context, arg1 database.DeleteGitAuthLinkParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteGitAuthLink", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteGitAuthLink indicates an expected call of DeleteGitAuthLink. +func (mr *MockStoreMockRecorder) DeleteGitAuthLink(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGitAuthLink", reflect.TypeOf((*MockStore)(nil).DeleteGitAuthLink), arg0, arg1) +} + // DeleteGitSSHKey mocks base method. func (m *MockStore) DeleteGitSSHKey(arg0 context.Context, arg1 uuid.UUID) error { m.ctrl.T.Helper() @@ -237,6 +251,20 @@ func (mr *MockStoreMockRecorder) DeleteReplicasUpdatedBefore(arg0, arg1 interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteReplicasUpdatedBefore", reflect.TypeOf((*MockStore)(nil).DeleteReplicasUpdatedBefore), arg0, arg1) } +// DeleteUserLinkByLinkedID mocks base method. +func (m *MockStore) DeleteUserLinkByLinkedID(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserLinkByLinkedID", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUserLinkByLinkedID indicates an expected call of DeleteUserLinkByLinkedID. +func (mr *MockStoreMockRecorder) DeleteUserLinkByLinkedID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserLinkByLinkedID", reflect.TypeOf((*MockStore)(nil).DeleteUserLinkByLinkedID), arg0, arg1) +} + // GetAPIKeyByID mocks base method. func (m *MockStore) GetAPIKeyByID(arg0 context.Context, arg1 string) (database.APIKey, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index e30207c7ba44e..17a01dc0e3de4 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -29,6 +29,7 @@ type sqlcQuerier interface { DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error + DeleteGitAuthLink(ctx context.Context, arg DeleteGitAuthLinkParams) error DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error DeleteGroupByID(ctx context.Context, id uuid.UUID) error DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteGroupMemberFromGroupParams) error @@ -40,6 +41,7 @@ type sqlcQuerier interface { DeleteOldWorkspaceAgentStats(ctx context.Context) error DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error + DeleteUserLinkByLinkedID(ctx context.Context, linkedID string) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) // there is no unique constraint on empty token names GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9955deb439ffd..c8347b41b49b9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -792,6 +792,20 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File return i, err } +const deleteGitAuthLink = `-- name: DeleteGitAuthLink :exec +DELETE FROM git_auth_links WHERE provider_id = $1 AND user_id = $2 +` + +type DeleteGitAuthLinkParams struct { + ProviderID string `db:"provider_id" json:"provider_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` +} + +func (q *sqlQuerier) DeleteGitAuthLink(ctx context.Context, arg DeleteGitAuthLinkParams) error { + _, err := q.db.ExecContext(ctx, deleteGitAuthLink, arg.ProviderID, arg.UserID) + return err +} + const getGitAuthLink = `-- name: GetGitAuthLink :one SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry FROM git_auth_links WHERE provider_id = $1 AND user_id = $2 ` @@ -4741,6 +4755,18 @@ func (q *sqlQuerier) InsertTemplateVersionVariable(ctx context.Context, arg Inse return i, err } +const deleteUserLinkByLinkedID = `-- name: DeleteUserLinkByLinkedID :exec +DELETE FROM + user_links +WHERE + linked_id = $1 +` + +func (q *sqlQuerier) DeleteUserLinkByLinkedID(ctx context.Context, linkedID string) error { + _, err := q.db.ExecContext(ctx, deleteUserLinkByLinkedID, linkedID) + return err +} + const getUserLinkByLinkedID = `-- name: GetUserLinkByLinkedID :one SELECT user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry diff --git a/coderd/database/queries/gitauth.sql b/coderd/database/queries/gitauth.sql index a35de98a08908..c2cd6853f7bcd 100644 --- a/coderd/database/queries/gitauth.sql +++ b/coderd/database/queries/gitauth.sql @@ -27,3 +27,6 @@ UPDATE git_auth_links SET oauth_refresh_token = $5, oauth_expiry = $6 WHERE provider_id = $1 AND user_id = $2 RETURNING *; + +-- name: DeleteGitAuthLink :exec +DELETE FROM git_auth_links WHERE provider_id = $1 AND user_id = $2; diff --git a/coderd/database/queries/user_links.sql b/coderd/database/queries/user_links.sql index 2390cb9782b30..d7e35ae677f19 100644 --- a/coderd/database/queries/user_links.sql +++ b/coderd/database/queries/user_links.sql @@ -44,3 +44,9 @@ SET oauth_expiry = $3 WHERE user_id = $4 AND login_type = $5 RETURNING *; + +-- name: DeleteUserLinkByLinkedID :exec +DELETE FROM + user_links +WHERE + linked_id = $1; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 768c74f8f640e..3688e0a953364 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -362,6 +362,7 @@ export interface DeploymentValues { readonly agent_fallback_troubleshooting_url?: string readonly browser_only?: boolean readonly scim_api_key?: string + readonly external_token_encryption_key: string readonly provisioner?: ProvisionerConfig readonly rate_limit?: RateLimitConfig // This is likely an enum in an external package ("github.com/coder/coder/cli/clibase.StringArray") @@ -1395,6 +1396,7 @@ export type FeatureName = | "audit_log" | "browser_only" | "external_provisioner_daemons" + | "external_token_encryption" | "high_availability" | "multiple_git_auth" | "scim" @@ -1407,6 +1409,7 @@ export const FeatureNames: FeatureName[] = [ "audit_log", "browser_only", "external_provisioner_daemons", + "external_token_encryption", "high_availability", "multiple_git_auth", "scim", From deb577b6bab23bac01103303d3eeef7860caa23a Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 12 Jun 2023 03:24:19 +0000 Subject: [PATCH 03/52] Automatically delete rows when not encrypted --- coderd/database/dbcrypt/dbcrypt.go | 28 +++++++++++++++++++------ coderd/database/dbcrypt/dbcrypt_test.go | 8 ++++++- coderd/httpmw/apikey.go | 6 ++++++ enterprise/coderd/coderd.go | 1 + 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/coderd/database/dbcrypt/dbcrypt.go b/coderd/database/dbcrypt/dbcrypt.go index ffdca87c2b8b9..2d1fd7d8b6096 100644 --- a/coderd/database/dbcrypt/dbcrypt.go +++ b/coderd/database/dbcrypt/dbcrypt.go @@ -3,9 +3,12 @@ package dbcrypt import ( "context" "database/sql" + "encoding/base64" + "runtime" "strings" "sync/atomic" + "cdr.dev/slog" "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" @@ -22,6 +25,7 @@ type Options struct { // to encrypt/decrypt user link and git auth link tokens. If this is nil, // then no encryption/decryption will be performed. ExternalTokenCipher *atomic.Pointer[cryptorand.Cipher] + Logger slog.Logger } // New creates a database.Store wrapper that encrypts/decrypts values @@ -128,7 +132,8 @@ func (db *dbCrypt) encryptFields(fields ...*string) error { if err != nil { return err } - *field = MagicPrefix + string(encrypted) + // Base64 is used to support UTF-8 encoding in PostgreSQL. + *field = MagicPrefix + base64.StdEncoding.EncodeToString(encrypted) } return nil } @@ -136,11 +141,16 @@ func (db *dbCrypt) encryptFields(fields ...*string) error { // decryptFields decrypts the given fields in place. // If the value fails to decrypt, sql.ErrNoRows will be returned. func (db *dbCrypt) decryptFields(deleteFn func() error, fields ...*string) error { - delete := func() error { + delete := func(reason string) error { err := deleteFn() if err != nil { return xerrors.Errorf("delete encrypted row: %w", err) } + pc, _, _, ok := runtime.Caller(2) + details := runtime.FuncForPC(pc) + if ok && details != nil { + db.Logger.Debug(context.Background(), "deleted row", slog.F("reason", reason), slog.F("caller", details.Name())) + } return sql.ErrNoRows } @@ -154,7 +164,7 @@ func (db *dbCrypt) decryptFields(deleteFn func() error, fields ...*string) error if strings.HasPrefix(*field, MagicPrefix) { // If we have a magic prefix but encryption is disabled, // we should delete the row. - return delete() + return delete("encryption disabled") } } return nil @@ -166,13 +176,19 @@ func (db *dbCrypt) decryptFields(deleteFn func() error, fields ...*string) error continue } if len(*field) < len(MagicPrefix) || !strings.HasPrefix(*field, MagicPrefix) { + // We do not force encryption of unencrypted rows. This could be damaging + // to the deployment, and admins can always manually purge data. continue } - - decrypted, err := cipher.Decrypt([]byte((*field)[len(MagicPrefix):])) + data, err := base64.StdEncoding.DecodeString((*field)[len(MagicPrefix):]) + if err != nil { + // If it's not base64 with the prefix, we should delete the row. + return delete("stored value was not base64 encoded") + } + decrypted, err := cipher.Decrypt(data) if err != nil { // If the encryption key changed, we should delete the row. - return delete() + return delete("encryption key changed") } *field = string(decrypted) } diff --git a/coderd/database/dbcrypt/dbcrypt_test.go b/coderd/database/dbcrypt/dbcrypt_test.go index fe7aaef7270f9..8c8a5b14ad8f5 100644 --- a/coderd/database/dbcrypt/dbcrypt_test.go +++ b/coderd/database/dbcrypt/dbcrypt_test.go @@ -4,10 +4,13 @@ import ( "context" "crypto/rand" "database/sql" + "encoding/base64" "io" "sync/atomic" "testing" + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/database" @@ -172,7 +175,9 @@ func TestGitAuthLinks(t *testing.T) { func requireEncryptedEquals(t *testing.T, cipher *atomic.Pointer[cryptorand.Cipher], value, expected string) { t.Helper() c := (*cipher.Load()) - got, err := c.Decrypt([]byte(value[len(dbcrypt.MagicPrefix):])) + data, err := base64.StdEncoding.DecodeString(value[len(dbcrypt.MagicPrefix):]) + require.NoError(t, err) + got, err := c.Decrypt(data) require.NoError(t, err) require.Equal(t, expected, string(got)) } @@ -193,5 +198,6 @@ func setup(t *testing.T) (db, cryptodb database.Store, cipher *atomic.Pointer[cr cipher = &atomic.Pointer[cryptorand.Cipher]{} return rawDB, dbcrypt.New(rawDB, &dbcrypt.Options{ ExternalTokenCipher: cipher, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), }), cipher } diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 509ce4a82a24a..25d1bf3ab97a7 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -229,6 +229,12 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon UserID: key.UserID, LoginType: key.LoginType, }) + if errors.Is(err, sql.ErrNoRows) { + return optionalWrite(http.StatusUnauthorized, codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: "You must re-authenticate with the login provider.", + }) + } if err != nil { return write(http.StatusInternalServerError, codersdk.Response{ Message: "A database error occurred", diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 93301bdb4a550..00a6a6928d95e 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -59,6 +59,7 @@ func New(ctx context.Context, options *Options) (*API, error) { externalTokenCipher := &atomic.Pointer[cryptorand.Cipher]{} options.Database = dbcrypt.New(options.Database, &dbcrypt.Options{ ExternalTokenCipher: externalTokenCipher, + Logger: options.Logger.Named("dbcrypt"), }) api := &API{ From faa20ad5426b787c11fdb8f12241d3247f7f79d7 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 12 Jun 2023 16:28:18 +0000 Subject: [PATCH 04/52] gen --- coderd/apidoc/docs.go | 3 +++ coderd/apidoc/swagger.json | 3 +++ docs/api/general.md | 1 + docs/api/schemas.md | 3 +++ docs/cli/server.md | 9 +++++++++ 5 files changed, 19 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index bb2f2b602621c..1c4b1b9c52460 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7373,6 +7373,9 @@ const docTemplate = `{ "type": "string" } }, + "external_token_encryption_key": { + "type": "string" + }, "git_auth": { "$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ba4da7aa72b43..ad12fd3bb4f86 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6575,6 +6575,9 @@ "type": "string" } }, + "external_token_encryption_key": { + "type": "string" + }, "git_auth": { "$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig" }, diff --git a/docs/api/general.md b/docs/api/general.md index b1b713a66bc6f..008d9f853218b 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -196,6 +196,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "disable_path_apps": true, "disable_session_expiry_refresh": true, "experiments": ["string"], + "external_token_encryption_key": "string", "git_auth": { "value": [ { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index e428c8bc2f9c3..e58e797e61c51 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1894,6 +1894,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a "disable_path_apps": true, "disable_session_expiry_refresh": true, "experiments": ["string"], + "external_token_encryption_key": "string", "git_auth": { "value": [ { @@ -2239,6 +2240,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a "disable_path_apps": true, "disable_session_expiry_refresh": true, "experiments": ["string"], + "external_token_encryption_key": "string", "git_auth": { "value": [ { @@ -2429,6 +2431,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `disable_path_apps` | boolean | false | | | | `disable_session_expiry_refresh` | boolean | false | | | | `experiments` | array of string | false | | | +| `external_token_encryption_key` | string | false | | | | `git_auth` | [clibase.Struct-array_codersdk_GitAuthConfig](#clibasestruct-array_codersdk_gitauthconfig) | false | | | | `http_address` | string | false | | Http address is a string because it may be set to zero to disable. | | `in_memory_database` | boolean | false | | | diff --git a/docs/cli/server.md b/docs/cli/server.md index 3cbcafe9bbc35..095766c389846 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -233,6 +233,15 @@ Expose the swagger endpoint via /swagger. Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or enter '\*' to opt-in to all available experiments. +### --external-token-encryption-key + +| | | +| ----------- | ------------------------------------------------- | +| Type | string | +| Environment | $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEY | + +Encrypt OIDC and Git authentication tokens with AES-256-GCM in the database. The value must be a base64-encoded key. + ### --provisioner-force-cancel-interval | | | From 7837f7116774e2867f131a239436ed34b30cfd6c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 22 Aug 2023 14:36:22 +0100 Subject: [PATCH 05/52] move cipher to dbcrypt package --- .../database/dbcrypt}/cipher.go | 26 ++++++++++- coderd/database/dbcrypt/cipher_test.go | 45 +++++++++++++++++++ coderd/database/dbcrypt/dbcrypt.go | 14 +++--- coderd/database/dbcrypt/dbcrypt_test.go | 14 +++--- cryptorand/cipher_test.go | 30 ------------- 5 files changed, 83 insertions(+), 46 deletions(-) rename {cryptorand => coderd/database/dbcrypt}/cipher.go (62%) create mode 100644 coderd/database/dbcrypt/cipher_test.go delete mode 100644 cryptorand/cipher_test.go diff --git a/cryptorand/cipher.go b/coderd/database/dbcrypt/cipher.go similarity index 62% rename from cryptorand/cipher.go rename to coderd/database/dbcrypt/cipher.go index 4be6c1917d0d3..15d657ab4d7b6 100644 --- a/cryptorand/cipher.go +++ b/coderd/database/dbcrypt/cipher.go @@ -1,9 +1,10 @@ -package cryptorand +package dbcrypt import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "errors" "io" "golang.org/x/xerrors" @@ -14,6 +15,23 @@ type Cipher interface { Decrypt([]byte) ([]byte, error) } +type DecryptFailedError struct { + Inner error +} + +func (e *DecryptFailedError) Error() string { + return xerrors.Errorf("decrypt failed: %w", e.Inner).Error() +} + +func (e *DecryptFailedError) Unwrap() error { + return e.Inner +} + +func IsDecryptFailedError(err error) bool { + var e *DecryptFailedError + return errors.As(err, &e) +} + // CipherAES256 returns a new AES-256 cipher. func CipherAES256(key []byte) (Cipher, error) { block, err := aes.NewCipher(key) @@ -44,5 +62,9 @@ func (a *aes256) Decrypt(ciphertext []byte) ([]byte, error) { if len(ciphertext) < a.aead.NonceSize() { return nil, xerrors.Errorf("ciphertext too short") } - return a.aead.Open(nil, ciphertext[:a.aead.NonceSize()], ciphertext[a.aead.NonceSize():], nil) + decrypted, err := a.aead.Open(nil, ciphertext[:a.aead.NonceSize()], ciphertext[a.aead.NonceSize():], nil) + if err != nil { + return nil, &DecryptFailedError{Inner: err} + } + return decrypted, nil } diff --git a/coderd/database/dbcrypt/cipher_test.go b/coderd/database/dbcrypt/cipher_test.go new file mode 100644 index 0000000000000..73d8d503377d4 --- /dev/null +++ b/coderd/database/dbcrypt/cipher_test.go @@ -0,0 +1,45 @@ +package dbcrypt_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database/dbcrypt" +) + +func TestCipherAES256(t *testing.T) { + t.Parallel() + + t.Run("ValidInput", func(t *testing.T) { + t.Parallel() + key := bytes.Repeat([]byte{'a'}, 32) + cipher, err := dbcrypt.CipherAES256(key) + require.NoError(t, err) + + output, err := cipher.Encrypt([]byte("hello world")) + require.NoError(t, err) + + response, err := cipher.Decrypt(output) + require.NoError(t, err) + require.Equal(t, "hello world", string(response)) + }) + + t.Run("InvalidInput", func(t *testing.T) { + t.Parallel() + key := bytes.Repeat([]byte{'a'}, 32) + cipher, err := dbcrypt.CipherAES256(key) + require.NoError(t, err) + _, err = cipher.Decrypt(bytes.Repeat([]byte{'a'}, 100)) + var decryptErr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &decryptErr) + }) + + t.Run("InvalidKeySize", func(t *testing.T) { + t.Parallel() + + _, err := dbcrypt.CipherAES256(bytes.Repeat([]byte{'a'}, 31)) + require.ErrorContains(t, err, "invalid key size") + }) +} diff --git a/coderd/database/dbcrypt/dbcrypt.go b/coderd/database/dbcrypt/dbcrypt.go index 47d143d0d03d8..8a63e538838fe 100644 --- a/coderd/database/dbcrypt/dbcrypt.go +++ b/coderd/database/dbcrypt/dbcrypt.go @@ -8,11 +8,11 @@ import ( "strings" "sync/atomic" - "cdr.dev/slog" "golang.org/x/xerrors" + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/cryptorand" ) // MagicPrefix is prepended to all encrypted values in the database. @@ -24,7 +24,7 @@ type Options struct { // ExternalTokenCipher is an optional cipher that is used // to encrypt/decrypt user link and git auth link tokens. If this is nil, // then no encryption/decryption will be performed. - ExternalTokenCipher *atomic.Pointer[cryptorand.Cipher] + ExternalTokenCipher *atomic.Pointer[Cipher] Logger slog.Logger } @@ -141,7 +141,7 @@ func (db *dbCrypt) encryptFields(fields ...*string) error { // decryptFields decrypts the given fields in place. // If the value fails to decrypt, sql.ErrNoRows will be returned. func (db *dbCrypt) decryptFields(deleteFn func() error, fields ...*string) error { - delete := func(reason string) error { + doDelete := func(reason string) error { err := deleteFn() if err != nil { return xerrors.Errorf("delete encrypted row: %w", err) @@ -164,7 +164,7 @@ func (db *dbCrypt) decryptFields(deleteFn func() error, fields ...*string) error if strings.HasPrefix(*field, MagicPrefix) { // If we have a magic prefix but encryption is disabled, // we should delete the row. - return delete("encryption disabled") + return doDelete("encryption disabled") } } return nil @@ -183,12 +183,12 @@ func (db *dbCrypt) decryptFields(deleteFn func() error, fields ...*string) error data, err := base64.StdEncoding.DecodeString((*field)[len(MagicPrefix):]) if err != nil { // If it's not base64 with the prefix, we should delete the row. - return delete("stored value was not base64 encoded") + return doDelete("stored value was not base64 encoded") } decrypted, err := cipher.Decrypt(data) if err != nil { // If the encryption key changed, we should delete the row. - return delete("encryption key changed") + return doDelete("encryption key changed") } *field = string(decrypted) } diff --git a/coderd/database/dbcrypt/dbcrypt_test.go b/coderd/database/dbcrypt/dbcrypt_test.go index 061a784d21c8e..94c24a82e8d26 100644 --- a/coderd/database/dbcrypt/dbcrypt_test.go +++ b/coderd/database/dbcrypt/dbcrypt_test.go @@ -9,15 +9,15 @@ import ( "sync/atomic" "testing" + "github.com/stretchr/testify/require" + "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbcrypt" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/cryptorand" ) func TestUserLinks(t *testing.T) { @@ -172,7 +172,7 @@ func TestGitAuthLinks(t *testing.T) { }) } -func requireEncryptedEquals(t *testing.T, cipher *atomic.Pointer[cryptorand.Cipher], value, expected string) { +func requireEncryptedEquals(t *testing.T, cipher *atomic.Pointer[dbcrypt.Cipher], value, expected string) { t.Helper() c := (*cipher.Load()) data, err := base64.StdEncoding.DecodeString(value[len(dbcrypt.MagicPrefix):]) @@ -182,20 +182,20 @@ func requireEncryptedEquals(t *testing.T, cipher *atomic.Pointer[cryptorand.Ciph require.Equal(t, expected, string(got)) } -func initCipher(t *testing.T, cipher *atomic.Pointer[cryptorand.Cipher]) { +func initCipher(t *testing.T, cipher *atomic.Pointer[dbcrypt.Cipher]) { t.Helper() key := make([]byte, 32) // AES-256 key size is 32 bytes _, err := io.ReadFull(rand.Reader, key) require.NoError(t, err) - c, err := cryptorand.CipherAES256(key) + c, err := dbcrypt.CipherAES256(key) require.NoError(t, err) cipher.Store(&c) } -func setup(t *testing.T) (db, cryptodb database.Store, cipher *atomic.Pointer[cryptorand.Cipher]) { +func setup(t *testing.T) (db, cryptodb database.Store, cipher *atomic.Pointer[dbcrypt.Cipher]) { t.Helper() rawDB := dbfake.New() - cipher = &atomic.Pointer[cryptorand.Cipher]{} + cipher = &atomic.Pointer[dbcrypt.Cipher]{} return rawDB, dbcrypt.New(rawDB, &dbcrypt.Options{ ExternalTokenCipher: cipher, Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), diff --git a/cryptorand/cipher_test.go b/cryptorand/cipher_test.go deleted file mode 100644 index cee6f4e46c1d2..0000000000000 --- a/cryptorand/cipher_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package cryptorand_test - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/cryptorand" -) - -func TestCipherAES256(t *testing.T) { - t.Parallel() - key := bytes.Repeat([]byte{'a'}, 32) - cipher, err := cryptorand.CipherAES256(key) - require.NoError(t, err) - - output, err := cipher.Encrypt([]byte("hello world")) - require.NoError(t, err) - - response, err := cipher.Decrypt(output) - require.NoError(t, err) - require.Equal(t, "hello world", string(response)) - - t.Run("InvalidInput", func(t *testing.T) { - t.Parallel() - _, err := cipher.Decrypt(bytes.Repeat([]byte{'a'}, 100)) - require.NoError(t, err) - }) -} From 82e7b3556cf5c14d551ab72f1622bbfc3f89c32a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 22 Aug 2023 13:41:36 +0000 Subject: [PATCH 06/52] fixup! move cipher to dbcrypt package --- coderd/database/dbcrypt/dbcrypt.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/dbcrypt/dbcrypt.go b/coderd/database/dbcrypt/dbcrypt.go index 8a63e538838fe..b9de4b8bca071 100644 --- a/coderd/database/dbcrypt/dbcrypt.go +++ b/coderd/database/dbcrypt/dbcrypt.go @@ -101,7 +101,7 @@ func (db *dbCrypt) GetGitAuthLink(ctx context.Context, params database.GetGitAut return database.GitAuthLink{}, err } return link, db.decryptFields(func() error { - return db.Store.DeleteGitAuthLink(ctx, database.DeleteGitAuthLinkParams{ + return db.Store.DeleteGitAuthLink(ctx, database.DeleteGitAuthLinkParams{ // nolint:gosimple ProviderID: params.ProviderID, UserID: params.UserID, }) From 2b404d1196b0e83d1ddb9a4d0fe770be7058634c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 22 Aug 2023 14:44:04 +0100 Subject: [PATCH 07/52] fixup! move cipher to dbcrypt package --- enterprise/coderd/coderd.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index f53479b7a9205..006f292ea429e 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -32,7 +32,6 @@ import ( "github.com/coder/coder/v2/coderd/rbac" agplschedule "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/enterprise/coderd/proxyhealth" "github.com/coder/coder/v2/enterprise/coderd/schedule" @@ -65,7 +64,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ctx, cancelFunc := context.WithCancel(ctx) - externalTokenCipher := &atomic.Pointer[cryptorand.Cipher]{} + externalTokenCipher := &atomic.Pointer[dbcrypt.Cipher]{} options.Database = dbcrypt.New(options.Database, &dbcrypt.Options{ ExternalTokenCipher: externalTokenCipher, }) @@ -372,7 +371,7 @@ type Options struct { // Whether to block non-browser connections. BrowserOnly bool SCIMAPIKey []byte - ExternalTokenEncryption cryptorand.Cipher + ExternalTokenEncryption dbcrypt.Cipher // Used for high availability. ReplicaSyncUpdateInterval time.Duration @@ -400,7 +399,7 @@ type API struct { ctx context.Context cancel context.CancelFunc - externalTokenCipher *atomic.Pointer[cryptorand.Cipher] + externalTokenCipher *atomic.Pointer[dbcrypt.Cipher] // Detects multiple Coder replicas running at the same time. replicaManager *replicasync.Manager From 02277a88962734592e6d215f6166d0f918d5f95f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 22 Aug 2023 14:44:40 +0100 Subject: [PATCH 08/52] fixup! move cipher to dbcrypt package --- enterprise/cli/server.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 336a3e0bbb035..10064f965fc34 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -15,6 +15,7 @@ import ( "tailscale.com/types/key" "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/coderd/database/dbcrypt" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/audit" "github.com/coder/coder/v2/enterprise/audit/backends" @@ -75,7 +76,7 @@ func (r *RootCmd) server() *clibase.Cmd { if err != nil { return nil, nil, xerrors.Errorf("decode external-token-encryption-key: %w", err) } - o.ExternalTokenEncryption, err = cryptorand.CipherAES256(key) + o.ExternalTokenEncryption, err = dbcrypt.CipherAES256(key) if err != nil { return nil, nil, xerrors.Errorf("create external-token-encryption-key cipher: %w", err) } From a4612c2936a11a07ef31faac60bdb7323a0dc4d9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 22 Aug 2023 15:01:51 +0100 Subject: [PATCH 09/52] fixup! move cipher to dbcrypt package --- enterprise/coderd/coderd.go | 5 ++--- enterprise/coderd/coderd_test.go | 26 ++++++++++++++------------ 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 006f292ea429e..4a02fbc253b7a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -383,11 +383,10 @@ type Options struct { EntitlementsUpdateInterval time.Duration ProxyHealthInterval time.Duration - Keys map[string]ed25519.PublicKey + LicenseKeys map[string]ed25519.PublicKey // optional pre-shared key for authentication of external provisioner daemons ProvisionerDaemonPSK string - LicenseKeys map[string]ed25519.PublicKey } type API struct { @@ -432,7 +431,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { entitlements, err := license.Entitlements( ctx, api.Database, - api.Logger, len(api.replicaManager.AllPrimary()), len(api.GitAuthConfigs), api.Keys, map[codersdk.FeatureName]bool{ + api.Logger, len(api.replicaManager.AllPrimary()), len(api.GitAuthConfigs), api.LicenseKeys, map[codersdk.FeatureName]bool{ codersdk.FeatureAuditLog: api.AuditLogging, codersdk.FeatureBrowserOnly: api.BrowserOnly, codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 123a22938fff2..13ec957cf6a0c 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -48,25 +48,27 @@ func TestEntitlements(t *testing.T) { AuditLogging: true, DontAddLicense: true, }) + // Enable all features + features := make(license.Features) + for _, feature := range codersdk.FeatureNames { + features[feature] = 1 + } + features[codersdk.FeatureUserLimit] = 100 coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureUserLimit: 100, - codersdk.FeatureAuditLog: 1, - codersdk.FeatureTemplateRBAC: 1, - codersdk.FeatureExternalProvisionerDaemons: 1, - codersdk.FeatureAdvancedTemplateScheduling: 1, - codersdk.FeatureWorkspaceProxy: 1, - codersdk.FeatureUserRoleManagement: 1, - }, - GraceAt: time.Now().Add(59 * 24 * time.Hour), + Features: features, + GraceAt: time.Now().Add(59 * 24 * time.Hour), }) res, err := client.Entitlements(context.Background()) require.NoError(t, err) assert.True(t, res.HasLicense) ul := res.Features[codersdk.FeatureUserLimit] assert.Equal(t, codersdk.EntitlementEntitled, ul.Entitlement) - assert.Equal(t, int64(100), *ul.Limit) - assert.Equal(t, int64(1), *ul.Actual) + if assert.NotNil(t, ul.Limit) { + assert.Equal(t, int64(100), *ul.Limit) + } + if assert.NotNil(t, ul.Actual) { + assert.Equal(t, int64(1), *ul.Actual) + } assert.True(t, ul.Enabled) al := res.Features[codersdk.FeatureAuditLog] assert.Equal(t, codersdk.EntitlementEntitled, al.Entitlement) From 8b1f835e73f4ac9ce3cd8e8187432ef564b07fd6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 22 Aug 2023 14:10:57 +0000 Subject: [PATCH 10/52] update golden files --- cli/testdata/coder_server_--help.golden | 4 ++++ enterprise/cli/testdata/coder_server_--help.golden | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 46fddeed2d6cc..e1f4ef2758046 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -451,6 +451,10 @@ 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-key string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEY + Encrypt OIDC and Git authentication tokens with AES-256-GCM in the + database. The value must be a base64-encoded key. + --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. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 46fddeed2d6cc..e1f4ef2758046 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -451,6 +451,10 @@ 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-key string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEY + Encrypt OIDC and Git authentication tokens with AES-256-GCM in the + database. The value must be a base64-encoded key. + --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. From 60d52f54580b92651004f9055ee0567bb791d0f8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 22 Aug 2023 14:13:52 +0000 Subject: [PATCH 11/52] enforce 32-byte key length --- cli/testdata/coder_server_--help.golden | 3 ++- coderd/database/dbcrypt/cipher.go | 3 +++ coderd/database/dbcrypt/cipher_test.go | 2 +- codersdk/deployment.go | 2 +- enterprise/cli/testdata/coder_server_--help.golden | 3 ++- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index e1f4ef2758046..f8dc814c6e1c8 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -453,7 +453,8 @@ These options are only available in the Enterprise Edition. --external-token-encryption-key string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEY Encrypt OIDC and Git authentication tokens with AES-256-GCM in the - database. The value must be a base64-encoded key. + database. The value must be a base64-encoded key exactly 32 bytes in + length. --scim-auth-header string, $CODER_SCIM_AUTH_HEADER Enables SCIM and sets the authentication header for the built-in SCIM diff --git a/coderd/database/dbcrypt/cipher.go b/coderd/database/dbcrypt/cipher.go index 15d657ab4d7b6..e760e186333d0 100644 --- a/coderd/database/dbcrypt/cipher.go +++ b/coderd/database/dbcrypt/cipher.go @@ -34,6 +34,9 @@ func IsDecryptFailedError(err error) bool { // CipherAES256 returns a new AES-256 cipher. func CipherAES256(key []byte) (Cipher, error) { + if len(key) != 32 { + return nil, xerrors.Errorf("key must be 32 bytes") + } block, err := aes.NewCipher(key) if err != nil { return nil, err diff --git a/coderd/database/dbcrypt/cipher_test.go b/coderd/database/dbcrypt/cipher_test.go index 73d8d503377d4..fc8fa90affac5 100644 --- a/coderd/database/dbcrypt/cipher_test.go +++ b/coderd/database/dbcrypt/cipher_test.go @@ -40,6 +40,6 @@ func TestCipherAES256(t *testing.T) { t.Parallel() _, err := dbcrypt.CipherAES256(bytes.Repeat([]byte{'a'}, 31)) - require.ErrorContains(t, err, "invalid key size") + require.ErrorContains(t, err, "key must be 32 bytes") }) } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index bb2ecac891e0c..67c6174e41cc9 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1580,7 +1580,7 @@ when required by your organization's security policy.`, }, { Name: "External Token Encryption Key", - Description: "Encrypt OIDC and Git authentication tokens with AES-256-GCM in the database. The value must be a base64-encoded key.", + Description: "Encrypt OIDC and Git authentication tokens with AES-256-GCM in the database. The value must be a base64-encoded key exactly 32 bytes in length.", Flag: "external-token-encryption-key", Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_KEY", Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"), diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index e1f4ef2758046..f8dc814c6e1c8 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -453,7 +453,8 @@ These options are only available in the Enterprise Edition. --external-token-encryption-key string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEY Encrypt OIDC and Git authentication tokens with AES-256-GCM in the - database. The value must be a base64-encoded key. + database. The value must be a base64-encoded key exactly 32 bytes in + length. --scim-auth-header string, $CODER_SCIM_AUTH_HEADER Enables SCIM and sets the authentication header for the built-in SCIM From dc69c4a2ccf132c589252fc2f8e790cec8ea1a8a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 22 Aug 2023 14:54:24 +0000 Subject: [PATCH 12/52] fix some failing tests - add external token encryption key to YAML excludes - ensure that secret external token encryption key is scrubbed from deployment values --- coderd/deployment_test.go | 2 ++ codersdk/deployment_test.go | 3 +++ 2 files changed, 5 insertions(+) diff --git a/coderd/deployment_test.go b/coderd/deployment_test.go index 617947e6eb607..eed72caeeca8a 100644 --- a/coderd/deployment_test.go +++ b/coderd/deployment_test.go @@ -26,6 +26,7 @@ func TestDeploymentValues(t *testing.T) { cfg.OIDC.EmailField.Set("some_random_field_you_never_expected") cfg.PostgresURL.Set(hi) cfg.SCIMAPIKey.Set(hi) + cfg.ExternalTokenEncryptionKey.Set("the_random_key_we_never_expected") // len:32 client := coderdtest.New(t, &coderdtest.Options{ DeploymentValues: cfg, @@ -44,6 +45,7 @@ func TestDeploymentValues(t *testing.T) { require.Empty(t, scrubbed.Values.OIDC.ClientSecret.Value()) require.Empty(t, scrubbed.Values.PostgresURL.Value()) require.Empty(t, scrubbed.Values.SCIMAPIKey.Value()) + require.Empty(t, scrubbed.Values.ExternalTokenEncryptionKey.Value()) } func TestDeploymentStats(t *testing.T) { diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 408aa4fd21ae5..91f35258e3e4c 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -57,6 +57,9 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) { "SCIM API Key": { yaml: true, }, + "External Token Encryption Key": { + yaml: true, + }, // These complex objects should be configured through YAML. "Support Links": { flag: true, From d9d050f034221b66fb8b77e62e26526330f810f0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 23 Aug 2023 08:56:44 +0000 Subject: [PATCH 13/52] make DecryptionFailedError unwrap to sql.ErrNoRows --- coderd/database/dbcrypt/cipher.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/coderd/database/dbcrypt/cipher.go b/coderd/database/dbcrypt/cipher.go index e760e186333d0..9b0379289fcc6 100644 --- a/coderd/database/dbcrypt/cipher.go +++ b/coderd/database/dbcrypt/cipher.go @@ -4,6 +4,7 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "database/sql" "errors" "io" @@ -15,6 +16,8 @@ type Cipher interface { Decrypt([]byte) ([]byte, error) } +// DecryptFailedError is returned when decryption fails. +// It unwraps to sql.ErrNoRows. type DecryptFailedError struct { Inner error } @@ -24,7 +27,7 @@ func (e *DecryptFailedError) Error() string { } func (e *DecryptFailedError) Unwrap() error { - return e.Inner + return sql.ErrNoRows } func IsDecryptFailedError(err error) bool { From 1cd48478be764c8f46c49826b1db0ed69f58c17f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 23 Aug 2023 08:57:18 +0000 Subject: [PATCH 14/52] modify dbCrypt to not delete rows silently --- coderd/database/dbcrypt/dbcrypt.go | 43 +++++++----------------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/coderd/database/dbcrypt/dbcrypt.go b/coderd/database/dbcrypt/dbcrypt.go index b9de4b8bca071..03277290df15b 100644 --- a/coderd/database/dbcrypt/dbcrypt.go +++ b/coderd/database/dbcrypt/dbcrypt.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "encoding/base64" - "runtime" "strings" "sync/atomic" @@ -56,9 +55,7 @@ func (db *dbCrypt) GetUserLinkByLinkedID(ctx context.Context, linkedID string) ( if err != nil { return database.UserLink{}, err } - return link, db.decryptFields(func() error { - return db.Store.DeleteUserLinkByLinkedID(ctx, linkedID) - }, &link.OAuthAccessToken, &link.OAuthRefreshToken) + return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) } func (db *dbCrypt) GetUserLinkByUserIDLoginType(ctx context.Context, params database.GetUserLinkByUserIDLoginTypeParams) (database.UserLink, error) { @@ -66,9 +63,7 @@ func (db *dbCrypt) GetUserLinkByUserIDLoginType(ctx context.Context, params data if err != nil { return database.UserLink{}, err } - return link, db.decryptFields(func() error { - return db.Store.DeleteUserLinkByLinkedID(ctx, link.LinkedID) - }, &link.OAuthAccessToken, &link.OAuthRefreshToken) + return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) } func (db *dbCrypt) InsertUserLink(ctx context.Context, params database.InsertUserLinkParams) (database.UserLink, error) { @@ -100,12 +95,7 @@ func (db *dbCrypt) GetGitAuthLink(ctx context.Context, params database.GetGitAut if err != nil { return database.GitAuthLink{}, err } - return link, db.decryptFields(func() error { - return db.Store.DeleteGitAuthLink(ctx, database.DeleteGitAuthLinkParams{ // nolint:gosimple - ProviderID: params.ProviderID, - UserID: params.UserID, - }) - }, &link.OAuthAccessToken, &link.OAuthRefreshToken) + return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) } func (db *dbCrypt) UpdateGitAuthLink(ctx context.Context, params database.UpdateGitAuthLinkParams) (database.GitAuthLink, error) { @@ -140,20 +130,7 @@ func (db *dbCrypt) encryptFields(fields ...*string) error { // decryptFields decrypts the given fields in place. // If the value fails to decrypt, sql.ErrNoRows will be returned. -func (db *dbCrypt) decryptFields(deleteFn func() error, fields ...*string) error { - doDelete := func(reason string) error { - err := deleteFn() - if err != nil { - return xerrors.Errorf("delete encrypted row: %w", err) - } - pc, _, _, ok := runtime.Caller(2) - details := runtime.FuncForPC(pc) - if ok && details != nil { - db.Logger.Debug(context.Background(), "deleted row", slog.F("reason", reason), slog.F("caller", details.Name())) - } - return sql.ErrNoRows - } - +func (db *dbCrypt) decryptFields(fields ...*string) error { cipherPtr := db.ExternalTokenCipher.Load() // If no cipher is loaded, then we don't need to encrypt or decrypt anything! if cipherPtr == nil { @@ -163,8 +140,8 @@ func (db *dbCrypt) decryptFields(deleteFn func() error, fields ...*string) error } if strings.HasPrefix(*field, MagicPrefix) { // If we have a magic prefix but encryption is disabled, - // we should delete the row. - return doDelete("encryption disabled") + // complain loudly. + return xerrors.Errorf("failed to decrypt field %q: encryption is disabled", *field) } } return nil @@ -182,13 +159,13 @@ func (db *dbCrypt) decryptFields(deleteFn func() error, fields ...*string) error } data, err := base64.StdEncoding.DecodeString((*field)[len(MagicPrefix):]) if err != nil { - // If it's not base64 with the prefix, we should delete the row. - return doDelete("stored value was not base64 encoded") + // If it's not base64 with the prefix, we should complain loudly. + return xerrors.Errorf("malformed encrypted field %q: %w", *field, err) } decrypted, err := cipher.Decrypt(data) if err != nil { - // If the encryption key changed, we should delete the row. - return doDelete("encryption key changed") + // If the encryption key changed, return our special error that unwraps to sql.ErrNoRows. + return &DecryptFailedError{Inner: err} } *field = string(decrypted) } From 832766cdcf2b6ffce6fb039158f36a29339ea615 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 23 Aug 2023 13:34:29 +0000 Subject: [PATCH 15/52] add dbcrypt_sentinel table to determine encryption status --- coderd/database/dbauthz/dbauthz.go | 14 ++++++ coderd/database/dbcrypt/dbcrypt.go | 30 +++++++++++ coderd/database/dbcrypt/dbcrypt_test.go | 50 +++++++++++++++++-- coderd/database/dbfake/dbfake.go | 18 +++++++ coderd/database/dbmetrics/dbmetrics.go | 14 ++++++ coderd/database/dbmock/dbmock.go | 29 +++++++++++ coderd/database/dump.sql | 14 ++++++ .../000151_dbcrypt_sentinel_value.down.sql | 1 + .../000151_dbcrypt_sentinel_value.up.sql | 8 +++ coderd/database/models.go | 8 +++ coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 20 ++++++++ coderd/database/queries/dbcrypt.sql | 5 ++ coderd/database/unique_constraint.go | 1 + 14 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 coderd/database/migrations/000151_dbcrypt_sentinel_value.down.sql create mode 100644 coderd/database/migrations/000151_dbcrypt_sentinel_value.up.sql create mode 100644 coderd/database/queries/dbcrypt.sql diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 2229b2e728aee..b8368901b3f39 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -841,6 +841,13 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI return q.db.GetAuthorizationUserRoles(ctx, userID) } +func (q *querier) GetDBCryptSentinelValue(ctx context.Context) (string, error) { + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { + return "", err + } + return q.db.GetDBCryptSentinelValue(ctx) +} + func (q *querier) GetDERPMeshKey(ctx context.Context) (string, error) { if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { return "", err @@ -2147,6 +2154,13 @@ func (q *querier) RegisterWorkspaceProxy(ctx context.Context, arg database.Regis return updateWithReturn(q.log, q.auth, fetch, q.db.RegisterWorkspaceProxy)(ctx, arg) } +func (q *querier) SetDBCryptSentinelValue(ctx context.Context, value string) error { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceSystem); err != nil { + return err + } + return q.db.SetDBCryptSentinelValue(ctx, value) +} + func (q *querier) TryAcquireLock(ctx context.Context, id int64) (bool, error) { return q.db.TryAcquireLock(ctx, id) } diff --git a/coderd/database/dbcrypt/dbcrypt.go b/coderd/database/dbcrypt/dbcrypt.go index 03277290df15b..609efeeb2e40f 100644 --- a/coderd/database/dbcrypt/dbcrypt.go +++ b/coderd/database/dbcrypt/dbcrypt.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/base64" + "errors" "strings" "sync/atomic" @@ -19,6 +20,13 @@ import ( // If it is encrypted but a key is not provided, an error is returned. const MagicPrefix = "dbcrypt-" +// sentinelValue is the value that is stored in the database to indicate +// whether encryption is enabled. If not enabled, the raw value is "coder". +// Otherwise, the value is encrypted. +const sentinelValue = "coder" + +var ErrNotEncrypted = xerrors.New("database is not encrypted") + type Options struct { // ExternalTokenCipher is an optional cipher that is used // to encrypt/decrypt user link and git auth link tokens. If this is nil, @@ -50,6 +58,20 @@ func (db *dbCrypt) InTx(function func(database.Store) error, txOpts *sql.TxOptio }, txOpts) } +func (db *dbCrypt) GetDBCryptSentinelValue(ctx context.Context) (string, error) { + rawValue, err := db.Store.GetDBCryptSentinelValue(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotEncrypted + } + return "", err + } + if rawValue == sentinelValue { + return "", ErrNotEncrypted + } + return rawValue, db.decryptFields(&rawValue) +} + func (db *dbCrypt) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (database.UserLink, error) { link, err := db.Store.GetUserLinkByLinkedID(ctx, linkedID) if err != nil { @@ -106,6 +128,14 @@ func (db *dbCrypt) UpdateGitAuthLink(ctx context.Context, params database.Update return db.Store.UpdateGitAuthLink(ctx, params) } +func (db *dbCrypt) SetDBCryptSentinelValue(ctx context.Context, value string) error { + err := db.encryptFields(&value) + if err != nil { + return err + } + return db.Store.SetDBCryptSentinelValue(ctx, value) +} + func (db *dbCrypt) encryptFields(fields ...*string) error { cipherPtr := db.ExternalTokenCipher.Load() // If no cipher is loaded, then we don't need to encrypt or decrypt anything! diff --git a/coderd/database/dbcrypt/dbcrypt_test.go b/coderd/database/dbcrypt/dbcrypt_test.go index 94c24a82e8d26..c2174a6894008 100644 --- a/coderd/database/dbcrypt/dbcrypt_test.go +++ b/coderd/database/dbcrypt/dbcrypt_test.go @@ -16,8 +16,8 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbcrypt" - "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" ) func TestUserLinks(t *testing.T) { @@ -28,7 +28,9 @@ func TestUserLinks(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) initCipher(t, cipher) + user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ + UserID: user.ID, OAuthAccessToken: "access", OAuthRefreshToken: "refresh", }) @@ -42,7 +44,10 @@ func TestUserLinks(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) initCipher(t, cipher) - link := dbgen.UserLink(t, crypt, database.UserLink{}) + user := dbgen.User(t, crypt, database.User{}) + link := dbgen.UserLink(t, crypt, database.UserLink{ + UserID: user.ID, + }) _, err := crypt.UpdateUserLink(ctx, database.UpdateUserLinkParams{ OAuthAccessToken: "access", OAuthRefreshToken: "refresh", @@ -60,7 +65,9 @@ func TestUserLinks(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) initCipher(t, cipher) + user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ + UserID: user.ID, OAuthAccessToken: "access", OAuthRefreshToken: "refresh", }) @@ -80,7 +87,9 @@ func TestUserLinks(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) initCipher(t, cipher) + user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ + UserID: user.ID, OAuthAccessToken: "access", OAuthRefreshToken: "refresh", }) @@ -172,9 +181,44 @@ func TestGitAuthLinks(t *testing.T) { }) } +func TestDBCryptSentinelValue(t *testing.T) { + t.Parallel() + ctx := context.Background() + db, crypt, cipher := setup(t) + // Initially, the database will not be encrypted. + _, err := db.GetDBCryptSentinelValue(ctx) + require.ErrorIs(t, err, sql.ErrNoRows) + _, err = crypt.GetDBCryptSentinelValue(ctx) + require.EqualError(t, err, dbcrypt.ErrNotEncrypted.Error()) + + // Now, we'll encrypt the value. + initCipher(t, cipher) + err = crypt.SetDBCryptSentinelValue(ctx, "coder") + require.NoError(t, err) + + // The value should be encrypted in the database. + crypted, err := db.GetDBCryptSentinelValue(ctx) + require.NoError(t, err) + require.NotEqual(t, "coder", crypted) + decrypted, err := crypt.GetDBCryptSentinelValue(ctx) + require.NoError(t, err) + require.Equal(t, "coder", decrypted) + requireEncryptedEquals(t, cipher, crypted, "coder") + + // Reset the key and empty values should be returned! + initCipher(t, cipher) + + _, err = db.GetDBCryptSentinelValue(ctx) // We can still read the raw value + require.NoError(t, err) + _, err = crypt.GetDBCryptSentinelValue(ctx) // Decryption should fail + require.ErrorIs(t, err, sql.ErrNoRows) +} + func requireEncryptedEquals(t *testing.T, cipher *atomic.Pointer[dbcrypt.Cipher], value, expected string) { t.Helper() c := (*cipher.Load()) + require.NotNil(t, c) + require.Greater(t, len(value), len(dbcrypt.MagicPrefix), "value is not encrypted") data, err := base64.StdEncoding.DecodeString(value[len(dbcrypt.MagicPrefix):]) require.NoError(t, err) got, err := c.Decrypt(data) @@ -194,7 +238,7 @@ func initCipher(t *testing.T, cipher *atomic.Pointer[dbcrypt.Cipher]) { func setup(t *testing.T) (db, cryptodb database.Store, cipher *atomic.Pointer[dbcrypt.Cipher]) { t.Helper() - rawDB := dbfake.New() + rawDB, _ := dbtestutil.NewDB(t) cipher = &atomic.Pointer[dbcrypt.Cipher]{} return rawDB, dbcrypt.New(rawDB, &dbcrypt.Options{ ExternalTokenCipher: cipher, diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 48e341cf97d91..4c03b51db3c71 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -44,6 +44,7 @@ func New() database.Store { organizationMembers: make([]database.OrganizationMember, 0), organizations: make([]database.Organization, 0), users: make([]database.User, 0), + dbcryptSentinelValue: nil, gitAuthLinks: make([]database.GitAuthLink, 0), groups: make([]database.Group, 0), groupMembers: make([]database.GroupMember, 0), @@ -116,6 +117,7 @@ type data struct { // New tables workspaceAgentStats []database.WorkspaceAgentStat auditLogs []database.AuditLog + dbcryptSentinelValue *string files []database.File gitAuthLinks []database.GitAuthLink gitSSHKey []database.GitSSHKey @@ -1183,6 +1185,15 @@ func (q *FakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U }, nil } +func (q *FakeQuerier) GetDBCryptSentinelValue(_ context.Context) (string, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + if q.dbcryptSentinelValue == nil { + return "", sql.ErrNoRows + } + return *q.dbcryptSentinelValue, nil +} + func (q *FakeQuerier) GetDERPMeshKey(_ context.Context) (string, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -4822,6 +4833,13 @@ func (q *FakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.Reg return database.WorkspaceProxy{}, sql.ErrNoRows } +func (q *FakeQuerier) SetDBCryptSentinelValue(_ context.Context, value string) error { + q.mutex.Lock() + defer q.mutex.Unlock() + q.dbcryptSentinelValue = &value + return nil +} + func (*FakeQuerier) TryAcquireLock(_ context.Context, _ int64) (bool, error) { return false, xerrors.New("TryAcquireLock must only be called within a transaction") } diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 352cf01cbbf44..efaeaf7413b11 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -293,6 +293,13 @@ func (m metricsStore) GetAuthorizationUserRoles(ctx context.Context, userID uuid return row, err } +func (m metricsStore) GetDBCryptSentinelValue(ctx context.Context) (string, error) { + start := time.Now() + r0, r1 := m.s.GetDBCryptSentinelValue(ctx) + m.queryLatencies.WithLabelValues("GetDBCryptSentinelValue").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetDERPMeshKey(ctx context.Context) (string, error) { start := time.Now() key, err := m.s.GetDERPMeshKey(ctx) @@ -1334,6 +1341,13 @@ func (m metricsStore) RegisterWorkspaceProxy(ctx context.Context, arg database.R return proxy, err } +func (m metricsStore) SetDBCryptSentinelValue(ctx context.Context, value string) error { + start := time.Now() + r0 := m.s.SetDBCryptSentinelValue(ctx, value) + m.queryLatencies.WithLabelValues("SetDBCryptSentinelValue").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) { start := time.Now() ok, err := m.s.TryAcquireLock(ctx, pgTryAdvisoryXactLock) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 330cefdebf820..43c7a53760863 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -534,6 +534,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspaces(arg0, arg1, arg2 interf return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspaces", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspaces), arg0, arg1, arg2) } +// GetDBCryptSentinelValue mocks base method. +func (m *MockStore) GetDBCryptSentinelValue(arg0 context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDBCryptSentinelValue", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDBCryptSentinelValue indicates an expected call of GetDBCryptSentinelValue. +func (mr *MockStoreMockRecorder) GetDBCryptSentinelValue(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDBCryptSentinelValue", reflect.TypeOf((*MockStore)(nil).GetDBCryptSentinelValue), arg0) +} + // GetDERPMeshKey mocks base method. func (m *MockStore) GetDERPMeshKey(arg0 context.Context) (string, error) { m.ctrl.T.Helper() @@ -2817,6 +2832,20 @@ func (mr *MockStoreMockRecorder) RegisterWorkspaceProxy(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterWorkspaceProxy", reflect.TypeOf((*MockStore)(nil).RegisterWorkspaceProxy), arg0, arg1) } +// SetDBCryptSentinelValue mocks base method. +func (m *MockStore) SetDBCryptSentinelValue(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetDBCryptSentinelValue", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetDBCryptSentinelValue indicates an expected call of SetDBCryptSentinelValue. +func (mr *MockStoreMockRecorder) SetDBCryptSentinelValue(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDBCryptSentinelValue", reflect.TypeOf((*MockStore)(nil).SetDBCryptSentinelValue), arg0, arg1) +} + // TryAcquireLock mocks base method. func (m *MockStore) TryAcquireLock(arg0 context.Context, arg1 int64) (bool, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 71106e5da771d..1e453857877fb 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -267,6 +267,17 @@ CREATE TABLE audit_logs ( resource_icon text NOT NULL ); +CREATE TABLE dbcrypt_sentinel ( + only_one integer GENERATED ALWAYS AS (1) STORED, + val text DEFAULT ''::text NOT NULL +); + +COMMENT ON TABLE dbcrypt_sentinel IS 'A table used to determine if the database is encrypted'; + +COMMENT ON COLUMN dbcrypt_sentinel.only_one IS 'Ensures that only one row exists in the table.'; + +COMMENT ON COLUMN dbcrypt_sentinel.val IS 'Used to determine if the database is encrypted.'; + CREATE TABLE files ( hash character varying(64) NOT NULL, created_at timestamp with time zone NOT NULL, @@ -1028,6 +1039,9 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY dbcrypt_sentinel + ADD CONSTRAINT dbcrypt_sentinel_only_one_key UNIQUE (only_one); + ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); diff --git a/coderd/database/migrations/000151_dbcrypt_sentinel_value.down.sql b/coderd/database/migrations/000151_dbcrypt_sentinel_value.down.sql new file mode 100644 index 0000000000000..615b2c087227b --- /dev/null +++ b/coderd/database/migrations/000151_dbcrypt_sentinel_value.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS dbcrypt_sentinel; diff --git a/coderd/database/migrations/000151_dbcrypt_sentinel_value.up.sql b/coderd/database/migrations/000151_dbcrypt_sentinel_value.up.sql new file mode 100644 index 0000000000000..8c46a02ee1301 --- /dev/null +++ b/coderd/database/migrations/000151_dbcrypt_sentinel_value.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS dbcrypt_sentinel ( + only_one integer GENERATED ALWAYS AS (1) STORED UNIQUE, + val text NOT NULL DEFAULT ''::text +); + +COMMENT ON TABLE dbcrypt_sentinel IS 'A table used to determine if the database is encrypted'; +COMMENT ON COLUMN dbcrypt_sentinel.only_one IS 'Ensures that only one row exists in the table.'; +COMMENT ON COLUMN dbcrypt_sentinel.val IS 'Used to determine if the database is encrypted.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index e795049c16413..1723c74f4c2ea 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1524,6 +1524,14 @@ type AuditLog struct { ResourceIcon string `db:"resource_icon" json:"resource_icon"` } +// A table used to determine if the database is encrypted +type DbcryptSentinel struct { + // Ensures that only one row exists in the table. + OnlyOne sql.NullInt32 `db:"only_one" json:"only_one"` + // Used to determine if the database is encrypted. + Val string `db:"val" json:"val"` +} + type File struct { Hash string `db:"hash" json:"hash"` CreatedAt time.Time `db:"created_at" json:"created_at"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 38fc3e0453d5d..e565b92605040 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -60,6 +60,7 @@ type sqlcQuerier interface { // This function returns roles for authorization purposes. Implied member roles // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) + GetDBCryptSentinelValue(ctx context.Context) (string, error) GetDERPMeshKey(ctx context.Context) (string, error) GetDefaultProxyConfig(ctx context.Context) (GetDefaultProxyConfigRow, error) GetDeploymentDAUs(ctx context.Context, tzOffset int32) ([]GetDeploymentDAUsRow, error) @@ -249,6 +250,7 @@ type sqlcQuerier interface { InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error) RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) + SetDBCryptSentinelValue(ctx context.Context, val string) error // Non blocking lock. Returns true if the lock was acquired, false otherwise. // // This must be called from within a transaction. The lock will be automatically diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5f2ed9acf4a94..452d3073534c7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -636,6 +636,26 @@ func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParam return i, err } +const getDBCryptSentinelValue = `-- name: GetDBCryptSentinelValue :one +SELECT val FROM dbcrypt_sentinel LIMIT 1 +` + +func (q *sqlQuerier) GetDBCryptSentinelValue(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getDBCryptSentinelValue) + var val string + err := row.Scan(&val) + return val, err +} + +const setDBCryptSentinelValue = `-- name: SetDBCryptSentinelValue :exec +INSERT INTO dbcrypt_sentinel (val) VALUES ($1) ON CONFLICT (only_one) DO UPDATE SET val = excluded.val +` + +func (q *sqlQuerier) SetDBCryptSentinelValue(ctx context.Context, val string) error { + _, err := q.db.ExecContext(ctx, setDBCryptSentinelValue, val) + return err +} + const getFileByHashAndCreator = `-- name: GetFileByHashAndCreator :one SELECT hash, created_at, created_by, mimetype, data, id diff --git a/coderd/database/queries/dbcrypt.sql b/coderd/database/queries/dbcrypt.sql new file mode 100644 index 0000000000000..780a4bc0952c4 --- /dev/null +++ b/coderd/database/queries/dbcrypt.sql @@ -0,0 +1,5 @@ +-- name: GetDBCryptSentinelValue :one +SELECT val FROM dbcrypt_sentinel LIMIT 1; + +-- name: SetDBCryptSentinelValue :exec +INSERT INTO dbcrypt_sentinel (val) VALUES ($1) ON CONFLICT (only_one) DO UPDATE SET val = excluded.val; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 294b4b12d51af..ba238421fcd93 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -6,6 +6,7 @@ type UniqueConstraint string // UniqueConstraint enums. const ( + UniqueDbcryptSentinelOnlyOneKey UniqueConstraint = "dbcrypt_sentinel_only_one_key" // ALTER TABLE ONLY dbcrypt_sentinel ADD CONSTRAINT dbcrypt_sentinel_only_one_key UNIQUE (only_one); UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY git_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); From 05c0cf98ccfbf569187c7e1f1cb10d7c90fd4635 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 23 Aug 2023 13:35:10 +0000 Subject: [PATCH 16/52] fix unused-receiver --- coderd/database/dbcrypt/cipher.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/dbcrypt/cipher.go b/coderd/database/dbcrypt/cipher.go index 9b0379289fcc6..3608328f2e299 100644 --- a/coderd/database/dbcrypt/cipher.go +++ b/coderd/database/dbcrypt/cipher.go @@ -26,7 +26,7 @@ func (e *DecryptFailedError) Error() string { return xerrors.Errorf("decrypt failed: %w", e.Inner).Error() } -func (e *DecryptFailedError) Unwrap() error { +func (*DecryptFailedError) Unwrap() error { return sql.ErrNoRows } From 655626904a8909a0cb540960b2453c8d6c65af23 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 23 Aug 2023 14:43:30 +0000 Subject: [PATCH 17/52] move dbcrypt to enterprise --- enterprise/cli/server.go | 2 +- enterprise/coderd/coderd.go | 2 +- {coderd/database => enterprise}/dbcrypt/cipher.go | 0 {coderd/database => enterprise}/dbcrypt/cipher_test.go | 2 +- {coderd/database => enterprise}/dbcrypt/dbcrypt.go | 0 {coderd/database => enterprise}/dbcrypt/dbcrypt_test.go | 2 +- 6 files changed, 4 insertions(+), 4 deletions(-) rename {coderd/database => enterprise}/dbcrypt/cipher.go (100%) rename {coderd/database => enterprise}/dbcrypt/cipher_test.go (95%) rename {coderd/database => enterprise}/dbcrypt/dbcrypt.go (100%) rename {coderd/database => enterprise}/dbcrypt/dbcrypt_test.go (99%) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 10064f965fc34..0ed963e540852 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -15,11 +15,11 @@ import ( "tailscale.com/types/key" "github.com/coder/coder/v2/cli/clibase" - "github.com/coder/coder/v2/coderd/database/dbcrypt" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/audit" "github.com/coder/coder/v2/enterprise/audit/backends" "github.com/coder/coder/v2/enterprise/coderd" + "github.com/coder/coder/v2/enterprise/dbcrypt" "github.com/coder/coder/v2/enterprise/trialer" "github.com/coder/coder/v2/tailnet" diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index bf0aba7eb1a3c..7e4b49d4ae103 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -26,7 +26,6 @@ import ( "github.com/coder/coder/v2/coderd" agplaudit "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database/dbauthz" - "github.com/coder/coder/v2/coderd/database/dbcrypt" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" @@ -35,6 +34,7 @@ import ( "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/enterprise/coderd/proxyhealth" "github.com/coder/coder/v2/enterprise/coderd/schedule" + "github.com/coder/coder/v2/enterprise/dbcrypt" "github.com/coder/coder/v2/enterprise/derpmesh" "github.com/coder/coder/v2/enterprise/replicasync" "github.com/coder/coder/v2/enterprise/tailnet" diff --git a/coderd/database/dbcrypt/cipher.go b/enterprise/dbcrypt/cipher.go similarity index 100% rename from coderd/database/dbcrypt/cipher.go rename to enterprise/dbcrypt/cipher.go diff --git a/coderd/database/dbcrypt/cipher_test.go b/enterprise/dbcrypt/cipher_test.go similarity index 95% rename from coderd/database/dbcrypt/cipher_test.go rename to enterprise/dbcrypt/cipher_test.go index fc8fa90affac5..e412e39828497 100644 --- a/coderd/database/dbcrypt/cipher_test.go +++ b/enterprise/dbcrypt/cipher_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/v2/coderd/database/dbcrypt" + "github.com/coder/coder/v2/enterprise/dbcrypt" ) func TestCipherAES256(t *testing.T) { diff --git a/coderd/database/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go similarity index 100% rename from coderd/database/dbcrypt/dbcrypt.go rename to enterprise/dbcrypt/dbcrypt.go diff --git a/coderd/database/dbcrypt/dbcrypt_test.go b/enterprise/dbcrypt/dbcrypt_test.go similarity index 99% rename from coderd/database/dbcrypt/dbcrypt_test.go rename to enterprise/dbcrypt/dbcrypt_test.go index c2174a6894008..13c4118cd6636 100644 --- a/coderd/database/dbcrypt/dbcrypt_test.go +++ b/enterprise/dbcrypt/dbcrypt_test.go @@ -15,9 +15,9 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbcrypt" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/enterprise/dbcrypt" ) func TestUserLinks(t *testing.T) { From cbd776fb4c3ce820f22c2e3dad09091444897903 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 24 Aug 2023 10:36:58 +0000 Subject: [PATCH 18/52] dbcrypt.New now marks database as encrypted --- enterprise/coderd/coderd.go | 7 +- enterprise/dbcrypt/dbcrypt.go | 47 +++++++--- enterprise/dbcrypt/dbcrypt_test.go | 134 +++++++++++++++++++++-------- 3 files changed, 141 insertions(+), 47 deletions(-) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 7e4b49d4ae103..2dd2ba6f21de3 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -65,9 +65,14 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ctx, cancelFunc := context.WithCancel(ctx) externalTokenCipher := &atomic.Pointer[dbcrypt.Cipher]{} - options.Database = dbcrypt.New(options.Database, &dbcrypt.Options{ + cryptDB, err := dbcrypt.New(ctx, options.Database, &dbcrypt.Options{ ExternalTokenCipher: externalTokenCipher, }) + if err != nil { + cancelFunc() + return nil, xerrors.Errorf("init dbcrypt: %w", err) + } + options.Database = cryptDB api := &API{ ctx: ctx, diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index 609efeeb2e40f..3652797dbbbb5 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -13,6 +13,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" ) // MagicPrefix is prepended to all encrypted values in the database. @@ -25,7 +26,7 @@ const MagicPrefix = "dbcrypt-" // Otherwise, the value is encrypted. const sentinelValue = "coder" -var ErrNotEncrypted = xerrors.New("database is not encrypted") +var ErrNotEnabled = xerrors.New("encryption is not enabled") type Options struct { // ExternalTokenCipher is an optional cipher that is used @@ -37,11 +38,15 @@ type Options struct { // New creates a database.Store wrapper that encrypts/decrypts values // stored at rest in the database. -func New(db database.Store, options *Options) database.Store { - return &dbCrypt{ +func New(ctx context.Context, db database.Store, options *Options) (database.Store, error) { + dbc := &dbCrypt{ Options: options, Store: db, } + if err := ensureEncrypted(dbauthz.AsSystemRestricted(ctx), dbc); err != nil { + return nil, xerrors.Errorf("ensure encrypted database fields: %w", err) + } + return dbc, nil } type dbCrypt struct { @@ -61,14 +66,8 @@ func (db *dbCrypt) InTx(function func(database.Store) error, txOpts *sql.TxOptio func (db *dbCrypt) GetDBCryptSentinelValue(ctx context.Context) (string, error) { rawValue, err := db.Store.GetDBCryptSentinelValue(ctx) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return "", ErrNotEncrypted - } return "", err } - if rawValue == sentinelValue { - return "", ErrNotEncrypted - } return rawValue, db.decryptFields(&rawValue) } @@ -171,7 +170,7 @@ func (db *dbCrypt) decryptFields(fields ...*string) error { if strings.HasPrefix(*field, MagicPrefix) { // If we have a magic prefix but encryption is disabled, // complain loudly. - return xerrors.Errorf("failed to decrypt field %q: encryption is disabled", *field) + return xerrors.Errorf("failed to decrypt field %q: %w", *field, ErrNotEnabled) } } return nil @@ -183,7 +182,7 @@ func (db *dbCrypt) decryptFields(fields ...*string) error { continue } if len(*field) < len(MagicPrefix) || !strings.HasPrefix(*field, MagicPrefix) { - // We do not force encryption of unencrypted rows. This could be damaging + // We do not force decryption of unencrypted rows. This could be damaging // to the deployment, and admins can always manually purge data. continue } @@ -201,3 +200,29 @@ func (db *dbCrypt) decryptFields(fields ...*string) error { } return nil } + +func ensureEncrypted(ctx context.Context, dbc *dbCrypt) error { + return dbc.InTx(func(s database.Store) error { + val, err := s.GetDBCryptSentinelValue(ctx) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return err + } + } + + if val != "" && val != sentinelValue { + // TODO: Handle key rotation. + return xerrors.Errorf("database is already encrypted with a different key and key rotation is not implemented yet") + } + + if val == sentinelValue { + return nil // nothing to do! + } + + if err := s.SetDBCryptSentinelValue(ctx, sentinelValue); err != nil { + return xerrors.Errorf("mark database as encrypted: %w", err) + } + + return nil + }, nil) +} diff --git a/enterprise/dbcrypt/dbcrypt_test.go b/enterprise/dbcrypt/dbcrypt_test.go index 13c4118cd6636..5c2c2b340679c 100644 --- a/enterprise/dbcrypt/dbcrypt_test.go +++ b/enterprise/dbcrypt/dbcrypt_test.go @@ -27,7 +27,6 @@ func TestUserLinks(t *testing.T) { t.Run("InsertUserLink", func(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) - initCipher(t, cipher) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -43,7 +42,6 @@ func TestUserLinks(t *testing.T) { t.Run("UpdateUserLink", func(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) - initCipher(t, cipher) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -64,7 +62,6 @@ func TestUserLinks(t *testing.T) { t.Run("GetUserLinkByLinkedID", func(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) - initCipher(t, cipher) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -86,7 +83,6 @@ func TestUserLinks(t *testing.T) { t.Run("GetUserLinkByUserIDLoginType", func(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) - initCipher(t, cipher) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -119,7 +115,6 @@ func TestGitAuthLinks(t *testing.T) { t.Run("InsertGitAuthLink", func(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) - initCipher(t, cipher) link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{ OAuthAccessToken: "access", OAuthRefreshToken: "refresh", @@ -136,7 +131,6 @@ func TestGitAuthLinks(t *testing.T) { t.Run("UpdateGitAuthLink", func(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) - initCipher(t, cipher) link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{}) _, err := crypt.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ ProviderID: link.ProviderID, @@ -157,7 +151,6 @@ func TestGitAuthLinks(t *testing.T) { t.Run("GetGitAuthLink", func(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) - initCipher(t, cipher) link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{ OAuthAccessToken: "access", OAuthRefreshToken: "refresh", @@ -181,37 +174,90 @@ func TestGitAuthLinks(t *testing.T) { }) } -func TestDBCryptSentinelValue(t *testing.T) { +func TestNew(t *testing.T) { t.Parallel() - ctx := context.Background() - db, crypt, cipher := setup(t) - // Initially, the database will not be encrypted. - _, err := db.GetDBCryptSentinelValue(ctx) - require.ErrorIs(t, err, sql.ErrNoRows) - _, err = crypt.GetDBCryptSentinelValue(ctx) - require.EqualError(t, err, dbcrypt.ErrNotEncrypted.Error()) - // Now, we'll encrypt the value. - initCipher(t, cipher) - err = crypt.SetDBCryptSentinelValue(ctx, "coder") - require.NoError(t, err) + t.Run("OK", func(t *testing.T) { + // Given: a cipher is loaded + cipher := &atomic.Pointer[dbcrypt.Cipher]{} + initCipher(t, cipher) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + rawDB, _ := dbtestutil.NewDB(t) - // The value should be encrypted in the database. - crypted, err := db.GetDBCryptSentinelValue(ctx) - require.NoError(t, err) - require.NotEqual(t, "coder", crypted) - decrypted, err := crypt.GetDBCryptSentinelValue(ctx) - require.NoError(t, err) - require.Equal(t, "coder", decrypted) - requireEncryptedEquals(t, cipher, crypted, "coder") + // When: we init the crypt db + cryptDB, err := dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ + ExternalTokenCipher: cipher, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) + require.NoError(t, err) - // Reset the key and empty values should be returned! - initCipher(t, cipher) + // Then: the sentinel value is encrypted + cryptVal, err := cryptDB.GetDBCryptSentinelValue(ctx) + require.NoError(t, err) + require.Equal(t, "coder", cryptVal) - _, err = db.GetDBCryptSentinelValue(ctx) // We can still read the raw value - require.NoError(t, err) - _, err = crypt.GetDBCryptSentinelValue(ctx) // Decryption should fail - require.ErrorIs(t, err, sql.ErrNoRows) + rawVal, err := rawDB.GetDBCryptSentinelValue(ctx) + require.NoError(t, err) + require.Contains(t, rawVal, dbcrypt.MagicPrefix) + }) + + t.Run("NoCipher", func(t *testing.T) { + // Given: no cipher is loaded + cipher := &atomic.Pointer[dbcrypt.Cipher]{} + // initCipher(t, cipher) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + rawDB, _ := dbtestutil.NewDB(t) + + // When: we init the crypt db + cryptDB, err := dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ + ExternalTokenCipher: cipher, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) + require.NoError(t, err) + + // Then: the sentinel value is not encrypted + cryptVal, err := cryptDB.GetDBCryptSentinelValue(ctx) + require.NoError(t, err) + require.Equal(t, "coder", cryptVal) + + rawVal, err := rawDB.GetDBCryptSentinelValue(ctx) + require.NoError(t, err) + require.Equal(t, "coder", rawVal) + }) + + t.Run("CipherChanged", func(t *testing.T) { + // Given: no cipher is loaded + cipher := &atomic.Pointer[dbcrypt.Cipher]{} + initCipher(t, cipher) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + rawDB, _ := dbtestutil.NewDB(t) + + // And: the sentinel value is encrypted with a different cipher + cipher2 := &atomic.Pointer[dbcrypt.Cipher]{} + initCipher(t, cipher2) + field := "coder" + encrypted, err := (*cipher2.Load()).Encrypt([]byte(field)) + require.NoError(t, err) + b64encrypted := base64.StdEncoding.EncodeToString(encrypted) + require.NoError(t, rawDB.SetDBCryptSentinelValue(ctx, b64encrypted)) + + // When: we init the crypt db + _, err = dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ + ExternalTokenCipher: cipher, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) + // Then: an error is returned + // TODO: when we implement key rotation, this should not fail. + require.ErrorContains(t, err, "database is already encrypted with a different key") + + // And the sentinel value should remain unchanged. For now. + rawVal, err := rawDB.GetDBCryptSentinelValue(ctx) + require.NoError(t, err) + require.Equal(t, b64encrypted, rawVal) + }) } func requireEncryptedEquals(t *testing.T, cipher *atomic.Pointer[dbcrypt.Cipher], value, expected string) { @@ -238,10 +284,28 @@ func initCipher(t *testing.T, cipher *atomic.Pointer[dbcrypt.Cipher]) { func setup(t *testing.T) (db, cryptodb database.Store, cipher *atomic.Pointer[dbcrypt.Cipher]) { t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) rawDB, _ := dbtestutil.NewDB(t) + + _, err := rawDB.GetDBCryptSentinelValue(ctx) + require.ErrorIs(t, err, sql.ErrNoRows) + cipher = &atomic.Pointer[dbcrypt.Cipher]{} - return rawDB, dbcrypt.New(rawDB, &dbcrypt.Options{ + initCipher(t, cipher) + cryptDB, err := dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ ExternalTokenCipher: cipher, Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - }), cipher + }) + require.NoError(t, err) + + rawVal, err := rawDB.GetDBCryptSentinelValue(ctx) + require.NoError(t, err) + require.Contains(t, rawVal, dbcrypt.MagicPrefix) + + cryptVal, err := cryptDB.GetDBCryptSentinelValue(ctx) + require.NoError(t, err) + require.Equal(t, "coder", cryptVal) + + return rawDB, cryptDB, cipher } From 22e7aebbe787c8a5b36a37264ae1840c255de93e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 24 Aug 2023 20:58:45 +0000 Subject: [PATCH 19/52] add hex digest of cipher to encrypted fields --- enterprise/dbcrypt/cipher.go | 34 +++++-------- enterprise/dbcrypt/dbcrypt.go | 79 ++++++++++++++++++++++++++---- enterprise/dbcrypt/dbcrypt_test.go | 6 ++- 3 files changed, 85 insertions(+), 34 deletions(-) diff --git a/enterprise/dbcrypt/cipher.go b/enterprise/dbcrypt/cipher.go index 3608328f2e299..de8630d14c96b 100644 --- a/enterprise/dbcrypt/cipher.go +++ b/enterprise/dbcrypt/cipher.go @@ -4,8 +4,8 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" - "database/sql" - "errors" + "crypto/sha256" + "fmt" "io" "golang.org/x/xerrors" @@ -14,25 +14,7 @@ import ( type Cipher interface { Encrypt([]byte) ([]byte, error) Decrypt([]byte) ([]byte, error) -} - -// DecryptFailedError is returned when decryption fails. -// It unwraps to sql.ErrNoRows. -type DecryptFailedError struct { - Inner error -} - -func (e *DecryptFailedError) Error() string { - return xerrors.Errorf("decrypt failed: %w", e.Inner).Error() -} - -func (*DecryptFailedError) Unwrap() error { - return sql.ErrNoRows -} - -func IsDecryptFailedError(err error) bool { - var e *DecryptFailedError - return errors.As(err, &e) + HexDigest() string } // CipherAES256 returns a new AES-256 cipher. @@ -48,11 +30,13 @@ func CipherAES256(key []byte) (Cipher, error) { if err != nil { return nil, err } - return &aes256{aead}, nil + digest := sha256.Sum256(key) + return &aes256{aead: aead, digest: digest[:]}, nil } type aes256 struct { - aead cipher.AEAD + aead cipher.AEAD + digest []byte } func (a *aes256) Encrypt(plaintext []byte) ([]byte, error) { @@ -74,3 +58,7 @@ func (a *aes256) Decrypt(ciphertext []byte) ([]byte, error) { } return decrypted, nil } + +func (a *aes256) HexDigest() string { + return fmt.Sprintf("%x", a.digest) +} diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index 3652797dbbbb5..2c18fc87e33c3 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -1,3 +1,21 @@ +// Package dbcrypt provides a database.Store wrapper that encrypts/decrypts +// values stored at rest in the database. +// +// Encryption is done using a Cipher. The Cipher is stored in an atomic pointer +// so that it can be rotated as required. +// +// The Cipher is currently used to encrypt/decrypt the following fields: +// - database.UserLink.OAuthAccessToken +// - database.UserLink.OAuthRefreshToken +// - database.GitAuthLink.OAuthAccessToken +// - database.GitAuthLink.OAuthRefreshToken +// - database.DBCryptSentinelValue +// +// Encrypted fields are stored in the following format: +// "dbcrypt--" +// +// The first 7 characters of the cipher's SHA256 digest are used to identify the cipher +// used to encrypt the value. package dbcrypt import ( @@ -19,14 +37,46 @@ import ( // MagicPrefix is prepended to all encrypted values in the database. // This is used to determine if a value is encrypted or not. // If it is encrypted but a key is not provided, an error is returned. +// MagicPrefix will be followed by the first 7 characters of the cipher's +// SHA256 digest, followed by a dash, followed by the base64-encoded +// encrypted value. const MagicPrefix = "dbcrypt-" +// MagicPrefixLength is the length of the entire prefix used to identify +// encrypted values. +const MagicPrefixLength = len(MagicPrefix) + 8 + // sentinelValue is the value that is stored in the database to indicate -// whether encryption is enabled. If not enabled, the raw value is "coder". -// Otherwise, the value is encrypted. +// whether encryption is enabled. If not enabled, the value either not +// present, or is the raw string "coder". +// Otherwise, the value must be the encrypted value of the string "coder" +// using the current cipher. const sentinelValue = "coder" -var ErrNotEnabled = xerrors.New("encryption is not enabled") +var ( + ErrNotEnabled = xerrors.New("encryption is not enabled") + b64encode = base64.StdEncoding.EncodeToString + b64decode = base64.StdEncoding.DecodeString +) + +// DecryptFailedError is returned when decryption fails. +// It unwraps to sql.ErrNoRows. +type DecryptFailedError struct { + Inner error +} + +func (e *DecryptFailedError) Error() string { + return xerrors.Errorf("decrypt failed: %w", e.Inner).Error() +} + +func (*DecryptFailedError) Unwrap() error { + return sql.ErrNoRows +} + +func IsDecryptFailedError(err error) bool { + var e *DecryptFailedError + return errors.As(err, &e) +} type Options struct { // ExternalTokenCipher is an optional cipher that is used @@ -152,7 +202,7 @@ func (db *dbCrypt) encryptFields(fields ...*string) error { return err } // Base64 is used to support UTF-8 encoding in PostgreSQL. - *field = MagicPrefix + base64.StdEncoding.EncodeToString(encrypted) + *field = MagicPrefix + cipher.HexDigest()[:7] + "-" + b64encode(encrypted) } return nil } @@ -181,15 +231,27 @@ func (db *dbCrypt) decryptFields(fields ...*string) error { if field == nil { continue } - if len(*field) < len(MagicPrefix) || !strings.HasPrefix(*field, MagicPrefix) { + + if len(*field) < 16 || !strings.HasPrefix(*field, MagicPrefix) { // We do not force decryption of unencrypted rows. This could be damaging // to the deployment, and admins can always manually purge data. continue } - data, err := base64.StdEncoding.DecodeString((*field)[len(MagicPrefix):]) + + // The first 7 characters of the digest are used to identify the cipher. + // If the cipher changes, we should complain loudly. + encPrefix := cipher.HexDigest()[:7] + if !strings.HasPrefix((*field)[8:15], encPrefix) { + return &DecryptFailedError{ + Inner: xerrors.Errorf("cipher mismatch: expected %q, got %q", encPrefix, (*field)[8:15]), + } + } + data, err := b64decode((*field)[16:]) if err != nil { // If it's not base64 with the prefix, we should complain loudly. - return xerrors.Errorf("malformed encrypted field %q: %w", *field, err) + return &DecryptFailedError{ + Inner: xerrors.Errorf("malformed encrypted field %q: %w", *field, err), + } } decrypted, err := cipher.Decrypt(data) if err != nil { @@ -211,8 +273,7 @@ func ensureEncrypted(ctx context.Context, dbc *dbCrypt) error { } if val != "" && val != sentinelValue { - // TODO: Handle key rotation. - return xerrors.Errorf("database is already encrypted with a different key and key rotation is not implemented yet") + return xerrors.Errorf("database is already encrypted with a different key") } if val == sentinelValue { diff --git a/enterprise/dbcrypt/dbcrypt_test.go b/enterprise/dbcrypt/dbcrypt_test.go index 5c2c2b340679c..a802ce5a8bd9e 100644 --- a/enterprise/dbcrypt/dbcrypt_test.go +++ b/enterprise/dbcrypt/dbcrypt_test.go @@ -200,6 +200,7 @@ func TestNew(t *testing.T) { rawVal, err := rawDB.GetDBCryptSentinelValue(ctx) require.NoError(t, err) require.Contains(t, rawVal, dbcrypt.MagicPrefix) + requireEncryptedEquals(t, cipher, rawVal, "coder") }) t.Run("NoCipher", func(t *testing.T) { @@ -264,8 +265,9 @@ func requireEncryptedEquals(t *testing.T, cipher *atomic.Pointer[dbcrypt.Cipher] t.Helper() c := (*cipher.Load()) require.NotNil(t, c) - require.Greater(t, len(value), len(dbcrypt.MagicPrefix), "value is not encrypted") - data, err := base64.StdEncoding.DecodeString(value[len(dbcrypt.MagicPrefix):]) + require.Greater(t, len(value), 16, "value is not encrypted") + require.Contains(t, value, dbcrypt.MagicPrefix+c.HexDigest()[:7]+"-") + data, err := base64.StdEncoding.DecodeString(value[16:]) require.NoError(t, err) got, err := c.Decrypt(data) require.NoError(t, err) From 17e694cb74756f9ee028364723f2dccd9ff3f23a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 25 Aug 2023 10:26:15 +0000 Subject: [PATCH 20/52] fixup! Merge remote-tracking branch 'origin/main' into cj/dbcrypt --- ...nel_value.down.sql => 000152_dbcrypt_sentinel_value.down.sql} | 0 ...entinel_value.up.sql => 000152_dbcrypt_sentinel_value.up.sql} | 0 coderd/database/migrations/migrate_test.go | 1 + 3 files changed, 1 insertion(+) rename coderd/database/migrations/{000151_dbcrypt_sentinel_value.down.sql => 000152_dbcrypt_sentinel_value.down.sql} (100%) rename coderd/database/migrations/{000151_dbcrypt_sentinel_value.up.sql => 000152_dbcrypt_sentinel_value.up.sql} (100%) diff --git a/coderd/database/migrations/000151_dbcrypt_sentinel_value.down.sql b/coderd/database/migrations/000152_dbcrypt_sentinel_value.down.sql similarity index 100% rename from coderd/database/migrations/000151_dbcrypt_sentinel_value.down.sql rename to coderd/database/migrations/000152_dbcrypt_sentinel_value.down.sql diff --git a/coderd/database/migrations/000151_dbcrypt_sentinel_value.up.sql b/coderd/database/migrations/000152_dbcrypt_sentinel_value.up.sql similarity index 100% rename from coderd/database/migrations/000151_dbcrypt_sentinel_value.up.sql rename to coderd/database/migrations/000152_dbcrypt_sentinel_value.up.sql diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index a138e58bac05f..de3d3995fb369 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -266,6 +266,7 @@ func TestMigrateUpWithFixtures(t *testing.T) { "template_version_parameters", "workspace_build_parameters", "template_version_variables", + "dbcrypt_sentinel", // having zero rows is a valid state for this table } s := &tableStats{s: make(map[string]int)} From dbe6915e8de224a92d62861332401ea11ab38032 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 25 Aug 2023 10:51:48 +0000 Subject: [PATCH 21/52] add previous external token encryption key deployment value --- cli/testdata/coder_server_--help.golden | 5 + coderd/deployment_test.go | 4 +- codersdk/deployment.go | 101 ++++++++++-------- codersdk/deployment_test.go | 3 + .../cli/testdata/coder_server_--help.golden | 5 + site/src/api/typesGenerated.ts | 1 + 6 files changed, 72 insertions(+), 47 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 61de6bf9c1f06..5099d53ad0b42 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -463,6 +463,11 @@ These options are only available in the Enterprise Edition. database. The value must be a base64-encoded key exactly 32 bytes in length. + --previous-external-token-encryption-key string, $CODER_PREVIOUS_EXTERNAL_TOKEN_ENCRYPTION_KEY + When rotating external token encryption key, provide the previous + encryption key. The value must be a base64-encoded key exactly 32 + bytes in length. + --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. diff --git a/coderd/deployment_test.go b/coderd/deployment_test.go index eed72caeeca8a..1a581b8466d1e 100644 --- a/coderd/deployment_test.go +++ b/coderd/deployment_test.go @@ -26,7 +26,8 @@ func TestDeploymentValues(t *testing.T) { cfg.OIDC.EmailField.Set("some_random_field_you_never_expected") cfg.PostgresURL.Set(hi) cfg.SCIMAPIKey.Set(hi) - cfg.ExternalTokenEncryptionKey.Set("the_random_key_we_never_expected") // len:32 + cfg.ExternalTokenEncryptionKey.Set("the_random_key_we_never_expected") // len:32 + cfg.PreviousExternalTokenEncryptionKey.Set("another_random_key_we_unexpected") // len:32 client := coderdtest.New(t, &coderdtest.Options{ DeploymentValues: cfg, @@ -46,6 +47,7 @@ func TestDeploymentValues(t *testing.T) { require.Empty(t, scrubbed.Values.PostgresURL.Value()) require.Empty(t, scrubbed.Values.SCIMAPIKey.Value()) require.Empty(t, scrubbed.Values.ExternalTokenEncryptionKey.Value()) + require.Empty(t, scrubbed.Values.PreviousExternalTokenEncryptionKey.Value()) } func TestDeploymentStats(t *testing.T) { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 7fa4e58e02b3d..882d7c308dded 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -129,52 +129,53 @@ type DeploymentValues struct { DocsURL clibase.URL `json:"docs_url,omitempty"` RedirectToAccessURL clibase.Bool `json:"redirect_to_access_url,omitempty"` // HTTPAddress is a string because it may be set to zero to disable. - HTTPAddress clibase.String `json:"http_address,omitempty" typescript:",notnull"` - AutobuildPollInterval clibase.Duration `json:"autobuild_poll_interval,omitempty"` - JobHangDetectorInterval clibase.Duration `json:"job_hang_detector_interval,omitempty"` - DERP DERP `json:"derp,omitempty" typescript:",notnull"` - Prometheus PrometheusConfig `json:"prometheus,omitempty" typescript:",notnull"` - Pprof PprofConfig `json:"pprof,omitempty" typescript:",notnull"` - ProxyTrustedHeaders clibase.StringArray `json:"proxy_trusted_headers,omitempty" typescript:",notnull"` - ProxyTrustedOrigins clibase.StringArray `json:"proxy_trusted_origins,omitempty" typescript:",notnull"` - CacheDir clibase.String `json:"cache_directory,omitempty" typescript:",notnull"` - InMemoryDatabase clibase.Bool `json:"in_memory_database,omitempty" typescript:",notnull"` - PostgresURL clibase.String `json:"pg_connection_url,omitempty" typescript:",notnull"` - OAuth2 OAuth2Config `json:"oauth2,omitempty" typescript:",notnull"` - OIDC OIDCConfig `json:"oidc,omitempty" typescript:",notnull"` - Telemetry TelemetryConfig `json:"telemetry,omitempty" typescript:",notnull"` - TLS TLSConfig `json:"tls,omitempty" typescript:",notnull"` - Trace TraceConfig `json:"trace,omitempty" typescript:",notnull"` - SecureAuthCookie clibase.Bool `json:"secure_auth_cookie,omitempty" typescript:",notnull"` - StrictTransportSecurity clibase.Int64 `json:"strict_transport_security,omitempty" typescript:",notnull"` - StrictTransportSecurityOptions clibase.StringArray `json:"strict_transport_security_options,omitempty" typescript:",notnull"` - SSHKeygenAlgorithm clibase.String `json:"ssh_keygen_algorithm,omitempty" typescript:",notnull"` - MetricsCacheRefreshInterval clibase.Duration `json:"metrics_cache_refresh_interval,omitempty" typescript:",notnull"` - AgentStatRefreshInterval clibase.Duration `json:"agent_stat_refresh_interval,omitempty" typescript:",notnull"` - AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` - BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"` - SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"` - ExternalTokenEncryptionKey clibase.String `json:"external_token_encryption_key"` - Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` - RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` - Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"` - UpdateCheck clibase.Bool `json:"update_check,omitempty" typescript:",notnull"` - MaxTokenLifetime clibase.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"` - Swagger SwaggerConfig `json:"swagger,omitempty" typescript:",notnull"` - Logging LoggingConfig `json:"logging,omitempty" typescript:",notnull"` - Dangerous DangerousConfig `json:"dangerous,omitempty" typescript:",notnull"` - DisablePathApps clibase.Bool `json:"disable_path_apps,omitempty" typescript:",notnull"` - SessionDuration clibase.Duration `json:"max_session_expiry,omitempty" typescript:",notnull"` - DisableSessionExpiryRefresh clibase.Bool `json:"disable_session_expiry_refresh,omitempty" typescript:",notnull"` - DisablePasswordAuth clibase.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` - Support SupportConfig `json:"support,omitempty" typescript:",notnull"` - GitAuthProviders clibase.Struct[[]GitAuthConfig] `json:"git_auth,omitempty" typescript:",notnull"` - SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` - WgtunnelHost clibase.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` - DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` - ProxyHealthStatusInterval clibase.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` - EnableTerraformDebugMode clibase.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` - UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` + HTTPAddress clibase.String `json:"http_address,omitempty" typescript:",notnull"` + AutobuildPollInterval clibase.Duration `json:"autobuild_poll_interval,omitempty"` + JobHangDetectorInterval clibase.Duration `json:"job_hang_detector_interval,omitempty"` + DERP DERP `json:"derp,omitempty" typescript:",notnull"` + Prometheus PrometheusConfig `json:"prometheus,omitempty" typescript:",notnull"` + Pprof PprofConfig `json:"pprof,omitempty" typescript:",notnull"` + ProxyTrustedHeaders clibase.StringArray `json:"proxy_trusted_headers,omitempty" typescript:",notnull"` + ProxyTrustedOrigins clibase.StringArray `json:"proxy_trusted_origins,omitempty" typescript:",notnull"` + CacheDir clibase.String `json:"cache_directory,omitempty" typescript:",notnull"` + InMemoryDatabase clibase.Bool `json:"in_memory_database,omitempty" typescript:",notnull"` + PostgresURL clibase.String `json:"pg_connection_url,omitempty" typescript:",notnull"` + OAuth2 OAuth2Config `json:"oauth2,omitempty" typescript:",notnull"` + OIDC OIDCConfig `json:"oidc,omitempty" typescript:",notnull"` + Telemetry TelemetryConfig `json:"telemetry,omitempty" typescript:",notnull"` + TLS TLSConfig `json:"tls,omitempty" typescript:",notnull"` + Trace TraceConfig `json:"trace,omitempty" typescript:",notnull"` + SecureAuthCookie clibase.Bool `json:"secure_auth_cookie,omitempty" typescript:",notnull"` + StrictTransportSecurity clibase.Int64 `json:"strict_transport_security,omitempty" typescript:",notnull"` + StrictTransportSecurityOptions clibase.StringArray `json:"strict_transport_security_options,omitempty" typescript:",notnull"` + SSHKeygenAlgorithm clibase.String `json:"ssh_keygen_algorithm,omitempty" typescript:",notnull"` + MetricsCacheRefreshInterval clibase.Duration `json:"metrics_cache_refresh_interval,omitempty" typescript:",notnull"` + AgentStatRefreshInterval clibase.Duration `json:"agent_stat_refresh_interval,omitempty" typescript:",notnull"` + AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` + BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"` + SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"` + ExternalTokenEncryptionKey clibase.String `json:"external_token_encryption_key"` + PreviousExternalTokenEncryptionKey clibase.String `json:"previous_external_token_encryption_key"` + Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` + RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` + Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"` + UpdateCheck clibase.Bool `json:"update_check,omitempty" typescript:",notnull"` + MaxTokenLifetime clibase.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"` + Swagger SwaggerConfig `json:"swagger,omitempty" typescript:",notnull"` + Logging LoggingConfig `json:"logging,omitempty" typescript:",notnull"` + Dangerous DangerousConfig `json:"dangerous,omitempty" typescript:",notnull"` + DisablePathApps clibase.Bool `json:"disable_path_apps,omitempty" typescript:",notnull"` + SessionDuration clibase.Duration `json:"max_session_expiry,omitempty" typescript:",notnull"` + DisableSessionExpiryRefresh clibase.Bool `json:"disable_session_expiry_refresh,omitempty" typescript:",notnull"` + DisablePasswordAuth clibase.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` + Support SupportConfig `json:"support,omitempty" typescript:",notnull"` + GitAuthProviders clibase.Struct[[]GitAuthConfig] `json:"git_auth,omitempty" typescript:",notnull"` + SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` + WgtunnelHost clibase.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` + DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` + ProxyHealthStatusInterval clibase.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` + EnableTerraformDebugMode clibase.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` + UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -1597,6 +1598,14 @@ when required by your organization's security policy.`, Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"), Value: &c.ExternalTokenEncryptionKey, }, + { + Name: "Previous External Token Encryption Key", + Description: "When rotating external token encryption key, provide the previous encryption key. The value must be a base64-encoded key exactly 32 bytes in length.", + Flag: "previous-external-token-encryption-key", + Env: "CODER_PREVIOUS_EXTERNAL_TOKEN_ENCRYPTION_KEY", + Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"), + Value: &c.PreviousExternalTokenEncryptionKey, + }, { Name: "Disable Path Apps", Description: "Disable workspace apps that are not served from subdomains. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured.", diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 91f35258e3e4c..0fcadd76cba2b 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -60,6 +60,9 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) { "External Token Encryption Key": { yaml: true, }, + "Previous External Token Encryption Key": { + yaml: true, + }, // These complex objects should be configured through YAML. "Support Links": { flag: true, diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 61de6bf9c1f06..5099d53ad0b42 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -463,6 +463,11 @@ These options are only available in the Enterprise Edition. database. The value must be a base64-encoded key exactly 32 bytes in length. + --previous-external-token-encryption-key string, $CODER_PREVIOUS_EXTERNAL_TOKEN_ENCRYPTION_KEY + When rotating external token encryption key, provide the previous + encryption key. The value must be a base64-encoded key exactly 32 + bytes in length. + --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. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 119008b6a168e..fffd5563bd25d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -379,6 +379,7 @@ export interface DeploymentValues { readonly browser_only?: boolean readonly scim_api_key?: string readonly external_token_encryption_key: string + readonly previous_external_token_encryption_key: string readonly provisioner?: ProvisionerConfig readonly rate_limit?: RateLimitConfig // This is likely an enum in an external package ("github.com/coder/coder/v2/cli/clibase.StringArray") From d4c74bfeb6b1b030acd02949ad6053fb5f62deb1 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 25 Aug 2023 13:42:54 +0000 Subject: [PATCH 22/52] support secondary cipher in dbcrypt --- coderd/deployment_test.go | 6 +- codersdk/deployment.go | 111 +++++++++++++---------------- codersdk/deployment_test.go | 5 +- enterprise/cli/server.go | 19 ++++- enterprise/coderd/coderd.go | 13 ++-- enterprise/dbcrypt/dbcrypt.go | 65 +++++++++++------ enterprise/dbcrypt/dbcrypt_test.go | 79 +++++++++++--------- site/src/api/typesGenerated.ts | 4 +- 8 files changed, 168 insertions(+), 134 deletions(-) diff --git a/coderd/deployment_test.go b/coderd/deployment_test.go index 1a581b8466d1e..66e3990e25ff3 100644 --- a/coderd/deployment_test.go +++ b/coderd/deployment_test.go @@ -26,8 +26,7 @@ func TestDeploymentValues(t *testing.T) { cfg.OIDC.EmailField.Set("some_random_field_you_never_expected") cfg.PostgresURL.Set(hi) cfg.SCIMAPIKey.Set(hi) - cfg.ExternalTokenEncryptionKey.Set("the_random_key_we_never_expected") // len:32 - cfg.PreviousExternalTokenEncryptionKey.Set("another_random_key_we_unexpected") // len:32 + cfg.ExternalTokenEncryptionKeys.Set("the_random_key_we_never_expected,an_other_key_we_never_unexpected") client := coderdtest.New(t, &coderdtest.Options{ DeploymentValues: cfg, @@ -46,8 +45,7 @@ func TestDeploymentValues(t *testing.T) { require.Empty(t, scrubbed.Values.OIDC.ClientSecret.Value()) require.Empty(t, scrubbed.Values.PostgresURL.Value()) require.Empty(t, scrubbed.Values.SCIMAPIKey.Value()) - require.Empty(t, scrubbed.Values.ExternalTokenEncryptionKey.Value()) - require.Empty(t, scrubbed.Values.PreviousExternalTokenEncryptionKey.Value()) + require.Empty(t, scrubbed.Values.ExternalTokenEncryptionKeys.Value()) } func TestDeploymentStats(t *testing.T) { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 882d7c308dded..6e6c7eb55cc2c 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -129,53 +129,52 @@ type DeploymentValues struct { DocsURL clibase.URL `json:"docs_url,omitempty"` RedirectToAccessURL clibase.Bool `json:"redirect_to_access_url,omitempty"` // HTTPAddress is a string because it may be set to zero to disable. - HTTPAddress clibase.String `json:"http_address,omitempty" typescript:",notnull"` - AutobuildPollInterval clibase.Duration `json:"autobuild_poll_interval,omitempty"` - JobHangDetectorInterval clibase.Duration `json:"job_hang_detector_interval,omitempty"` - DERP DERP `json:"derp,omitempty" typescript:",notnull"` - Prometheus PrometheusConfig `json:"prometheus,omitempty" typescript:",notnull"` - Pprof PprofConfig `json:"pprof,omitempty" typescript:",notnull"` - ProxyTrustedHeaders clibase.StringArray `json:"proxy_trusted_headers,omitempty" typescript:",notnull"` - ProxyTrustedOrigins clibase.StringArray `json:"proxy_trusted_origins,omitempty" typescript:",notnull"` - CacheDir clibase.String `json:"cache_directory,omitempty" typescript:",notnull"` - InMemoryDatabase clibase.Bool `json:"in_memory_database,omitempty" typescript:",notnull"` - PostgresURL clibase.String `json:"pg_connection_url,omitempty" typescript:",notnull"` - OAuth2 OAuth2Config `json:"oauth2,omitempty" typescript:",notnull"` - OIDC OIDCConfig `json:"oidc,omitempty" typescript:",notnull"` - Telemetry TelemetryConfig `json:"telemetry,omitempty" typescript:",notnull"` - TLS TLSConfig `json:"tls,omitempty" typescript:",notnull"` - Trace TraceConfig `json:"trace,omitempty" typescript:",notnull"` - SecureAuthCookie clibase.Bool `json:"secure_auth_cookie,omitempty" typescript:",notnull"` - StrictTransportSecurity clibase.Int64 `json:"strict_transport_security,omitempty" typescript:",notnull"` - StrictTransportSecurityOptions clibase.StringArray `json:"strict_transport_security_options,omitempty" typescript:",notnull"` - SSHKeygenAlgorithm clibase.String `json:"ssh_keygen_algorithm,omitempty" typescript:",notnull"` - MetricsCacheRefreshInterval clibase.Duration `json:"metrics_cache_refresh_interval,omitempty" typescript:",notnull"` - AgentStatRefreshInterval clibase.Duration `json:"agent_stat_refresh_interval,omitempty" typescript:",notnull"` - AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` - BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"` - SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"` - ExternalTokenEncryptionKey clibase.String `json:"external_token_encryption_key"` - PreviousExternalTokenEncryptionKey clibase.String `json:"previous_external_token_encryption_key"` - Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` - RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` - Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"` - UpdateCheck clibase.Bool `json:"update_check,omitempty" typescript:",notnull"` - MaxTokenLifetime clibase.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"` - Swagger SwaggerConfig `json:"swagger,omitempty" typescript:",notnull"` - Logging LoggingConfig `json:"logging,omitempty" typescript:",notnull"` - Dangerous DangerousConfig `json:"dangerous,omitempty" typescript:",notnull"` - DisablePathApps clibase.Bool `json:"disable_path_apps,omitempty" typescript:",notnull"` - SessionDuration clibase.Duration `json:"max_session_expiry,omitempty" typescript:",notnull"` - DisableSessionExpiryRefresh clibase.Bool `json:"disable_session_expiry_refresh,omitempty" typescript:",notnull"` - DisablePasswordAuth clibase.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` - Support SupportConfig `json:"support,omitempty" typescript:",notnull"` - GitAuthProviders clibase.Struct[[]GitAuthConfig] `json:"git_auth,omitempty" typescript:",notnull"` - SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` - WgtunnelHost clibase.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` - DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` - ProxyHealthStatusInterval clibase.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` - EnableTerraformDebugMode clibase.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` - UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` + HTTPAddress clibase.String `json:"http_address,omitempty" typescript:",notnull"` + AutobuildPollInterval clibase.Duration `json:"autobuild_poll_interval,omitempty"` + JobHangDetectorInterval clibase.Duration `json:"job_hang_detector_interval,omitempty"` + DERP DERP `json:"derp,omitempty" typescript:",notnull"` + Prometheus PrometheusConfig `json:"prometheus,omitempty" typescript:",notnull"` + Pprof PprofConfig `json:"pprof,omitempty" typescript:",notnull"` + ProxyTrustedHeaders clibase.StringArray `json:"proxy_trusted_headers,omitempty" typescript:",notnull"` + ProxyTrustedOrigins clibase.StringArray `json:"proxy_trusted_origins,omitempty" typescript:",notnull"` + CacheDir clibase.String `json:"cache_directory,omitempty" typescript:",notnull"` + InMemoryDatabase clibase.Bool `json:"in_memory_database,omitempty" typescript:",notnull"` + PostgresURL clibase.String `json:"pg_connection_url,omitempty" typescript:",notnull"` + OAuth2 OAuth2Config `json:"oauth2,omitempty" typescript:",notnull"` + OIDC OIDCConfig `json:"oidc,omitempty" typescript:",notnull"` + Telemetry TelemetryConfig `json:"telemetry,omitempty" typescript:",notnull"` + TLS TLSConfig `json:"tls,omitempty" typescript:",notnull"` + Trace TraceConfig `json:"trace,omitempty" typescript:",notnull"` + SecureAuthCookie clibase.Bool `json:"secure_auth_cookie,omitempty" typescript:",notnull"` + StrictTransportSecurity clibase.Int64 `json:"strict_transport_security,omitempty" typescript:",notnull"` + StrictTransportSecurityOptions clibase.StringArray `json:"strict_transport_security_options,omitempty" typescript:",notnull"` + SSHKeygenAlgorithm clibase.String `json:"ssh_keygen_algorithm,omitempty" typescript:",notnull"` + MetricsCacheRefreshInterval clibase.Duration `json:"metrics_cache_refresh_interval,omitempty" typescript:",notnull"` + AgentStatRefreshInterval clibase.Duration `json:"agent_stat_refresh_interval,omitempty" typescript:",notnull"` + AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` + BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"` + SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"` + ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys"` + Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` + RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` + Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"` + UpdateCheck clibase.Bool `json:"update_check,omitempty" typescript:",notnull"` + MaxTokenLifetime clibase.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"` + Swagger SwaggerConfig `json:"swagger,omitempty" typescript:",notnull"` + Logging LoggingConfig `json:"logging,omitempty" typescript:",notnull"` + Dangerous DangerousConfig `json:"dangerous,omitempty" typescript:",notnull"` + DisablePathApps clibase.Bool `json:"disable_path_apps,omitempty" typescript:",notnull"` + SessionDuration clibase.Duration `json:"max_session_expiry,omitempty" typescript:",notnull"` + DisableSessionExpiryRefresh clibase.Bool `json:"disable_session_expiry_refresh,omitempty" typescript:",notnull"` + DisablePasswordAuth clibase.Bool `json:"disable_password_auth,omitempty" typescript:",notnull"` + Support SupportConfig `json:"support,omitempty" typescript:",notnull"` + GitAuthProviders clibase.Struct[[]GitAuthConfig] `json:"git_auth,omitempty" typescript:",notnull"` + SSHConfig SSHConfig `json:"config_ssh,omitempty" typescript:",notnull"` + WgtunnelHost clibase.String `json:"wgtunnel_host,omitempty" typescript:",notnull"` + DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` + ProxyHealthStatusInterval clibase.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` + EnableTerraformDebugMode clibase.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` + UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -1591,20 +1590,12 @@ when required by your organization's security policy.`, Value: &c.SCIMAPIKey, }, { - Name: "External Token Encryption Key", - Description: "Encrypt OIDC and Git authentication tokens with AES-256-GCM in the database. The value must be a base64-encoded key exactly 32 bytes in length.", - Flag: "external-token-encryption-key", - Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_KEY", + Name: "External Token Encryption Keys", + Description: "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. A maximum of two keys may be provided. 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.", + Flag: "external-token-encryption-keys", + Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS", Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"), - Value: &c.ExternalTokenEncryptionKey, - }, - { - Name: "Previous External Token Encryption Key", - Description: "When rotating external token encryption key, provide the previous encryption key. The value must be a base64-encoded key exactly 32 bytes in length.", - Flag: "previous-external-token-encryption-key", - Env: "CODER_PREVIOUS_EXTERNAL_TOKEN_ENCRYPTION_KEY", - Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"), - Value: &c.PreviousExternalTokenEncryptionKey, + Value: &c.ExternalTokenEncryptionKeys, }, { Name: "Disable Path Apps", diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 0fcadd76cba2b..287e34c741226 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -57,10 +57,7 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) { "SCIM API Key": { yaml: true, }, - "External Token Encryption Key": { - yaml: true, - }, - "Previous External Token Encryption Key": { + "External Token Encryption Keys": { yaml: true, }, // These complex objects should be configured through YAML. diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 0ed963e540852..5b8485ae456d6 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -71,15 +71,28 @@ func (r *RootCmd) server() *clibase.Cmd { ProvisionerDaemonPSK: options.DeploymentValues.Provisioner.DaemonPSK.Value(), } - if options.DeploymentValues.ExternalTokenEncryptionKey.Value() != "" { - key, err := base64.StdEncoding.DecodeString(options.DeploymentValues.ExternalTokenEncryptionKey.String()) + if encKeys := options.DeploymentValues.ExternalTokenEncryptionKeys.Value(); len(encKeys) != 0 { + if len(encKeys) > 2 { + return nil, nil, xerrors.Errorf("at most 2 external-token-encryption-keys may be specified") + } + k1, err := base64.StdEncoding.DecodeString(encKeys[0]) if err != nil { return nil, nil, xerrors.Errorf("decode external-token-encryption-key: %w", err) } - o.ExternalTokenEncryption, err = dbcrypt.CipherAES256(key) + o.PrimaryExternalTokenEncryption, err = dbcrypt.CipherAES256(k1) if err != nil { return nil, nil, xerrors.Errorf("create external-token-encryption-key cipher: %w", err) } + if len(encKeys) > 1 { + k2, err := base64.StdEncoding.DecodeString(encKeys[0]) + if err != nil { + return nil, nil, xerrors.Errorf("decode external-token-encryption-key: %w", err) + } + o.SecondaryExternalTokenEncryption, err = dbcrypt.CipherAES256(k2) + if err != nil { + return nil, nil, xerrors.Errorf("create external-token-encryption-key cipher: %w", err) + } + } } api, err := coderd.New(ctx, o) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 2dd2ba6f21de3..3ec31c03d8d0f 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -66,7 +66,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { externalTokenCipher := &atomic.Pointer[dbcrypt.Cipher]{} cryptDB, err := dbcrypt.New(ctx, options.Database, &dbcrypt.Options{ - ExternalTokenCipher: externalTokenCipher, + PrimaryCipher: externalTokenCipher, }) if err != nil { cancelFunc() @@ -375,9 +375,12 @@ type Options struct { RBAC bool AuditLogging bool // Whether to block non-browser connections. - BrowserOnly bool - SCIMAPIKey []byte - ExternalTokenEncryption dbcrypt.Cipher + BrowserOnly bool + SCIMAPIKey []byte + + // TODO: wire these up properly + PrimaryExternalTokenEncryption dbcrypt.Cipher + SecondaryExternalTokenEncryption dbcrypt.Cipher // Used for high availability. ReplicaSyncUpdateInterval time.Duration @@ -447,7 +450,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "", codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1, codersdk.FeatureTemplateRBAC: api.RBAC, - codersdk.FeatureExternalTokenEncryption: api.ExternalTokenEncryption != nil, + codersdk.FeatureExternalTokenEncryption: api.PrimaryExternalTokenEncryption != nil, codersdk.FeatureExternalProvisionerDaemons: true, codersdk.FeatureAdvancedTemplateScheduling: true, // FeatureTemplateRestartRequirement depends on diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index 2c18fc87e33c3..e81195807e767 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -16,6 +16,10 @@ // // The first 7 characters of the cipher's SHA256 digest are used to identify the cipher // used to encrypt the value. +// +// Two ciphers can be provided to support key rotation. The primary cipher is used to encrypt +// and decrypt all values. We only use the secondary cipher to decrypt values if decryption +// with the primary cipher fails. package dbcrypt import ( @@ -26,6 +30,8 @@ import ( "strings" "sync/atomic" + "github.com/hashicorp/go-multierror" + "golang.org/x/xerrors" "cdr.dev/slog" @@ -79,16 +85,23 @@ func IsDecryptFailedError(err error) bool { } type Options struct { - // ExternalTokenCipher is an optional cipher that is used + // PrimaryCipher is an optional cipher that is used // to encrypt/decrypt user link and git auth link tokens. If this is nil, // then no encryption/decryption will be performed. - ExternalTokenCipher *atomic.Pointer[Cipher] - Logger slog.Logger + PrimaryCipher *atomic.Pointer[Cipher] + // SecondaryCipher is an optional cipher that is only used + // to decrypt user link and git auth link tokens. + // This should only be used when rotating the primary cipher. + SecondaryCipher *atomic.Pointer[Cipher] + Logger slog.Logger } // New creates a database.Store wrapper that encrypts/decrypts values // stored at rest in the database. func New(ctx context.Context, db database.Store, options *Options) (database.Store, error) { + if options.PrimaryCipher.Load() == nil { + return nil, xerrors.Errorf("at least one cipher is required") + } dbc := &dbCrypt{ Options: options, Store: db, @@ -186,10 +199,11 @@ func (db *dbCrypt) SetDBCryptSentinelValue(ctx context.Context, value string) er } func (db *dbCrypt) encryptFields(fields ...*string) error { - cipherPtr := db.ExternalTokenCipher.Load() - // If no cipher is loaded, then we don't need to encrypt or decrypt anything! + // Encryption ALWAYS happens with the primary cipher. + cipherPtr := db.PrimaryCipher.Load() + // If no cipher is loaded, then we can't encrypt anything! if cipherPtr == nil { - return nil + return ErrNotEnabled } cipher := *cipherPtr for _, field := range fields { @@ -210,20 +224,28 @@ func (db *dbCrypt) encryptFields(fields ...*string) error { // decryptFields decrypts the given fields in place. // If the value fails to decrypt, sql.ErrNoRows will be returned. func (db *dbCrypt) decryptFields(fields ...*string) error { - cipherPtr := db.ExternalTokenCipher.Load() - // If no cipher is loaded, then we don't need to encrypt or decrypt anything! - if cipherPtr == nil { - for _, field := range fields { - if field == nil { - continue - } - if strings.HasPrefix(*field, MagicPrefix) { - // If we have a magic prefix but encryption is disabled, - // complain loudly. - return xerrors.Errorf("failed to decrypt field %q: %w", *field, ErrNotEnabled) - } - } + var merr *multierror.Error + + // We try to decrypt with both the primary and secondary cipher. + primaryCipherPtr := db.PrimaryCipher.Load() + if err := decryptWithCipher(primaryCipherPtr, fields...); err == nil { return nil + } else { + merr = multierror.Append(merr, err) + } + secondaryCipherPtr := db.SecondaryCipher.Load() + if err := decryptWithCipher(secondaryCipherPtr, fields...); err == nil { + return nil + } else { + merr = multierror.Append(merr, err) + } + return merr +} + +func decryptWithCipher(cipherPtr *Cipher, fields ...*string) error { + // If no cipher is loaded, then we can't decrypt anything! + if cipherPtr == nil { + return ErrNotEnabled } cipher := *cipherPtr @@ -276,10 +298,7 @@ func ensureEncrypted(ctx context.Context, dbc *dbCrypt) error { return xerrors.Errorf("database is already encrypted with a different key") } - if val == sentinelValue { - return nil // nothing to do! - } - + // Mark the database as officially having been touched by the new cipher. if err := s.SetDBCryptSentinelValue(ctx, sentinelValue); err != nil { return xerrors.Errorf("mark database as encrypted: %w", err) } diff --git a/enterprise/dbcrypt/dbcrypt_test.go b/enterprise/dbcrypt/dbcrypt_test.go index a802ce5a8bd9e..bf5ed876c0317 100644 --- a/enterprise/dbcrypt/dbcrypt_test.go +++ b/enterprise/dbcrypt/dbcrypt_test.go @@ -26,7 +26,7 @@ func TestUserLinks(t *testing.T) { t.Run("InsertUserLink", func(t *testing.T) { t.Parallel() - db, crypt, cipher := setup(t) + db, crypt, cipher, _ := setup(t) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -41,7 +41,7 @@ func TestUserLinks(t *testing.T) { t.Run("UpdateUserLink", func(t *testing.T) { t.Parallel() - db, crypt, cipher := setup(t) + db, crypt, cipher, _ := setup(t) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -61,7 +61,7 @@ func TestUserLinks(t *testing.T) { t.Run("GetUserLinkByLinkedID", func(t *testing.T) { t.Parallel() - db, crypt, cipher := setup(t) + db, crypt, cipher, _ := setup(t) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -82,7 +82,7 @@ func TestUserLinks(t *testing.T) { t.Run("GetUserLinkByUserIDLoginType", func(t *testing.T) { t.Parallel() - db, crypt, cipher := setup(t) + db, crypt, cipher, _ := setup(t) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -114,7 +114,7 @@ func TestGitAuthLinks(t *testing.T) { t.Run("InsertGitAuthLink", func(t *testing.T) { t.Parallel() - db, crypt, cipher := setup(t) + db, crypt, cipher, _ := setup(t) link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{ OAuthAccessToken: "access", OAuthRefreshToken: "refresh", @@ -130,7 +130,7 @@ func TestGitAuthLinks(t *testing.T) { t.Run("UpdateGitAuthLink", func(t *testing.T) { t.Parallel() - db, crypt, cipher := setup(t) + db, crypt, cipher, _ := setup(t) link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{}) _, err := crypt.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ ProviderID: link.ProviderID, @@ -150,7 +150,7 @@ func TestGitAuthLinks(t *testing.T) { t.Run("GetGitAuthLink", func(t *testing.T) { t.Parallel() - db, crypt, cipher := setup(t) + db, crypt, cipher, _ := setup(t) link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{ OAuthAccessToken: "access", OAuthRefreshToken: "refresh", @@ -187,8 +187,8 @@ func TestNew(t *testing.T) { // When: we init the crypt db cryptDB, err := dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ - ExternalTokenCipher: cipher, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + PrimaryCipher: cipher, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), }) require.NoError(t, err) @@ -206,23 +206,20 @@ func TestNew(t *testing.T) { t.Run("NoCipher", func(t *testing.T) { // Given: no cipher is loaded cipher := &atomic.Pointer[dbcrypt.Cipher]{} - // initCipher(t, cipher) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) rawDB, _ := dbtestutil.NewDB(t) // When: we init the crypt db - cryptDB, err := dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ - ExternalTokenCipher: cipher, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + _, err := dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ + PrimaryCipher: cipher, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), }) - require.NoError(t, err) - // Then: the sentinel value is not encrypted - cryptVal, err := cryptDB.GetDBCryptSentinelValue(ctx) - require.NoError(t, err) - require.Equal(t, "coder", cryptVal) + // Then: an error is returned + require.ErrorContains(t, err, "at least one cipher must be provided") + // And: the sentinel value is not encrypted rawVal, err := rawDB.GetDBCryptSentinelValue(ctx) require.NoError(t, err) require.Equal(t, "coder", rawVal) @@ -237,27 +234,41 @@ func TestNew(t *testing.T) { rawDB, _ := dbtestutil.NewDB(t) // And: the sentinel value is encrypted with a different cipher - cipher2 := &atomic.Pointer[dbcrypt.Cipher]{} - initCipher(t, cipher2) + oldCipher := &atomic.Pointer[dbcrypt.Cipher]{} + initCipher(t, oldCipher) field := "coder" - encrypted, err := (*cipher2.Load()).Encrypt([]byte(field)) + encrypted, err := (*oldCipher.Load()).Encrypt([]byte(field)) require.NoError(t, err) b64encrypted := base64.StdEncoding.EncodeToString(encrypted) - require.NoError(t, rawDB.SetDBCryptSentinelValue(ctx, b64encrypted)) + require.NoError(t, rawDB.SetDBCryptSentinelValue(ctx, "dbcrypt-"+(*oldCipher.Load()).HexDigest()[:7]+"-"+b64encrypted)) - // When: we init the crypt db + // When: we init the crypt db with no access to the old cipher _, err = dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ - ExternalTokenCipher: cipher, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + PrimaryCipher: cipher, + SecondaryCipher: &atomic.Pointer[dbcrypt.Cipher]{}, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), }) // Then: an error is returned - // TODO: when we implement key rotation, this should not fail. require.ErrorContains(t, err, "database is already encrypted with a different key") // And the sentinel value should remain unchanged. For now. rawVal, err := rawDB.GetDBCryptSentinelValue(ctx) require.NoError(t, err) - require.Equal(t, b64encrypted, rawVal) + requireEncryptedEquals(t, oldCipher, rawVal, field) + + // When: we set the secondary cipher + _, err = dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ + PrimaryCipher: cipher, + SecondaryCipher: oldCipher, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) + // Then: no error is returned + require.NoError(t, err) + + // And the sentinel value should be re-encrypted with the new value. + rawVal, err = rawDB.GetDBCryptSentinelValue(ctx) + require.NoError(t, err) + requireEncryptedEquals(t, cipher, rawVal, field) }) } @@ -284,7 +295,7 @@ func initCipher(t *testing.T, cipher *atomic.Pointer[dbcrypt.Cipher]) { cipher.Store(&c) } -func setup(t *testing.T) (db, cryptodb database.Store, cipher *atomic.Pointer[dbcrypt.Cipher]) { +func setup(t *testing.T) (db, cryptodb database.Store, cipher1 *atomic.Pointer[dbcrypt.Cipher], cipher2 *atomic.Pointer[dbcrypt.Cipher]) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -293,11 +304,13 @@ func setup(t *testing.T) (db, cryptodb database.Store, cipher *atomic.Pointer[db _, err := rawDB.GetDBCryptSentinelValue(ctx) require.ErrorIs(t, err, sql.ErrNoRows) - cipher = &atomic.Pointer[dbcrypt.Cipher]{} - initCipher(t, cipher) + cipher1 = &atomic.Pointer[dbcrypt.Cipher]{} + cipher2 = &atomic.Pointer[dbcrypt.Cipher]{} + initCipher(t, cipher1) cryptDB, err := dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ - ExternalTokenCipher: cipher, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + PrimaryCipher: cipher1, + SecondaryCipher: cipher2, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), }) require.NoError(t, err) @@ -309,5 +322,5 @@ func setup(t *testing.T) (db, cryptodb database.Store, cipher *atomic.Pointer[db require.NoError(t, err) require.Equal(t, "coder", cryptVal) - return rawDB, cryptDB, cipher + return rawDB, cryptDB, cipher1, cipher2 } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fffd5563bd25d..1bb88a639efee 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -378,8 +378,8 @@ export interface DeploymentValues { readonly agent_fallback_troubleshooting_url?: string readonly browser_only?: boolean readonly scim_api_key?: string - readonly external_token_encryption_key: string - readonly previous_external_token_encryption_key: string + // This is likely an enum in an external package ("github.com/coder/coder/v2/cli/clibase.StringArray") + readonly external_token_encryption_keys: string[] readonly provisioner?: ProvisionerConfig readonly rate_limit?: RateLimitConfig // This is likely an enum in an external package ("github.com/coder/coder/v2/cli/clibase.StringArray") From e14272c995b894d0008599d738ce08543c981a0d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 25 Aug 2023 13:56:29 +0000 Subject: [PATCH 23/52] fixup! support secondary cipher in dbcrypt --- cli/testdata/coder_server_--help.golden | 14 ++++---- .../cli/testdata/coder_server_--help.golden | 14 ++++---- enterprise/coderd/coderd.go | 33 +++++++++++-------- enterprise/dbcrypt/dbcrypt_test.go | 9 +++-- 4 files changed, 35 insertions(+), 35 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 5099d53ad0b42..c670fadd90bbd 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -458,15 +458,13 @@ 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-key string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEY + --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 base64-encoded key exactly 32 bytes in - length. - - --previous-external-token-encryption-key string, $CODER_PREVIOUS_EXTERNAL_TOKEN_ENCRYPTION_KEY - When rotating external token encryption key, provide the previous - encryption key. The value must be a base64-encoded key exactly 32 - bytes in length. + database. The value must be a comma-separated list of base64-encoded + keys. A maximum of two keys may be provided. 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. --scim-auth-header string, $CODER_SCIM_AUTH_HEADER Enables SCIM and sets the authentication header for the built-in SCIM diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 5099d53ad0b42..c670fadd90bbd 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -458,15 +458,13 @@ 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-key string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEY + --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 base64-encoded key exactly 32 bytes in - length. - - --previous-external-token-encryption-key string, $CODER_PREVIOUS_EXTERNAL_TOKEN_ENCRYPTION_KEY - When rotating external token encryption key, provide the previous - encryption key. The value must be a base64-encoded key exactly 32 - bytes in length. + database. The value must be a comma-separated list of base64-encoded + keys. A maximum of two keys may be provided. 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. --scim-auth-header string, $CODER_SCIM_AUTH_HEADER Enables SCIM and sets the authentication header for the built-in SCIM diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 3ec31c03d8d0f..ec61491ae3060 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -64,21 +64,28 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ctx, cancelFunc := context.WithCancel(ctx) - externalTokenCipher := &atomic.Pointer[dbcrypt.Cipher]{} - cryptDB, err := dbcrypt.New(ctx, options.Database, &dbcrypt.Options{ - PrimaryCipher: externalTokenCipher, - }) - if err != nil { - cancelFunc() - return nil, xerrors.Errorf("init dbcrypt: %w", err) + if options.PrimaryExternalTokenEncryption != nil { + primaryExternalTokenCipher := atomic.Pointer[dbcrypt.Cipher]{} + primaryExternalTokenCipher.Store(&options.PrimaryExternalTokenEncryption) + secondaryExternalTokenCipher := atomic.Pointer[dbcrypt.Cipher]{} + if options.SecondaryExternalTokenEncryption != nil { + secondaryExternalTokenCipher.Store(&options.SecondaryExternalTokenEncryption) + } + cryptDB, err := dbcrypt.New(ctx, options.Database, &dbcrypt.Options{ + PrimaryCipher: &primaryExternalTokenCipher, + SecondaryCipher: &secondaryExternalTokenCipher, + }) + + if err != nil { + cancelFunc() + return nil, xerrors.Errorf("init dbcrypt: %w", err) + } + options.Database = cryptDB } - options.Database = cryptDB api := &API{ - ctx: ctx, - cancel: cancelFunc, - externalTokenCipher: externalTokenCipher, - + ctx: ctx, + cancel: cancelFunc, AGPL: coderd.New(options.Options), Options: options, provisionerDaemonAuth: &provisionerDaemonAuth{ @@ -407,8 +414,6 @@ type API struct { ctx context.Context cancel context.CancelFunc - externalTokenCipher *atomic.Pointer[dbcrypt.Cipher] - // Detects multiple Coder replicas running at the same time. replicaManager *replicasync.Manager // Meshes DERP connections from multiple replicas. diff --git a/enterprise/dbcrypt/dbcrypt_test.go b/enterprise/dbcrypt/dbcrypt_test.go index bf5ed876c0317..bd04f41d74995 100644 --- a/enterprise/dbcrypt/dbcrypt_test.go +++ b/enterprise/dbcrypt/dbcrypt_test.go @@ -217,12 +217,11 @@ func TestNew(t *testing.T) { }) // Then: an error is returned - require.ErrorContains(t, err, "at least one cipher must be provided") + require.ErrorContains(t, err, "at least one cipher is required") - // And: the sentinel value is not encrypted - rawVal, err := rawDB.GetDBCryptSentinelValue(ctx) - require.NoError(t, err) - require.Equal(t, "coder", rawVal) + // And: the sentinel value is not present + _, err = rawDB.GetDBCryptSentinelValue(ctx) + require.ErrorIs(t, err, sql.ErrNoRows) }) t.Run("CipherChanged", func(t *testing.T) { From 15c4919deb07b084746d328eede72c67188d8afc Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 25 Aug 2023 15:58:21 +0000 Subject: [PATCH 24/52] fix DeploymentValues.WithoutSecrets() --- codersdk/deployment.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 6e6c7eb55cc2c..d9d1632d198e3 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -154,7 +154,7 @@ type DeploymentValues struct { AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"` SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"` - ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys"` + ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys" typescript:",notnull"` Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"` @@ -1774,7 +1774,7 @@ func (c *DeploymentValues) WithoutSecrets() (*DeploymentValues, error) { // This only works with string values for now. switch v := opt.Value.(type) { - case *clibase.String: + case *clibase.String, *clibase.StringArray: err := v.Set("") if err != nil { panic(err) From 09cad5b9982266e8db8e7ca0d188195f06a1bf88 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 28 Aug 2023 14:07:38 +0000 Subject: [PATCH 25/52] export cli.connectToPostgres --- cli/server.go | 4 +-- cli/server_createadminuser.go | 2 +- enterprise/cli/dbcrypt_rotate.go | 22 ++++++++++++++++ enterprise/cli/dbcrypt_rotate_test.go | 36 +++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 enterprise/cli/dbcrypt_rotate.go create mode 100644 enterprise/cli/dbcrypt_rotate_test.go diff --git a/cli/server.go b/cli/server.go index 779215f0fce35..13482ee434fd8 100644 --- a/cli/server.go +++ b/cli/server.go @@ -685,7 +685,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.Database = dbfake.New() options.Pubsub = pubsub.NewInMemory() } else { - sqlDB, err := connectToPostgres(ctx, logger, sqlDriver, vals.PostgresURL.String()) + sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, vals.PostgresURL.String()) if err != nil { return xerrors.Errorf("connect to postgres: %w", err) } @@ -1950,7 +1950,7 @@ func BuildLogger(inv *clibase.Invocation, cfg *codersdk.DeploymentValues) (slog. }, nil } -func connectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (*sql.DB, error) { +func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (*sql.DB, error) { logger.Debug(ctx, "connecting to postgresql") // Try to connect for 30 seconds. diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index 8f146f8f95ead..93a5815a17fbb 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -62,7 +62,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd { newUserDBURL = url } - sqlDB, err := connectToPostgres(ctx, logger, "postgres", newUserDBURL) + sqlDB, err := ConnectToPostgres(ctx, logger, "postgres", newUserDBURL) if err != nil { return xerrors.Errorf("connect to postgres: %w", err) } diff --git a/enterprise/cli/dbcrypt_rotate.go b/enterprise/cli/dbcrypt_rotate.go new file mode 100644 index 0000000000000..7ceba78ced492 --- /dev/null +++ b/enterprise/cli/dbcrypt_rotate.go @@ -0,0 +1,22 @@ +//go:build !slim + +package cli + +import ( + "github.com/coder/coder/v2/cli/clibase" +) + +func (r *RootCmd) dbcryptRotate() *clibase.Cmd { + cmd := &clibase.Cmd{ + Use: "dbcrypt-rotate", + Short: "Rotate database encryption keys", + Middleware: clibase.Chain( + clibase.RequireNArgs(0), + ), + Handler: func(inv *clibase.Invocation) error { + // TODO: implement + return nil + }, + } + return cmd +} diff --git a/enterprise/cli/dbcrypt_rotate_test.go b/enterprise/cli/dbcrypt_rotate_test.go new file mode 100644 index 0000000000000..9577627b13064 --- /dev/null +++ b/enterprise/cli/dbcrypt_rotate_test.go @@ -0,0 +1,36 @@ +package cli_test + +import ( + "testing" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/stretchr/testify/require" +) + +func TestDBCryptRotate(t *testing.T) { + t.Parallel() + + // TODO: create a test database and populate some encrypted data with cipher A + + client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalTokenEncryption: 1, + }, + }}) + + // Run the cmd with ciphers B,A + + inv, conf := newCLI(t, "dbcrypt-rotate") // TODO: env? + pty := ptytest.New(t) + inv.Stdout = pty.Output() + clitest.SetupConfig(t, client, conf) + + err := inv.Run() + require.NoError(t, err) + + // TODO: validate that all data has been updated with the checksum of the new cipher. +} From dd4a94c30ed93dead9dfd8e24d8c46de4600d8e0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 28 Aug 2023 19:53:55 +0000 Subject: [PATCH 26/52] add queries to support rotating dbcrypt keys --- coderd/database/dbauthz/dbauthz.go | 8 +++ coderd/database/dbfake/dbfake.go | 8 +++ coderd/database/dbmetrics/dbmetrics.go | 14 ++++++ coderd/database/dbmock/dbmock.go | 30 +++++++++++ coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 69 ++++++++++++++++++++++++++ coderd/database/queries/gitauth.sql | 4 ++ coderd/database/queries/user_links.sql | 3 ++ 8 files changed, 138 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 70721746cf097..4f080059b6609 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -924,6 +924,10 @@ func (q *querier) GetGitAuthLink(ctx context.Context, arg database.GetGitAuthLin return fetch(q.log, q.auth, q.db.GetGitAuthLink)(ctx, arg) } +func (q *querier) GetGitAuthLinksByUserID(_ context.Context, _ uuid.UUID) ([]database.GitAuthLink, error) { + return nil, xerrors.Errorf("this is intentionally not implemented") +} + func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { return fetch(q.log, q.auth, q.db.GetGitSSHKey)(ctx, userID) } @@ -1492,6 +1496,10 @@ func (q *querier) GetUserLinkByUserIDLoginType(ctx context.Context, arg database return q.db.GetUserLinkByUserIDLoginType(ctx, arg) } +func (q *querier) GetUserLinksByUserID(_ context.Context, _ uuid.UUID) ([]database.UserLink, error) { + return nil, xerrors.Errorf("this is intentionally not implemented") +} + func (q *querier) GetUsers(ctx context.Context, arg database.GetUsersParams) ([]database.GetUsersRow, error) { // This does the filtering in SQL. prep, err := prepareSQLFilter(ctx, q.auth, rbac.ActionRead, rbac.ResourceUser.Type) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index b1b94dfcdcb07..4200058228810 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -1436,6 +1436,10 @@ func (q *FakeQuerier) GetGitAuthLink(_ context.Context, arg database.GetGitAuthL return database.GitAuthLink{}, sql.ErrNoRows } +func (q *FakeQuerier) GetGitAuthLinksByUserID(_ context.Context, _ uuid.UUID) ([]database.GitAuthLink, error) { + panic("this is intentionally not implemented") +} + func (q *FakeQuerier) GetGitSSHKey(_ context.Context, userID uuid.UUID) (database.GitSSHKey, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -2876,6 +2880,10 @@ func (q *FakeQuerier) GetUserLinkByUserIDLoginType(_ context.Context, params dat return database.UserLink{}, sql.ErrNoRows } +func (q *FakeQuerier) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.UserLink, error) { + panic("this is intentionally not implemented") +} + func (q *FakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams) ([]database.GetUsersRow, error) { if err := validateDatabaseType(params); err != nil { return nil, err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index e6869977282b1..4297565db91bc 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -370,6 +370,13 @@ func (m metricsStore) GetGitAuthLink(ctx context.Context, arg database.GetGitAut return link, err } +func (m metricsStore) GetGitAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.GitAuthLink, error) { + start := time.Now() + r0, r1 := m.s.GetGitAuthLinksByUserID(ctx, userID) + m.queryLatencies.WithLabelValues("GetGitAuthLinksByUserID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { start := time.Now() key, err := m.s.GetGitSSHKey(ctx, userID) @@ -795,6 +802,13 @@ func (m metricsStore) GetUserLinkByUserIDLoginType(ctx context.Context, arg data return link, err } +func (m metricsStore) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.UserLink, error) { + start := time.Now() + r0, r1 := m.s.GetUserLinksByUserID(ctx, userID) + m.queryLatencies.WithLabelValues("GetUserLinksByUserID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetUsers(ctx context.Context, arg database.GetUsersParams) ([]database.GetUsersRow, error) { start := time.Now() users, err := m.s.GetUsers(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index aa6894a099fc8..287a5fcb7f654 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -699,6 +699,21 @@ func (mr *MockStoreMockRecorder) GetGitAuthLink(arg0, arg1 interface{}) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGitAuthLink", reflect.TypeOf((*MockStore)(nil).GetGitAuthLink), arg0, arg1) } +// GetGitAuthLinksByUserID mocks base method. +func (m *MockStore) GetGitAuthLinksByUserID(arg0 context.Context, arg1 uuid.UUID) ([]database.GitAuthLink, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGitAuthLinksByUserID", arg0, arg1) + ret0, _ := ret[0].([]database.GitAuthLink) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGitAuthLinksByUserID indicates an expected call of GetGitAuthLinksByUserID. +func (mr *MockStoreMockRecorder) GetGitAuthLinksByUserID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGitAuthLinksByUserID", reflect.TypeOf((*MockStore)(nil).GetGitAuthLinksByUserID), arg0, arg1) +} + // GetGitSSHKey mocks base method. func (m *MockStore) GetGitSSHKey(arg0 context.Context, arg1 uuid.UUID) (database.GitSSHKey, error) { m.ctrl.T.Helper() @@ -1644,6 +1659,21 @@ func (mr *MockStoreMockRecorder) GetUserLinkByUserIDLoginType(arg0, arg1 interfa return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserLinkByUserIDLoginType", reflect.TypeOf((*MockStore)(nil).GetUserLinkByUserIDLoginType), arg0, arg1) } +// GetUserLinksByUserID mocks base method. +func (m *MockStore) GetUserLinksByUserID(arg0 context.Context, arg1 uuid.UUID) ([]database.UserLink, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserLinksByUserID", arg0, arg1) + ret0, _ := ret[0].([]database.UserLink) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserLinksByUserID indicates an expected call of GetUserLinksByUserID. +func (mr *MockStoreMockRecorder) GetUserLinksByUserID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserLinksByUserID", reflect.TypeOf((*MockStore)(nil).GetUserLinksByUserID), arg0, arg1) +} + // GetUsers mocks base method. func (m *MockStore) GetUsers(arg0 context.Context, arg1 database.GetUsersParams) ([]database.GetUsersRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 735102f7fc9e1..f117df50805d8 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -72,6 +72,7 @@ type sqlcQuerier interface { // Get all templates that use a file. GetFileTemplates(ctx context.Context, fileID uuid.UUID) ([]GetFileTemplatesRow, error) GetGitAuthLink(ctx context.Context, arg GetGitAuthLinkParams) (GitAuthLink, error) + GetGitAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]GitAuthLink, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) @@ -153,6 +154,7 @@ type sqlcQuerier interface { GetUserLatencyInsights(ctx context.Context, arg GetUserLatencyInsightsParams) ([]GetUserLatencyInsightsRow, error) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error) GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) + GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) // This will never return deleted users. GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) // This shouldn't check for deleted, because it's frequently used diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7c925ec02ef0d..09f4f1b2e5f75 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -857,6 +857,41 @@ func (q *sqlQuerier) GetGitAuthLink(ctx context.Context, arg GetGitAuthLinkParam return i, err } +const getGitAuthLinksByUserID = `-- name: GetGitAuthLinksByUserID :many +SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry FROM git_auth_links WHERE user_id = $1 +` + +func (q *sqlQuerier) GetGitAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]GitAuthLink, error) { + rows, err := q.db.QueryContext(ctx, getGitAuthLinksByUserID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GitAuthLink + for rows.Next() { + var i GitAuthLink + if err := rows.Scan( + &i.ProviderID, + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OAuthAccessToken, + &i.OAuthRefreshToken, + &i.OAuthExpiry, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertGitAuthLink = `-- name: InsertGitAuthLink :one INSERT INTO git_auth_links ( provider_id, @@ -5545,6 +5580,40 @@ func (q *sqlQuerier) GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUs return i, err } +const getUserLinksByUserID = `-- name: GetUserLinksByUserID :many +SELECT user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry FROM user_links WHERE user_id = $1 +` + +func (q *sqlQuerier) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) { + rows, err := q.db.QueryContext(ctx, getUserLinksByUserID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserLink + for rows.Next() { + var i UserLink + if err := rows.Scan( + &i.UserID, + &i.LoginType, + &i.LinkedID, + &i.OAuthAccessToken, + &i.OAuthRefreshToken, + &i.OAuthExpiry, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertUserLink = `-- name: InsertUserLink :one INSERT INTO user_links ( diff --git a/coderd/database/queries/gitauth.sql b/coderd/database/queries/gitauth.sql index c2cd6853f7bcd..aeee8d7430d54 100644 --- a/coderd/database/queries/gitauth.sql +++ b/coderd/database/queries/gitauth.sql @@ -1,6 +1,10 @@ -- name: GetGitAuthLink :one SELECT * FROM git_auth_links WHERE provider_id = $1 AND user_id = $2; +-- name: GetGitAuthLinksByUserID :many +SELECT * FROM git_auth_links WHERE user_id = $1; + + -- name: InsertGitAuthLink :one INSERT INTO git_auth_links ( provider_id, diff --git a/coderd/database/queries/user_links.sql b/coderd/database/queries/user_links.sql index d7e35ae677f19..8f172956b8074 100644 --- a/coderd/database/queries/user_links.sql +++ b/coderd/database/queries/user_links.sql @@ -14,6 +14,9 @@ FROM WHERE user_id = $1 AND login_type = $2; +-- name: GetUserLinksByUserID :many +SELECT * FROM user_links WHERE user_id = $1; + -- name: InsertUserLink :one INSERT INTO user_links ( From 75e4014cb10204c4ba33b2560664c1f62490dbf2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 28 Aug 2023 19:54:28 +0000 Subject: [PATCH 27/52] make gen --- coderd/apidoc/docs.go | 7 +++++-- coderd/apidoc/swagger.json | 7 +++++-- docs/api/general.md | 2 +- docs/api/schemas.md | 6 +++--- docs/cli.md | 1 + docs/cli/dbcrypt-rotate.md | 11 +++++++++++ docs/cli/server.md | 12 ++++++------ docs/manifest.json | 5 +++++ 8 files changed, 37 insertions(+), 14 deletions(-) create mode 100644 docs/cli/dbcrypt-rotate.md diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4147b057e5d04..6643e929ab707 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7951,8 +7951,11 @@ const docTemplate = `{ "type": "string" } }, - "external_token_encryption_key": { - "type": "string" + "external_token_encryption_keys": { + "type": "array", + "items": { + "type": "string" + } }, "git_auth": { "$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 717ea1accaf5d..a421049a87a01 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7106,8 +7106,11 @@ "type": "string" } }, - "external_token_encryption_key": { - "type": "string" + "external_token_encryption_keys": { + "type": "array", + "items": { + "type": "string" + } }, "git_auth": { "$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig" diff --git a/docs/api/general.md b/docs/api/general.md index 042be28554548..d86c70e9ee0e9 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -212,7 +212,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ }, "enable_terraform_debug_mode": true, "experiments": ["string"], - "external_token_encryption_key": "string", + "external_token_encryption_keys": ["string"], "git_auth": { "value": [ { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 6e9c07e5f1c70..209a483db6259 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2034,7 +2034,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in }, "enable_terraform_debug_mode": true, "experiments": ["string"], - "external_token_encryption_key": "string", + "external_token_encryption_keys": ["string"], "git_auth": { "value": [ { @@ -2398,7 +2398,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in }, "enable_terraform_debug_mode": true, "experiments": ["string"], - "external_token_encryption_key": "string", + "external_token_encryption_keys": ["string"], "git_auth": { "value": [ { @@ -2611,7 +2611,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `docs_url` | [clibase.URL](#clibaseurl) | false | | | | `enable_terraform_debug_mode` | boolean | false | | | | `experiments` | array of string | false | | | -| `external_token_encryption_key` | string | false | | | +| `external_token_encryption_keys` | array of string | false | | | | `git_auth` | [clibase.Struct-array_codersdk_GitAuthConfig](#clibasestruct-array_codersdk_gitauthconfig) | false | | | | `http_address` | string | false | | Http address is a string because it may be set to zero to disable. | | `in_memory_database` | boolean | false | | | diff --git a/docs/cli.md b/docs/cli.md index c9ffdc7c46421..40e5a9430f6fb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,6 +27,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | [config-ssh](./cli/config-ssh.md) | Add an SSH Host entry for your workspaces "ssh coder.workspace" | | [create](./cli/create.md) | Create a workspace | +| [dbcrypt-rotate](./cli/dbcrypt-rotate.md) | Rotate database encryption keys | | [delete](./cli/delete.md) | Delete a workspace | | [dotfiles](./cli/dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | | [features](./cli/features.md) | List Enterprise features | diff --git a/docs/cli/dbcrypt-rotate.md b/docs/cli/dbcrypt-rotate.md new file mode 100644 index 0000000000000..760608898b7ab --- /dev/null +++ b/docs/cli/dbcrypt-rotate.md @@ -0,0 +1,11 @@ + + +# dbcrypt-rotate + +Rotate database encryption keys + +## Usage + +```console +coder dbcrypt-rotate +``` diff --git a/docs/cli/server.md b/docs/cli/server.md index 452764140738a..05452cb4b3881 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -273,14 +273,14 @@ Expose the swagger endpoint via /swagger. Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or enter '\*' to opt-in to all available experiments. -### --external-token-encryption-key +### --external-token-encryption-keys -| | | -| ----------- | ------------------------------------------------- | -| Type | string | -| Environment | $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEY | +| | | +| ----------- | -------------------------------------------------- | +| Type | string-array | +| Environment | $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS | -Encrypt OIDC and Git authentication tokens with AES-256-GCM in the database. The value must be a base64-encoded key. +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. A maximum of two keys may be provided. 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. ### --provisioner-force-cancel-interval diff --git a/docs/manifest.json b/docs/manifest.json index 38c6103286346..a856fb109be5f 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -535,6 +535,11 @@ "description": "Create a workspace", "path": "cli/create.md" }, + { + "title": "dbcrypt-rotate", + "description": "Rotate database encryption keys", + "path": "cli/dbcrypt-rotate.md" + }, { "title": "delete", "description": "Delete a workspace", From 0c01b36523d1f2d9cc081975ba2395ebfd80248b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 28 Aug 2023 19:56:53 +0000 Subject: [PATCH 28/52] fixup! add queries to support rotating dbcrypt keys --- enterprise/dbcrypt/dbcrypt.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index e81195807e767..ca4c7ed98bf1c 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -27,6 +27,7 @@ import ( "database/sql" "encoding/base64" "errors" + "github.com/google/uuid" "strings" "sync/atomic" @@ -142,6 +143,19 @@ func (db *dbCrypt) GetUserLinkByLinkedID(ctx context.Context, linkedID string) ( return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) } +func (db *dbCrypt) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.UserLink, error) { + links, err := db.Store.GetUserLinksByUserID(ctx, userID) + if err != nil { + return nil, err + } + for _, link := range links { + if err := db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken); err != nil { + return nil, err + } + } + return links, nil +} + func (db *dbCrypt) GetUserLinkByUserIDLoginType(ctx context.Context, params database.GetUserLinkByUserIDLoginTypeParams) (database.UserLink, error) { link, err := db.Store.GetUserLinkByUserIDLoginType(ctx, params) if err != nil { @@ -182,6 +196,19 @@ func (db *dbCrypt) GetGitAuthLink(ctx context.Context, params database.GetGitAut return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) } +func (db *dbCrypt) GetGitAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.GitAuthLink, error) { + links, err := db.Store.GetGitAuthLinksByUserID(ctx, userID) + if err != nil { + return nil, err + } + for _, link := range links { + if err := db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken); err != nil { + return nil, err + } + } + return links, nil +} + func (db *dbCrypt) UpdateGitAuthLink(ctx context.Context, params database.UpdateGitAuthLinkParams) (database.GitAuthLink, error) { err := db.encryptFields(¶ms.OAuthAccessToken, ¶ms.OAuthRefreshToken) if err != nil { From a4573076e7355d142e4f356e396e41d3e8021814 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 28 Aug 2023 19:57:09 +0000 Subject: [PATCH 29/52] flesh out unit test --- enterprise/cli/dbcrypt_rotate_test.go | 129 +++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 15 deletions(-) diff --git a/enterprise/cli/dbcrypt_rotate_test.go b/enterprise/cli/dbcrypt_rotate_test.go index 9577627b13064..5b66e21c53330 100644 --- a/enterprise/cli/dbcrypt_rotate_test.go +++ b/enterprise/cli/dbcrypt_rotate_test.go @@ -1,36 +1,135 @@ package cli_test import ( + "context" + "database/sql" + "encoding/base64" + "fmt" + + "sync/atomic" "testing" - "github.com/coder/coder/v2/cli/clitest" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" - "github.com/coder/coder/v2/enterprise/coderd/license" + "cdr.dev/slog/sloggers/slogtest" + + "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" + "github.com/stretchr/testify/require" ) func TestDBCryptRotate(t *testing.T) { - t.Parallel() + //nolint: paralleltest // use of t.Setenv + 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) - // TODO: create a test database and populate some encrypted data with cipher A + // Setup an initial cipher + keyA := mustString(t, 32) + cA, err := dbcrypt.CipherAES256([]byte(keyA)) + require.NoError(t, err) + cipherA := &atomic.Pointer[dbcrypt.Cipher]{} + cipherB := &atomic.Pointer[dbcrypt.Cipher]{} + cipherA.Store(&cA) + + // Create an encrypted database + log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + cryptdb, err := dbcrypt.New(ctx, db, &dbcrypt.Options{ + PrimaryCipher: cipherA, + SecondaryCipher: cipherB, + Logger: log, + }) + require.NoError(t, err) - client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureExternalTokenEncryption: 1, - }, - }}) + // Populate the database with some data encrypted with cipher A. + var users []database.User + for i := 0; i < 10; i++ { + usr := dbgen.User(t, cryptdb, database.User{ + LoginType: database.LoginTypeOIDC, + }) + _ = dbgen.UserLink(t, cryptdb, database.UserLink{ + UserID: usr.ID, + LoginType: usr.LoginType, + OAuthAccessToken: mustString(t, 16), + OAuthRefreshToken: mustString(t, 16), + }) + _ = dbgen.GitAuthLink(t, cryptdb, database.GitAuthLink{ + UserID: usr.ID, + ProviderID: "fake", + OAuthAccessToken: mustString(t, 16), + OAuthRefreshToken: mustString(t, 16), + }) + users = append(users, usr) + } // Run the cmd with ciphers B,A + keyB := mustString(t, 32) + cB, err := dbcrypt.CipherAES256([]byte(keyB)) + require.NoError(t, err) + externalTokensArg := fmt.Sprintf( + "%s,%s", + base64.StdEncoding.EncodeToString([]byte(keyB)), + base64.StdEncoding.EncodeToString([]byte(keyA)), + ) - inv, conf := newCLI(t, "dbcrypt-rotate") // TODO: env? + inv, _ := newCLI(t, "dbcrypt-rotate", + "--postgres-url", connectionURL, + "--external-token-encryption-keys", externalTokensArg, + ) pty := ptytest.New(t) inv.Stdout = pty.Output() - clitest.SetupConfig(t, client, conf) - err := inv.Run() + err = inv.Run() require.NoError(t, err) - // TODO: validate that all data has been updated with the checksum of the new cipher. + // Validate that all data has been updated with the checksum of the new cipher. + expectedPrefixA := fmt.Sprintf("dbcrypt-%s-", cA.HexDigest()[:7]) + expectedPrefixB := fmt.Sprintf("dbcrypt-%s-", cB.HexDigest()[:7]) + for _, usr := range users { + ul, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ + UserID: usr.ID, + LoginType: usr.LoginType, + }) + require.NoError(t, err, "failed to get user link for user %s", usr.ID) + require.NotContains(t, ul.OAuthAccessToken, expectedPrefixA, "user_link.oauth_access_token should not contain the old cipher checksum") + require.NotContains(t, ul.OAuthRefreshToken, expectedPrefixA, "user_link.oauth_refresh_token should not contain the old cipher checksum") + require.Contains(t, ul.OAuthAccessToken, expectedPrefixB, "user_link.oauth_access_token should contain the new cipher checksum") + require.Contains(t, ul.OAuthRefreshToken, expectedPrefixB, "user_link.oauth_refresh_token should contain the new cipher checksum") + + gal, err := db.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ + UserID: usr.ID, + ProviderID: "fake", + }) + require.NoError(t, err, "failed to get git auth link for user %s", usr.ID) + require.NotContains(t, gal.OAuthAccessToken, expectedPrefixA, "git_auth_link.oauth_access_token should not contain the old cipher checksum") + require.NotContains(t, gal.OAuthRefreshToken, expectedPrefixA, "git_auth_link.oauth_refresh_token should not contain the old cipher checksum") + require.Contains(t, gal.OAuthAccessToken, expectedPrefixB, "git_auth_link.oauth_access_token should contain the new cipher checksum") + require.Contains(t, gal.OAuthRefreshToken, expectedPrefixB, "git_auth_link.oauth_refresh_token should contain the new cipher checksum") + } +} + +func mustString(t *testing.T, n int) string { + t.Helper() + s, err := cryptorand.String(n) + require.NoError(t, err) + return s } From 4d2874666d0ef49cf0f60f34bcf81d4d2d3035eb Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 28 Aug 2023 19:57:19 +0000 Subject: [PATCH 30/52] make the test pass --- cli/root.go | 3 +- enterprise/cli/dbcrypt_rotate.go | 122 ++++++++++++++++++++++++++++++- enterprise/cli/root.go | 1 + enterprise/dbcrypt/dbcrypt.go | 3 +- 4 files changed, 123 insertions(+), 6 deletions(-) diff --git a/cli/root.go b/cli/root.go index 3ab2f0d7f33b9..3c3fa7535dd73 100644 --- a/cli/root.go +++ b/cli/root.go @@ -243,7 +243,8 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) { for _, opt := range cmd.Options { // Verify that every option is configurable. if opt.Flag == "" && opt.Env == "" { - if cmd.Name() == "server" { + // TODO(Cian): maybe move dbcrypt-rotate under server to work around this? + if cmd.Name() == "server" || cmd.Name() == "dbcrypt-rotate" { // The server command is funky and has YAML-only options, e.g. // support links. return diff --git a/enterprise/cli/dbcrypt_rotate.go b/enterprise/cli/dbcrypt_rotate.go index 7ceba78ced492..ef8b3eb61602a 100644 --- a/enterprise/cli/dbcrypt_rotate.go +++ b/enterprise/cli/dbcrypt_rotate.go @@ -3,18 +3,134 @@ package cli import ( + "bytes" + "cdr.dev/slog" + "context" + "encoding/base64" + "sync/atomic" + + "github.com/coder/coder/v2/cli" "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/dbcrypt" + + "golang.org/x/xerrors" ) func (r *RootCmd) dbcryptRotate() *clibase.Cmd { + var ( + vals = new(codersdk.DeploymentValues) + opts = vals.Options() + ) cmd := &clibase.Cmd{ - Use: "dbcrypt-rotate", - Short: "Rotate database encryption keys", + Use: "dbcrypt-rotate --postgres-url --external-token-encryption-keys ,", + Short: "Rotate database encryption keys", + Options: opts, Middleware: clibase.Chain( clibase.RequireNArgs(0), ), Handler: func(inv *clibase.Invocation) error { - // TODO: implement + ctx, cancel := context.WithCancel(inv.Context()) + defer cancel() + logger, closeLogger, err := cli.BuildLogger(inv, vals) + if err != nil { + return xerrors.Errorf("set up logging: %w", err) + } + defer closeLogger() + + if vals.PostgresURL == "" { + return xerrors.Errorf("no database configured") + } + + if vals.ExternalTokenEncryptionKeys == nil || len(vals.ExternalTokenEncryptionKeys) != 2 { + return xerrors.Errorf("dbcrypt-rotate requires exactly two external token encryption keys") + } + + newKey, err := base64.StdEncoding.DecodeString(vals.ExternalTokenEncryptionKeys[0]) + if err != nil { + return xerrors.Errorf("new key must be base64-encoded") + } + oldKey, err := base64.StdEncoding.DecodeString(vals.ExternalTokenEncryptionKeys[1]) + if err != nil { + return xerrors.Errorf("old key must be base64-encoded") + } + if bytes.Compare(newKey, oldKey) == 0 { + return xerrors.Errorf("old and new keys must be different") + } + + primaryCipherPtr := &atomic.Pointer[dbcrypt.Cipher]{} + secondaryCipherPtr := &atomic.Pointer[dbcrypt.Cipher]{} + primaryCipher, err := dbcrypt.CipherAES256(newKey) + if err != nil { + return xerrors.Errorf("create primary cipher: %w", err) + } + secondaryCipher, err := dbcrypt.CipherAES256(oldKey) + if err != nil { + return xerrors.Errorf("create secondary cipher: %w", err) + } + primaryCipherPtr.Store(&primaryCipher) + secondaryCipherPtr.Store(&secondaryCipher) + + sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", vals.PostgresURL.Value()) + if err != nil { + return xerrors.Errorf("connect to postgres: %w", err) + } + defer func() { + _ = sqlDB.Close() + }() + logger.Info(ctx, "connected to postgres") + + db := database.New(sqlDB) + + cryptDB, err := dbcrypt.New(ctx, db, &dbcrypt.Options{ + PrimaryCipher: primaryCipherPtr, + SecondaryCipher: secondaryCipherPtr, + Logger: logger.Named("cryptdb"), + }) + if err != nil { + return xerrors.Errorf("create cryptdb: %w", err) + } + + users, err := cryptDB.GetUsers(ctx, database.GetUsersParams{}) + if err != nil { + return xerrors.Errorf("get users: %w", err) + } + for idx, usr := range users { + userLinks, err := cryptDB.GetUserLinksByUserID(ctx, usr.ID) + if err != nil { + return xerrors.Errorf("get user links for user: %w", err) + } + for _, userLink := range userLinks { + if _, err := cryptDB.UpdateUserLink(ctx, database.UpdateUserLinkParams{ + OAuthAccessToken: userLink.OAuthAccessToken, + OAuthRefreshToken: userLink.OAuthRefreshToken, + OAuthExpiry: userLink.OAuthExpiry, + UserID: usr.ID, + LoginType: usr.LoginType, + }); err != nil { + return xerrors.Errorf("update user link: %w", err) + } + } + gitAuthLinks, err := cryptDB.GetGitAuthLinksByUserID(ctx, usr.ID) + if err != nil { + return xerrors.Errorf("get git auth links for user: %w", err) + } + for _, gitAuthLink := range gitAuthLinks { + if _, err := cryptDB.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ + ProviderID: gitAuthLink.ProviderID, + UserID: usr.ID, + UpdatedAt: gitAuthLink.UpdatedAt, + OAuthAccessToken: gitAuthLink.OAuthAccessToken, + OAuthRefreshToken: gitAuthLink.OAuthRefreshToken, + OAuthExpiry: gitAuthLink.OAuthExpiry, + }); err != nil { + return xerrors.Errorf("update git auth link: %w", err) + } + } + logger.Info(ctx, "encrypted user tokens", slog.F("current", idx+1), slog.F("of", len(users))) + } + logger.Info(ctx, "operation completed successfully!") return nil }, } diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index 9f7bfb9039683..ef956af3f5471 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -17,6 +17,7 @@ func (r *RootCmd) enterpriseOnly() []*clibase.Cmd { r.licenses(), r.groups(), r.provisionerDaemons(), + r.dbcryptRotate(), } } diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index ca4c7ed98bf1c..83a30996fe4be 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -27,12 +27,11 @@ import ( "database/sql" "encoding/base64" "errors" - "github.com/google/uuid" "strings" "sync/atomic" + "github.com/google/uuid" "github.com/hashicorp/go-multierror" - "golang.org/x/xerrors" "cdr.dev/slog" From 67ee610a4490e92552333dee06ea4d3ab9e34588 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 08:15:24 +0000 Subject: [PATCH 31/52] remove unused queries --- coderd/database/dbauthz/dbauthz.go | 13 ---------- coderd/database/dbfake/dbfake.go | 33 -------------------------- coderd/database/dbmetrics/dbmetrics.go | 14 ----------- coderd/database/dbmock/dbmock.go | 28 ---------------------- coderd/database/querier.go | 2 -- coderd/database/queries.sql.go | 26 -------------------- coderd/database/queries/gitauth.sql | 3 --- coderd/database/queries/user_links.sql | 6 ----- 8 files changed, 125 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4f080059b6609..094dc6ad79d14 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -685,15 +685,6 @@ func (q *querier) DeleteCoordinator(ctx context.Context, id uuid.UUID) error { return q.db.DeleteCoordinator(ctx, id) } -func (q *querier) DeleteGitAuthLink(ctx context.Context, arg database.DeleteGitAuthLinkParams) error { - return deleteQ(q.log, q.auth, func(ctx context.Context, arg database.DeleteGitAuthLinkParams) (database.GitAuthLink, error) { - return q.db.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ - UserID: arg.UserID, - ProviderID: arg.ProviderID, - }) - }, q.db.DeleteGitAuthLink)(ctx, arg) -} - func (q *querier) DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error { return deleteQ(q.log, q.auth, q.db.GetGitSSHKey, q.db.DeleteGitSSHKey)(ctx, userID) } @@ -766,10 +757,6 @@ func (q *querier) DeleteTailnetClient(ctx context.Context, arg database.DeleteTa return q.db.DeleteTailnetClient(ctx, arg) } -func (q *querier) DeleteUserLinkByLinkedID(ctx context.Context, linkedID string) error { - return deleteQ(q.log, q.auth, q.db.GetUserLinkByLinkedID, q.db.DeleteUserLinkByLinkedID)(ctx, linkedID) -} - func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id) } diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 4200058228810..3d46be7fb3e60 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -774,24 +774,6 @@ func (*FakeQuerier) DeleteCoordinator(context.Context, uuid.UUID) error { return ErrUnimplemented } -func (q *FakeQuerier) DeleteGitAuthLink(_ context.Context, arg database.DeleteGitAuthLinkParams) error { - q.mutex.Lock() - defer q.mutex.Unlock() - - for index, link := range q.gitAuthLinks { - if link.ProviderID != arg.ProviderID { - continue - } - if link.UserID != arg.UserID { - continue - } - q.gitAuthLinks[index] = q.gitAuthLinks[len(q.gitAuthLinks)-1] - q.gitAuthLinks = q.gitAuthLinks[:len(q.gitAuthLinks)-1] - return nil - } - return sql.ErrNoRows -} - func (q *FakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -908,21 +890,6 @@ func (*FakeQuerier) DeleteTailnetClient(context.Context, database.DeleteTailnetC return database.DeleteTailnetClientRow{}, ErrUnimplemented } -func (q *FakeQuerier) DeleteUserLinkByLinkedID(_ context.Context, linkedID string) error { - q.mutex.Lock() - defer q.mutex.Unlock() - - for index, link := range q.userLinks { - if link.LinkedID != linkedID { - continue - } - q.userLinks[index] = q.userLinks[len(q.userLinks)-1] - q.userLinks = q.userLinks[:len(q.userLinks)-1] - return nil - } - return sql.ErrNoRows -} - func (q *FakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIKey, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 4297565db91bc..b94fa6270f5fc 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -127,13 +127,6 @@ func (m metricsStore) DeleteCoordinator(ctx context.Context, id uuid.UUID) error return m.s.DeleteCoordinator(ctx, id) } -func (m metricsStore) DeleteGitAuthLink(ctx context.Context, arg database.DeleteGitAuthLinkParams) error { - start := time.Now() - r0 := m.s.DeleteGitAuthLink(ctx, arg) - m.queryLatencies.WithLabelValues("DeleteGitAuthLink").Observe(time.Since(start).Seconds()) - return r0 -} - func (m metricsStore) DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error { start := time.Now() err := m.s.DeleteGitSSHKey(ctx, userID) @@ -202,13 +195,6 @@ func (m metricsStore) DeleteTailnetClient(ctx context.Context, arg database.Dele return m.s.DeleteTailnetClient(ctx, arg) } -func (m metricsStore) DeleteUserLinkByLinkedID(ctx context.Context, linkedID string) error { - start := time.Now() - r0 := m.s.DeleteUserLinkByLinkedID(ctx, linkedID) - m.queryLatencies.WithLabelValues("DeleteUserLinkByLinkedID").Observe(time.Since(start).Seconds()) - return r0 -} - func (m metricsStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { start := time.Now() apiKey, err := m.s.GetAPIKeyByID(ctx, id) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 287a5fcb7f654..bb05528036946 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -138,20 +138,6 @@ func (mr *MockStoreMockRecorder) DeleteCoordinator(arg0, arg1 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCoordinator", reflect.TypeOf((*MockStore)(nil).DeleteCoordinator), arg0, arg1) } -// DeleteGitAuthLink mocks base method. -func (m *MockStore) DeleteGitAuthLink(arg0 context.Context, arg1 database.DeleteGitAuthLinkParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteGitAuthLink", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteGitAuthLink indicates an expected call of DeleteGitAuthLink. -func (mr *MockStoreMockRecorder) DeleteGitAuthLink(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGitAuthLink", reflect.TypeOf((*MockStore)(nil).DeleteGitAuthLink), arg0, arg1) -} - // DeleteGitSSHKey mocks base method. func (m *MockStore) DeleteGitSSHKey(arg0 context.Context, arg1 uuid.UUID) error { m.ctrl.T.Helper() @@ -295,20 +281,6 @@ func (mr *MockStoreMockRecorder) DeleteTailnetClient(arg0, arg1 interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTailnetClient", reflect.TypeOf((*MockStore)(nil).DeleteTailnetClient), arg0, arg1) } -// DeleteUserLinkByLinkedID mocks base method. -func (m *MockStore) DeleteUserLinkByLinkedID(arg0 context.Context, arg1 string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteUserLinkByLinkedID", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteUserLinkByLinkedID indicates an expected call of DeleteUserLinkByLinkedID. -func (mr *MockStoreMockRecorder) DeleteUserLinkByLinkedID(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserLinkByLinkedID", reflect.TypeOf((*MockStore)(nil).DeleteUserLinkByLinkedID), arg0, arg1) -} - // GetAPIKeyByID mocks base method. func (m *MockStore) GetAPIKeyByID(arg0 context.Context, arg1 string) (database.APIKey, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f117df50805d8..b4c4469bfe55b 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -29,7 +29,6 @@ type sqlcQuerier interface { DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error DeleteCoordinator(ctx context.Context, id uuid.UUID) error - DeleteGitAuthLink(ctx context.Context, arg DeleteGitAuthLinkParams) error DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error DeleteGroupByID(ctx context.Context, id uuid.UUID) error DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteGroupMemberFromGroupParams) error @@ -42,7 +41,6 @@ type sqlcQuerier interface { DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error DeleteTailnetAgent(ctx context.Context, arg DeleteTailnetAgentParams) (DeleteTailnetAgentRow, error) DeleteTailnetClient(ctx context.Context, arg DeleteTailnetClientParams) (DeleteTailnetClientRow, error) - DeleteUserLinkByLinkedID(ctx context.Context, linkedID string) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) // there is no unique constraint on empty token names GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 09f4f1b2e5f75..be565e3e2e407 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -819,20 +819,6 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File return i, err } -const deleteGitAuthLink = `-- name: DeleteGitAuthLink :exec -DELETE FROM git_auth_links WHERE provider_id = $1 AND user_id = $2 -` - -type DeleteGitAuthLinkParams struct { - ProviderID string `db:"provider_id" json:"provider_id"` - UserID uuid.UUID `db:"user_id" json:"user_id"` -} - -func (q *sqlQuerier) DeleteGitAuthLink(ctx context.Context, arg DeleteGitAuthLinkParams) error { - _, err := q.db.ExecContext(ctx, deleteGitAuthLink, arg.ProviderID, arg.UserID) - return err -} - const getGitAuthLink = `-- name: GetGitAuthLink :one SELECT provider_id, user_id, created_at, updated_at, oauth_access_token, oauth_refresh_token, oauth_expiry FROM git_auth_links WHERE provider_id = $1 AND user_id = $2 ` @@ -5517,18 +5503,6 @@ func (q *sqlQuerier) InsertTemplateVersionVariable(ctx context.Context, arg Inse return i, err } -const deleteUserLinkByLinkedID = `-- name: DeleteUserLinkByLinkedID :exec -DELETE FROM - user_links -WHERE - linked_id = $1 -` - -func (q *sqlQuerier) DeleteUserLinkByLinkedID(ctx context.Context, linkedID string) error { - _, err := q.db.ExecContext(ctx, deleteUserLinkByLinkedID, linkedID) - return err -} - const getUserLinkByLinkedID = `-- name: GetUserLinkByLinkedID :one SELECT user_id, login_type, linked_id, oauth_access_token, oauth_refresh_token, oauth_expiry diff --git a/coderd/database/queries/gitauth.sql b/coderd/database/queries/gitauth.sql index aeee8d7430d54..b83b481cf4672 100644 --- a/coderd/database/queries/gitauth.sql +++ b/coderd/database/queries/gitauth.sql @@ -31,6 +31,3 @@ UPDATE git_auth_links SET oauth_refresh_token = $5, oauth_expiry = $6 WHERE provider_id = $1 AND user_id = $2 RETURNING *; - --- name: DeleteGitAuthLink :exec -DELETE FROM git_auth_links WHERE provider_id = $1 AND user_id = $2; diff --git a/coderd/database/queries/user_links.sql b/coderd/database/queries/user_links.sql index 8f172956b8074..69cd058b56caf 100644 --- a/coderd/database/queries/user_links.sql +++ b/coderd/database/queries/user_links.sql @@ -47,9 +47,3 @@ SET oauth_expiry = $3 WHERE user_id = $4 AND login_type = $5 RETURNING *; - --- name: DeleteUserLinkByLinkedID :exec -DELETE FROM - user_links -WHERE - linked_id = $1; From 5a0161c4789f3fdd9a83aa6032afd5cd8c32771e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 08:58:36 +0000 Subject: [PATCH 32/52] refactor: add Ciphers to abstract over multiple ciphers --- enterprise/dbcrypt/cipher.go | 60 ++++++++++++++++++++++++++++--- enterprise/dbcrypt/cipher_test.go | 40 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/enterprise/dbcrypt/cipher.go b/enterprise/dbcrypt/cipher.go index de8630d14c96b..77a914a819c81 100644 --- a/enterprise/dbcrypt/cipher.go +++ b/enterprise/dbcrypt/cipher.go @@ -30,13 +30,14 @@ func CipherAES256(key []byte) (Cipher, error) { if err != nil { return nil, err } - digest := sha256.Sum256(key) - return &aes256{aead: aead, digest: digest[:]}, nil + digest := fmt.Sprintf("%x", sha256.Sum256(key))[:7] + return &aes256{aead: aead, digest: digest}, nil } type aes256 struct { - aead cipher.AEAD - digest []byte + aead cipher.AEAD + // digest is the first 7 bytes of the hex-encoded SHA-256 digest of aead. + digest string } func (a *aes256) Encrypt(plaintext []byte) ([]byte, error) { @@ -60,5 +61,54 @@ func (a *aes256) Decrypt(ciphertext []byte) ([]byte, error) { } func (a *aes256) HexDigest() string { - return fmt.Sprintf("%x", a.digest) + return a.digest +} + +type CipherDigest string +type Ciphers struct { + primary string + m map[string]Cipher +} + +// CiphersAES256 returns a new Ciphers instance with the given ciphers. +// The first cipher in the list is the primary cipher. Any ciphers after the +// first are considered secondary ciphers and are only used for decryption. +func CiphersAES256(cs ...Cipher) Ciphers { + var primary string + m := make(map[string]Cipher) + for idx, c := range cs { + m[c.HexDigest()] = c + if idx == 0 { + primary = c.HexDigest() + } + } + return Ciphers{primary: primary, m: m} +} + +// Encrypt encrypts the given plaintext using the primary cipher and returns the +// ciphertext. The ciphertext is prefixed with the primary cipher's digest. +func (cs Ciphers) Encrypt(plaintext []byte) ([]byte, error) { + c, ok := cs.m[cs.primary] + if !ok { + return nil, xerrors.Errorf("no ciphers configured") + } + prefix := []byte(c.HexDigest() + "-") + crypted, err := c.Encrypt(plaintext) + if err != nil { + return nil, err + } + return append(prefix, crypted...), nil +} + +// Decrypt decrypts the given ciphertext using the cipher indicated by the +// ciphertext's prefix. The prefix is the first 7 bytes of the hex-encoded +// SHA-256 digest of the cipher's key. Decryption will fail if the prefix +// does not match any of the configured ciphers. +func (cs Ciphers) Decrypt(ciphertext []byte) ([]byte, error) { + requiredPrefix := string(ciphertext[:7]) + c, ok := cs.m[requiredPrefix] + if !ok { + return nil, xerrors.Errorf("missing required decryption cipher %s", requiredPrefix) + } + return c.Decrypt(ciphertext[8:]) } diff --git a/enterprise/dbcrypt/cipher_test.go b/enterprise/dbcrypt/cipher_test.go index e412e39828497..543038d4b637b 100644 --- a/enterprise/dbcrypt/cipher_test.go +++ b/enterprise/dbcrypt/cipher_test.go @@ -43,3 +43,43 @@ func TestCipherAES256(t *testing.T) { require.ErrorContains(t, err, "key must be 32 bytes") }) } + +func TestCiphersAES256(t *testing.T) { + t.Parallel() + + // Given: two ciphers + key1 := bytes.Repeat([]byte{'a'}, 32) + key2 := bytes.Repeat([]byte{'b'}, 32) + cipher1, err := dbcrypt.CipherAES256(key1) + require.NoError(t, err) + cipher2, err := dbcrypt.CipherAES256(key2) + require.NoError(t, err) + + ciphers := dbcrypt.CiphersAES256( + cipher1, + cipher2, + ) + + // Then: it should encrypt with the cipher1 + output, err := ciphers.Encrypt([]byte("hello world")) + require.NoError(t, err) + // The first 7 bytes of the output should be the hex digest of cipher1 + require.Equal(t, cipher1.HexDigest(), string(output[:7])) + + // And: it should decrypt successfully + decrypted, err := ciphers.Decrypt(output) + require.NoError(t, err) + require.Equal(t, "hello world", string(decrypted)) + + // Decryption of the above should fail with cipher2 + _, err = cipher2.Decrypt(output) + var decryptErr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &decryptErr) + + // Decryption of data encrypted with cipher2 should succeed + output2, err := cipher2.Encrypt([]byte("hello world")) + require.NoError(t, err) + decrypted2, err := ciphers.Decrypt(bytes.Join([][]byte{[]byte(cipher2.HexDigest()), output2}, []byte{'-'})) + require.NoError(t, err) + require.Equal(t, "hello world", string(decrypted2)) +} From 4142fb2b13939be2ff09683408804fa66a591ad3 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 10:47:33 +0000 Subject: [PATCH 33/52] refactor dbcrypt: add Ciphers to wrap multiple AES256 --- enterprise/dbcrypt/cipher.go | 26 ++++--- enterprise/dbcrypt/cipher_test.go | 19 +++-- enterprise/dbcrypt/dbcrypt.go | 98 +++++------------------ enterprise/dbcrypt/dbcrypt_test.go | 121 +++++++++-------------------- 4 files changed, 87 insertions(+), 177 deletions(-) diff --git a/enterprise/dbcrypt/cipher.go b/enterprise/dbcrypt/cipher.go index 77a914a819c81..ba7b06bc77682 100644 --- a/enterprise/dbcrypt/cipher.go +++ b/enterprise/dbcrypt/cipher.go @@ -18,7 +18,7 @@ type Cipher interface { } // CipherAES256 returns a new AES-256 cipher. -func CipherAES256(key []byte) (Cipher, error) { +func CipherAES256(key []byte) (*AES256, error) { if len(key) != 32 { return nil, xerrors.Errorf("key must be 32 bytes") } @@ -31,16 +31,16 @@ func CipherAES256(key []byte) (Cipher, error) { return nil, err } digest := fmt.Sprintf("%x", sha256.Sum256(key))[:7] - return &aes256{aead: aead, digest: digest}, nil + return &AES256{aead: aead, digest: digest}, nil } -type aes256 struct { +type AES256 struct { aead cipher.AEAD // digest is the first 7 bytes of the hex-encoded SHA-256 digest of aead. digest string } -func (a *aes256) Encrypt(plaintext []byte) ([]byte, error) { +func (a *AES256) Encrypt(plaintext []byte) ([]byte, error) { nonce := make([]byte, a.aead.NonceSize()) _, err := io.ReadFull(rand.Reader, nonce) if err != nil { @@ -49,7 +49,7 @@ func (a *aes256) Encrypt(plaintext []byte) ([]byte, error) { return a.aead.Seal(nonce, nonce, plaintext, nil), nil } -func (a *aes256) Decrypt(ciphertext []byte) ([]byte, error) { +func (a *AES256) Decrypt(ciphertext []byte) ([]byte, error) { if len(ciphertext) < a.aead.NonceSize() { return nil, xerrors.Errorf("ciphertext too short") } @@ -60,7 +60,7 @@ func (a *aes256) Decrypt(ciphertext []byte) ([]byte, error) { return decrypted, nil } -func (a *aes256) HexDigest() string { +func (a *AES256) HexDigest() string { return a.digest } @@ -70,19 +70,22 @@ type Ciphers struct { m map[string]Cipher } -// CiphersAES256 returns a new Ciphers instance with the given ciphers. +// NewCiphers returns a new Ciphers instance with the given ciphers. // The first cipher in the list is the primary cipher. Any ciphers after the // first are considered secondary ciphers and are only used for decryption. -func CiphersAES256(cs ...Cipher) Ciphers { +func NewCiphers(cs ...Cipher) *Ciphers { var primary string m := make(map[string]Cipher) for idx, c := range cs { + if _, ok := c.(*Ciphers); ok { + panic("developer error: do not nest Ciphers") + } m[c.HexDigest()] = c if idx == 0 { primary = c.HexDigest() } } - return Ciphers{primary: primary, m: m} + return &Ciphers{primary: primary, m: m} } // Encrypt encrypts the given plaintext using the primary cipher and returns the @@ -112,3 +115,8 @@ func (cs Ciphers) Decrypt(ciphertext []byte) ([]byte, error) { } return c.Decrypt(ciphertext[8:]) } + +// HexDigest returns the digest of the primary cipher. +func (cs Ciphers) HexDigest() string { + return cs.primary +} diff --git a/enterprise/dbcrypt/cipher_test.go b/enterprise/dbcrypt/cipher_test.go index 543038d4b637b..6f18c260815a3 100644 --- a/enterprise/dbcrypt/cipher_test.go +++ b/enterprise/dbcrypt/cipher_test.go @@ -44,7 +44,7 @@ func TestCipherAES256(t *testing.T) { }) } -func TestCiphersAES256(t *testing.T) { +func TestCiphers(t *testing.T) { t.Parallel() // Given: two ciphers @@ -55,10 +55,7 @@ func TestCiphersAES256(t *testing.T) { cipher2, err := dbcrypt.CipherAES256(key2) require.NoError(t, err) - ciphers := dbcrypt.CiphersAES256( - cipher1, - cipher2, - ) + ciphers := dbcrypt.NewCiphers(cipher1, cipher2) // Then: it should encrypt with the cipher1 output, err := ciphers.Encrypt([]byte("hello world")) @@ -82,4 +79,16 @@ func TestCiphersAES256(t *testing.T) { decrypted2, err := ciphers.Decrypt(bytes.Join([][]byte{[]byte(cipher2.HexDigest()), output2}, []byte{'-'})) require.NoError(t, err) require.Equal(t, "hello world", string(decrypted2)) + + // Decryption of data encrypted with cipher1 should succeed + output1, err := cipher1.Encrypt([]byte("hello world")) + require.NoError(t, err) + decrypted1, err := ciphers.Decrypt(bytes.Join([][]byte{[]byte(cipher1.HexDigest()), output1}, []byte{'-'})) + require.NoError(t, err) + require.Equal(t, "hello world", string(decrypted1)) + + // Wrapping a Ciphers with itself should panic. + require.PanicsWithValue(t, "developer error: do not nest Ciphers", func() { + _ = dbcrypt.NewCiphers(ciphers) + }) } diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index 83a30996fe4be..611540a7d850c 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -12,14 +12,14 @@ // - database.DBCryptSentinelValue // // Encrypted fields are stored in the following format: -// "dbcrypt--" +// "dbcrypt-${b64encode(-)}" // // The first 7 characters of the cipher's SHA256 digest are used to identify the cipher // used to encrypt the value. // -// Two ciphers can be provided to support key rotation. The primary cipher is used to encrypt -// and decrypt all values. We only use the secondary cipher to decrypt values if decryption -// with the primary cipher fails. +// Multiple ciphers can be provided to support key rotation. The primary cipher is used +// to encrypt and decrypt all data. Secondary ciphers are only used for decryption. +// We currently only use a single secondary cipher. package dbcrypt import ( @@ -28,16 +28,12 @@ import ( "encoding/base64" "errors" "strings" - "sync/atomic" - - "github.com/google/uuid" - "github.com/hashicorp/go-multierror" - "golang.org/x/xerrors" - - "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + + "github.com/google/uuid" + "golang.org/x/xerrors" ) // MagicPrefix is prepended to all encrypted values in the database. @@ -48,10 +44,6 @@ import ( // encrypted value. const MagicPrefix = "dbcrypt-" -// MagicPrefixLength is the length of the entire prefix used to identify -// encrypted values. -const MagicPrefixLength = len(MagicPrefix) + 8 - // sentinelValue is the value that is stored in the database to indicate // whether encryption is enabled. If not enabled, the value either not // present, or is the raw string "coder". @@ -79,31 +71,14 @@ func (*DecryptFailedError) Unwrap() error { return sql.ErrNoRows } -func IsDecryptFailedError(err error) bool { - var e *DecryptFailedError - return errors.As(err, &e) -} - -type Options struct { - // PrimaryCipher is an optional cipher that is used - // to encrypt/decrypt user link and git auth link tokens. If this is nil, - // then no encryption/decryption will be performed. - PrimaryCipher *atomic.Pointer[Cipher] - // SecondaryCipher is an optional cipher that is only used - // to decrypt user link and git auth link tokens. - // This should only be used when rotating the primary cipher. - SecondaryCipher *atomic.Pointer[Cipher] - Logger slog.Logger -} - // New creates a database.Store wrapper that encrypts/decrypts values // stored at rest in the database. -func New(ctx context.Context, db database.Store, options *Options) (database.Store, error) { - if options.PrimaryCipher.Load() == nil { - return nil, xerrors.Errorf("at least one cipher is required") +func New(ctx context.Context, db database.Store, cs *Ciphers) (database.Store, error) { + if cs == nil { + return nil, xerrors.Errorf("no ciphers configured") } dbc := &dbCrypt{ - Options: options, + ciphers: cs, Store: db, } if err := ensureEncrypted(dbauthz.AsSystemRestricted(ctx), dbc); err != nil { @@ -113,14 +88,14 @@ func New(ctx context.Context, db database.Store, options *Options) (database.Sto } type dbCrypt struct { - *Options + ciphers *Ciphers database.Store } func (db *dbCrypt) InTx(function func(database.Store) error, txOpts *sql.TxOptions) error { return db.Store.InTx(func(s database.Store) error { return function(&dbCrypt{ - Options: db.Options, + ciphers: db.ciphers, Store: s, }) }, txOpts) @@ -225,24 +200,22 @@ func (db *dbCrypt) SetDBCryptSentinelValue(ctx context.Context, value string) er } func (db *dbCrypt) encryptFields(fields ...*string) error { - // Encryption ALWAYS happens with the primary cipher. - cipherPtr := db.PrimaryCipher.Load() // If no cipher is loaded, then we can't encrypt anything! - if cipherPtr == nil { + if db.ciphers == nil { return ErrNotEnabled } - cipher := *cipherPtr + for _, field := range fields { if field == nil { continue } - encrypted, err := cipher.Encrypt([]byte(*field)) + encrypted, err := db.ciphers.Encrypt([]byte(*field)) if err != nil { return err } // Base64 is used to support UTF-8 encoding in PostgreSQL. - *field = MagicPrefix + cipher.HexDigest()[:7] + "-" + b64encode(encrypted) + *field = MagicPrefix + b64encode(encrypted) } return nil } @@ -250,58 +223,29 @@ func (db *dbCrypt) encryptFields(fields ...*string) error { // decryptFields decrypts the given fields in place. // If the value fails to decrypt, sql.ErrNoRows will be returned. func (db *dbCrypt) decryptFields(fields ...*string) error { - var merr *multierror.Error - - // We try to decrypt with both the primary and secondary cipher. - primaryCipherPtr := db.PrimaryCipher.Load() - if err := decryptWithCipher(primaryCipherPtr, fields...); err == nil { - return nil - } else { - merr = multierror.Append(merr, err) - } - secondaryCipherPtr := db.SecondaryCipher.Load() - if err := decryptWithCipher(secondaryCipherPtr, fields...); err == nil { - return nil - } else { - merr = multierror.Append(merr, err) - } - return merr -} - -func decryptWithCipher(cipherPtr *Cipher, fields ...*string) error { - // If no cipher is loaded, then we can't decrypt anything! - if cipherPtr == nil { + if db.ciphers == nil { return ErrNotEnabled } - cipher := *cipherPtr for _, field := range fields { if field == nil { continue } - if len(*field) < 16 || !strings.HasPrefix(*field, MagicPrefix) { + if len(*field) < 8 || !strings.HasPrefix(*field, MagicPrefix) { // We do not force decryption of unencrypted rows. This could be damaging // to the deployment, and admins can always manually purge data. continue } - // The first 7 characters of the digest are used to identify the cipher. - // If the cipher changes, we should complain loudly. - encPrefix := cipher.HexDigest()[:7] - if !strings.HasPrefix((*field)[8:15], encPrefix) { - return &DecryptFailedError{ - Inner: xerrors.Errorf("cipher mismatch: expected %q, got %q", encPrefix, (*field)[8:15]), - } - } - data, err := b64decode((*field)[16:]) + data, err := b64decode((*field)[8:]) if err != nil { // If it's not base64 with the prefix, we should complain loudly. return &DecryptFailedError{ Inner: xerrors.Errorf("malformed encrypted field %q: %w", *field, err), } } - decrypted, err := cipher.Decrypt(data) + decrypted, err := db.ciphers.Decrypt(data) if err != nil { // If the encryption key changed, return our special error that unwraps to sql.ErrNoRows. return &DecryptFailedError{Inner: err} diff --git a/enterprise/dbcrypt/dbcrypt_test.go b/enterprise/dbcrypt/dbcrypt_test.go index bd04f41d74995..c2e6a04546ac2 100644 --- a/enterprise/dbcrypt/dbcrypt_test.go +++ b/enterprise/dbcrypt/dbcrypt_test.go @@ -6,14 +6,10 @@ import ( "database/sql" "encoding/base64" "io" - "sync/atomic" "testing" "github.com/stretchr/testify/require" - "cdr.dev/slog" - "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" @@ -26,7 +22,7 @@ func TestUserLinks(t *testing.T) { t.Run("InsertUserLink", func(t *testing.T) { t.Parallel() - db, crypt, cipher, _ := setup(t) + db, crypt, cipher := setup(t) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -41,7 +37,7 @@ func TestUserLinks(t *testing.T) { t.Run("UpdateUserLink", func(t *testing.T) { t.Parallel() - db, crypt, cipher, _ := setup(t) + db, crypt, cipher := setup(t) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -61,7 +57,7 @@ func TestUserLinks(t *testing.T) { t.Run("GetUserLinkByLinkedID", func(t *testing.T) { t.Parallel() - db, crypt, cipher, _ := setup(t) + db, crypt, cipher := setup(t) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -72,17 +68,11 @@ func TestUserLinks(t *testing.T) { require.NoError(t, err) requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") requireEncryptedEquals(t, cipher, link.OAuthRefreshToken, "refresh") - - // Reset the key and empty values should be returned! - initCipher(t, cipher) - - link, err = crypt.GetUserLinkByLinkedID(ctx, link.LinkedID) - require.ErrorIs(t, err, sql.ErrNoRows) }) t.Run("GetUserLinkByUserIDLoginType", func(t *testing.T) { t.Parallel() - db, crypt, cipher, _ := setup(t) + db, crypt, cipher := setup(t) user := dbgen.User(t, crypt, database.User{}) link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, @@ -96,15 +86,6 @@ func TestUserLinks(t *testing.T) { require.NoError(t, err) requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") requireEncryptedEquals(t, cipher, link.OAuthRefreshToken, "refresh") - - // Reset the key and empty values should be returned! - initCipher(t, cipher) - - link, err = crypt.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ - UserID: link.UserID, - LoginType: link.LoginType, - }) - require.ErrorIs(t, err, sql.ErrNoRows) }) } @@ -114,7 +95,7 @@ func TestGitAuthLinks(t *testing.T) { t.Run("InsertGitAuthLink", func(t *testing.T) { t.Parallel() - db, crypt, cipher, _ := setup(t) + db, crypt, cipher := setup(t) link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{ OAuthAccessToken: "access", OAuthRefreshToken: "refresh", @@ -130,7 +111,7 @@ func TestGitAuthLinks(t *testing.T) { t.Run("UpdateGitAuthLink", func(t *testing.T) { t.Parallel() - db, crypt, cipher, _ := setup(t) + db, crypt, cipher := setup(t) link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{}) _, err := crypt.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ ProviderID: link.ProviderID, @@ -150,7 +131,7 @@ func TestGitAuthLinks(t *testing.T) { t.Run("GetGitAuthLink", func(t *testing.T) { t.Parallel() - db, crypt, cipher, _ := setup(t) + db, crypt, cipher := setup(t) link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{ OAuthAccessToken: "access", OAuthRefreshToken: "refresh", @@ -162,15 +143,6 @@ func TestGitAuthLinks(t *testing.T) { require.NoError(t, err) requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") requireEncryptedEquals(t, cipher, link.OAuthRefreshToken, "refresh") - - // Reset the key and empty values should be returned! - initCipher(t, cipher) - - link, err = crypt.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ - UserID: link.UserID, - ProviderID: link.ProviderID, - }) - require.ErrorIs(t, err, sql.ErrNoRows) }) } @@ -179,17 +151,13 @@ func TestNew(t *testing.T) { t.Run("OK", func(t *testing.T) { // Given: a cipher is loaded - cipher := &atomic.Pointer[dbcrypt.Cipher]{} - initCipher(t, cipher) + cipher := dbcrypt.NewCiphers(initCipher(t)) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) rawDB, _ := dbtestutil.NewDB(t) // When: we init the crypt db - cryptDB, err := dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ - PrimaryCipher: cipher, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - }) + cryptDB, err := dbcrypt.New(ctx, rawDB, cipher) require.NoError(t, err) // Then: the sentinel value is encrypted @@ -205,19 +173,15 @@ func TestNew(t *testing.T) { t.Run("NoCipher", func(t *testing.T) { // Given: no cipher is loaded - cipher := &atomic.Pointer[dbcrypt.Cipher]{} ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) rawDB, _ := dbtestutil.NewDB(t) // When: we init the crypt db - _, err := dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ - PrimaryCipher: cipher, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - }) + _, err := dbcrypt.New(ctx, rawDB, nil) // Then: an error is returned - require.ErrorContains(t, err, "at least one cipher is required") + require.ErrorContains(t, err, "no ciphers configured") // And: the sentinel value is not present _, err = rawDB.GetDBCryptSentinelValue(ctx) @@ -226,75 +190,66 @@ func TestNew(t *testing.T) { t.Run("CipherChanged", func(t *testing.T) { // Given: no cipher is loaded - cipher := &atomic.Pointer[dbcrypt.Cipher]{} - initCipher(t, cipher) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) rawDB, _ := dbtestutil.NewDB(t) // And: the sentinel value is encrypted with a different cipher - oldCipher := &atomic.Pointer[dbcrypt.Cipher]{} - initCipher(t, oldCipher) + cipher1 := initCipher(t) field := "coder" - encrypted, err := (*oldCipher.Load()).Encrypt([]byte(field)) + encrypted, err := dbcrypt.NewCiphers(cipher1).Encrypt([]byte(field)) require.NoError(t, err) b64encrypted := base64.StdEncoding.EncodeToString(encrypted) - require.NoError(t, rawDB.SetDBCryptSentinelValue(ctx, "dbcrypt-"+(*oldCipher.Load()).HexDigest()[:7]+"-"+b64encrypted)) + require.NoError(t, rawDB.SetDBCryptSentinelValue(ctx, dbcrypt.MagicPrefix+b64encrypted)) // When: we init the crypt db with no access to the old cipher - _, err = dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ - PrimaryCipher: cipher, - SecondaryCipher: &atomic.Pointer[dbcrypt.Cipher]{}, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - }) + cipher2 := initCipher(t) + _, err = dbcrypt.New(ctx, rawDB, dbcrypt.NewCiphers(cipher2)) // Then: an error is returned require.ErrorContains(t, err, "database is already encrypted with a different key") // And the sentinel value should remain unchanged. For now. rawVal, err := rawDB.GetDBCryptSentinelValue(ctx) require.NoError(t, err) - requireEncryptedEquals(t, oldCipher, rawVal, field) + requireEncryptedEquals(t, dbcrypt.NewCiphers(cipher1), rawVal, field) // When: we set the secondary cipher - _, err = dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ - PrimaryCipher: cipher, - SecondaryCipher: oldCipher, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - }) + cs := dbcrypt.NewCiphers(cipher2, cipher1) + _, err = dbcrypt.New(ctx, rawDB, cs) // Then: no error is returned require.NoError(t, err) // And the sentinel value should be re-encrypted with the new value. rawVal, err = rawDB.GetDBCryptSentinelValue(ctx) require.NoError(t, err) - requireEncryptedEquals(t, cipher, rawVal, field) + requireEncryptedEquals(t, dbcrypt.NewCiphers(cipher2), rawVal, field) }) } -func requireEncryptedEquals(t *testing.T, cipher *atomic.Pointer[dbcrypt.Cipher], value, expected string) { +func requireEncryptedEquals(t *testing.T, c dbcrypt.Cipher, value, expected string) { t.Helper() - c := (*cipher.Load()) - require.NotNil(t, c) - require.Greater(t, len(value), 16, "value is not encrypted") - require.Contains(t, value, dbcrypt.MagicPrefix+c.HexDigest()[:7]+"-") - data, err := base64.StdEncoding.DecodeString(value[16:]) - require.NoError(t, err) + require.Greater(t, len(value), 8, "value is not encrypted") + require.Equal(t, dbcrypt.MagicPrefix, value[:8], "missing magic prefix") + data, err := base64.StdEncoding.DecodeString(value[8:]) + require.NoError(t, err, "invalid base64") + require.Greater(t, len(data), 8, "missing cipher digest") + require.Equal(t, c.HexDigest(), string(data[:7]), "cipher digest mismatch") got, err := c.Decrypt(data) - require.NoError(t, err) - require.Equal(t, expected, string(got)) + require.NoError(t, err, "failed to decrypt data") + require.Equal(t, expected, string(got), "decrypted data does not match") } -func initCipher(t *testing.T, cipher *atomic.Pointer[dbcrypt.Cipher]) { +func initCipher(t *testing.T) *dbcrypt.AES256 { t.Helper() key := make([]byte, 32) // AES-256 key size is 32 bytes _, err := io.ReadFull(rand.Reader, key) require.NoError(t, err) c, err := dbcrypt.CipherAES256(key) require.NoError(t, err) - cipher.Store(&c) + return c } -func setup(t *testing.T) (db, cryptodb database.Store, cipher1 *atomic.Pointer[dbcrypt.Cipher], cipher2 *atomic.Pointer[dbcrypt.Cipher]) { +func setup(t *testing.T) (db, cryptodb database.Store, ciphers *dbcrypt.Ciphers) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -303,14 +258,8 @@ func setup(t *testing.T) (db, cryptodb database.Store, cipher1 *atomic.Pointer[d _, err := rawDB.GetDBCryptSentinelValue(ctx) require.ErrorIs(t, err, sql.ErrNoRows) - cipher1 = &atomic.Pointer[dbcrypt.Cipher]{} - cipher2 = &atomic.Pointer[dbcrypt.Cipher]{} - initCipher(t, cipher1) - cryptDB, err := dbcrypt.New(ctx, rawDB, &dbcrypt.Options{ - PrimaryCipher: cipher1, - SecondaryCipher: cipher2, - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - }) + ciphers = dbcrypt.NewCiphers(initCipher(t)) + cryptDB, err := dbcrypt.New(ctx, rawDB, ciphers) require.NoError(t, err) rawVal, err := rawDB.GetDBCryptSentinelValue(ctx) @@ -321,5 +270,5 @@ func setup(t *testing.T) (db, cryptodb database.Store, cipher1 *atomic.Pointer[d require.NoError(t, err) require.Equal(t, "coder", cryptVal) - return rawDB, cryptDB, cipher1, cipher2 + return rawDB, cryptDB, ciphers } From 7a64a4ed9f88945622ccf9fd3d3b004610bd344f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 11:03:54 +0000 Subject: [PATCH 34/52] fixup! refactor dbcrypt: add Ciphers to wrap multiple AES256 --- enterprise/cli/dbcrypt_rotate.go | 13 ++------- enterprise/cli/dbcrypt_rotate_test.go | 40 ++++++++++++--------------- enterprise/coderd/coderd.go | 14 +++------- 3 files changed, 24 insertions(+), 43 deletions(-) diff --git a/enterprise/cli/dbcrypt_rotate.go b/enterprise/cli/dbcrypt_rotate.go index ef8b3eb61602a..6a2ad814a62dc 100644 --- a/enterprise/cli/dbcrypt_rotate.go +++ b/enterprise/cli/dbcrypt_rotate.go @@ -7,8 +7,6 @@ import ( "cdr.dev/slog" "context" "encoding/base64" - "sync/atomic" - "github.com/coder/coder/v2/cli" "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/coderd/database" @@ -59,8 +57,6 @@ func (r *RootCmd) dbcryptRotate() *clibase.Cmd { return xerrors.Errorf("old and new keys must be different") } - primaryCipherPtr := &atomic.Pointer[dbcrypt.Cipher]{} - secondaryCipherPtr := &atomic.Pointer[dbcrypt.Cipher]{} primaryCipher, err := dbcrypt.CipherAES256(newKey) if err != nil { return xerrors.Errorf("create primary cipher: %w", err) @@ -69,8 +65,7 @@ func (r *RootCmd) dbcryptRotate() *clibase.Cmd { if err != nil { return xerrors.Errorf("create secondary cipher: %w", err) } - primaryCipherPtr.Store(&primaryCipher) - secondaryCipherPtr.Store(&secondaryCipher) + ciphers := dbcrypt.NewCiphers(primaryCipher, secondaryCipher) sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", vals.PostgresURL.Value()) if err != nil { @@ -83,11 +78,7 @@ func (r *RootCmd) dbcryptRotate() *clibase.Cmd { db := database.New(sqlDB) - cryptDB, err := dbcrypt.New(ctx, db, &dbcrypt.Options{ - PrimaryCipher: primaryCipherPtr, - SecondaryCipher: secondaryCipherPtr, - Logger: logger.Named("cryptdb"), - }) + cryptDB, err := dbcrypt.New(ctx, db, ciphers) if err != nil { return xerrors.Errorf("create cryptdb: %w", err) } diff --git a/enterprise/cli/dbcrypt_rotate_test.go b/enterprise/cli/dbcrypt_rotate_test.go index 5b66e21c53330..f2ccceb4f0f8c 100644 --- a/enterprise/cli/dbcrypt_rotate_test.go +++ b/enterprise/cli/dbcrypt_rotate_test.go @@ -6,11 +6,8 @@ import ( "encoding/base64" "fmt" - "sync/atomic" "testing" - "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" @@ -47,17 +44,10 @@ func TestDBCryptRotate(t *testing.T) { keyA := mustString(t, 32) cA, err := dbcrypt.CipherAES256([]byte(keyA)) require.NoError(t, err) - cipherA := &atomic.Pointer[dbcrypt.Cipher]{} - cipherB := &atomic.Pointer[dbcrypt.Cipher]{} - cipherA.Store(&cA) + ciphers := dbcrypt.NewCiphers(cA) // Create an encrypted database - log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - cryptdb, err := dbcrypt.New(ctx, db, &dbcrypt.Options{ - PrimaryCipher: cipherA, - SecondaryCipher: cipherB, - Logger: log, - }) + cryptdb, err := dbcrypt.New(ctx, db, ciphers) require.NoError(t, err) // Populate the database with some data encrypted with cipher A. @@ -102,31 +92,37 @@ func TestDBCryptRotate(t *testing.T) { require.NoError(t, err) // Validate that all data has been updated with the checksum of the new cipher. - expectedPrefixA := fmt.Sprintf("dbcrypt-%s-", cA.HexDigest()[:7]) - expectedPrefixB := fmt.Sprintf("dbcrypt-%s-", cB.HexDigest()[:7]) for _, usr := range users { ul, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{ UserID: usr.ID, LoginType: usr.LoginType, }) require.NoError(t, err, "failed to get user link for user %s", usr.ID) - require.NotContains(t, ul.OAuthAccessToken, expectedPrefixA, "user_link.oauth_access_token should not contain the old cipher checksum") - require.NotContains(t, ul.OAuthRefreshToken, expectedPrefixA, "user_link.oauth_refresh_token should not contain the old cipher checksum") - require.Contains(t, ul.OAuthAccessToken, expectedPrefixB, "user_link.oauth_access_token should contain the new cipher checksum") - require.Contains(t, ul.OAuthRefreshToken, expectedPrefixB, "user_link.oauth_refresh_token should contain the new cipher checksum") + requireEncrypted(t, cB, ul.OAuthAccessToken) + requireEncrypted(t, cB, ul.OAuthRefreshToken) gal, err := db.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ UserID: usr.ID, ProviderID: "fake", }) require.NoError(t, err, "failed to get git auth link for user %s", usr.ID) - require.NotContains(t, gal.OAuthAccessToken, expectedPrefixA, "git_auth_link.oauth_access_token should not contain the old cipher checksum") - require.NotContains(t, gal.OAuthRefreshToken, expectedPrefixA, "git_auth_link.oauth_refresh_token should not contain the old cipher checksum") - require.Contains(t, gal.OAuthAccessToken, expectedPrefixB, "git_auth_link.oauth_access_token should contain the new cipher checksum") - require.Contains(t, gal.OAuthRefreshToken, expectedPrefixB, "git_auth_link.oauth_refresh_token should contain the new cipher checksum") + requireEncrypted(t, cB, gal.OAuthAccessToken) + requireEncrypted(t, cB, gal.OAuthRefreshToken) } } +func requireEncrypted(t *testing.T, c dbcrypt.Cipher, s string) { + t.Helper() + require.Greater(t, len(s), 8, "encrypted string is too short") + require.Equal(t, dbcrypt.MagicPrefix, s[:8], "missing magic prefix") + decodedVal, err := base64.StdEncoding.DecodeString(s[8:]) + require.NoError(t, err, "failed to decode base64 string") + require.Greater(t, len(decodedVal), 8, "base64-decoded value is too short") + require.Equal(t, c.HexDigest(), string(decodedVal[:7]), "cipher digest does not match") + _, err = c.Decrypt(decodedVal[8:]) + require.NoError(t, err, "failed to decrypt value") +} + func mustString(t *testing.T, n int) string { t.Helper() s, err := cryptorand.String(n) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index ec61491ae3060..45a25c4c7057d 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -12,7 +12,6 @@ import ( "strconv" "strings" "sync" - "sync/atomic" "time" "golang.org/x/xerrors" @@ -65,17 +64,12 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ctx, cancelFunc := context.WithCancel(ctx) if options.PrimaryExternalTokenEncryption != nil { - primaryExternalTokenCipher := atomic.Pointer[dbcrypt.Cipher]{} - primaryExternalTokenCipher.Store(&options.PrimaryExternalTokenEncryption) - secondaryExternalTokenCipher := atomic.Pointer[dbcrypt.Cipher]{} + cs := make([]dbcrypt.Cipher, 0) + cs = append(cs, options.PrimaryExternalTokenEncryption) if options.SecondaryExternalTokenEncryption != nil { - secondaryExternalTokenCipher.Store(&options.SecondaryExternalTokenEncryption) + cs = append(cs, options.SecondaryExternalTokenEncryption) } - cryptDB, err := dbcrypt.New(ctx, options.Database, &dbcrypt.Options{ - PrimaryCipher: &primaryExternalTokenCipher, - SecondaryCipher: &secondaryExternalTokenCipher, - }) - + cryptDB, err := dbcrypt.New(ctx, options.Database, dbcrypt.NewCiphers(cs...)) if err != nil { cancelFunc() return nil, xerrors.Errorf("init dbcrypt: %w", err) From 600391fbf2f1515321c6a45b99385958328b5086 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 11:18:01 +0000 Subject: [PATCH 35/52] fixup! refactor dbcrypt: add Ciphers to wrap multiple AES256 --- enterprise/cli/server.go | 25 +++++++++---------------- enterprise/coderd/coderd.go | 15 ++++----------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 5b8485ae456d6..a255d1d269fee 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -72,27 +72,20 @@ func (r *RootCmd) server() *clibase.Cmd { } if encKeys := options.DeploymentValues.ExternalTokenEncryptionKeys.Value(); len(encKeys) != 0 { - if len(encKeys) > 2 { - return nil, nil, xerrors.Errorf("at most 2 external-token-encryption-keys may be specified") - } - k1, err := base64.StdEncoding.DecodeString(encKeys[0]) - if err != nil { - return nil, nil, xerrors.Errorf("decode external-token-encryption-key: %w", err) - } - o.PrimaryExternalTokenEncryption, err = dbcrypt.CipherAES256(k1) - if err != nil { - return nil, nil, xerrors.Errorf("create external-token-encryption-key cipher: %w", err) - } - if len(encKeys) > 1 { - k2, err := base64.StdEncoding.DecodeString(encKeys[0]) + cs := make([]dbcrypt.Cipher, 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: %w", err) + return nil, nil, xerrors.Errorf("decode external-token-encryption-key %d: %w", idx, err) } - o.SecondaryExternalTokenEncryption, err = dbcrypt.CipherAES256(k2) + c, err := dbcrypt.CipherAES256(dk) if err != nil { - return nil, nil, xerrors.Errorf("create external-token-encryption-key cipher: %w", err) + return nil, nil, xerrors.Errorf("create external-token-encryption-key cipher %d: %w", idx, err) + } + cs = append(cs, c) } + o.ExternalTokenEncryption = dbcrypt.NewCiphers(cs...) } api, err := coderd.New(ctx, o) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 45a25c4c7057d..8e3e4bc417cc3 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -63,13 +63,8 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ctx, cancelFunc := context.WithCancel(ctx) - if options.PrimaryExternalTokenEncryption != nil { - cs := make([]dbcrypt.Cipher, 0) - cs = append(cs, options.PrimaryExternalTokenEncryption) - if options.SecondaryExternalTokenEncryption != nil { - cs = append(cs, options.SecondaryExternalTokenEncryption) - } - cryptDB, err := dbcrypt.New(ctx, options.Database, dbcrypt.NewCiphers(cs...)) + if options.ExternalTokenEncryption != nil { + cryptDB, err := dbcrypt.New(ctx, options.Database, options.ExternalTokenEncryption) if err != nil { cancelFunc() return nil, xerrors.Errorf("init dbcrypt: %w", err) @@ -379,9 +374,7 @@ type Options struct { BrowserOnly bool SCIMAPIKey []byte - // TODO: wire these up properly - PrimaryExternalTokenEncryption dbcrypt.Cipher - SecondaryExternalTokenEncryption dbcrypt.Cipher + ExternalTokenEncryption *dbcrypt.Ciphers // Used for high availability. ReplicaSyncUpdateInterval time.Duration @@ -449,7 +442,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "", codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1, codersdk.FeatureTemplateRBAC: api.RBAC, - codersdk.FeatureExternalTokenEncryption: api.PrimaryExternalTokenEncryption != nil, + codersdk.FeatureExternalTokenEncryption: api.ExternalTokenEncryption != nil, codersdk.FeatureExternalProvisionerDaemons: true, codersdk.FeatureAdvancedTemplateScheduling: true, // FeatureTemplateRestartRequirement depends on From ae6f623de2a17c854d8fdc6c1297308487ac5b53 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 11:22:12 +0000 Subject: [PATCH 36/52] fixup! refactor dbcrypt: add Ciphers to wrap multiple AES256 --- enterprise/cli/server.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index a255d1d269fee..9c03c96423eb1 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -72,6 +72,9 @@ func (r *RootCmd) server() *clibase.Cmd { } if encKeys := options.DeploymentValues.ExternalTokenEncryptionKeys.Value(); len(encKeys) != 0 { + if len(encKeys) > 2 { + return nil, nil, xerrors.Errorf("only 2 external-token-encryption-keys are supported") + } cs := make([]dbcrypt.Cipher, 0, len(encKeys)) for idx, ek := range encKeys { dk, err := base64.StdEncoding.DecodeString(ek) From 8b076040729b6fa46774d7ba5bd0a2653a11890f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 11:22:57 +0000 Subject: [PATCH 37/52] make gen --- docs/cli/dbcrypt-rotate.md | 22 +++++++++++++++++++++- enterprise/cli/dbcrypt_rotate.go | 9 ++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/cli/dbcrypt-rotate.md b/docs/cli/dbcrypt-rotate.md index 760608898b7ab..ebb76a6c2d0d4 100644 --- a/docs/cli/dbcrypt-rotate.md +++ b/docs/cli/dbcrypt-rotate.md @@ -7,5 +7,25 @@ Rotate database encryption keys ## Usage ```console -coder dbcrypt-rotate +coder dbcrypt-rotate [flags] --postgres-url --external-token-encryption-keys , ``` + +## Options + +### --external-token-encryption-keys + +| | | +| ----------- | -------------------------------------------------- | +| Type | string-array | +| Environment | $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. A maximum of two keys may be provided. 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. + +### --postgres-url + +| | | +| ----------- | ------------------------------------- | +| Type | string | +| Environment | $CODER_PG_CONNECTION_URL | + +URL of a PostgreSQL database. If empty, PostgreSQL binaries will be downloaded from Maven (https://repo1.maven.org/maven2) and store all data in the config root. Access the built-in database with "coder server postgres-builtin-url". diff --git a/enterprise/cli/dbcrypt_rotate.go b/enterprise/cli/dbcrypt_rotate.go index 6a2ad814a62dc..bd83a70ca626d 100644 --- a/enterprise/cli/dbcrypt_rotate.go +++ b/enterprise/cli/dbcrypt_rotate.go @@ -22,9 +22,12 @@ func (r *RootCmd) dbcryptRotate() *clibase.Cmd { opts = vals.Options() ) cmd := &clibase.Cmd{ - Use: "dbcrypt-rotate --postgres-url --external-token-encryption-keys ,", - Short: "Rotate database encryption keys", - Options: opts, + Use: "dbcrypt-rotate --postgres-url --external-token-encryption-keys ,", + Short: "Rotate database encryption keys", + Options: clibase.OptionSet{ + *opts.ByName("Postgres Connection URL"), + *opts.ByName("External Token Encryption Keys"), + }, Middleware: clibase.Chain( clibase.RequireNArgs(0), ), From a2b7935746aafe1e0ffb9faea0ef7f7b9015ac69 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 11:23:23 +0000 Subject: [PATCH 38/52] make fmt --- enterprise/cli/dbcrypt_rotate.go | 4 +++- enterprise/cli/dbcrypt_rotate_test.go | 1 - enterprise/cli/server.go | 1 - enterprise/dbcrypt/cipher.go | 12 +++++++----- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/enterprise/cli/dbcrypt_rotate.go b/enterprise/cli/dbcrypt_rotate.go index bd83a70ca626d..f7d7e01b7bb49 100644 --- a/enterprise/cli/dbcrypt_rotate.go +++ b/enterprise/cli/dbcrypt_rotate.go @@ -4,9 +4,11 @@ package cli import ( "bytes" - "cdr.dev/slog" "context" "encoding/base64" + + "cdr.dev/slog" + "github.com/coder/coder/v2/cli" "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/coderd/database" diff --git a/enterprise/cli/dbcrypt_rotate_test.go b/enterprise/cli/dbcrypt_rotate_test.go index f2ccceb4f0f8c..2c567ac750c68 100644 --- a/enterprise/cli/dbcrypt_rotate_test.go +++ b/enterprise/cli/dbcrypt_rotate_test.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/base64" "fmt" - "testing" "github.com/coder/coder/v2/coderd/database" diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 9c03c96423eb1..26f627125017a 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -84,7 +84,6 @@ func (r *RootCmd) server() *clibase.Cmd { c, err := dbcrypt.CipherAES256(dk) if err != nil { return nil, nil, xerrors.Errorf("create external-token-encryption-key cipher %d: %w", idx, err) - } cs = append(cs, c) } diff --git a/enterprise/dbcrypt/cipher.go b/enterprise/dbcrypt/cipher.go index ba7b06bc77682..71252e938103b 100644 --- a/enterprise/dbcrypt/cipher.go +++ b/enterprise/dbcrypt/cipher.go @@ -64,11 +64,13 @@ func (a *AES256) HexDigest() string { return a.digest } -type CipherDigest string -type Ciphers struct { - primary string - m map[string]Cipher -} +type ( + CipherDigest string + Ciphers struct { + primary string + m map[string]Cipher + } +) // NewCiphers returns a new Ciphers instance with the given ciphers. // The first cipher in the list is the primary cipher. Any ciphers after the From e3dd4c0413cdeffdff79c90570150fd29ff5d3ed Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 11:38:18 +0000 Subject: [PATCH 39/52] make lint --- coderd/database/dbauthz/dbauthz.go | 14 ++++++++++---- coderd/database/dbfake/dbfake.go | 24 ++++++++++++++++++++---- codersdk/deployment.go | 2 +- enterprise/cli/dbcrypt_rotate.go | 6 +++--- enterprise/cli/dbcrypt_rotate_test.go | 2 +- enterprise/dbcrypt/dbcrypt.go | 1 + enterprise/dbcrypt/dbcrypt_test.go | 3 +++ site/src/api/typesGenerated.ts | 2 +- 8 files changed, 40 insertions(+), 14 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 094dc6ad79d14..10ec5c1a5c287 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -911,8 +911,11 @@ func (q *querier) GetGitAuthLink(ctx context.Context, arg database.GetGitAuthLin return fetch(q.log, q.auth, q.db.GetGitAuthLink)(ctx, arg) } -func (q *querier) GetGitAuthLinksByUserID(_ context.Context, _ uuid.UUID) ([]database.GitAuthLink, error) { - return nil, xerrors.Errorf("this is intentionally not implemented") +func (q *querier) GetGitAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.GitAuthLink, error) { + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetGitAuthLinksByUserID(ctx, userID) } func (q *querier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) { @@ -1483,8 +1486,11 @@ func (q *querier) GetUserLinkByUserIDLoginType(ctx context.Context, arg database return q.db.GetUserLinkByUserIDLoginType(ctx, arg) } -func (q *querier) GetUserLinksByUserID(_ context.Context, _ uuid.UUID) ([]database.UserLink, error) { - return nil, xerrors.Errorf("this is intentionally not implemented") +func (q *querier) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.UserLink, error) { + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetUserLinksByUserID(ctx, userID) } func (q *querier) GetUsers(ctx context.Context, arg database.GetUsersParams) ([]database.GetUsersRow, error) { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 3d46be7fb3e60..1a2824f5b054c 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -1403,8 +1403,16 @@ func (q *FakeQuerier) GetGitAuthLink(_ context.Context, arg database.GetGitAuthL return database.GitAuthLink{}, sql.ErrNoRows } -func (q *FakeQuerier) GetGitAuthLinksByUserID(_ context.Context, _ uuid.UUID) ([]database.GitAuthLink, error) { - panic("this is intentionally not implemented") +func (q *FakeQuerier) GetGitAuthLinksByUserID(_ context.Context, userID uuid.UUID) ([]database.GitAuthLink, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + gals := make([]database.GitAuthLink, 0) + for _, gal := range q.gitAuthLinks { + if gal.UserID == userID { + gals = append(gals, gal) + } + } + return gals, nil } func (q *FakeQuerier) GetGitSSHKey(_ context.Context, userID uuid.UUID) (database.GitSSHKey, error) { @@ -2847,8 +2855,16 @@ func (q *FakeQuerier) GetUserLinkByUserIDLoginType(_ context.Context, params dat return database.UserLink{}, sql.ErrNoRows } -func (q *FakeQuerier) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]database.UserLink, error) { - panic("this is intentionally not implemented") +func (q *FakeQuerier) GetUserLinksByUserID(_ context.Context, userID uuid.UUID) ([]database.UserLink, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + uls := make([]database.UserLink, 0) + for _, ul := range q.userLinks { + if ul.UserID == userID { + uls = append(uls, ul) + } + } + return uls, nil } func (q *FakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams) ([]database.GetUsersRow, error) { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index d9d1632d198e3..3b0e492dd61c5 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -154,7 +154,7 @@ type DeploymentValues struct { AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"` BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"` SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"` - ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys" typescript:",notnull"` + ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"` Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"` RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"` Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"` diff --git a/enterprise/cli/dbcrypt_rotate.go b/enterprise/cli/dbcrypt_rotate.go index f7d7e01b7bb49..554fd6a07df5b 100644 --- a/enterprise/cli/dbcrypt_rotate.go +++ b/enterprise/cli/dbcrypt_rotate.go @@ -18,7 +18,7 @@ import ( "golang.org/x/xerrors" ) -func (r *RootCmd) dbcryptRotate() *clibase.Cmd { +func (*RootCmd) dbcryptRotate() *clibase.Cmd { var ( vals = new(codersdk.DeploymentValues) opts = vals.Options() @@ -58,7 +58,7 @@ func (r *RootCmd) dbcryptRotate() *clibase.Cmd { if err != nil { return xerrors.Errorf("old key must be base64-encoded") } - if bytes.Compare(newKey, oldKey) == 0 { + if bytes.Equal(newKey, oldKey) { return xerrors.Errorf("old and new keys must be different") } @@ -126,7 +126,7 @@ func (r *RootCmd) dbcryptRotate() *clibase.Cmd { } logger.Info(ctx, "encrypted user tokens", slog.F("current", idx+1), slog.F("of", len(users))) } - logger.Info(ctx, "operation completed successfully!") + logger.Info(ctx, "operation completed successfully") return nil }, } diff --git a/enterprise/cli/dbcrypt_rotate_test.go b/enterprise/cli/dbcrypt_rotate_test.go index 2c567ac750c68..a88540a0c65d1 100644 --- a/enterprise/cli/dbcrypt_rotate_test.go +++ b/enterprise/cli/dbcrypt_rotate_test.go @@ -18,8 +18,8 @@ import ( "github.com/stretchr/testify/require" ) +// nolint: paralleltest // use of t.Setenv func TestDBCryptRotate(t *testing.T) { - //nolint: paralleltest // use of t.Setenv if !dbtestutil.WillUsePostgres() { t.Skip("this test requires a postgres instance") } diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index 611540a7d850c..eab22f49797f7 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -81,6 +81,7 @@ func New(ctx context.Context, db database.Store, cs *Ciphers) (database.Store, e ciphers: cs, Store: db, } + // nolint: gocritic // This is allowed. if err := ensureEncrypted(dbauthz.AsSystemRestricted(ctx), dbc); err != nil { return nil, xerrors.Errorf("ensure encrypted database fields: %w", err) } diff --git a/enterprise/dbcrypt/dbcrypt_test.go b/enterprise/dbcrypt/dbcrypt_test.go index c2e6a04546ac2..54d14d9560b12 100644 --- a/enterprise/dbcrypt/dbcrypt_test.go +++ b/enterprise/dbcrypt/dbcrypt_test.go @@ -150,6 +150,7 @@ func TestNew(t *testing.T) { t.Parallel() t.Run("OK", func(t *testing.T) { + t.Parallel() // Given: a cipher is loaded cipher := dbcrypt.NewCiphers(initCipher(t)) ctx, cancel := context.WithCancel(context.Background()) @@ -172,6 +173,7 @@ func TestNew(t *testing.T) { }) t.Run("NoCipher", func(t *testing.T) { + t.Parallel() // Given: no cipher is loaded ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -189,6 +191,7 @@ func TestNew(t *testing.T) { }) t.Run("CipherChanged", func(t *testing.T) { + t.Parallel() // Given: no cipher is loaded ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ce685c8192585..611d11ea8af1b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -379,7 +379,7 @@ export interface DeploymentValues { readonly browser_only?: boolean readonly scim_api_key?: string // This is likely an enum in an external package ("github.com/coder/coder/v2/cli/clibase.StringArray") - readonly external_token_encryption_keys: string[] + readonly external_token_encryption_keys?: string[] readonly provisioner?: ProvisionerConfig readonly rate_limit?: RateLimitConfig // This is likely an enum in an external package ("github.com/coder/coder/v2/cli/clibase.StringArray") From db30bddacd16b9694fe89a8c59cad644c8bff933 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 11:55:02 +0000 Subject: [PATCH 40/52] update-golden-files --- enterprise/cli/testdata/coder_--help.golden | 1 + .../coder_dbcrypt-rotate_--help.golden | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 enterprise/cli/testdata/coder_dbcrypt-rotate_--help.golden diff --git a/enterprise/cli/testdata/coder_--help.golden b/enterprise/cli/testdata/coder_--help.golden index ae24592079a69..de2b9c5f9e7d9 100644 --- a/enterprise/cli/testdata/coder_--help.golden +++ b/enterprise/cli/testdata/coder_--help.golden @@ -10,6 +10,7 @@ Coder v0.0.0-devel — A tool for provisioning self-hosted development environme  $ coder templates init  Subcommands + dbcrypt-rotate Rotate database encryption keys features List Enterprise features groups Manage groups licenses Add, delete, and list licenses diff --git a/enterprise/cli/testdata/coder_dbcrypt-rotate_--help.golden b/enterprise/cli/testdata/coder_dbcrypt-rotate_--help.golden new file mode 100644 index 0000000000000..011118531a0ef --- /dev/null +++ b/enterprise/cli/testdata/coder_dbcrypt-rotate_--help.golden @@ -0,0 +1,24 @@ +Usage: coder dbcrypt-rotate [flags] --postgres-url --external-token-encryption-keys , + +Rotate database encryption keys + +Options + --postgres-url string, $CODER_PG_CONNECTION_URL + URL of a PostgreSQL database. If empty, PostgreSQL binaries will be + downloaded from Maven (https://repo1.maven.org/maven2) and store all + data in the config root. Access the built-in database with "coder + server postgres-builtin-url". + +Enterprise Options +These options are only available in the Enterprise Edition. + + --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. A maximum of two keys may be provided. 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. + +--- +Run `coder --help` for a list of global options. From 4c6a93f3f2f1bae5bbac87a5e7059453ce2faa84 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 12:15:01 +0000 Subject: [PATCH 41/52] fix logging --- enterprise/cli/dbcrypt_rotate.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/enterprise/cli/dbcrypt_rotate.go b/enterprise/cli/dbcrypt_rotate.go index 554fd6a07df5b..e4775f8d5a20b 100644 --- a/enterprise/cli/dbcrypt_rotate.go +++ b/enterprise/cli/dbcrypt_rotate.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" "github.com/coder/coder/v2/cli" "github.com/coder/coder/v2/cli/clibase" @@ -36,11 +37,7 @@ func (*RootCmd) dbcryptRotate() *clibase.Cmd { Handler: func(inv *clibase.Invocation) error { ctx, cancel := context.WithCancel(inv.Context()) defer cancel() - logger, closeLogger, err := cli.BuildLogger(inv, vals) - if err != nil { - return xerrors.Errorf("set up logging: %w", err) - } - defer closeLogger() + logger := slog.Make(sloghuman.Sink(inv.Stdout)) if vals.PostgresURL == "" { return xerrors.Errorf("no database configured") From da8c984b35fe84a58c1aa1f6bd9e9c92fd1c0b5c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 12:51:39 +0000 Subject: [PATCH 42/52] appease the linter --- cli/root.go | 3 +-- enterprise/dbcrypt/cipher.go | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cli/root.go b/cli/root.go index 3c3fa7535dd73..3ab2f0d7f33b9 100644 --- a/cli/root.go +++ b/cli/root.go @@ -243,8 +243,7 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) { for _, opt := range cmd.Options { // Verify that every option is configurable. if opt.Flag == "" && opt.Env == "" { - // TODO(Cian): maybe move dbcrypt-rotate under server to work around this? - if cmd.Name() == "server" || cmd.Name() == "dbcrypt-rotate" { + if cmd.Name() == "server" { // The server command is funky and has YAML-only options, e.g. // support links. return diff --git a/enterprise/dbcrypt/cipher.go b/enterprise/dbcrypt/cipher.go index 71252e938103b..84613bf5c8608 100644 --- a/enterprise/dbcrypt/cipher.go +++ b/enterprise/dbcrypt/cipher.go @@ -98,11 +98,11 @@ func (cs Ciphers) Encrypt(plaintext []byte) ([]byte, error) { return nil, xerrors.Errorf("no ciphers configured") } prefix := []byte(c.HexDigest() + "-") - crypted, err := c.Encrypt(plaintext) + encrypted, err := c.Encrypt(plaintext) if err != nil { return nil, err } - return append(prefix, crypted...), nil + return append(prefix, encrypted...), nil } // Decrypt decrypts the given ciphertext using the cipher indicated by the From e1a77a6163ee1b2da1acb764406834765a087a97 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 14:13:00 +0000 Subject: [PATCH 43/52] address some comments from original PR --- enterprise/dbcrypt/cipher.go | 2 ++ enterprise/dbcrypt/cipher_test.go | 50 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/enterprise/dbcrypt/cipher.go b/enterprise/dbcrypt/cipher.go index 84613bf5c8608..ada9271cbce2a 100644 --- a/enterprise/dbcrypt/cipher.go +++ b/enterprise/dbcrypt/cipher.go @@ -46,6 +46,8 @@ func (a *AES256) Encrypt(plaintext []byte) ([]byte, error) { if err != nil { return nil, err } + // TODO: the below fails with "cipher: message authentication failed" + //return a.aead.Seal(nil, nonce, plaintext, nil), nil return a.aead.Seal(nonce, nonce, plaintext, nil), nil } diff --git a/enterprise/dbcrypt/cipher_test.go b/enterprise/dbcrypt/cipher_test.go index 6f18c260815a3..e34f3264180d0 100644 --- a/enterprise/dbcrypt/cipher_test.go +++ b/enterprise/dbcrypt/cipher_test.go @@ -2,6 +2,7 @@ package dbcrypt_test import ( "bytes" + "encoding/base64" "testing" "github.com/stretchr/testify/require" @@ -42,6 +43,26 @@ func TestCipherAES256(t *testing.T) { _, err := dbcrypt.CipherAES256(bytes.Repeat([]byte{'a'}, 31)) require.ErrorContains(t, err, "key must be 32 bytes") }) + + t.Run("TestNonce", func(t *testing.T) { + key := bytes.Repeat([]byte{'a'}, 32) + cipher, err := dbcrypt.CipherAES256(key) + require.NoError(t, err) + require.Equal(t, "3ba3f5f", cipher.HexDigest()) + + encrypted1, err := cipher.Encrypt([]byte("hello world")) + require.NoError(t, err) + encrypted2, err := cipher.Encrypt([]byte("hello world")) + require.NoError(t, err) + require.NotEqual(t, encrypted1, encrypted2, "nonce should be different for each encryption") + + munged := make([]byte, len(encrypted1)) + copy(munged, encrypted1) + munged[0] = munged[0] ^ 0xff + _, err = cipher.Decrypt(munged) + var decryptErr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &decryptErr, "munging the first byte of the encrypted data should cause decryption to fail") + }) } func TestCiphers(t *testing.T) { @@ -92,3 +113,32 @@ func TestCiphers(t *testing.T) { _ = dbcrypt.NewCiphers(ciphers) }) } + +// This test ensures backwards compatibility. If it breaks, something is very wrong. +func TestCiphersBackwardCompatibility(t *testing.T) { + t.Parallel() + var ( + msg = "hello world" + key = bytes.Repeat([]byte{'a'}, 32) + //nolint: gosec // The below is the base64-encoded result of encrypting the above message with the above key. + encoded = `M2JhM2Y1Zi3r1KSStbmfMBXDzdjVcCrtumdMFsJ4QiYlb3fV1HB8yxg9obHaz5I=` + ) + + // This is the code that was used to generate the above. + // Note that the output of this code will change every time it is run. + //encrypted, err := cs.Encrypt([]byte(msg)) + //require.NoError(t, err) + //t.Logf("encoded: %q", base64.StdEncoding.EncodeToString(encrypted)) + + cipher, err := dbcrypt.CipherAES256(key) + require.NoError(t, err) + require.Equal(t, "3ba3f5f", cipher.HexDigest()) + cs := dbcrypt.NewCiphers(cipher) + + decoded, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(t, err, "the encoded string should be valid base64") + decrypted, err := cs.Decrypt(decoded) + require.NoError(t, err, "decryption should succeed") + require.Equal(t, msg, string(decrypted), "decrypted message should match original message") + +} From 552e425ed22a398c6fbe6b3a9cdbb8d212cce108 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 14:28:28 +0000 Subject: [PATCH 44/52] fixup! address some comments from original PR --- enterprise/dbcrypt/cipher.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/enterprise/dbcrypt/cipher.go b/enterprise/dbcrypt/cipher.go index ada9271cbce2a..8c7b870e07660 100644 --- a/enterprise/dbcrypt/cipher.go +++ b/enterprise/dbcrypt/cipher.go @@ -46,9 +46,9 @@ func (a *AES256) Encrypt(plaintext []byte) ([]byte, error) { if err != nil { return nil, err } - // TODO: the below fails with "cipher: message authentication failed" - //return a.aead.Seal(nil, nonce, plaintext, nil), nil - return a.aead.Seal(nonce, nonce, plaintext, nil), nil + dst := make([]byte, len(nonce)) + copy(dst, nonce) + return a.aead.Seal(dst, nonce, plaintext, nil), nil } func (a *AES256) Decrypt(ciphertext []byte) ([]byte, error) { From 2e5b5c0ddcc072d82ce644621101483552802873 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 14:41:22 +0000 Subject: [PATCH 45/52] lint --- enterprise/dbcrypt/cipher_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise/dbcrypt/cipher_test.go b/enterprise/dbcrypt/cipher_test.go index e34f3264180d0..bb12cf211cd04 100644 --- a/enterprise/dbcrypt/cipher_test.go +++ b/enterprise/dbcrypt/cipher_test.go @@ -45,6 +45,7 @@ func TestCipherAES256(t *testing.T) { }) t.Run("TestNonce", func(t *testing.T) { + t.Parallel() key := bytes.Repeat([]byte{'a'}, 32) cipher, err := dbcrypt.CipherAES256(key) require.NoError(t, err) @@ -140,5 +141,4 @@ func TestCiphersBackwardCompatibility(t *testing.T) { decrypted, err := cs.Decrypt(decoded) require.NoError(t, err, "decryption should succeed") require.Equal(t, msg, string(decrypted), "decrypted message should match original message") - } From ad44e1e7ff777d0d391c4d83a8adee5a2f7fbb91 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 14:41:57 +0000 Subject: [PATCH 46/52] fixup! lint --- enterprise/dbcrypt/cipher_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/enterprise/dbcrypt/cipher_test.go b/enterprise/dbcrypt/cipher_test.go index bb12cf211cd04..b638c93ad2fa4 100644 --- a/enterprise/dbcrypt/cipher_test.go +++ b/enterprise/dbcrypt/cipher_test.go @@ -127,9 +127,9 @@ func TestCiphersBackwardCompatibility(t *testing.T) { // This is the code that was used to generate the above. // Note that the output of this code will change every time it is run. - //encrypted, err := cs.Encrypt([]byte(msg)) - //require.NoError(t, err) - //t.Logf("encoded: %q", base64.StdEncoding.EncodeToString(encrypted)) + // encrypted, err := cs.Encrypt([]byte(msg)) + // require.NoError(t, err) + // t.Logf("encoded: %q", base64.StdEncoding.EncodeToString(encrypted)) cipher, err := dbcrypt.CipherAES256(key) require.NoError(t, err) From 128ad09c158290bcbdffaef1d34451c83d3fb757 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 15:12:57 +0000 Subject: [PATCH 47/52] handle sentinel mismatch with a specific message --- enterprise/coderd/coderd.go | 3 +++ enterprise/dbcrypt/dbcrypt.go | 9 +++++---- enterprise/dbcrypt/dbcrypt_test.go | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 8e3e4bc417cc3..e06d262be7426 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -67,6 +67,9 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { cryptDB, err := dbcrypt.New(ctx, options.Database, options.ExternalTokenEncryption) if err != nil { cancelFunc() + if xerrors.Is(err, dbcrypt.ErrSentinelMismatch) { + panic(`Coder has shut down to prevent data corruption: your configured database is encrypted with an unknown external token encryption key. Please check your configuration and try again.`) + } return nil, xerrors.Errorf("init dbcrypt: %w", err) } options.Database = cryptDB diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index eab22f49797f7..bf174075dd223 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -52,9 +52,10 @@ const MagicPrefix = "dbcrypt-" const sentinelValue = "coder" var ( - ErrNotEnabled = xerrors.New("encryption is not enabled") - b64encode = base64.StdEncoding.EncodeToString - b64decode = base64.StdEncoding.DecodeString + ErrNotEnabled = xerrors.New("encryption is not enabled") + ErrSentinelMismatch = xerrors.New("database is already encrypted under a different key") + b64encode = base64.StdEncoding.EncodeToString + b64decode = base64.StdEncoding.DecodeString ) // DecryptFailedError is returned when decryption fails. @@ -266,7 +267,7 @@ func ensureEncrypted(ctx context.Context, dbc *dbCrypt) error { } if val != "" && val != sentinelValue { - return xerrors.Errorf("database is already encrypted with a different key") + return ErrSentinelMismatch } // Mark the database as officially having been touched by the new cipher. diff --git a/enterprise/dbcrypt/dbcrypt_test.go b/enterprise/dbcrypt/dbcrypt_test.go index 54d14d9560b12..0ff58722d650f 100644 --- a/enterprise/dbcrypt/dbcrypt_test.go +++ b/enterprise/dbcrypt/dbcrypt_test.go @@ -208,8 +208,8 @@ func TestNew(t *testing.T) { // When: we init the crypt db with no access to the old cipher cipher2 := initCipher(t) _, err = dbcrypt.New(ctx, rawDB, dbcrypt.NewCiphers(cipher2)) - // Then: an error is returned - require.ErrorContains(t, err, "database is already encrypted with a different key") + // Then: a special error is returned + require.ErrorIs(t, err, dbcrypt.ErrSentinelMismatch) // And the sentinel value should remain unchanged. For now. rawVal, err := rawDB.GetDBCryptSentinelValue(ctx) From 1851fffab7575278525edc5298ef307b6b8feb17 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 15:13:12 +0000 Subject: [PATCH 48/52] fix build issue --- enterprise/cli/dbcrypt_rotate_slim.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 enterprise/cli/dbcrypt_rotate_slim.go diff --git a/enterprise/cli/dbcrypt_rotate_slim.go b/enterprise/cli/dbcrypt_rotate_slim.go new file mode 100644 index 0000000000000..63c5bb9205ee0 --- /dev/null +++ b/enterprise/cli/dbcrypt_rotate_slim.go @@ -0,0 +1,20 @@ +//go:build slim + +package cli + +import ( + "github.com/coder/coder/v2/cli/clibase" + "golang.org/x/xerrors" +) + +func (*RootCmd) dbcryptRotate() *clibase.Cmd { + return &clibase.Cmd{ + Use: "dbcrypt-rotate --postgres-url --external-token-encryption-keys ,", + Short: "Rotate database encryption keys", + Options: clibase.OptionSet{}, + Hidden: true, + Handler: func(inv *clibase.Invocation) error { + return xerrors.Errorf("slim build does not support `coder dbcrypt-rotate`") + }, + } +} From 6ad09043fbe12cbfa468b1096056e3d580cef405 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 15:24:10 +0000 Subject: [PATCH 49/52] add external token encryption keys to ./scripts/develop.sh by default --- scripts/develop.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/develop.sh b/scripts/develop.sh index 39f81c2951bc4..ef1057e714664 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -15,6 +15,7 @@ set -euo pipefail CODER_DEV_ACCESS_URL="${CODER_DEV_ACCESS_URL:-http://127.0.0.1:3000}" DEFAULT_PASSWORD="SomeSecurePassword!" +EXTERNAL_TOKEN_ENCRYPTION_KEYS="Y29kZXItY29kZXItY29kZXItY29kZXItY29kZXItMQo=" password="${CODER_DEV_ADMIN_PASSWORD:-${DEFAULT_PASSWORD}}" use_proxy=0 @@ -136,7 +137,7 @@ fatal() { trap 'fatal "Script encountered an error"' ERR cdroot - start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "${CODER_DEV_ACCESS_URL}" --dangerous-allow-cors-requests=true "$@" + start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "${CODER_DEV_ACCESS_URL}" --dangerous-allow-cors-requests=true --external-token-encryption-keys="${EXTERNAL_TOKEN_ENCRYPTION_KEYS}" "$@" echo '== Waiting for Coder to become ready' # Start the timeout in the background so interrupting this script From fe21f2635d9c97dd84fbb12ca607f8c62db97882 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 15:50:00 +0000 Subject: [PATCH 50/52] fixup! add external token encryption keys to ./scripts/develop.sh by default --- scripts/develop.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/develop.sh b/scripts/develop.sh index ef1057e714664..327f2192ce2c4 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -15,7 +15,7 @@ set -euo pipefail CODER_DEV_ACCESS_URL="${CODER_DEV_ACCESS_URL:-http://127.0.0.1:3000}" DEFAULT_PASSWORD="SomeSecurePassword!" -EXTERNAL_TOKEN_ENCRYPTION_KEYS="Y29kZXItY29kZXItY29kZXItY29kZXItY29kZXItMQo=" +EXTERNAL_TOKEN_ENCRYPTION_KEYS="Y29kZXItY29kZXItY29kZXItY29kZXItY29kZXItMSE=" password="${CODER_DEV_ADMIN_PASSWORD:-${DEFAULT_PASSWORD}}" use_proxy=0 From 8cb07bad581589106ef4a2f03d80991bc18baaeb Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 29 Aug 2023 20:31:42 +0000 Subject: [PATCH 51/52] decrypt fields when inserting and updating! --- .../coderd/coderdenttest/coderdenttest.go | 11 ++++++++- enterprise/dbcrypt/dbcrypt.go | 24 +++++++++++++++---- enterprise/dbcrypt/dbcrypt_test.go | 16 +++++++++++-- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 7da4318d397d0..f3ab4d071268b 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -21,10 +21,12 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd" "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/enterprise/dbcrypt" ) const ( - testKeyID = "enterprise-test" + testKeyID = "enterprise-test" + testEncryptionKey = "coder-coder-coder-coder-coder-1!" // nolint:gosec ) var ( @@ -56,6 +58,7 @@ type Options struct { DontAddLicense bool DontAddFirstUser bool ReplicaSyncUpdateInterval time.Duration + ExternalTokenEncryption *dbcrypt.Ciphers ProvisionerDaemonPSK string } @@ -82,6 +85,11 @@ func NewWithAPI(t *testing.T, options *Options) ( err := oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Set("0 0 * * *") require.NoError(t, err) } + if options.ExternalTokenEncryption == nil { + c, err := dbcrypt.CipherAES256([]byte(testEncryptionKey)) + require.NoError(t, err) + options.ExternalTokenEncryption = dbcrypt.NewCiphers(c) + } coderAPI, err := coderd.New(context.Background(), &coderd.Options{ RBAC: true, AuditLogging: options.AuditLogging, @@ -96,6 +104,7 @@ func NewWithAPI(t *testing.T, options *Options) ( ProxyHealthInterval: options.ProxyHealthInterval, DefaultQuietHoursSchedule: oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(), ProvisionerDaemonPSK: options.ProvisionerDaemonPSK, + ExternalTokenEncryption: options.ExternalTokenEncryption, }) require.NoError(t, err) setHandler(coderAPI.AGPL.RootHandler) diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index bf174075dd223..d197704af7f7d 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -145,7 +145,11 @@ func (db *dbCrypt) InsertUserLink(ctx context.Context, params database.InsertUse if err != nil { return database.UserLink{}, err } - return db.Store.InsertUserLink(ctx, params) + link, err := db.Store.InsertUserLink(ctx, params) + if err != nil { + return database.UserLink{}, err + } + return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) } func (db *dbCrypt) UpdateUserLink(ctx context.Context, params database.UpdateUserLinkParams) (database.UserLink, error) { @@ -153,7 +157,11 @@ func (db *dbCrypt) UpdateUserLink(ctx context.Context, params database.UpdateUse if err != nil { return database.UserLink{}, err } - return db.Store.UpdateUserLink(ctx, params) + updated, err := db.Store.UpdateUserLink(ctx, params) + if err != nil { + return database.UserLink{}, err + } + return updated, db.decryptFields(&updated.OAuthAccessToken, &updated.OAuthRefreshToken) } func (db *dbCrypt) InsertGitAuthLink(ctx context.Context, params database.InsertGitAuthLinkParams) (database.GitAuthLink, error) { @@ -161,7 +169,11 @@ func (db *dbCrypt) InsertGitAuthLink(ctx context.Context, params database.Insert if err != nil { return database.GitAuthLink{}, err } - return db.Store.InsertGitAuthLink(ctx, params) + link, err := db.Store.InsertGitAuthLink(ctx, params) + if err != nil { + return database.GitAuthLink{}, err + } + return link, db.decryptFields(&link.OAuthAccessToken, &link.OAuthRefreshToken) } func (db *dbCrypt) GetGitAuthLink(ctx context.Context, params database.GetGitAuthLinkParams) (database.GitAuthLink, error) { @@ -190,7 +202,11 @@ func (db *dbCrypt) UpdateGitAuthLink(ctx context.Context, params database.Update if err != nil { return database.GitAuthLink{}, err } - return db.Store.UpdateGitAuthLink(ctx, params) + updated, err := db.Store.UpdateGitAuthLink(ctx, params) + if err != nil { + return database.GitAuthLink{}, err + } + return updated, db.decryptFields(&updated.OAuthAccessToken, &updated.OAuthRefreshToken) } func (db *dbCrypt) SetDBCryptSentinelValue(ctx context.Context, value string) error { diff --git a/enterprise/dbcrypt/dbcrypt_test.go b/enterprise/dbcrypt/dbcrypt_test.go index 0ff58722d650f..d5d088aab30d9 100644 --- a/enterprise/dbcrypt/dbcrypt_test.go +++ b/enterprise/dbcrypt/dbcrypt_test.go @@ -29,6 +29,9 @@ func TestUserLinks(t *testing.T) { OAuthAccessToken: "access", OAuthRefreshToken: "refresh", }) + require.Equal(t, link.OAuthAccessToken, "access") + require.Equal(t, link.OAuthRefreshToken, "refresh") + link, err := db.GetUserLinkByLinkedID(ctx, link.LinkedID) require.NoError(t, err) requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") @@ -42,13 +45,16 @@ func TestUserLinks(t *testing.T) { link := dbgen.UserLink(t, crypt, database.UserLink{ UserID: user.ID, }) - _, err := crypt.UpdateUserLink(ctx, database.UpdateUserLinkParams{ + updated, err := crypt.UpdateUserLink(ctx, database.UpdateUserLinkParams{ OAuthAccessToken: "access", OAuthRefreshToken: "refresh", UserID: link.UserID, LoginType: link.LoginType, }) require.NoError(t, err) + require.Equal(t, updated.OAuthAccessToken, "access") + require.Equal(t, updated.OAuthRefreshToken, "refresh") + link, err = db.GetUserLinkByLinkedID(ctx, link.LinkedID) require.NoError(t, err) requireEncryptedEquals(t, cipher, link.OAuthAccessToken, "access") @@ -100,6 +106,9 @@ func TestGitAuthLinks(t *testing.T) { OAuthAccessToken: "access", OAuthRefreshToken: "refresh", }) + require.Equal(t, link.OAuthAccessToken, "access") + require.Equal(t, link.OAuthRefreshToken, "refresh") + link, err := db.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ ProviderID: link.ProviderID, UserID: link.UserID, @@ -113,13 +122,16 @@ func TestGitAuthLinks(t *testing.T) { t.Parallel() db, crypt, cipher := setup(t) link := dbgen.GitAuthLink(t, crypt, database.GitAuthLink{}) - _, err := crypt.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ + updated, err := crypt.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{ ProviderID: link.ProviderID, UserID: link.UserID, OAuthAccessToken: "access", OAuthRefreshToken: "refresh", }) require.NoError(t, err) + require.Equal(t, updated.OAuthAccessToken, "access") + require.Equal(t, updated.OAuthRefreshToken, "refresh") + link, err = db.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{ ProviderID: link.ProviderID, UserID: link.UserID, From 625a85fc365905b4407de86b628dfef90cd17f2a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 30 Aug 2023 11:44:21 +0000 Subject: [PATCH 52/52] add docs for encryption --- docs/admin/encryption.md | 146 +++++++++++++++++++++++++++++++++++++ docs/images/icons/lock.svg | 3 + docs/manifest.json | 7 ++ 3 files changed, 156 insertions(+) create mode 100644 docs/admin/encryption.md create mode 100644 docs/images/icons/lock.svg diff --git a/docs/admin/encryption.md b/docs/admin/encryption.md new file mode 100644 index 0000000000000..ac03333db25d8 --- /dev/null +++ b/docs/admin/encryption.md @@ -0,0 +1,146 @@ +# Database Encryption + +By default, Coder stores external user tokens in plaintext in the database. This +is undesirable in high-security environments, as an attacker with access to the +database can use these tokens to impersonate users. Database Encryption allows +Coder administrators to encrypt these tokens at-rest, preventing attackers from +using them. + +## How it works + +Coder allows administrators to specify up to two +[external token encryption keys](../cli/server.md#external-token-encryption-keys). +If configured, Coder will use these keys to encrypt external user tokens before +storing them in the database. The encryption algorithm used is AES-256-GCM with +a 32-byte key length. + +Coder will use the first key provided for both encryption and decryption. If a +second key is provided, Coder will use it for decryption only. This allows +administrators to rotate encryption keys without invalidating existing tokens. + +The following database fields are currently encrypted: + +- `user_links.oauth_access_token` +- `user_links.oauth_refresh_token` +- `git_auth_links.oauth_access_token` +- `git_auth_links.oauth_refresh_token` + +Additional database fields may be encrypted in the future. + +> Implementation note: there is an additional encrypted database field +> `dbcrypt_sentinel.value`. This field is used to verify that the encryption +> keys are valid for the configured database. It is not used to encrypt any user +> data. + +Encrypted data is stored in the following format: + +- `encrypted_data = dbcrypt-` +- `b64data = -` + +All encrypted data is prefixed with the string `dbcrypt-`. The cipher checksum +is the first 7 bytes of the SHA256 hex digest of the encryption key used to +encrypt the data. + +## Enabling encryption + +1. Ensure you have a valid backup of your database. **Do not skip this step.** + If you are using the built-in PostgreSQL database, you can run + [`coder server postgres-builtin-url`](../cli/server_postgres-builtin-url.md) + to get the connection URL. + +1. Generate a 32-byte random key and base64-encode it. For example: + +```shell +dd if=/dev/urandom bs=32 count=1 | base64 +``` + +1. Store this key in a secure location (for example, a Kubernetes secret): + +```shell +kubectl create secret generate coder-external-token-encryption-keys --from-literal=keys= +``` + +1. In your Coder configuration set the `external_token_encryption_keys` field to + a comma-separated list of base64-encoded keys. For example, in your Helm + `values.yaml`: + +```yaml +coder: + env: + [...] + - name: CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS + valueFrom: + secretKeyRef: + name: coder-external-token-encryption-keys + key: keys +``` + +## Rotating keys + +We recommend only having one active encryption key at a time normally. However, +if you need to rotate keys, you can perform the following procedure: + +1. Ensure you have a valid backup of your database. **Do not skip this step.** + +1. Generate a new encryption key following the same procedure as above. + +1. Add the above key to the list of + [external token encryption keys](../cli/server.md#external-token-encryption-keys). + **The new key must appear first in the list**. For example, in the Kubernetes + secret created above: + +```yaml +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: coder-external-token-encryption-keys + namespace: coder-namespace +data: + keys: , +``` + +1. After updating the configuration, restart the Coder server. The server will + now encrypt all new data with the new key, but will be able to decrypt tokens + encrypted with the old key. + +1. To re-encrypt all encrypted database fields with the new key, run + [`coder dbcrypt-rotate`](../cli/dbcrypt-rotate.md). This command will + re-encrypt all tokens with the first key in the list of external token + encryption keys. We recommend performing this action during a maintenance + window. + + > Note: this command requires direct access to the database. If you are using + > the built-in PostgreSQL database, you can run + > [`coder server postgres-builtin-url`](../cli/server_postgres-builtin-url.md) + > to get the connection URL. + +1. Once the above command completes successfully, remove the old encryption key + from Coder's configuration and restart Coder once more. You can now safely + delete the old key from your secret store. + +## Disabling encryption + +Automatically disabling encryption is currently not supported. Encryption can be +disabled by removing the encrypted data manually from the database: + +```sql +DELETE FROM user_links WHERE oauth_access_token LIKE 'dbcrypt-%'; +DELETE FROM user_links WHERE oauth_refresh_token LIKE 'dbcrypt-%'; +DELETE FROM git_auth_links WHERE oauth_access_token LIKE 'dbcrypt-%'; +DELETE FROM git_auth_links WHERE oauth_refresh_token LIKE 'dbcrypt-%'; +DELETE FROM dbcrypt_sentinel WHERE value LIKE 'dbcrypt-%'; +``` + +Users will then need to re-authenticate with external authentication providers. + +## Troubleshooting + +- If Coder detects that the data stored in the database under + `dbcrypt_sentinel.value` was not encrypted with a known key, it will refuse to + start. If you are seeing this behaviour, ensure that the encryption keys + provided are correct. +- If Coder is unable to decrypt a token, it will be treated as if the data were + not present. This means that the user will be prompted to re-authenticate with + the external provider. If you are seeing this behaviour consistently, ensure + that the encryption keys are correct. diff --git a/docs/images/icons/lock.svg b/docs/images/icons/lock.svg new file mode 100644 index 0000000000000..620af5152163c --- /dev/null +++ b/docs/images/icons/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/manifest.json b/docs/manifest.json index e465fc69ff04d..9e2492a23692d 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -385,6 +385,13 @@ "description": "Learn what usage telemetry Coder collects", "path": "./admin/telemetry.md", "icon_path": "./images/icons/science.svg" + }, + { + "title": "Database Encryption", + "description": "Learn how to encrypt sensitive data at rest in Coder", + "path": "./admin/database-encryption.md", + "icon_path": "./images/icons/lock.svg", + "state": "enterprise" } ] },