Skip to content

Commit 3b8140b

Browse files
committed
feat(coderd): plumb through dbcrypt package
This builds upon a previous PR. It is recommended to read that first. - Adds a command dbcrypt-rotate to re-enncrypt encrypted data - Plumbs through dbcrypt in enterprise/coderd (including unit tests) - Enables database encryption in develop.sh by default - Adds documentation in admin/encryption.md
1 parent fb953e4 commit 3b8140b

31 files changed

+700
-30
lines changed

cli/server.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
691691
options.Database = dbfake.New()
692692
options.Pubsub = pubsub.NewInMemory()
693693
} else {
694-
sqlDB, err := connectToPostgres(ctx, logger, sqlDriver, vals.PostgresURL.String())
694+
sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, vals.PostgresURL.String())
695695
if err != nil {
696696
return xerrors.Errorf("connect to postgres: %w", err)
697697
}
@@ -1953,7 +1953,7 @@ func BuildLogger(inv *clibase.Invocation, cfg *codersdk.DeploymentValues) (slog.
19531953
}, nil
19541954
}
19551955

1956-
func connectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (*sql.DB, error) {
1956+
func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string) (*sql.DB, error) {
19571957
logger.Debug(ctx, "connecting to postgresql")
19581958

19591959
// Try to connect for 30 seconds.

cli/server_createadminuser.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *clibase.Cmd {
6363
newUserDBURL = url
6464
}
6565

66-
sqlDB, err := connectToPostgres(ctx, logger, "postgres", newUserDBURL)
66+
sqlDB, err := ConnectToPostgres(ctx, logger, "postgres", newUserDBURL)
6767
if err != nil {
6868
return xerrors.Errorf("connect to postgres: %w", err)
6969
}

cli/testdata/coder_server_--help.golden

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,14 @@ These options are only available in the Enterprise Edition.
458458
An HTTP URL that is accessible by other replicas to relay DERP
459459
traffic. Required for high availability.
460460

461+
--external-token-encryption-keys string-array, $CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS
462+
Encrypt OIDC and Git authentication tokens with AES-256-GCM in the
463+
database. The value must be a comma-separated list of base64-encoded
464+
keys. Each key, when base64-decoded, must be exactly 32 bytes in
465+
length. The first key will be used to encrypt new values. Subsequent
466+
keys will be used as a fallback when decrypting. During normal
467+
operation it is recommended to only set one key.
468+
461469
--scim-auth-header string, $CODER_SCIM_AUTH_HEADER
462470
Enables SCIM and sets the authentication header for the built-in SCIM
463471
server. New users are automatically created with OIDC authentication.

coderd/apidoc/docs.go

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/deployment_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func TestDeploymentValues(t *testing.T) {
2626
cfg.OIDC.EmailField.Set("some_random_field_you_never_expected")
2727
cfg.PostgresURL.Set(hi)
2828
cfg.SCIMAPIKey.Set(hi)
29+
cfg.ExternalTokenEncryptionKeys.Set("the_random_key_we_never_expected,an_other_key_we_never_unexpected")
2930

3031
client := coderdtest.New(t, &coderdtest.Options{
3132
DeploymentValues: cfg,
@@ -44,6 +45,7 @@ func TestDeploymentValues(t *testing.T) {
4445
require.Empty(t, scrubbed.Values.OIDC.ClientSecret.Value())
4546
require.Empty(t, scrubbed.Values.PostgresURL.Value())
4647
require.Empty(t, scrubbed.Values.SCIMAPIKey.Value())
48+
require.Empty(t, scrubbed.Values.ExternalTokenEncryptionKeys.Value())
4749
}
4850

4951
func TestDeploymentStats(t *testing.T) {

coderd/httpmw/apikey.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
248248
UserID: key.UserID,
249249
LoginType: key.LoginType,
250250
})
251+
if errors.Is(err, sql.ErrNoRows) {
252+
return optionalWrite(http.StatusUnauthorized, codersdk.Response{
253+
Message: SignedOutErrorMessage,
254+
Detail: "You must re-authenticate with the login provider.",
255+
})
256+
}
251257
if err != nil {
252258
return write(http.StatusInternalServerError, codersdk.Response{
253259
Message: "A database error occurred",

codersdk/deployment.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ const (
4646
FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons"
4747
FeatureAppearance FeatureName = "appearance"
4848
FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling"
49-
FeatureTemplateAutostopRequirement FeatureName = "template_autostop_requirement"
5049
FeatureWorkspaceProxy FeatureName = "workspace_proxy"
50+
FeatureExternalTokenEncryption FeatureName = "external_token_encryption"
51+
FeatureTemplateAutostopRequirement FeatureName = "template_autostop_requirement"
5152
FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions"
5253
)
5354

@@ -65,6 +66,8 @@ var FeatureNames = []FeatureName{
6566
FeatureAdvancedTemplateScheduling,
6667
FeatureWorkspaceProxy,
6768
FeatureUserRoleManagement,
69+
FeatureExternalTokenEncryption,
70+
FeatureTemplateAutostopRequirement,
6871
FeatureWorkspaceBatchActions,
6972
}
7073

@@ -154,6 +157,7 @@ type DeploymentValues struct {
154157
AgentFallbackTroubleshootingURL clibase.URL `json:"agent_fallback_troubleshooting_url,omitempty" typescript:",notnull"`
155158
BrowserOnly clibase.Bool `json:"browser_only,omitempty" typescript:",notnull"`
156159
SCIMAPIKey clibase.String `json:"scim_api_key,omitempty" typescript:",notnull"`
160+
ExternalTokenEncryptionKeys clibase.StringArray `json:"external_token_encryption_keys,omitempty" typescript:",notnull"`
157161
Provisioner ProvisionerConfig `json:"provisioner,omitempty" typescript:",notnull"`
158162
RateLimit RateLimitConfig `json:"rate_limit,omitempty" typescript:",notnull"`
159163
Experiments clibase.StringArray `json:"experiments,omitempty" typescript:",notnull"`
@@ -1605,7 +1609,14 @@ when required by your organization's security policy.`,
16051609
Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"),
16061610
Value: &c.SCIMAPIKey,
16071611
},
1608-
1612+
{
1613+
Name: "External Token Encryption Keys",
1614+
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.",
1615+
Flag: "external-token-encryption-keys",
1616+
Env: "CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS",
1617+
Annotations: clibase.Annotations{}.Mark(annotationEnterpriseKey, "true").Mark(annotationSecretKey, "true"),
1618+
Value: &c.ExternalTokenEncryptionKeys,
1619+
},
16091620
{
16101621
Name: "Disable Path Apps",
16111622
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) {
17831794

17841795
// This only works with string values for now.
17851796
switch v := opt.Value.(type) {
1786-
case *clibase.String:
1797+
case *clibase.String, *clibase.StringArray:
17871798
err := v.Set("")
17881799
if err != nil {
17891800
panic(err)

codersdk/deployment_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) {
5757
"SCIM API Key": {
5858
yaml: true,
5959
},
60+
"External Token Encryption Keys": {
61+
yaml: true,
62+
},
6063
// These complex objects should be configured through YAML.
6164
"Support Links": {
6265
flag: true,

docs/admin/encryption.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Database Encryption
2+
3+
By default, Coder stores external user tokens in plaintext in the database.
4+
Database Encryption allows Coder administrators to encrypt these tokens at-rest,
5+
preventing attackers with database access from using them to impersonate users.
6+
7+
## How it works
8+
9+
Coder allows administrators to specify up to two
10+
[external token encryption keys](../cli/server.md#external-token-encryption-keys).
11+
If configured, Coder will use these keys to encrypt external user tokens before
12+
storing them in the database. The encryption algorithm used is AES-256-GCM with
13+
a 32-byte key length.
14+
15+
Coder will use the first key provided for both encryption and decryption. If a
16+
second key is provided, Coder will use it for decryption only. This allows
17+
administrators to rotate encryption keys without invalidating existing tokens.
18+
19+
The following database fields are currently encrypted:
20+
21+
- `user_links.oauth_access_token`
22+
- `user_links.oauth_refresh_token`
23+
- `git_auth_links.oauth_access_token`
24+
- `git_auth_links.oauth_refresh_token`
25+
26+
Additional database fields may be encrypted in the future.
27+
28+
> Implementation notes: each encrypted database column `$C` has a corresponding
29+
> `$C_key_id` column. This column is used to determine which encryption key was
30+
> used to encrypt the data. This allows Coder to rotate encryption keys without
31+
> invalidating existing tokens, and provides referential integrity for encrypted
32+
> data.
33+
>
34+
> The `$C_key_id` column stores the first 7 bytes of the SHA-256 hash of the
35+
> encryption key used to encrypt the data.
36+
>
37+
> Encryption keys in use are stored in `dbcrypt_keys`. This table stores a
38+
> record of all encryption keys that have been used to encrypt data. Active keys
39+
> have a null `revoked_key_id` column, and revoked keys have a non-null
40+
> `revoked_key_id` column. A key cannot be revoked until all rows referring to
41+
> it have been re-encrypted with a different key.
42+
43+
## Enabling encryption
44+
45+
1. Ensure you have a valid backup of your database. **Do not skip this step.**
46+
If you are using the built-in PostgreSQL database, you can run
47+
[`coder server postgres-builtin-url`](../cli/server_postgres-builtin-url.md)
48+
to get the connection URL.
49+
50+
1. Generate a 32-byte random key and base64-encode it. For example:
51+
52+
```shell
53+
dd if=/dev/urandom bs=32 count=1 | base64
54+
```
55+
56+
1. Store this key in a secure location (for example, a Kubernetes secret):
57+
58+
```shell
59+
kubectl create secret generic coder-external-token-encryption-keys --from-literal=keys=<key>
60+
```
61+
62+
1. In your Coder configuration set `CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS` to a
63+
comma-separated list of base64-encoded keys. For example, in your Helm
64+
`values.yaml`:
65+
66+
```yaml
67+
coder:
68+
env:
69+
[...]
70+
- name: CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS
71+
valueFrom:
72+
secretKeyRef:
73+
name: coder-external-token-encryption-keys
74+
key: keys
75+
```
76+
77+
## Rotating keys
78+
79+
We recommend only having one active encryption key at a time normally. However,
80+
if you need to rotate keys, you can perform the following procedure:
81+
82+
1. Ensure you have a valid backup of your database. **Do not skip this step.**
83+
84+
1. Generate a new encryption key following the same procedure as above.
85+
86+
1. Add the above key to the list of
87+
[external token encryption keys](../cli/server.md#external-token-encryption-keys).
88+
**The new key must appear first in the list**. For example, in the Kubernetes
89+
secret created above:
90+
91+
```yaml
92+
apiVersion: v1
93+
kind: Secret
94+
type: Opaque
95+
metadata:
96+
name: coder-external-token-encryption-keys
97+
namespace: coder-namespace
98+
data:
99+
keys: <new-key>,<old-key1>,<old-key2>,...
100+
```
101+
102+
1. After updating the configuration, restart the Coder server. The server will
103+
now encrypt all new data with the new key, but will be able to decrypt tokens
104+
encrypted with the old key(s).
105+
106+
1. To re-encrypt all encrypted database fields with the new key, run
107+
[`coder dbcrypt-rotate`](../cli/dbcrypt-rotate.md). This command will
108+
re-encrypt all tokens with the first key in the list of external token
109+
encryption keys. We recommend performing this action during a maintenance
110+
window.
111+
112+
> Note: this command requires direct access to the database. If you are using
113+
> the built-in PostgreSQL database, you can run
114+
> [`coder server postgres-builtin-url`](../cli/server_postgres-builtin-url.md)
115+
> to get the connection URL.
116+
117+
1. Once the above command completes successfully, remove the old encryption key
118+
from Coder's configuration and restart Coder once more. You can now safely
119+
delete the old key from your secret store.
120+
121+
## Disabling encryption
122+
123+
Disabling encryption is currently not supported.
124+
125+
## Troubleshooting
126+
127+
- If Coder detects that the data stored in the database was not encrypted with
128+
any known keys, it will refuse to start. If you are seeing this behaviour,
129+
ensure that the encryption keys provided are correct.

docs/api/general.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/api/schemas.md

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr
2727
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
2828
| [<code>config-ssh</code>](./cli/config-ssh.md) | Add an SSH Host entry for your workspaces "ssh coder.workspace" |
2929
| [<code>create</code>](./cli/create.md) | Create a workspace |
30+
| [<code>dbcrypt-rotate</code>](./cli/dbcrypt-rotate.md) | Rotate database encryption keys |
3031
| [<code>delete</code>](./cli/delete.md) | Delete a workspace |
3132
| [<code>dotfiles</code>](./cli/dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository |
3233
| [<code>features</code>](./cli/features.md) | List Enterprise features |

docs/cli/dbcrypt-rotate.md

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/server.md

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/images/icons/lock.svg

Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)