Skip to content

Commit be996ba

Browse files
committed
Merge remote-tracking branch 'origin/dbcrypt' into cj/dbcrypt
2 parents 545a256 + 614065b commit be996ba

File tree

25 files changed

+679
-12
lines changed

25 files changed

+679
-12
lines changed

coderd/apidoc/docs.go

Lines changed: 3 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: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbauthz/dbauthz.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,15 @@ func (q *querier) DeleteCoordinator(ctx context.Context, id uuid.UUID) error {
685685
return q.db.DeleteCoordinator(ctx, id)
686686
}
687687

688+
func (q *querier) DeleteGitAuthLink(ctx context.Context, arg database.DeleteGitAuthLinkParams) error {
689+
return deleteQ(q.log, q.auth, func(ctx context.Context, arg database.DeleteGitAuthLinkParams) (database.GitAuthLink, error) {
690+
return q.db.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
691+
UserID: arg.UserID,
692+
ProviderID: arg.ProviderID,
693+
})
694+
}, q.db.DeleteGitAuthLink)(ctx, arg)
695+
}
696+
688697
func (q *querier) DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error {
689698
return deleteQ(q.log, q.auth, q.db.GetGitSSHKey, q.db.DeleteGitSSHKey)(ctx, userID)
690699
}
@@ -757,6 +766,10 @@ func (q *querier) DeleteTailnetClient(ctx context.Context, arg database.DeleteTa
757766
return q.db.DeleteTailnetClient(ctx, arg)
758767
}
759768

769+
func (q *querier) DeleteUserLinkByLinkedID(ctx context.Context, linkedID string) error {
770+
return deleteQ(q.log, q.auth, q.db.GetUserLinkByLinkedID, q.db.DeleteUserLinkByLinkedID)(ctx, linkedID)
771+
}
772+
760773
func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
761774
return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id)
762775
}

coderd/database/dbcrypt/dbcrypt.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package dbcrypt
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"encoding/base64"
7+
"runtime"
8+
"strings"
9+
"sync/atomic"
10+
11+
"cdr.dev/slog"
12+
"golang.org/x/xerrors"
13+
14+
"github.com/coder/coder/v2/coderd/database"
15+
"github.com/coder/coder/v2/cryptorand"
16+
)
17+
18+
// MagicPrefix is prepended to all encrypted values in the database.
19+
// This is used to determine if a value is encrypted or not.
20+
// If it is encrypted but a key is not provided, an error is returned.
21+
const MagicPrefix = "dbcrypt-"
22+
23+
type Options struct {
24+
// ExternalTokenCipher is an optional cipher that is used
25+
// to encrypt/decrypt user link and git auth link tokens. If this is nil,
26+
// then no encryption/decryption will be performed.
27+
ExternalTokenCipher *atomic.Pointer[cryptorand.Cipher]
28+
Logger slog.Logger
29+
}
30+
31+
// New creates a database.Store wrapper that encrypts/decrypts values
32+
// stored at rest in the database.
33+
func New(db database.Store, options *Options) database.Store {
34+
return &dbCrypt{
35+
Options: options,
36+
Store: db,
37+
}
38+
}
39+
40+
type dbCrypt struct {
41+
*Options
42+
database.Store
43+
}
44+
45+
func (db *dbCrypt) InTx(function func(database.Store) error, txOpts *sql.TxOptions) error {
46+
return db.Store.InTx(func(s database.Store) error {
47+
return function(&dbCrypt{
48+
Options: db.Options,
49+
Store: s,
50+
})
51+
}, txOpts)
52+
}
53+
54+
func (db *dbCrypt) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (database.UserLink, error) {
55+
link, err := db.Store.GetUserLinkByLinkedID(ctx, linkedID)
56+
if err != nil {
57+
return database.UserLink{}, err
58+
}
59+
return link, db.decryptFields(func() error {
60+
return db.Store.DeleteUserLinkByLinkedID(ctx, linkedID)
61+
}, &link.OAuthAccessToken, &link.OAuthRefreshToken)
62+
}
63+
64+
func (db *dbCrypt) GetUserLinkByUserIDLoginType(ctx context.Context, params database.GetUserLinkByUserIDLoginTypeParams) (database.UserLink, error) {
65+
link, err := db.Store.GetUserLinkByUserIDLoginType(ctx, params)
66+
if err != nil {
67+
return database.UserLink{}, err
68+
}
69+
return link, db.decryptFields(func() error {
70+
return db.Store.DeleteUserLinkByLinkedID(ctx, link.LinkedID)
71+
}, &link.OAuthAccessToken, &link.OAuthRefreshToken)
72+
}
73+
74+
func (db *dbCrypt) InsertUserLink(ctx context.Context, params database.InsertUserLinkParams) (database.UserLink, error) {
75+
err := db.encryptFields(&params.OAuthAccessToken, &params.OAuthRefreshToken)
76+
if err != nil {
77+
return database.UserLink{}, err
78+
}
79+
return db.Store.InsertUserLink(ctx, params)
80+
}
81+
82+
func (db *dbCrypt) UpdateUserLink(ctx context.Context, params database.UpdateUserLinkParams) (database.UserLink, error) {
83+
err := db.encryptFields(&params.OAuthAccessToken, &params.OAuthRefreshToken)
84+
if err != nil {
85+
return database.UserLink{}, err
86+
}
87+
return db.Store.UpdateUserLink(ctx, params)
88+
}
89+
90+
func (db *dbCrypt) InsertGitAuthLink(ctx context.Context, params database.InsertGitAuthLinkParams) (database.GitAuthLink, error) {
91+
err := db.encryptFields(&params.OAuthAccessToken, &params.OAuthRefreshToken)
92+
if err != nil {
93+
return database.GitAuthLink{}, err
94+
}
95+
return db.Store.InsertGitAuthLink(ctx, params)
96+
}
97+
98+
func (db *dbCrypt) GetGitAuthLink(ctx context.Context, params database.GetGitAuthLinkParams) (database.GitAuthLink, error) {
99+
link, err := db.Store.GetGitAuthLink(ctx, params)
100+
if err != nil {
101+
return database.GitAuthLink{}, err
102+
}
103+
return link, db.decryptFields(func() error {
104+
return db.Store.DeleteGitAuthLink(ctx, database.DeleteGitAuthLinkParams{
105+
ProviderID: params.ProviderID,
106+
UserID: params.UserID,
107+
})
108+
}, &link.OAuthAccessToken, &link.OAuthRefreshToken)
109+
}
110+
111+
func (db *dbCrypt) UpdateGitAuthLink(ctx context.Context, params database.UpdateGitAuthLinkParams) (database.GitAuthLink, error) {
112+
err := db.encryptFields(&params.OAuthAccessToken, &params.OAuthRefreshToken)
113+
if err != nil {
114+
return database.GitAuthLink{}, err
115+
}
116+
return db.Store.UpdateGitAuthLink(ctx, params)
117+
}
118+
119+
func (db *dbCrypt) encryptFields(fields ...*string) error {
120+
cipherPtr := db.ExternalTokenCipher.Load()
121+
// If no cipher is loaded, then we don't need to encrypt or decrypt anything!
122+
if cipherPtr == nil {
123+
return nil
124+
}
125+
cipher := *cipherPtr
126+
for _, field := range fields {
127+
if field == nil {
128+
continue
129+
}
130+
131+
encrypted, err := cipher.Encrypt([]byte(*field))
132+
if err != nil {
133+
return err
134+
}
135+
// Base64 is used to support UTF-8 encoding in PostgreSQL.
136+
*field = MagicPrefix + base64.StdEncoding.EncodeToString(encrypted)
137+
}
138+
return nil
139+
}
140+
141+
// decryptFields decrypts the given fields in place.
142+
// If the value fails to decrypt, sql.ErrNoRows will be returned.
143+
func (db *dbCrypt) decryptFields(deleteFn func() error, fields ...*string) error {
144+
delete := func(reason string) error {
145+
err := deleteFn()
146+
if err != nil {
147+
return xerrors.Errorf("delete encrypted row: %w", err)
148+
}
149+
pc, _, _, ok := runtime.Caller(2)
150+
details := runtime.FuncForPC(pc)
151+
if ok && details != nil {
152+
db.Logger.Debug(context.Background(), "deleted row", slog.F("reason", reason), slog.F("caller", details.Name()))
153+
}
154+
return sql.ErrNoRows
155+
}
156+
157+
cipherPtr := db.ExternalTokenCipher.Load()
158+
// If no cipher is loaded, then we don't need to encrypt or decrypt anything!
159+
if cipherPtr == nil {
160+
for _, field := range fields {
161+
if field == nil {
162+
continue
163+
}
164+
if strings.HasPrefix(*field, MagicPrefix) {
165+
// If we have a magic prefix but encryption is disabled,
166+
// we should delete the row.
167+
return delete("encryption disabled")
168+
}
169+
}
170+
return nil
171+
}
172+
173+
cipher := *cipherPtr
174+
for _, field := range fields {
175+
if field == nil {
176+
continue
177+
}
178+
if len(*field) < len(MagicPrefix) || !strings.HasPrefix(*field, MagicPrefix) {
179+
// We do not force encryption of unencrypted rows. This could be damaging
180+
// to the deployment, and admins can always manually purge data.
181+
continue
182+
}
183+
data, err := base64.StdEncoding.DecodeString((*field)[len(MagicPrefix):])
184+
if err != nil {
185+
// If it's not base64 with the prefix, we should delete the row.
186+
return delete("stored value was not base64 encoded")
187+
}
188+
decrypted, err := cipher.Decrypt(data)
189+
if err != nil {
190+
// If the encryption key changed, we should delete the row.
191+
return delete("encryption key changed")
192+
}
193+
*field = string(decrypted)
194+
}
195+
return nil
196+
}

0 commit comments

Comments
 (0)