diff --git a/cli/server.go b/cli/server.go index b9abe82b47dab..5b202ac923bd6 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/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index d3a5d74bcddbe..c670fadd90bbd 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -458,6 +458,14 @@ These options are only available in the Enterprise Edition. An HTTP URL that is accessible by other replicas to relay DERP traffic. Required for high availability. + --external-token-encryption-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS + Encrypt OIDC and Git authentication tokens with AES-256-GCM in the + database. The value must be a comma-separated list of base64-encoded + keys. 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 server. New users are automatically created with OIDC authentication. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 57d815510fe43..c06e01d2fe2f4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7951,6 +7951,12 @@ const docTemplate = `{ "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 ec1deeea54689..cdc220f826d2d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7106,6 +7106,12 @@ "type": "string" } }, + "external_token_encryption_keys": { + "type": "array", + "items": { + "type": "string" + } + }, "git_auth": { "$ref": "#/definitions/clibase.Struct-array_codersdk_GitAuthConfig" }, diff --git a/coderd/deployment_test.go b/coderd/deployment_test.go index 617947e6eb607..66e3990e25ff3 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.ExternalTokenEncryptionKeys.Set("the_random_key_we_never_expected,an_other_key_we_never_unexpected") 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.ExternalTokenEncryptionKeys.Value()) } func TestDeploymentStats(t *testing.T) { diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 26b8258206f33..ec77a346056cd 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -247,6 +247,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/codersdk/deployment.go b/codersdk/deployment.go index 95624faf97925..d4cf2dcb1cea8 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -46,8 +46,9 @@ const ( FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons" FeatureAppearance FeatureName = "appearance" FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling" - FeatureTemplateAutostopRequirement FeatureName = "template_autostop_requirement" FeatureWorkspaceProxy FeatureName = "workspace_proxy" + FeatureExternalTokenEncryption FeatureName = "external_token_encryption" + FeatureTemplateAutostopRequirement FeatureName = "template_autostop_requirement" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -64,6 +65,8 @@ var FeatureNames = []FeatureName{ FeatureAdvancedTemplateScheduling, FeatureWorkspaceProxy, FeatureUserRoleManagement, + FeatureExternalTokenEncryption, + FeatureTemplateAutostopRequirement, } // Humanize returns the feature name in a human-readable format. @@ -152,6 +155,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,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"` @@ -1603,7 +1607,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 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.ExternalTokenEncryptionKeys, + }, { 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.", @@ -1781,7 +1792,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) diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 408aa4fd21ae5..287e34c741226 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 Keys": { + yaml: true, + }, // These complex objects should be configured through YAML. "Support Links": { flag: true, 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/api/general.md b/docs/api/general.md index b50771e16d2ad..ded8e5df4d319 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -212,6 +212,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ }, "enable_terraform_debug_mode": true, "experiments": ["string"], + "external_token_encryption_keys": ["string"], "git_auth": { "value": [ { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 456b32b07bcae..b2d04a575c542 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2034,6 +2034,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in }, "enable_terraform_debug_mode": true, "experiments": ["string"], + "external_token_encryption_keys": ["string"], "git_auth": { "value": [ { @@ -2398,6 +2399,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in }, "enable_terraform_debug_mode": true, "experiments": ["string"], + "external_token_encryption_keys": ["string"], "git_auth": { "value": [ { @@ -2611,6 +2613,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_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..ebb76a6c2d0d4 --- /dev/null +++ b/docs/cli/dbcrypt-rotate.md @@ -0,0 +1,31 @@ + + +# dbcrypt-rotate + +Rotate database encryption keys + +## Usage + +```console +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/docs/cli/server.md b/docs/cli/server.md index 49ba37d7a4236..05452cb4b3881 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -273,6 +273,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-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. + ### --provisioner-force-cancel-interval | | | 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 8c18bb26ad9f5..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" } ] }, @@ -535,6 +542,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", diff --git a/enterprise/cli/dbcrypt_rotate.go b/enterprise/cli/dbcrypt_rotate.go new file mode 100644 index 0000000000000..e4775f8d5a20b --- /dev/null +++ b/enterprise/cli/dbcrypt_rotate.go @@ -0,0 +1,131 @@ +//go:build !slim + +package cli + +import ( + "bytes" + "context" + "encoding/base64" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + + "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/dbcrypt" + + "golang.org/x/xerrors" +) + +func (*RootCmd) dbcryptRotate() *clibase.Cmd { + var ( + vals = new(codersdk.DeploymentValues) + opts = vals.Options() + ) + cmd := &clibase.Cmd{ + 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), + ), + Handler: func(inv *clibase.Invocation) error { + ctx, cancel := context.WithCancel(inv.Context()) + defer cancel() + logger := slog.Make(sloghuman.Sink(inv.Stdout)) + + 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.Equal(newKey, oldKey) { + return xerrors.Errorf("old and new keys must be different") + } + + 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) + } + ciphers := dbcrypt.NewCiphers(primaryCipher, 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, ciphers) + 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 + }, + } + return cmd +} 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`") + }, + } +} diff --git a/enterprise/cli/dbcrypt_rotate_test.go b/enterprise/cli/dbcrypt_rotate_test.go new file mode 100644 index 0000000000000..a88540a0c65d1 --- /dev/null +++ b/enterprise/cli/dbcrypt_rotate_test.go @@ -0,0 +1,130 @@ +package cli_test + +import ( + "context" + "database/sql" + "encoding/base64" + "fmt" + "testing" + + "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" +) + +// nolint: paralleltest // use of t.Setenv +func TestDBCryptRotate(t *testing.T) { + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires a postgres instance") + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + // Setup a postgres database. + connectionURL, closePg, err := postgres.Open() + require.NoError(t, err) + t.Cleanup(closePg) + + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + _ = sqlDB.Close() + }) + db := database.New(sqlDB) + + // Setup an initial cipher + keyA := mustString(t, 32) + cA, err := dbcrypt.CipherAES256([]byte(keyA)) + require.NoError(t, err) + ciphers := dbcrypt.NewCiphers(cA) + + // Create an encrypted database + cryptdb, err := dbcrypt.New(ctx, db, ciphers) + require.NoError(t, err) + + // 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, _ := newCLI(t, "dbcrypt-rotate", + "--postgres-url", connectionURL, + "--external-token-encryption-keys", externalTokensArg, + ) + pty := ptytest.New(t) + inv.Stdout = pty.Output() + + err = inv.Run() + require.NoError(t, err) + + // Validate that all data has been updated with the checksum of the new cipher. + 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) + 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) + 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) + require.NoError(t, err) + return s +} 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/cli/server.go b/enterprise/cli/server.go index 197eab61e10e9..26f627125017a 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" @@ -18,6 +19,7 @@ import ( "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" @@ -69,6 +71,25 @@ func (r *RootCmd) server() *clibase.Cmd { ProvisionerDaemonPSK: options.DeploymentValues.Provisioner.DaemonPSK.Value(), } + 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) + if err != nil { + return nil, nil, xerrors.Errorf("decode external-token-encryption-key %d: %w", idx, err) + } + 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) + } + o.ExternalTokenEncryption = dbcrypt.NewCiphers(cs...) + } + api, err := coderd.New(ctx, o) if err != nil { return nil, nil, err 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. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index d3a5d74bcddbe..c670fadd90bbd 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -458,6 +458,14 @@ These options are only available in the Enterprise Edition. An HTTP URL that is accessible by other replicas to relay DERP traffic. Required for high availability. + --external-token-encryption-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS + Encrypt OIDC and Git authentication tokens with AES-256-GCM in the + database. The value must be a comma-separated list of base64-encoded + keys. 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 server. New users are automatically created with OIDC authentication. diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 7943c701dee33..a0118f3dcd392 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -33,6 +33,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" @@ -47,8 +48,8 @@ func New(ctx context.Context, options *Options) (_ *API, err 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{} @@ -61,10 +62,22 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { } ctx, cancelFunc := context.WithCancel(ctx) - api := &API{ - ctx: ctx, - cancel: cancelFunc, + if options.ExternalTokenEncryption != nil { + 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 + } + + api := &API{ + ctx: ctx, + cancel: cancelFunc, AGPL: coderd.New(options.Options), Options: options, provisionerDaemonAuth: &provisionerDaemonAuth{ @@ -364,6 +377,8 @@ type Options struct { BrowserOnly bool SCIMAPIKey []byte + ExternalTokenEncryption *dbcrypt.Ciphers + // Used for high availability. ReplicaSyncUpdateInterval time.Duration DERPServerRelayAddress string @@ -374,7 +389,7 @@ 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 @@ -423,13 +438,14 @@ 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, 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, // FeatureTemplateAutostopRequirement depends on 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) diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 81e43c4fd5755..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, @@ -92,10 +100,11 @@ func NewWithAPI(t *testing.T, options *Options) ( ReplicaSyncUpdateInterval: options.ReplicaSyncUpdateInterval, Options: oop, EntitlementsUpdateInterval: options.EntitlementsUpdateInterval, - Keys: Keys, + LicenseKeys: Keys, 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/coderd/licenses.go b/enterprise/coderd/licenses.go index aff3f41fa5a76..771f5639bb63a 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -83,7 +83,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", @@ -101,7 +101,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", diff --git a/scripts/develop.sh b/scripts/develop.sh index 39f81c2951bc4..327f2192ce2c4 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="Y29kZXItY29kZXItY29kZXItY29kZXItY29kZXItMSE=" 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 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a7ddd9420e54b..b78ac9e63a746 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -378,6 +378,8 @@ export interface DeploymentValues { readonly agent_fallback_troubleshooting_url?: string 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 provisioner?: ProvisionerConfig readonly rate_limit?: RateLimitConfig // This is likely an enum in an external package ("github.com/coder/coder/v2/cli/clibase.StringArray") @@ -1624,6 +1626,7 @@ export type FeatureName = | "audit_log" | "browser_only" | "external_provisioner_daemons" + | "external_token_encryption" | "high_availability" | "multiple_git_auth" | "scim" @@ -1638,6 +1641,7 @@ export const FeatureNames: FeatureName[] = [ "audit_log", "browser_only", "external_provisioner_daemons", + "external_token_encryption", "high_availability", "multiple_git_auth", "scim",