Skip to content

feat(coderd): plumb through dbcrypt package #9433

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cli/server_createadminuser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
8 changes: 8 additions & 0 deletions cli/testdata/coder_server_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems a little confusing that there's a maximum of two keys but this refers to "subsequent keys". Maybe it should say "the optional second key" instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on other comments, I'm considering removing the restriction on the number of keys. I can see a legitimate use case for needing to have three keys for a period of time.

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.
Expand Down
6 changes: 6 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions coderd/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions coderd/httpmw/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 14 additions & 3 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -64,6 +65,8 @@ var FeatureNames = []FeatureName{
FeatureAdvancedTemplateScheduling,
FeatureWorkspaceProxy,
FeatureUserRoleManagement,
FeatureExternalTokenEncryption,
FeatureTemplateAutostopRequirement,
}

// Humanize returns the feature name in a human-readable format.
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions codersdk/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
146 changes: 146 additions & 0 deletions docs/admin/encryption.md
Original file line number Diff line number Diff line change
@@ -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>`
- `b64data = <cipher checksum>-<ciphertext>`

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=<key>
```

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: <new-key>,<old-key>
```

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.
1 change: 1 addition & 0 deletions docs/api/general.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions docs/api/schemas.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| [<code>config-ssh</code>](./cli/config-ssh.md) | Add an SSH Host entry for your workspaces "ssh coder.workspace" |
| [<code>create</code>](./cli/create.md) | Create a workspace |
| [<code>dbcrypt-rotate</code>](./cli/dbcrypt-rotate.md) | Rotate database encryption keys |
| [<code>delete</code>](./cli/delete.md) | Delete a workspace |
| [<code>dotfiles</code>](./cli/dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository |
| [<code>features</code>](./cli/features.md) | List Enterprise features |
Expand Down
31 changes: 31 additions & 0 deletions docs/cli/dbcrypt-rotate.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions docs/cli/server.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions docs/images/icons/lock.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading