Skip to content

Commit 22e7aeb

Browse files
committed
add hex digest of cipher to encrypted fields
1 parent 5929c96 commit 22e7aeb

File tree

3 files changed

+85
-34
lines changed

3 files changed

+85
-34
lines changed

enterprise/dbcrypt/cipher.go

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import (
44
"crypto/aes"
55
"crypto/cipher"
66
"crypto/rand"
7-
"database/sql"
8-
"errors"
7+
"crypto/sha256"
8+
"fmt"
99
"io"
1010

1111
"golang.org/x/xerrors"
@@ -14,25 +14,7 @@ import (
1414
type Cipher interface {
1515
Encrypt([]byte) ([]byte, error)
1616
Decrypt([]byte) ([]byte, error)
17-
}
18-
19-
// DecryptFailedError is returned when decryption fails.
20-
// It unwraps to sql.ErrNoRows.
21-
type DecryptFailedError struct {
22-
Inner error
23-
}
24-
25-
func (e *DecryptFailedError) Error() string {
26-
return xerrors.Errorf("decrypt failed: %w", e.Inner).Error()
27-
}
28-
29-
func (*DecryptFailedError) Unwrap() error {
30-
return sql.ErrNoRows
31-
}
32-
33-
func IsDecryptFailedError(err error) bool {
34-
var e *DecryptFailedError
35-
return errors.As(err, &e)
17+
HexDigest() string
3618
}
3719

3820
// CipherAES256 returns a new AES-256 cipher.
@@ -48,11 +30,13 @@ func CipherAES256(key []byte) (Cipher, error) {
4830
if err != nil {
4931
return nil, err
5032
}
51-
return &aes256{aead}, nil
33+
digest := sha256.Sum256(key)
34+
return &aes256{aead: aead, digest: digest[:]}, nil
5235
}
5336

5437
type aes256 struct {
55-
aead cipher.AEAD
38+
aead cipher.AEAD
39+
digest []byte
5640
}
5741

5842
func (a *aes256) Encrypt(plaintext []byte) ([]byte, error) {
@@ -74,3 +58,7 @@ func (a *aes256) Decrypt(ciphertext []byte) ([]byte, error) {
7458
}
7559
return decrypted, nil
7660
}
61+
62+
func (a *aes256) HexDigest() string {
63+
return fmt.Sprintf("%x", a.digest)
64+
}

enterprise/dbcrypt/dbcrypt.go

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
// Package dbcrypt provides a database.Store wrapper that encrypts/decrypts
2+
// values stored at rest in the database.
3+
//
4+
// Encryption is done using a Cipher. The Cipher is stored in an atomic pointer
5+
// so that it can be rotated as required.
6+
//
7+
// The Cipher is currently used to encrypt/decrypt the following fields:
8+
// - database.UserLink.OAuthAccessToken
9+
// - database.UserLink.OAuthRefreshToken
10+
// - database.GitAuthLink.OAuthAccessToken
11+
// - database.GitAuthLink.OAuthRefreshToken
12+
// - database.DBCryptSentinelValue
13+
//
14+
// Encrypted fields are stored in the following format:
15+
// "dbcrypt-<first 7 characters of cipher's SHA256 digest>-<base64-encoded encrypted value>"
16+
//
17+
// The first 7 characters of the cipher's SHA256 digest are used to identify the cipher
18+
// used to encrypt the value.
119
package dbcrypt
220

321
import (
@@ -19,14 +37,46 @@ import (
1937
// MagicPrefix is prepended to all encrypted values in the database.
2038
// This is used to determine if a value is encrypted or not.
2139
// If it is encrypted but a key is not provided, an error is returned.
40+
// MagicPrefix will be followed by the first 7 characters of the cipher's
41+
// SHA256 digest, followed by a dash, followed by the base64-encoded
42+
// encrypted value.
2243
const MagicPrefix = "dbcrypt-"
2344

45+
// MagicPrefixLength is the length of the entire prefix used to identify
46+
// encrypted values.
47+
const MagicPrefixLength = len(MagicPrefix) + 8
48+
2449
// sentinelValue is the value that is stored in the database to indicate
25-
// whether encryption is enabled. If not enabled, the raw value is "coder".
26-
// Otherwise, the value is encrypted.
50+
// whether encryption is enabled. If not enabled, the value either not
51+
// present, or is the raw string "coder".
52+
// Otherwise, the value must be the encrypted value of the string "coder"
53+
// using the current cipher.
2754
const sentinelValue = "coder"
2855

29-
var ErrNotEnabled = xerrors.New("encryption is not enabled")
56+
var (
57+
ErrNotEnabled = xerrors.New("encryption is not enabled")
58+
b64encode = base64.StdEncoding.EncodeToString
59+
b64decode = base64.StdEncoding.DecodeString
60+
)
61+
62+
// DecryptFailedError is returned when decryption fails.
63+
// It unwraps to sql.ErrNoRows.
64+
type DecryptFailedError struct {
65+
Inner error
66+
}
67+
68+
func (e *DecryptFailedError) Error() string {
69+
return xerrors.Errorf("decrypt failed: %w", e.Inner).Error()
70+
}
71+
72+
func (*DecryptFailedError) Unwrap() error {
73+
return sql.ErrNoRows
74+
}
75+
76+
func IsDecryptFailedError(err error) bool {
77+
var e *DecryptFailedError
78+
return errors.As(err, &e)
79+
}
3080

3181
type Options struct {
3282
// ExternalTokenCipher is an optional cipher that is used
@@ -152,7 +202,7 @@ func (db *dbCrypt) encryptFields(fields ...*string) error {
152202
return err
153203
}
154204
// Base64 is used to support UTF-8 encoding in PostgreSQL.
155-
*field = MagicPrefix + base64.StdEncoding.EncodeToString(encrypted)
205+
*field = MagicPrefix + cipher.HexDigest()[:7] + "-" + b64encode(encrypted)
156206
}
157207
return nil
158208
}
@@ -181,15 +231,27 @@ func (db *dbCrypt) decryptFields(fields ...*string) error {
181231
if field == nil {
182232
continue
183233
}
184-
if len(*field) < len(MagicPrefix) || !strings.HasPrefix(*field, MagicPrefix) {
234+
235+
if len(*field) < 16 || !strings.HasPrefix(*field, MagicPrefix) {
185236
// We do not force decryption of unencrypted rows. This could be damaging
186237
// to the deployment, and admins can always manually purge data.
187238
continue
188239
}
189-
data, err := base64.StdEncoding.DecodeString((*field)[len(MagicPrefix):])
240+
241+
// The first 7 characters of the digest are used to identify the cipher.
242+
// If the cipher changes, we should complain loudly.
243+
encPrefix := cipher.HexDigest()[:7]
244+
if !strings.HasPrefix((*field)[8:15], encPrefix) {
245+
return &DecryptFailedError{
246+
Inner: xerrors.Errorf("cipher mismatch: expected %q, got %q", encPrefix, (*field)[8:15]),
247+
}
248+
}
249+
data, err := b64decode((*field)[16:])
190250
if err != nil {
191251
// If it's not base64 with the prefix, we should complain loudly.
192-
return xerrors.Errorf("malformed encrypted field %q: %w", *field, err)
252+
return &DecryptFailedError{
253+
Inner: xerrors.Errorf("malformed encrypted field %q: %w", *field, err),
254+
}
193255
}
194256
decrypted, err := cipher.Decrypt(data)
195257
if err != nil {
@@ -211,8 +273,7 @@ func ensureEncrypted(ctx context.Context, dbc *dbCrypt) error {
211273
}
212274

213275
if val != "" && val != sentinelValue {
214-
// TODO: Handle key rotation.
215-
return xerrors.Errorf("database is already encrypted with a different key and key rotation is not implemented yet")
276+
return xerrors.Errorf("database is already encrypted with a different key")
216277
}
217278

218279
if val == sentinelValue {

enterprise/dbcrypt/dbcrypt_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ func TestNew(t *testing.T) {
200200
rawVal, err := rawDB.GetDBCryptSentinelValue(ctx)
201201
require.NoError(t, err)
202202
require.Contains(t, rawVal, dbcrypt.MagicPrefix)
203+
requireEncryptedEquals(t, cipher, rawVal, "coder")
203204
})
204205

205206
t.Run("NoCipher", func(t *testing.T) {
@@ -264,8 +265,9 @@ func requireEncryptedEquals(t *testing.T, cipher *atomic.Pointer[dbcrypt.Cipher]
264265
t.Helper()
265266
c := (*cipher.Load())
266267
require.NotNil(t, c)
267-
require.Greater(t, len(value), len(dbcrypt.MagicPrefix), "value is not encrypted")
268-
data, err := base64.StdEncoding.DecodeString(value[len(dbcrypt.MagicPrefix):])
268+
require.Greater(t, len(value), 16, "value is not encrypted")
269+
require.Contains(t, value, dbcrypt.MagicPrefix+c.HexDigest()[:7]+"-")
270+
data, err := base64.StdEncoding.DecodeString(value[16:])
269271
require.NoError(t, err)
270272
got, err := c.Decrypt(data)
271273
require.NoError(t, err)

0 commit comments

Comments
 (0)