SecretsManager: Add base encryption manager (#107562)
Co-authored-by: Michael Mandrus <michael.mandrus@grafana.com> Co-authored-by: Matheus Macabu <macabu@users.noreply.github.com>
This commit is contained in:
co-authored by
Michael Mandrus
Matheus Macabu
parent
93c14c52da
commit
4d8678c7f2
@@ -10,7 +10,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher"
|
||||
)
|
||||
|
||||
const gcmSaltLength = 8
|
||||
const (
|
||||
gcmSaltLength = 8
|
||||
AesGcm = "aes-gcm"
|
||||
)
|
||||
|
||||
var (
|
||||
_ cipher.Encrypter = (*aesGcmCipher)(nil)
|
||||
@@ -23,7 +26,7 @@ type aesGcmCipher struct {
|
||||
randReader io.Reader
|
||||
}
|
||||
|
||||
func newAesGcmCipher() aesGcmCipher {
|
||||
func NewAesGcmCipher() aesGcmCipher {
|
||||
return aesGcmCipher{
|
||||
randReader: rand.Reader,
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestGcmEncryption(t *testing.T) {
|
||||
salt := []byte("abcdefgh")
|
||||
nonce := []byte("123456789012")
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher := NewAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader(append(salt, nonce...))
|
||||
|
||||
payload := []byte("grafana unit test")
|
||||
@@ -40,7 +40,7 @@ func TestGcmEncryption(t *testing.T) {
|
||||
t.Run("fails if random source is empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher := NewAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader([]byte{})
|
||||
|
||||
payload := []byte("grafana unit test")
|
||||
@@ -56,7 +56,7 @@ func TestGcmEncryption(t *testing.T) {
|
||||
// Scenario: the random source has enough entropy for the salt, but not for the nonce.
|
||||
// In this case, we should fail with an error.
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher := NewAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader([]byte("abcdefgh")) // 8 bytes for salt, but not enough for nonce
|
||||
|
||||
payload := []byte("grafana unit test")
|
||||
@@ -75,7 +75,7 @@ func TestGcmDecryption(t *testing.T) {
|
||||
|
||||
// The expected values are generated by test_fixtures/aesgcm_encrypt_correct_output.rb
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher := NewAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader([]byte{}) // should not be used
|
||||
|
||||
payload, err := hex.DecodeString("61626364656667683132333435363738393031328123655291d1f5eebe34c54ba55900f68a2700818a8fda9e2921190b67271d97ce")
|
||||
@@ -90,7 +90,7 @@ func TestGcmDecryption(t *testing.T) {
|
||||
t.Run("fails if payload is shorter than salt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher := NewAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader([]byte{}) // should not be used
|
||||
|
||||
payload := []byte{1, 2, 3, 4}
|
||||
@@ -103,7 +103,7 @@ func TestGcmDecryption(t *testing.T) {
|
||||
t.Run("fails if payload has length of salt but no nonce", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher := NewAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader([]byte{}) // should not be used
|
||||
|
||||
payload := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} // salt and a little more
|
||||
@@ -116,7 +116,7 @@ func TestGcmDecryption(t *testing.T) {
|
||||
t.Run("fails when authentication tag is wrong", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher := NewAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader([]byte{}) // should not be used
|
||||
|
||||
// Removed 2 bytes from the end of the payload to simulate a wrong authentication tag.
|
||||
@@ -131,7 +131,7 @@ func TestGcmDecryption(t *testing.T) {
|
||||
t.Run("fails if secret does not match", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher := NewAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader([]byte{}) // should not be used
|
||||
|
||||
payload, err := hex.DecodeString("61626364656667683132333435363738393031328123655291d1f5eebe34c54ba55900f68a2700818a8fda9e2921190b67271d97ce")
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
cpr "crypto/cipher"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher"
|
||||
)
|
||||
|
||||
const cfbSaltLength = 8
|
||||
|
||||
var _ cipher.Decrypter = aesCfbDecipher{}
|
||||
|
||||
type aesCfbDecipher struct{}
|
||||
|
||||
func (aesCfbDecipher) Decrypt(_ context.Context, payload []byte, secret string) ([]byte, error) {
|
||||
// payload is formatted:
|
||||
// Salt Nonce Encrypted
|
||||
// | | Payload
|
||||
// | | |
|
||||
// | +---------v-------------+ |
|
||||
// +-->SSSSSSSNNNNNNNEEEEEEEEE<--+
|
||||
// +-----------------------+
|
||||
|
||||
if len(payload) < cfbSaltLength+aes.BlockSize {
|
||||
// If we don't return here, we'd panic.
|
||||
return nil, ErrPayloadTooShort
|
||||
}
|
||||
|
||||
salt := payload[:cfbSaltLength]
|
||||
|
||||
key, err := aes256CipherKey(secret, salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iv, payload := payload[cfbSaltLength:][:aes.BlockSize], payload[cfbSaltLength+aes.BlockSize:]
|
||||
payloadDst := make([]byte, len(payload))
|
||||
|
||||
//nolint:staticcheck // We need to support CFB _decryption_, though we don't support it for future encryption.
|
||||
stream := cpr.NewCFBDecrypter(block, iv)
|
||||
|
||||
// XORKeyStream can work in-place if the two arguments are the same.
|
||||
stream.XORKeyStream(payloadDst, payload)
|
||||
return payloadDst, nil
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCfbDecryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("decrypts correctly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The expected values are generated by test_fixtures/aescfb_encrypt_correct_output.rb
|
||||
|
||||
cipher := aesCfbDecipher{}
|
||||
|
||||
payload, err := hex.DecodeString("616263646566676831323334353637383930313234353637f1114227cb6af678cad6ee35f67f25f40b")
|
||||
require.NoError(t, err, "failed to decode hex string")
|
||||
secret := "secret here"
|
||||
|
||||
decrypted, err := cipher.Decrypt(t.Context(), payload, secret)
|
||||
require.NoError(t, err, "failed to decrypt with CFB")
|
||||
require.Equal(t, "grafana unit test", string(decrypted), "decrypted payload should match expected value")
|
||||
})
|
||||
|
||||
t.Run("fails if payload is too short", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher := aesCfbDecipher{}
|
||||
|
||||
payload := []byte{1, 2, 3, 4}
|
||||
secret := "secret here"
|
||||
|
||||
_, err := cipher.Decrypt(t.Context(), payload, secret)
|
||||
require.Error(t, err, "expected error when payload is shorter than salt")
|
||||
})
|
||||
|
||||
t.Run("fails if payload is not an AES-encrypted value", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher := aesCfbDecipher{}
|
||||
|
||||
payload, err := hex.DecodeString("616263646566676831323334353637383930313234353637f1114227cb")
|
||||
require.NoError(t, err, "failed to decode hex string")
|
||||
secret := "secret here"
|
||||
|
||||
// We don't have any authentication tag, so we can't return an error in this case.
|
||||
decrypted, err := cipher.Decrypt(t.Context(), payload, secret)
|
||||
require.NoError(t, err, "expected no error")
|
||||
require.NotEqual(t, "grafana unit test", string(decrypted), "decrypted payload should not match real exposed secret")
|
||||
})
|
||||
|
||||
t.Run("fails if secret is wrong", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher := aesCfbDecipher{}
|
||||
|
||||
payload, err := hex.DecodeString("616263646566676831323334353637383930313234353637f1114227cb6af678cad6ee35f67f25f40b")
|
||||
require.NoError(t, err, "failed to decode hex string")
|
||||
secret := "should've been 'secret here'"
|
||||
|
||||
// We don't have any authentication tag, so we can't return an error in this case.
|
||||
decrypted, err := cipher.Decrypt(t.Context(), payload, secret)
|
||||
require.NoError(t, err, "expected no error")
|
||||
require.NotEqual(t, "grafana unit test", string(decrypted), "decrypted payload should not match real exposed secret")
|
||||
})
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher"
|
||||
)
|
||||
|
||||
func ProvideCiphers() map[string]cipher.Encrypter {
|
||||
return map[string]cipher.Encrypter{
|
||||
cipher.AesGcm: newAesGcmCipher(),
|
||||
}
|
||||
}
|
||||
|
||||
func ProvideDeciphers() map[string]cipher.Decrypter {
|
||||
return map[string]cipher.Decrypter{
|
||||
cipher.AesGcm: newAesGcmCipher(),
|
||||
cipher.AesCfb: aesCfbDecipher{},
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package provider_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher/provider"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNoCfbEncryptionCipher(t *testing.T) {
|
||||
// CFB encryption is insecure, and as such we should not permit any cipher for encryption to be added.
|
||||
// Changing/removing this test MUST be accompanied with an approval from the app security team.
|
||||
|
||||
ciphers := provider.ProvideCiphers()
|
||||
require.NotContains(t, ciphers, cipher.AesCfb, "CFB cipher should not be used for encryption")
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Used by ../decipher_aescfb_test.go
|
||||
# Why Ruby? It has a mostly available OpenSSL library that can be easily fetched (and most who have Ruby already have it!). And it is easy to read for this purpose.
|
||||
|
||||
require 'openssl'
|
||||
|
||||
salt = "abcdefgh"
|
||||
nonce = "1234567890124567"
|
||||
|
||||
secret = "secret here"
|
||||
plaintext = "grafana unit test"
|
||||
|
||||
# reimpl of aes256CipherKey
|
||||
# the key is always the same value given the inputs
|
||||
iterations = 10_000
|
||||
len = 32
|
||||
hash = OpenSSL::Digest::SHA256.new
|
||||
key = OpenSSL::KDF.pbkdf2_hmac(secret, salt: salt, iterations: iterations, length: len, hash: hash)
|
||||
|
||||
cipher = OpenSSL::Cipher::AES256.new(:CFB).encrypt
|
||||
cipher.iv = nonce
|
||||
cipher.key = key
|
||||
encrypted = cipher.update(plaintext)
|
||||
|
||||
def to_hex(s)
|
||||
s.unpack('H*').first
|
||||
end
|
||||
|
||||
# Salt Nonce Encrypted
|
||||
# | | Payload
|
||||
# | | |
|
||||
# | +---------v-------------+ |
|
||||
# +-->SSSSSSSNNNNNNNEEEEEEEEE<--+
|
||||
# +-----------------------+
|
||||
printf("%s%s%s%s\n", to_hex(salt), to_hex(nonce), cipher.final, to_hex(encrypted))
|
||||
Reference in New Issue
Block a user