diff --git a/cli/server.go b/cli/server.go index 683d8aec58fb9..63fca59ec3fec 100644 --- a/cli/server.go +++ b/cli/server.go @@ -691,7 +691,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) } @@ -1953,7 +1953,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 a9f4bfb00b906..fa82e4fbcd051 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -63,7 +63,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/server_test.go b/cli/server_test.go index 7b38bb76f9e15..3adcc3c4b39dd 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -34,10 +34,13 @@ import ( "go.uber.org/goleak" "gopkg.in/yaml.v3" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/cli" "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/cli/config" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/postgres" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/codersdk" @@ -1657,3 +1660,26 @@ func TestServerYAMLConfig(t *testing.T) { require.Equal(t, string(wantByt), string(got)) } + +func TestConnectToPostgres(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("this test does not make sense without postgres") + } + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + + log := slogtest.Make(t, nil) + + dbURL, closeFunc, err := postgres.Open() + require.NoError(t, err) + t.Cleanup(closeFunc) + + sqlDB, err := cli.ConnectToPostgres(ctx, log, "postgres", dbURL) + require.NoError(t, err) + t.Cleanup(func() { + _ = sqlDB.Close() + }) + require.NoError(t, sqlDB.PingContext(ctx)) +} diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index b0a1447b18081..cd23076e30a3a 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -458,6 +458,16 @@ These options are only available in the Enterprise Edition. An HTTP URL that is accessible by other replicas to relay DERP traffic. Required for high availability. + --external-token-encryption-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS + Encrypt OIDC and Git authentication tokens with AES-256-GCM in the + database. The value must be a comma-separated list of base64-encoded + keys. Each key, when base64-decoded, must be exactly 32 bytes in + length. The first key will be used to encrypt new values. Subsequent + keys will be used as a fallback when decrypting. During normal + operation it is recommended to only set one key unless you are in the + process of rotating keys with the `coder server dbcrypt rotate` + command. + --scim-auth-header string, $CODER_SCIM_AUTH_HEADER Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index bc382c82221e3..2c45b383cc90b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7956,6 +7956,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 50c68490d8415..21fbd82b348ca 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7112,6 +7112,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 5fbb68f410cda..f2a7a0d3ececf 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -248,6 +248,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/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index f29067dec522f..f3ceba017d773 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -153,6 +153,34 @@ func TestAPIKey(t *testing.T) { require.Equal(t, http.StatusUnauthorized, res.StatusCode) }) + t.Run("UserLinkNotFound", func(t *testing.T) { + t.Parallel() + var ( + db = dbfake.New() + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() + user = dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeGithub, + }) + // Intentionally not inserting any user link + _, token = dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + LoginType: user.LoginType, + }) + ) + r.Header.Set(codersdk.SessionTokenHeader, token) + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + })(successHandler).ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + var resp codersdk.Response + require.NoError(t, json.NewDecoder(res.Body).Decode(&resp)) + require.Equal(t, resp.Message, httpmw.SignedOutErrorMessage) + }) + t.Run("InvalidSecret", func(t *testing.T) { t.Parallel() var ( diff --git a/codersdk/deployment.go b/codersdk/deployment.go index d73579d090968..dcbe2a1e94679 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" FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions" ) @@ -65,6 +66,8 @@ var FeatureNames = []FeatureName{ FeatureAdvancedTemplateScheduling, FeatureWorkspaceProxy, FeatureUserRoleManagement, + FeatureExternalTokenEncryption, + FeatureTemplateAutostopRequirement, FeatureWorkspaceBatchActions, } @@ -154,6 +157,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"` @@ -1605,7 +1609,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. Each key, when base64-decoded, must be exactly 32 bytes in length. The first key will be used to encrypt new values. Subsequent keys will be used as a fallback when decrypting. During normal operation it is recommended to only set one key unless you are in the process of rotating keys with the `coder server dbcrypt rotate` command.", + 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.", @@ -1783,7 +1794,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..1a5f0f6c08fe9 --- /dev/null +++ b/docs/admin/encryption.md @@ -0,0 +1,173 @@ +# Database Encryption + +By default, Coder stores external user tokens in plaintext in the database. +Database Encryption allows Coder administrators to encrypt these tokens at-rest, +preventing attackers with database access from using them to impersonate users. + +## How it works + +Coder allows administrators to specify +[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 +additional keys are 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 notes: each encrypted database column `$C` has a corresponding +> `$C_key_id` column. This column is used to determine which encryption key was +> used to encrypt the data. This allows Coder to rotate encryption keys without +> invalidating existing tokens, and provides referential integrity for encrypted +> data. +> +> The `$C_key_id` column stores the first 7 bytes of the SHA-256 hash of the +> encryption key used to encrypt the data. +> +> Encryption keys in use are stored in `dbcrypt_keys`. This table stores a +> record of all encryption keys that have been used to encrypt data. Active keys +> have a null `revoked_key_id` column, and revoked keys have a non-null +> `revoked_key_id` column. You cannot revoke a key until you have rotated all +> values using that key to a new key. + +## 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 generic coder-external-token-encryption-keys --from-literal=keys= +``` + +1. In your Coder configuration set `CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS` 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 +``` + +1. Restart the Coder server. The server will now encrypt all new data with the + provided key. + +## 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(s). + +1. To re-encrypt all encrypted database fields with the new key, run + [`coder server dbcrypt rotate`](../cli/server_dbcrypt_rotate.md). This + command will re-encrypt all tokens with the specified new encryption key. 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 + +To disable encryption, perform the following actions: + +1. Ensure you have a valid backup of your database. **Do not skip this step.** + +1. Stop all active coderd instances. This will prevent new encrypted data from + being written. + +1. Run [`coder server dbcrypt decrypt`](../cli/server_dbcrypt_decrypt.md). This + command will decrypt all encrypted user tokens and revoke all active + encryption keys. + +1. Remove all + [external token encryption keys](../cli/server.md#external-token-encryption-keys) + from Coder's configuration. + +1. Start coderd. You can now safely delete the encryption keys from your secret + store. + +## Deleting Encrypted Data + +> NOTE: This is a destructive operation. + +To delete all encrypted data from your database, perform the following actions: + +1. Ensure you have a valid backup of your database. **Do not skip this step.** + +1. Stop all active coderd instances. This will prevent new encrypted data from + being written. + +1. Run [`coder server dbcrypt delete`](../cli/server_dbcrypt_delete.md). This + command will delete all encrypted user tokens and revoke all active + encryption keys. + +1. Remove all + [external token encryption keys](../cli/server.md#external-token-encryption-keys) + from Coder's configuration. + +1. Start coderd. You can now safely delete the encryption keys from your secret + store. + +## Troubleshooting + +- If Coder detects that the data stored in the database was not encrypted with + any known keys, it will refuse to start. If you are seeing this behaviour, + ensure that the encryption keys provided are correct. +- If Coder detects that the data stored in the database was encrypted with a key + that is no longer active, it will refuse to start. If you are seeing this + behaviour, ensure that the encryption keys provided are correct and that you + have not revoked any keys that are still in use. 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 31d23faad36f6..4cf60a773f81d 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2036,6 +2036,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": [ { @@ -2400,6 +2401,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": [ { @@ -2613,6 +2615,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/server.md b/docs/cli/server.md index 25733a36e3e91..47da792c0d5ea 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -15,6 +15,7 @@ coder server [flags] | Name | Purpose | | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | [create-admin-user](./server_create-admin-user.md) | Create a new admin user with the given username, email and password and adds it to every organization. | +| [dbcrypt](./server_dbcrypt.md) | Manage database encryption. | | [postgres-builtin-serve](./server_postgres-builtin-serve.md) | Run the built-in PostgreSQL deployment. | | [postgres-builtin-url](./server_postgres-builtin-url.md) | Output the connection URL for the built-in PostgreSQL deployment. | @@ -273,6 +274,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. Each key, when base64-decoded, must be exactly 32 bytes in length. The first key will be used to encrypt new values. Subsequent keys will be used as a fallback when decrypting. During normal operation it is recommended to only set one key unless you are in the process of rotating keys with the `coder server dbcrypt rotate` command. + ### --provisioner-force-cancel-interval | | | diff --git a/docs/cli/server_dbcrypt.md b/docs/cli/server_dbcrypt.md new file mode 100644 index 0000000000000..be06560a275ca --- /dev/null +++ b/docs/cli/server_dbcrypt.md @@ -0,0 +1,19 @@ + + +# server dbcrypt + +Manage database encryption. + +## Usage + +```console +coder server dbcrypt +``` + +## Subcommands + +| Name | Purpose | +| --------------------------------------------------- | ----------------------------------------------------------------------------- | +| [decrypt](./server_dbcrypt_decrypt.md) | Decrypt a previously encrypted database. | +| [delete](./server_dbcrypt_delete.md) | Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION. | +| [rotate](./server_dbcrypt_rotate.md) | Rotate database encryption keys. | diff --git a/docs/cli/server_dbcrypt_decrypt.md b/docs/cli/server_dbcrypt_decrypt.md new file mode 100644 index 0000000000000..1141ccc0da94d --- /dev/null +++ b/docs/cli/server_dbcrypt_decrypt.md @@ -0,0 +1,39 @@ + + +# server dbcrypt decrypt + +Decrypt a previously encrypted database. + +## Usage + +```console +coder server dbcrypt decrypt [flags] +``` + +## Options + +### --keys + +| | | +| ----------- | ---------------------------------------------------------- | +| Type | string-array | +| Environment | $CODER_EXTERNAL_TOKEN_ENCRYPTION_DECRYPT_KEYS | + +Keys required to decrypt existing data. Must be a comma-separated list of base64-encoded keys. + +### --postgres-url + +| | | +| ----------- | ------------------------------------- | +| Type | string | +| Environment | $CODER_PG_CONNECTION_URL | + +The connection URL for the Postgres database. + +### -y, --yes + +| | | +| ---- | ----------------- | +| Type | bool | + +Bypass prompts. diff --git a/docs/cli/server_dbcrypt_delete.md b/docs/cli/server_dbcrypt_delete.md new file mode 100644 index 0000000000000..ed81a776035f6 --- /dev/null +++ b/docs/cli/server_dbcrypt_delete.md @@ -0,0 +1,34 @@ + + +# server dbcrypt delete + +Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION. + +Aliases: + +- rm + +## Usage + +```console +coder server dbcrypt delete [flags] +``` + +## Options + +### --postgres-url + +| | | +| ----------- | ---------------------------------------------------------- | +| Type | string | +| Environment | $CODER_EXTERNAL_TOKEN_ENCRYPTION_POSTGRES_URL | + +The connection URL for the Postgres database. + +### -y, --yes + +| | | +| ---- | ----------------- | +| Type | bool | + +Bypass prompts. diff --git a/docs/cli/server_dbcrypt_rotate.md b/docs/cli/server_dbcrypt_rotate.md new file mode 100644 index 0000000000000..e2679e5127869 --- /dev/null +++ b/docs/cli/server_dbcrypt_rotate.md @@ -0,0 +1,48 @@ + + +# server dbcrypt rotate + +Rotate database encryption keys. + +## Usage + +```console +coder server dbcrypt rotate [flags] +``` + +## Options + +### --new-key + +| | | +| ----------- | ------------------------------------------------------------- | +| Type | string | +| Environment | $CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_NEW_KEY | + +The new external token encryption key. Must be base64-encoded. + +### --old-keys + +| | | +| ----------- | -------------------------------------------------------------- | +| Type | string-array | +| Environment | $CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_OLD_KEYS | + +The old external token encryption keys. Must be a comma-separated list of base64-encoded keys. + +### --postgres-url + +| | | +| ----------- | ------------------------------------- | +| Type | string | +| Environment | $CODER_PG_CONNECTION_URL | + +The connection URL for the Postgres database. + +### -y, --yes + +| | | +| ---- | ----------------- | +| Type | bool | + +Bypass prompts. 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 141d36a749e64..c68d22032f5eb 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -390,6 +390,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" } ] }, @@ -699,6 +706,26 @@ "description": "Create a new admin user with the given username, email and password and adds it to every organization.", "path": "cli/server_create-admin-user.md" }, + { + "title": "server dbcrypt", + "description": "Manage database encryption.", + "path": "cli/server_dbcrypt.md" + }, + { + "title": "server dbcrypt decrypt", + "description": "Decrypt a previously encrypted database.", + "path": "cli/server_dbcrypt_decrypt.md" + }, + { + "title": "server dbcrypt delete", + "description": "Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION.", + "path": "cli/server_dbcrypt_delete.md" + }, + { + "title": "server dbcrypt rotate", + "description": "Rotate database encryption keys.", + "path": "cli/server_dbcrypt_rotate.md" + }, { "title": "server postgres-builtin-serve", "description": "Run the built-in PostgreSQL deployment.", diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 021b66f2f7a60..7fb1526c50197 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" @@ -19,6 +20,7 @@ import ( "github.com/coder/coder/v2/enterprise/audit/backends" "github.com/coder/coder/v2/enterprise/coderd" "github.com/coder/coder/v2/enterprise/coderd/dormancy" + "github.com/coder/coder/v2/enterprise/dbcrypt" "github.com/coder/coder/v2/enterprise/trialer" "github.com/coder/coder/v2/tailnet" @@ -74,11 +76,31 @@ func (r *RootCmd) Server(_ func()) *clibase.Cmd { CheckInactiveUsersCancelFunc: dormancy.CheckInactiveUsers(ctx, options.Logger, options.Database), } + if encKeys := options.DeploymentValues.ExternalTokenEncryptionKeys.Value(); len(encKeys) != 0 { + keys := make([][]byte, 0, len(encKeys)) + for idx, ek := range encKeys { + dk, err := base64.StdEncoding.DecodeString(ek) + if err != nil { + return nil, nil, xerrors.Errorf("decode external-token-encryption-key %d: %w", idx, err) + } + keys = append(keys, dk) + } + cs, err := dbcrypt.NewCiphers(keys...) + if err != nil { + return nil, nil, xerrors.Errorf("initialize encryption: %w", err) + } + o.ExternalTokenEncryption = cs + } + api, err := coderd.New(ctx, o) if err != nil { return nil, nil, err } return api.AGPL, api, nil }) + + cmd.AddSubcommands( + r.dbcryptCmd(), + ) return cmd } diff --git a/enterprise/cli/server_dbcrypt.go b/enterprise/cli/server_dbcrypt.go new file mode 100644 index 0000000000000..481df1dae6c2e --- /dev/null +++ b/enterprise/cli/server_dbcrypt.go @@ -0,0 +1,344 @@ +//go:build !slim + +package cli + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/enterprise/dbcrypt" + + "golang.org/x/xerrors" +) + +func (r *RootCmd) dbcryptCmd() *clibase.Cmd { + dbcryptCmd := &clibase.Cmd{ + Use: "dbcrypt", + Short: "Manage database encryption.", + Handler: func(inv *clibase.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + } + dbcryptCmd.AddSubcommands( + r.dbcryptDecryptCmd(), + r.dbcryptDeleteCmd(), + r.dbcryptRotateCmd(), + ) + return dbcryptCmd +} + +func (*RootCmd) dbcryptRotateCmd() *clibase.Cmd { + var flags rotateFlags + cmd := &clibase.Cmd{ + Use: "rotate", + Short: "Rotate database encryption keys.", + Handler: func(inv *clibase.Invocation) error { + ctx, cancel := context.WithCancel(inv.Context()) + defer cancel() + logger := slog.Make(sloghuman.Sink(inv.Stdout)) + if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok { + logger = logger.Leveled(slog.LevelDebug) + } + + if err := flags.valid(); err != nil { + return err + } + + ks := [][]byte{} + dk, err := base64.StdEncoding.DecodeString(flags.New) + if err != nil { + return xerrors.Errorf("decode new key: %w", err) + } + ks = append(ks, dk) + + for _, k := range flags.Old { + dk, err := base64.StdEncoding.DecodeString(k) + if err != nil { + return xerrors.Errorf("decode old key: %w", err) + } + ks = append(ks, dk) + } + + ciphers, err := dbcrypt.NewCiphers(ks...) + if err != nil { + return xerrors.Errorf("create ciphers: %w", err) + } + + var act string + switch len(flags.Old) { + case 0: + act = "Data will be encrypted with the new key." + default: + act = "Data will be decrypted with all available keys and re-encrypted with new key." + } + + msg := fmt.Sprintf("%s\n\n- New key: %s\n- Old keys: %s\n\nRotate external token encryption keys?\n", + act, + flags.New, + strings.Join(flags.Old, ", "), + ) + if _, err := cliui.Prompt(inv, cliui.PromptOptions{Text: msg, IsConfirm: true}); err != nil { + return err + } + + sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", flags.PostgresURL) + if err != nil { + return xerrors.Errorf("connect to postgres: %w", err) + } + defer func() { + _ = sqlDB.Close() + }() + logger.Info(ctx, "connected to postgres") + if err := dbcrypt.Rotate(ctx, logger, sqlDB, ciphers); err != nil { + return xerrors.Errorf("rotate ciphers: %w", err) + } + logger.Info(ctx, "operation completed successfully") + return nil + }, + } + flags.attach(&cmd.Options) + return cmd +} + +func (*RootCmd) dbcryptDecryptCmd() *clibase.Cmd { + var flags decryptFlags + cmd := &clibase.Cmd{ + Use: "decrypt", + Short: "Decrypt a previously encrypted database.", + Handler: func(inv *clibase.Invocation) error { + ctx, cancel := context.WithCancel(inv.Context()) + defer cancel() + logger := slog.Make(sloghuman.Sink(inv.Stdout)) + if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok { + logger = logger.Leveled(slog.LevelDebug) + } + + if err := flags.valid(); err != nil { + return err + } + + ks := make([][]byte, 0, len(flags.Keys)) + for _, k := range flags.Keys { + dk, err := base64.StdEncoding.DecodeString(k) + if err != nil { + return xerrors.Errorf("decode key: %w", err) + } + ks = append(ks, dk) + } + + ciphers, err := dbcrypt.NewCiphers(ks...) + if err != nil { + return xerrors.Errorf("create ciphers: %w", err) + } + + if _, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "This will decrypt all encrypted data in the database. Are you sure you want to continue?", + IsConfirm: true, + }); err != nil { + return err + } + + sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", flags.PostgresURL) + if err != nil { + return xerrors.Errorf("connect to postgres: %w", err) + } + defer func() { + _ = sqlDB.Close() + }() + logger.Info(ctx, "connected to postgres") + if err := dbcrypt.Decrypt(ctx, logger, sqlDB, ciphers); err != nil { + return xerrors.Errorf("rotate ciphers: %w", err) + } + logger.Info(ctx, "operation completed successfully") + return nil + }, + } + flags.attach(&cmd.Options) + return cmd +} + +func (*RootCmd) dbcryptDeleteCmd() *clibase.Cmd { + var flags deleteFlags + cmd := &clibase.Cmd{ + Use: "delete", + Short: "Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION.", + Handler: func(inv *clibase.Invocation) error { + ctx, cancel := context.WithCancel(inv.Context()) + defer cancel() + logger := slog.Make(sloghuman.Sink(inv.Stdout)) + if ok, _ := inv.ParsedFlags().GetBool("verbose"); ok { + logger = logger.Leveled(slog.LevelDebug) + } + + if err := flags.valid(); err != nil { + return err + } + msg := `All encrypted data will be deleted from the database: +- Encrypted user OAuth access and refresh tokens +- Encrypted user Git authentication access and refresh tokens + +Are you sure you want to continue?` + if _, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: msg, + IsConfirm: true, + }); err != nil { + return err + } + + sqlDB, err := cli.ConnectToPostgres(inv.Context(), logger, "postgres", flags.PostgresURL) + if err != nil { + return xerrors.Errorf("connect to postgres: %w", err) + } + defer func() { + _ = sqlDB.Close() + }() + logger.Info(ctx, "connected to postgres") + if err := dbcrypt.Delete(ctx, logger, sqlDB); err != nil { + return xerrors.Errorf("delete encrypted data: %w", err) + } + logger.Info(ctx, "operation completed successfully") + return nil + }, + } + flags.attach(&cmd.Options) + return cmd +} + +type rotateFlags struct { + PostgresURL string + New string + Old []string +} + +func (f *rotateFlags) attach(opts *clibase.OptionSet) { + *opts = append( + *opts, + clibase.Option{ + Flag: "postgres-url", + Env: "CODER_PG_CONNECTION_URL", + Description: "The connection URL for the Postgres database.", + Value: clibase.StringOf(&f.PostgresURL), + }, + clibase.Option{ + Flag: "new-key", + Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_NEW_KEY", + Description: "The new external token encryption key. Must be base64-encoded.", + Value: clibase.StringOf(&f.New), + }, + clibase.Option{ + Flag: "old-keys", + Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_OLD_KEYS", + Description: "The old external token encryption keys. Must be a comma-separated list of base64-encoded keys.", + Value: clibase.StringArrayOf(&f.Old), + }, + cliui.SkipPromptOption(), + ) +} + +func (f *rotateFlags) valid() error { + if f.PostgresURL == "" { + return xerrors.Errorf("no database configured") + } + + if f.New == "" { + return xerrors.Errorf("no new key provided") + } + + if val, err := base64.StdEncoding.DecodeString(f.New); err != nil { + return xerrors.Errorf("new key must be base64-encoded") + } else if len(val) != 32 { + return xerrors.Errorf("new key must be exactly 32 bytes in length") + } + + for i, k := range f.Old { + if val, err := base64.StdEncoding.DecodeString(k); err != nil { + return xerrors.Errorf("old key at index %d must be base64-encoded", i) + } else if len(val) != 32 { + return xerrors.Errorf("old key at index %d must be exactly 32 bytes in length", i) + } + + // Pedantic, but typos here will ruin your day. + if k == f.New { + return xerrors.Errorf("old key at index %d is the same as the new key", i) + } + } + + return nil +} + +type decryptFlags struct { + PostgresURL string + Keys []string +} + +func (f *decryptFlags) attach(opts *clibase.OptionSet) { + *opts = append( + *opts, + clibase.Option{ + Flag: "postgres-url", + Env: "CODER_PG_CONNECTION_URL", + Description: "The connection URL for the Postgres database.", + Value: clibase.StringOf(&f.PostgresURL), + }, + clibase.Option{ + Flag: "keys", + Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_DECRYPT_KEYS", + Description: "Keys required to decrypt existing data. Must be a comma-separated list of base64-encoded keys.", + Value: clibase.StringArrayOf(&f.Keys), + }, + cliui.SkipPromptOption(), + ) +} + +func (f *decryptFlags) valid() error { + if f.PostgresURL == "" { + return xerrors.Errorf("no database configured") + } + + if len(f.Keys) == 0 { + return xerrors.Errorf("no keys provided") + } + + for i, k := range f.Keys { + if val, err := base64.StdEncoding.DecodeString(k); err != nil { + return xerrors.Errorf("key at index %d must be base64-encoded", i) + } else if len(val) != 32 { + return xerrors.Errorf("key at index %d must be exactly 32 bytes in length", i) + } + } + + return nil +} + +type deleteFlags struct { + PostgresURL string + Confirm bool +} + +func (f *deleteFlags) attach(opts *clibase.OptionSet) { + *opts = append( + *opts, + clibase.Option{ + Flag: "postgres-url", + Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_POSTGRES_URL", + Description: "The connection URL for the Postgres database.", + Value: clibase.StringOf(&f.PostgresURL), + }, + cliui.SkipPromptOption(), + ) +} + +func (f *deleteFlags) valid() error { + if f.PostgresURL == "" { + return xerrors.Errorf("no database configured") + } + + return nil +} diff --git a/enterprise/cli/server_dbcrypt_test.go b/enterprise/cli/server_dbcrypt_test.go new file mode 100644 index 0000000000000..cebf014a7ce58 --- /dev/null +++ b/enterprise/cli/server_dbcrypt_test.go @@ -0,0 +1,277 @@ +package cli_test + +import ( + "context" + "database/sql" + "encoding/base64" + "testing" + + "github.com/google/uuid" + "github.com/lib/pq" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/postgres" + "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/enterprise/dbcrypt" + "github.com/coder/coder/v2/pty/ptytest" +) + +// nolint: paralleltest // use of t.Setenv +func TestServerDBCrypt(t *testing.T) { + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires a postgres instance") + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + // Setup a postgres database. + connectionURL, closePg, err := postgres.Open() + require.NoError(t, err) + t.Cleanup(closePg) + + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + _ = sqlDB.Close() + }) + db := database.New(sqlDB) + + // Populate the database with some unencrypted data. + users := genData(t, db, 10) + + // Setup an initial cipher + keyA := mustString(t, 32) + cipherA, err := dbcrypt.NewCiphers([]byte(keyA)) + require.NoError(t, err) + + // Encrypt all the data with the initial cipher. + inv, _ := newCLI(t, "server", "dbcrypt", "rotate", + "--postgres-url", connectionURL, + "--new-key", base64.StdEncoding.EncodeToString([]byte(keyA)), + "--yes", + ) + pty := ptytest.New(t) + inv.Stdout = pty.Output() + err = inv.Run() + require.NoError(t, err) + + // Validate that all existing data has been encrypted with cipher A. + for _, usr := range users { + requireEncryptedWithCipher(ctx, t, db, cipherA[0], usr.ID) + } + + // Create an encrypted database + cryptdb, err := dbcrypt.New(ctx, db, cipherA...) + require.NoError(t, err) + + // Populate the database with some encrypted data using cipher A. + users = append(users, genData(t, cryptdb, 10)...) + + // Re-encrypt all existing data with a new cipher. + keyB := mustString(t, 32) + cipherBA, err := dbcrypt.NewCiphers([]byte(keyB), []byte(keyA)) + require.NoError(t, err) + + inv, _ = newCLI(t, "server", "dbcrypt", "rotate", + "--postgres-url", connectionURL, + "--new-key", base64.StdEncoding.EncodeToString([]byte(keyB)), + "--old-keys", base64.StdEncoding.EncodeToString([]byte(keyA)), + "--yes", + ) + pty = ptytest.New(t) + inv.Stdout = pty.Output() + err = inv.Run() + require.NoError(t, err) + + // Validate that all data has been re-encrypted with cipher B. + for _, usr := range users { + requireEncryptedWithCipher(ctx, t, db, cipherBA[0], usr.ID) + } + + // Assert that we can revoke the old key. + err = db.RevokeDBCryptKey(ctx, cipherA[0].HexDigest()) + require.NoError(t, err, "failed to revoke old key") + + // Assert that the key has been revoked in the database. + keys, err := db.GetDBCryptKeys(ctx) + oldKey := keys[0] // ORDER BY number ASC; + newKey := keys[1] + require.NoError(t, err, "failed to get db crypt keys") + require.Len(t, keys, 2, "expected exactly 2 keys") + require.Equal(t, cipherBA[0].HexDigest(), newKey.ActiveKeyDigest.String, "expected the new key to be the active key") + require.Empty(t, newKey.RevokedKeyDigest.String, "expected the new key to not be revoked") + require.Equal(t, cipherBA[1].HexDigest(), oldKey.RevokedKeyDigest.String, "expected the old key to be revoked") + require.Empty(t, oldKey.ActiveKeyDigest.String, "expected the old key to not be active") + + // Revoking the new key should fail. + err = db.RevokeDBCryptKey(ctx, cipherBA[0].HexDigest()) + require.Error(t, err, "expected to fail to revoke the new key") + var pgErr *pq.Error + require.True(t, xerrors.As(err, &pgErr), "expected a pg error") + require.EqualValues(t, "23503", pgErr.Code, "expected a foreign key constraint violation error") + + // Decrypt the data using only cipher B. This should result in the key being revoked. + inv, _ = newCLI(t, "server", "dbcrypt", "decrypt", + "--postgres-url", connectionURL, + "--keys", base64.StdEncoding.EncodeToString([]byte(keyB)), + "--yes", + ) + pty = ptytest.New(t) + inv.Stdout = pty.Output() + err = inv.Run() + require.NoError(t, err) + + // Validate that both keys have been revoked. + keys, err = db.GetDBCryptKeys(ctx) + require.NoError(t, err, "failed to get db crypt keys") + require.Len(t, keys, 2, "expected exactly 2 keys") + for _, key := range keys { + require.Empty(t, key.ActiveKeyDigest.String, "expected the new key to not be active") + } + + // Validate that all data has been decrypted. + for _, usr := range users { + requireEncryptedWithCipher(ctx, t, db, &nullCipher{}, usr.ID) + } + + // Re-encrypt all existing data with a new cipher. + keyC := mustString(t, 32) + cipherC, err := dbcrypt.NewCiphers([]byte(keyC)) + require.NoError(t, err) + + inv, _ = newCLI(t, "server", "dbcrypt", "rotate", + "--postgres-url", connectionURL, + "--new-key", base64.StdEncoding.EncodeToString([]byte(keyC)), + "--yes", + ) + + pty = ptytest.New(t) + inv.Stdout = pty.Output() + err = inv.Run() + require.NoError(t, err) + + // Validate that all data has been re-encrypted with cipher C. + for _, usr := range users { + requireEncryptedWithCipher(ctx, t, db, cipherC[0], usr.ID) + } + + // Now delete all the encrypted data. + inv, _ = newCLI(t, "server", "dbcrypt", "delete", + "--postgres-url", connectionURL, + "--external-token-encryption-keys", base64.StdEncoding.EncodeToString([]byte(keyC)), + "--yes", + ) + pty = ptytest.New(t) + inv.Stdout = pty.Output() + err = inv.Run() + require.NoError(t, err) + + // Assert that no user links remain. + for _, usr := range users { + userLinks, err := db.GetUserLinksByUserID(ctx, usr.ID) + require.NoError(t, err, "failed to get user links for user %s", usr.ID) + require.Empty(t, userLinks) + gitAuthLinks, err := db.GetGitAuthLinksByUserID(ctx, usr.ID) + require.NoError(t, err, "failed to get git auth links for user %s", usr.ID) + require.Empty(t, gitAuthLinks) + } + + // Validate that the key has been revoked in the database. + keys, err = db.GetDBCryptKeys(ctx) + require.NoError(t, err, "failed to get db crypt keys") + require.Len(t, keys, 3, "expected exactly 3 keys") + for _, k := range keys { + require.Empty(t, k.ActiveKeyDigest.String, "expected the key to not be active") + require.NotEmpty(t, k.RevokedKeyDigest.String, "expected the key to be revoked") + } +} + +func genData(t *testing.T, db database.Store, n int) []database.User { + t.Helper() + var users []database.User + for i := 0; i < n; i++ { + usr := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + }) + _ = dbgen.UserLink(t, db, database.UserLink{ + UserID: usr.ID, + LoginType: usr.LoginType, + OAuthAccessToken: "access-" + usr.ID.String(), + OAuthRefreshToken: "refresh-" + usr.ID.String(), + }) + _ = dbgen.GitAuthLink(t, db, database.GitAuthLink{ + UserID: usr.ID, + ProviderID: "fake", + OAuthAccessToken: "access-" + usr.ID.String(), + OAuthRefreshToken: "refresh-" + usr.ID.String(), + }) + users = append(users, usr) + } + return users +} + +func mustString(t *testing.T, n int) string { + t.Helper() + s, err := cryptorand.String(n) + require.NoError(t, err) + return s +} + +func requireEncryptedEquals(t *testing.T, c dbcrypt.Cipher, expected, actual string) { + t.Helper() + var decodedVal []byte + var err error + if _, ok := c.(*nullCipher); !ok { + decodedVal, err = base64.StdEncoding.DecodeString(actual) + require.NoError(t, err, "failed to decode base64 string") + } else { + // If a nullCipher is being used, we expect the value not to be encrypted. + decodedVal = []byte(actual) + } + val, err := c.Decrypt(decodedVal) + require.NoError(t, err, "failed to decrypt value") + require.Equal(t, expected, string(val)) +} + +func requireEncryptedWithCipher(ctx context.Context, t *testing.T, db database.Store, c dbcrypt.Cipher, userID uuid.UUID) { + t.Helper() + userLinks, err := db.GetUserLinksByUserID(ctx, userID) + require.NoError(t, err, "failed to get user links for user %s", userID) + for _, ul := range userLinks { + requireEncryptedEquals(t, c, "access-"+userID.String(), ul.OAuthAccessToken) + requireEncryptedEquals(t, c, "refresh-"+userID.String(), ul.OAuthRefreshToken) + require.Equal(t, c.HexDigest(), ul.OAuthAccessTokenKeyID.String) + require.Equal(t, c.HexDigest(), ul.OAuthRefreshTokenKeyID.String) + } + gitAuthLinks, err := db.GetGitAuthLinksByUserID(ctx, userID) + require.NoError(t, err, "failed to get git auth links for user %s", userID) + for _, gal := range gitAuthLinks { + requireEncryptedEquals(t, c, "access-"+userID.String(), gal.OAuthAccessToken) + requireEncryptedEquals(t, c, "refresh-"+userID.String(), gal.OAuthRefreshToken) + require.Equal(t, c.HexDigest(), gal.OAuthAccessTokenKeyID.String) + require.Equal(t, c.HexDigest(), gal.OAuthRefreshTokenKeyID.String) + } +} + +// nullCipher is a dbcrypt.Cipher that does not encrypt or decrypt. +// used for testing +type nullCipher struct{} + +func (*nullCipher) Encrypt(b []byte) ([]byte, error) { + return b, nil +} + +func (*nullCipher) Decrypt(b []byte) ([]byte, error) { + return b, nil +} + +func (*nullCipher) HexDigest() string { + return "" // This co-incidentally happens to be the value of sql.NullString{}.String... +} + +var _ dbcrypt.Cipher = (*nullCipher)(nil) diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index b0a1447b18081..9d6ad20e2a5e2 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -6,6 +6,7 @@ Start a Coder server create-admin-user Create a new admin user with the given username, email and password and adds it to every organization. + dbcrypt Manage database encryption. postgres-builtin-serve Run the built-in PostgreSQL deployment. postgres-builtin-url Output the connection URL for the built-in PostgreSQL deployment. @@ -458,6 +459,16 @@ These options are only available in the Enterprise Edition. An HTTP URL that is accessible by other replicas to relay DERP traffic. Required for high availability. + --external-token-encryption-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS + Encrypt OIDC and Git authentication tokens with AES-256-GCM in the + database. The value must be a comma-separated list of base64-encoded + keys. Each key, when base64-decoded, must be exactly 32 bytes in + length. The first key will be used to encrypt new values. Subsequent + keys will be used as a fallback when decrypting. During normal + operation it is recommended to only set one key unless you are in the + process of rotating keys with the `coder server dbcrypt rotate` + command. + --scim-auth-header string, $CODER_SCIM_AUTH_HEADER Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication. diff --git a/enterprise/cli/testdata/coder_server_dbcrypt_--help.golden b/enterprise/cli/testdata/coder_server_dbcrypt_--help.golden new file mode 100644 index 0000000000000..f74fed93b74a9 --- /dev/null +++ b/enterprise/cli/testdata/coder_server_dbcrypt_--help.golden @@ -0,0 +1,12 @@ +Usage: coder server dbcrypt + +Manage database encryption. + +Subcommands + decrypt Decrypt a previously encrypted database. + delete Delete all encrypted data from the database. THIS IS A + DESTRUCTIVE OPERATION. + rotate Rotate database encryption keys. + +--- +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_server_dbcrypt_decrypt_--help.golden b/enterprise/cli/testdata/coder_server_dbcrypt_decrypt_--help.golden new file mode 100644 index 0000000000000..761e1aa6f5b4e --- /dev/null +++ b/enterprise/cli/testdata/coder_server_dbcrypt_decrypt_--help.golden @@ -0,0 +1,17 @@ +Usage: coder server dbcrypt decrypt [flags] + +Decrypt a previously encrypted database. + +Options + --keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_DECRYPT_KEYS + Keys required to decrypt existing data. Must be a comma-separated list + of base64-encoded keys. + + --postgres-url string, $CODER_PG_CONNECTION_URL + The connection URL for the Postgres database. + + -y, --yes bool + Bypass prompts. + +--- +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_server_dbcrypt_delete_--help.golden b/enterprise/cli/testdata/coder_server_dbcrypt_delete_--help.golden new file mode 100644 index 0000000000000..619282db58e6e --- /dev/null +++ b/enterprise/cli/testdata/coder_server_dbcrypt_delete_--help.golden @@ -0,0 +1,15 @@ +Usage: coder server dbcrypt delete [flags] + +Delete all encrypted data from the database. THIS IS A DESTRUCTIVE OPERATION. + +Aliases: rm + +Options + --postgres-url string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_POSTGRES_URL + The connection URL for the Postgres database. + + -y, --yes bool + Bypass prompts. + +--- +Run `coder --help` for a list of global options. diff --git a/enterprise/cli/testdata/coder_server_dbcrypt_rotate_--help.golden b/enterprise/cli/testdata/coder_server_dbcrypt_rotate_--help.golden new file mode 100644 index 0000000000000..203831631c15d --- /dev/null +++ b/enterprise/cli/testdata/coder_server_dbcrypt_rotate_--help.golden @@ -0,0 +1,20 @@ +Usage: coder server dbcrypt rotate [flags] + +Rotate database encryption keys. + +Options + --new-key string, $CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_NEW_KEY + The new external token encryption key. Must be base64-encoded. + + --old-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_ENCRYPT_OLD_KEYS + The old external token encryption keys. Must be a comma-separated list + of base64-encoded keys. + + --postgres-url string, $CODER_PG_CONNECTION_URL + The connection URL for the Postgres database. + + -y, --yes bool + Bypass prompts. + +--- +Run `coder --help` for a list of global options. diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 1bc0815cd5248..badfdb3d6f0da 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,38 @@ 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 { + options.ExternalTokenEncryption = make([]dbcrypt.Cipher, 0) + } + // Database encryption is an enterprise feature, but as checking license entitlements + // depends on the database, we end up in a chicken-and-egg situation. To avoid this, + // we always enable it but only soft-enforce it. + if len(options.ExternalTokenEncryption) > 0 { + var keyDigests []string + for _, cipher := range options.ExternalTokenEncryption { + keyDigests = append(keyDigests, cipher.HexDigest()) + } + options.Logger.Info(ctx, "database encryption enabled", slog.F("keys", keyDigests)) + } + + cryptDB, err := dbcrypt.New(ctx, options.Database, options.ExternalTokenEncryption...) + if err != nil { + cancelFunc() + // If we fail to initialize the database, it's likely that the + // database is encrypted with an unknown external token encryption key. + // This is a fatal error. + var derr *dbcrypt.DecryptFailedError + if xerrors.As(err, &derr) { + return nil, xerrors.Errorf("database encrypted with unknown key, either add the key or see https://coder.com/docs/v2/latest/admin/encryption#disabling-encryption: %w", derr) + } + return nil, xerrors.Errorf("init database encryption: %w", err) + } + options.Database = cryptDB + + api := &API{ + ctx: ctx, + cancel: cancelFunc, AGPL: coderd.New(options.Options), Options: options, provisionerDaemonAuth: &provisionerDaemonAuth{ @@ -364,6 +393,8 @@ type Options struct { BrowserOnly bool SCIMAPIKey []byte + ExternalTokenEncryption []dbcrypt.Cipher + // Used for high availability. ReplicaSyncUpdateInterval time.Duration DERPServerRelayAddress string @@ -374,7 +405,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 @@ -429,13 +460,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: len(api.ExternalTokenEncryption) > 0, codersdk.FeatureExternalProvisionerDaemons: true, codersdk.FeatureAdvancedTemplateScheduling: true, // FeatureTemplateAutostopRequirement depends on @@ -615,6 +647,16 @@ func (api *API) updateEntitlements(ctx context.Context) error { } } + // External token encryption is soft-enforced + featureExternalTokenEncryption := entitlements.Features[codersdk.FeatureExternalTokenEncryption] + featureExternalTokenEncryption.Enabled = len(api.ExternalTokenEncryption) > 0 + if featureExternalTokenEncryption.Enabled && featureExternalTokenEncryption.Entitlement != codersdk.EntitlementEntitled { + msg := fmt.Sprintf("%s is enabled (due to setting external token encryption keys) but your license is not entitled to this feature.", codersdk.FeatureExternalTokenEncryption.Humanize()) + api.Logger.Warn(ctx, msg) + entitlements.Warnings = append(entitlements.Warnings, msg) + } + entitlements.Features[codersdk.FeatureExternalTokenEncryption] = featureExternalTokenEncryption + api.entitlementsMu.Lock() defer api.entitlementsMu.Unlock() api.entitlements = entitlements diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index d34d146a7079f..e6756887317c8 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -1,8 +1,10 @@ package coderd_test import ( + "bytes" "context" "reflect" + "strings" "testing" "time" @@ -16,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" @@ -23,6 +26,7 @@ import ( "github.com/coder/coder/v2/enterprise/coderd" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/enterprise/dbcrypt" "github.com/coder/coder/v2/testutil" ) @@ -48,25 +52,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) @@ -228,6 +234,134 @@ func TestAuditLogging(t *testing.T) { }) } +func TestExternalTokenEncryption(t *testing.T) { + t.Parallel() + + t.Run("Enabled", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, ps := dbtestutil.NewDB(t) + ciphers, err := dbcrypt.NewCiphers(bytes.Repeat([]byte("a"), 32)) + require.NoError(t, err) + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + EntitlementsUpdateInterval: 25 * time.Millisecond, + ExternalTokenEncryption: ciphers, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalTokenEncryption: 1, + }, + }, + Options: &coderdtest.Options{ + Database: db, + Pubsub: ps, + }, + }) + keys, err := db.GetDBCryptKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + require.Equal(t, ciphers[0].HexDigest(), keys[0].ActiveKeyDigest.String) + + require.Eventually(t, func() bool { + entitlements, err := client.Entitlements(context.Background()) + assert.NoError(t, err) + feature := entitlements.Features[codersdk.FeatureExternalTokenEncryption] + entitled := feature.Entitlement == codersdk.EntitlementEntitled + var warningExists bool + for _, warning := range entitlements.Warnings { + if strings.Contains(warning, codersdk.FeatureExternalTokenEncryption.Humanize()) { + warningExists = true + break + } + } + t.Logf("feature: %+v, warnings: %+v, errors: %+v", feature, entitlements.Warnings, entitlements.Errors) + return feature.Enabled && entitled && !warningExists + }, testutil.WaitShort, testutil.IntervalFast) + }) + + t.Run("Disabled", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, ps := dbtestutil.NewDB(t) + ciphers, err := dbcrypt.NewCiphers() + require.NoError(t, err) + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + DontAddLicense: true, + EntitlementsUpdateInterval: 25 * time.Millisecond, + ExternalTokenEncryption: ciphers, + Options: &coderdtest.Options{ + Database: db, + Pubsub: ps, + }, + }) + keys, err := db.GetDBCryptKeys(ctx) + require.NoError(t, err) + require.Empty(t, keys) + + require.Eventually(t, func() bool { + entitlements, err := client.Entitlements(context.Background()) + assert.NoError(t, err) + feature := entitlements.Features[codersdk.FeatureExternalTokenEncryption] + entitled := feature.Entitlement == codersdk.EntitlementEntitled + var warningExists bool + for _, warning := range entitlements.Warnings { + if strings.Contains(warning, codersdk.FeatureExternalTokenEncryption.Humanize()) { + warningExists = true + break + } + } + t.Logf("feature: %+v, warnings: %+v, errors: %+v", feature, entitlements.Warnings, entitlements.Errors) + return !feature.Enabled && !entitled && !warningExists + }, testutil.WaitShort, testutil.IntervalFast) + }) + + t.Run("PreviouslyEnabledButMissingFromLicense", func(t *testing.T) { + // If this test fails, it potentially means that a customer who has + // actively been using this feature is now unable _start coderd_ + // because of a licensing issue. This should never happen. + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + db, ps := dbtestutil.NewDB(t) + ciphers, err := dbcrypt.NewCiphers(bytes.Repeat([]byte("a"), 32)) + require.NoError(t, err) + + dbc, err := dbcrypt.New(ctx, db, ciphers...) // should insert key + require.NoError(t, err) + + keys, err := dbc.GetDBCryptKeys(ctx) + require.NoError(t, err) + require.Len(t, keys, 1) + + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + DontAddLicense: true, + EntitlementsUpdateInterval: 25 * time.Millisecond, + ExternalTokenEncryption: ciphers, + Options: &coderdtest.Options{ + Database: db, + Pubsub: ps, + }, + }) + + require.Eventually(t, func() bool { + entitlements, err := client.Entitlements(context.Background()) + assert.NoError(t, err) + feature := entitlements.Features[codersdk.FeatureExternalTokenEncryption] + entitled := feature.Entitlement == codersdk.EntitlementEntitled + var warningExists bool + for _, warning := range entitlements.Warnings { + if strings.Contains(warning, codersdk.FeatureExternalTokenEncryption.Humanize()) { + warningExists = true + break + } + } + t.Logf("feature: %+v, warnings: %+v, errors: %+v", feature, entitlements.Warnings, entitlements.Errors) + return feature.Enabled && !entitled && warningExists + }, testutil.WaitShort, testutil.IntervalFast) + }) +} + // testDBAuthzRole returns a context with a subject that has a role // with permissions required for test setup. func testDBAuthzRole(ctx context.Context) context.Context { diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 81e43c4fd5755..1c3f7c4fc83e0 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -21,6 +21,7 @@ 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 ( @@ -56,6 +57,7 @@ type Options struct { DontAddLicense bool DontAddFirstUser bool ReplicaSyncUpdateInterval time.Duration + ExternalTokenEncryption []dbcrypt.Cipher ProvisionerDaemonPSK string } @@ -92,10 +94,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 9796d21937b35..b7c7b5af6e4f0 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -84,7 +84,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", @@ -102,7 +102,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/enterprise/dbcrypt/cliutil.go b/enterprise/dbcrypt/cliutil.go new file mode 100644 index 0000000000000..7f68e284afe77 --- /dev/null +++ b/enterprise/dbcrypt/cliutil.go @@ -0,0 +1,214 @@ +package dbcrypt + +import ( + "context" + "database/sql" + + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" +) + +// Rotate rotates the database encryption keys by re-encrypting all user tokens +// with the first cipher and revoking all other ciphers. +func Rotate(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Cipher) error { + db := database.New(sqlDB) + cryptDB, err := 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) + } + log.Info(ctx, "encrypting user tokens", slog.F("user_count", len(users))) + for idx, usr := range users { + err := cryptDB.InTx(func(tx database.Store) error { + userLinks, err := tx.GetUserLinksByUserID(ctx, usr.ID) + if err != nil { + return xerrors.Errorf("get user links for user: %w", err) + } + for _, userLink := range userLinks { + if userLink.OAuthAccessTokenKeyID.String == ciphers[0].HexDigest() && userLink.OAuthRefreshTokenKeyID.String == ciphers[0].HexDigest() { + log.Debug(ctx, "skipping user link", slog.F("user_id", usr.ID), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest())) + continue + } + if _, err := tx.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 user_id=%s linked_id=%s: %w", userLink.UserID, userLink.LinkedID, err) + } + } + + gitAuthLinks, err := tx.GetGitAuthLinksByUserID(ctx, usr.ID) + if err != nil { + return xerrors.Errorf("get git auth links for user: %w", err) + } + for _, gitAuthLink := range gitAuthLinks { + if gitAuthLink.OAuthAccessTokenKeyID.String == ciphers[0].HexDigest() && gitAuthLink.OAuthRefreshTokenKeyID.String == ciphers[0].HexDigest() { + log.Debug(ctx, "skipping git auth link", slog.F("user_id", usr.ID), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest())) + continue + } + if _, err := tx.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 user_id=%s provider_id=%s: %w", gitAuthLink.UserID, gitAuthLink.ProviderID, err) + } + } + return nil + }, &sql.TxOptions{ + Isolation: sql.LevelRepeatableRead, + }) + if err != nil { + return xerrors.Errorf("update user links: %w", err) + } + log.Debug(ctx, "encrypted user tokens", slog.F("user_id", usr.ID), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest())) + } + + // Revoke old keys + for _, c := range ciphers[1:] { + if err := db.RevokeDBCryptKey(ctx, c.HexDigest()); err != nil { + return xerrors.Errorf("revoke key: %w", err) + } + log.Info(ctx, "revoked unused key", slog.F("digest", c.HexDigest())) + } + + return nil +} + +// Decrypt decrypts all user tokens and revokes all ciphers. +func Decrypt(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Cipher) error { + db := database.New(sqlDB) + cdb, err := New(ctx, db, ciphers...) + if err != nil { + return xerrors.Errorf("create cryptdb: %w", err) + } + + // HACK: instead of adding logic to configure the primary cipher, we just + // set it to the empty string so that it will not encrypt anything. + cryptDB, ok := cdb.(*dbCrypt) + if !ok { + return xerrors.Errorf("developer error: dbcrypt.New did not return *dbCrypt") + } + cryptDB.primaryCipherDigest = "" + + users, err := cryptDB.GetUsers(ctx, database.GetUsersParams{}) + if err != nil { + return xerrors.Errorf("get users: %w", err) + } + log.Info(ctx, "decrypting user tokens", slog.F("user_count", len(users))) + for idx, usr := range users { + err := cryptDB.InTx(func(tx database.Store) error { + userLinks, err := tx.GetUserLinksByUserID(ctx, usr.ID) + if err != nil { + return xerrors.Errorf("get user links for user: %w", err) + } + for _, userLink := range userLinks { + if !userLink.OAuthAccessTokenKeyID.Valid && !userLink.OAuthRefreshTokenKeyID.Valid { + log.Debug(ctx, "skipping user link", slog.F("user_id", usr.ID), slog.F("current", idx+1)) + continue + } + if _, err := tx.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 user_id=%s linked_id=%s: %w", userLink.UserID, userLink.LinkedID, err) + } + } + + gitAuthLinks, err := tx.GetGitAuthLinksByUserID(ctx, usr.ID) + if err != nil { + return xerrors.Errorf("get git auth links for user: %w", err) + } + for _, gitAuthLink := range gitAuthLinks { + if !gitAuthLink.OAuthAccessTokenKeyID.Valid && !gitAuthLink.OAuthRefreshTokenKeyID.Valid { + log.Debug(ctx, "skipping git auth link", slog.F("user_id", usr.ID), slog.F("current", idx+1)) + continue + } + if _, err := tx.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 user_id=%s provider_id=%s: %w", gitAuthLink.UserID, gitAuthLink.ProviderID, err) + } + } + return nil + }, &sql.TxOptions{ + Isolation: sql.LevelRepeatableRead, + }) + if err != nil { + return xerrors.Errorf("update user links: %w", err) + } + log.Debug(ctx, "decrypted user tokens", slog.F("user_id", usr.ID), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest())) + } + + // Revoke _all_ keys + for _, c := range ciphers { + if err := db.RevokeDBCryptKey(ctx, c.HexDigest()); err != nil { + return xerrors.Errorf("revoke key: %w", err) + } + log.Info(ctx, "revoked unused key", slog.F("digest", c.HexDigest())) + } + + return nil +} + +// nolint: gosec +const sqlDeleteEncryptedUserTokens = ` +BEGIN; +DELETE FROM user_links + WHERE oauth_access_token_key_id IS NOT NULL + OR oauth_refresh_token_key_id IS NOT NULL; +DELETE FROM git_auth_links + WHERE oauth_access_token_key_id IS NOT NULL + OR oauth_refresh_token_key_id IS NOT NULL; +COMMIT; +` + +// Delete deletes all user tokens and revokes all ciphers. +// This is a destructive operation and should only be used +// as a last resort, for example, if the database encryption key has been +// lost. +func Delete(ctx context.Context, log slog.Logger, sqlDB *sql.DB) error { + store := database.New(sqlDB) + _, err := sqlDB.ExecContext(ctx, sqlDeleteEncryptedUserTokens) + if err != nil { + return xerrors.Errorf("delete user links: %w", err) + } + log.Info(ctx, "deleted encrypted user tokens") + + log.Info(ctx, "revoking all active keys") + keys, err := store.GetDBCryptKeys(ctx) + if err != nil { + return xerrors.Errorf("get db crypt keys: %w", err) + } + for _, k := range keys { + if !k.ActiveKeyDigest.Valid { + continue + } + if err := store.RevokeDBCryptKey(ctx, k.ActiveKeyDigest.String); err != nil { + return xerrors.Errorf("revoke key: %w", err) + } + log.Info(ctx, "revoked unused key", slog.F("digest", k.ActiveKeyDigest.String)) + } + + return nil +} diff --git a/enterprise/dbcrypt/dbcrypt_internal_test.go b/enterprise/dbcrypt/dbcrypt_internal_test.go index 1b457373b28f8..346a4977c8cb9 100644 --- a/enterprise/dbcrypt/dbcrypt_internal_test.go +++ b/enterprise/dbcrypt/dbcrypt_internal_test.go @@ -645,25 +645,19 @@ func initCipher(t *testing.T) *aes256 { func setup(t *testing.T) (db database.Store, cryptDB *dbCrypt, cs []Cipher) { t.Helper() - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) rawDB, _ := dbtestutil.NewDB(t) - cs = append(cs, initCipher(t)) - cdb, err := New(ctx, rawDB, cs...) + cdb, err := New(context.Background(), rawDB, cs...) require.NoError(t, err) cryptDB, ok := cdb.(*dbCrypt) require.True(t, ok) - return rawDB, cryptDB, cs } func setupNoCiphers(t *testing.T) (db database.Store, cryptodb *dbCrypt) { t.Helper() - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) rawDB, _ := dbtestutil.NewDB(t) - cdb, err := New(ctx, rawDB) + cdb, err := New(context.Background(), rawDB) require.NoError(t, err) cryptDB, ok := cdb.(*dbCrypt) require.True(t, ok) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 77d81a3a4a890..f7511276a081e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -379,6 +379,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") @@ -1639,6 +1641,7 @@ export type FeatureName = | "audit_log" | "browser_only" | "external_provisioner_daemons" + | "external_token_encryption" | "high_availability" | "multiple_git_auth" | "scim" @@ -1654,6 +1657,7 @@ export const FeatureNames: FeatureName[] = [ "audit_log", "browser_only", "external_provisioner_daemons", + "external_token_encryption", "high_availability", "multiple_git_auth", "scim",