SecretsManager: Add (en/de)cryption packages (#104923)
Merging the code as-is from the feature branch: secret-service/feature-branch Co-authored-by: PoorlyDefinedBehaviour <brunotj2015@hotmail.com> Co-authored-by: Dana Axinte <53751979+dana-axinte@users.noreply.github.com> Co-authored-by: Leandro Deveikis <leandro.deveikis@gmail.com> Co-authored-by: Mariell Hoversholm <mariell.hoversholm@grafana.com> Co-authored-by: Michael Mandrus <michael.mandrus@grafana.com>
This commit is contained in:
co-authored by
PoorlyDefinedBehaviour
Dana Axinte
Leandro Deveikis
Mariell Hoversholm
Michael Mandrus
parent
7900a53e05
commit
c90e2e8e5e
@@ -0,0 +1,28 @@
|
||||
package cipher
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const (
|
||||
AesCfb = "aes-cfb"
|
||||
AesGcm = "aes-gcm"
|
||||
)
|
||||
|
||||
type Cipher interface {
|
||||
Encrypter
|
||||
Decrypter
|
||||
}
|
||||
|
||||
type Encrypter interface {
|
||||
Encrypt(ctx context.Context, payload []byte, secret string) ([]byte, error)
|
||||
}
|
||||
|
||||
type Decrypter interface {
|
||||
Decrypt(ctx context.Context, payload []byte, secret string) ([]byte, error)
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
ProvideCiphers() map[string]Encrypter
|
||||
ProvideDeciphers() map[string]Decrypter
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"crypto/pbkdf2"
|
||||
"crypto/sha256"
|
||||
)
|
||||
|
||||
// aes256CipherKey is used to calculate a key for AES-256 blocks.
|
||||
// It returns a key of 32 bytes, which causes aes.NewCipher to choose AES-256.
|
||||
// The implementation is equal to that of the legacy secrets system.
|
||||
// If this changes, we either need to rotate all encrypted secrets, or keep a fallback implementation (being this).
|
||||
func aes256CipherKey(password string, salt []byte) ([]byte, error) {
|
||||
return pbkdf2.Key(sha256.New, password, salt, 10000, 32)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAes256CipherKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("with regular password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := aes256CipherKey("password", []byte("salt"))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, key, 32)
|
||||
})
|
||||
|
||||
t.Run("with very long password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := aes256CipherKey("a very long secret key that is much larger than 32 bytes", []byte("salt"))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, key, 32)
|
||||
})
|
||||
|
||||
t.Run("withstands randomness", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
password := make([]byte, 512)
|
||||
salt := make([]byte, 512)
|
||||
_, err := rand.Read(password)
|
||||
require.NoError(t, err, "failed to generate random password")
|
||||
_, err = rand.Read(salt)
|
||||
require.NoError(t, err, "failed to generate random salt")
|
||||
|
||||
key, err := aes256CipherKey(hex.EncodeToString(password), salt)
|
||||
require.NoError(t, err, "failed to generate key")
|
||||
require.Len(t, key, 32, "key should be 32 bytes long")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
cpr "crypto/cipher"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher"
|
||||
)
|
||||
|
||||
const gcmSaltLength = 8
|
||||
|
||||
var (
|
||||
_ cipher.Encrypter = (*aesGcmCipher)(nil)
|
||||
_ cipher.Decrypter = (*aesGcmCipher)(nil)
|
||||
)
|
||||
|
||||
type aesGcmCipher struct {
|
||||
// randReader is used to generate random bytes for the nonce.
|
||||
// This allows us to change out the entropy source for testing.
|
||||
randReader io.Reader
|
||||
}
|
||||
|
||||
func newAesGcmCipher() aesGcmCipher {
|
||||
return aesGcmCipher{
|
||||
randReader: rand.Reader,
|
||||
}
|
||||
}
|
||||
|
||||
func (c aesGcmCipher) Encrypt(_ context.Context, payload []byte, secret string) ([]byte, error) {
|
||||
salt, err := c.readEntropy(gcmSaltLength)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := aes256CipherKey(secret, salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cpr.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce, err := c.readEntropy(gcm.NonceSize())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nil, nonce, payload, nil)
|
||||
|
||||
// Salt Nonce Encrypted
|
||||
// | | Payload
|
||||
// | | |
|
||||
// | +---------v-------------+ |
|
||||
// +-->SSSSSSSNNNNNNNEEEEEEEEE<--+
|
||||
// +-----------------------+
|
||||
prefix := append(salt, nonce...)
|
||||
ciphertext = append(prefix, ciphertext...)
|
||||
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func (c aesGcmCipher) Decrypt(_ context.Context, payload []byte, secret string) ([]byte, error) {
|
||||
// The input payload looks like:
|
||||
// Salt Nonce Encrypted
|
||||
// | | Payload
|
||||
// | | |
|
||||
// | +---------v-------------+ |
|
||||
// +-->SSSSSSSNNNNNNNEEEEEEEEE<--+
|
||||
// +-----------------------+
|
||||
|
||||
if len(payload) < gcmSaltLength {
|
||||
// If we don't return here, we'd panic.
|
||||
return nil, ErrPayloadTooShort
|
||||
}
|
||||
salt, payload := payload[:gcmSaltLength], payload[gcmSaltLength:]
|
||||
// Can't get nonce until we get a size from the AEAD interface.
|
||||
|
||||
key, err := aes256CipherKey(secret, salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cpr.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(payload) < gcm.NonceSize() {
|
||||
// If we don't return here, we'd panic.
|
||||
return nil, ErrPayloadTooShort
|
||||
}
|
||||
nonce, payload := payload[:gcm.NonceSize()], payload[gcm.NonceSize():]
|
||||
|
||||
return gcm.Open(nil, nonce, payload, nil)
|
||||
}
|
||||
|
||||
func (c aesGcmCipher) readEntropy(n int) ([]byte, error) {
|
||||
entropy := make([]byte, n)
|
||||
if _, err := io.ReadFull(c.randReader, entropy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entropy, nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGcmEncryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("encrypts correctly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The expected values are generated by test_fixtures/aesgcm_encrypt_correct_output.rb
|
||||
|
||||
salt := []byte("abcdefgh")
|
||||
nonce := []byte("123456789012")
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader(append(salt, nonce...))
|
||||
|
||||
payload := []byte("grafana unit test")
|
||||
secret := "secret here"
|
||||
|
||||
encrypted, err := cipher.Encrypt(t.Context(), payload, secret)
|
||||
require.NoError(t, err, "failed to encrypt with GCM")
|
||||
require.NotEmpty(t, encrypted, "encrypted payload should not be empty")
|
||||
require.Equal(t, "61626364656667683132333435363738393031328123655291d1f5eebe34c54ba55900f68a2700818a8fda9e2921190b67271d97ce",
|
||||
hex.EncodeToString(encrypted), "encrypted payload should match expected value")
|
||||
|
||||
// Sanity check that all our pre-provided random data is used.
|
||||
_, err = cipher.randReader.Read([]byte{0})
|
||||
require.ErrorIs(t, err, io.EOF, "expected us to have read the entire random source")
|
||||
})
|
||||
|
||||
t.Run("fails if random source is empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader([]byte{})
|
||||
|
||||
payload := []byte("grafana unit test")
|
||||
secret := "secret here"
|
||||
|
||||
_, err := cipher.Encrypt(t.Context(), payload, secret)
|
||||
require.Error(t, err, "expected error when random source is empty")
|
||||
})
|
||||
|
||||
t.Run("fails if random source does not provide nonce", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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.randReader = bytes.NewReader([]byte("abcdefgh")) // 8 bytes for salt, but not enough for nonce
|
||||
|
||||
payload := []byte("grafana unit test")
|
||||
secret := "secret here"
|
||||
|
||||
_, err := cipher.Encrypt(t.Context(), payload, secret)
|
||||
require.Error(t, err, "expected error when random source does not provide nonce")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGcmDecryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("decrypts correctly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The expected values are generated by test_fixtures/aesgcm_encrypt_correct_output.rb
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader([]byte{}) // should not be used
|
||||
|
||||
payload, err := hex.DecodeString("61626364656667683132333435363738393031328123655291d1f5eebe34c54ba55900f68a2700818a8fda9e2921190b67271d97ce")
|
||||
require.NoError(t, err, "failed to decode pre-computed encrypted payload")
|
||||
secret := "secret here"
|
||||
|
||||
decrypted, err := cipher.Decrypt(t.Context(), payload, secret)
|
||||
require.NoError(t, err, "failed to decrypt with GCM")
|
||||
require.Equal(t, "grafana unit test", string(decrypted), "decrypted payload should match expected value")
|
||||
})
|
||||
|
||||
t.Run("fails if payload is shorter than salt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader([]byte{}) // should not be used
|
||||
|
||||
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 has length of salt but no nonce", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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
|
||||
secret := "secret here"
|
||||
|
||||
_, err := cipher.Decrypt(t.Context(), payload, secret)
|
||||
require.Error(t, err, "expected error when payload has length of salt but no nonce")
|
||||
})
|
||||
|
||||
t.Run("fails when authentication tag is wrong", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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.
|
||||
payload, err := hex.DecodeString("61626364656667683132333435363738393031328123655291d1f5eebe34c54ba55900f68a2700818a8fda9e2921190b67271d")
|
||||
require.NoError(t, err, "failed to decode pre-computed encrypted payload")
|
||||
secret := "secret here"
|
||||
|
||||
_, err = cipher.Decrypt(t.Context(), payload, secret)
|
||||
require.Error(t, err, "expected to fail validation")
|
||||
})
|
||||
|
||||
t.Run("fails if secret does not match", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher := newAesGcmCipher()
|
||||
cipher.randReader = bytes.NewReader([]byte{}) // should not be used
|
||||
|
||||
payload, err := hex.DecodeString("61626364656667683132333435363738393031328123655291d1f5eebe34c54ba55900f68a2700818a8fda9e2921190b67271d97ce")
|
||||
require.NoError(t, err, "failed to decode pre-computed encrypted payload")
|
||||
secret := "should have been 'secret here'"
|
||||
|
||||
_, err = cipher.Decrypt(t.Context(), payload, secret)
|
||||
require.Error(t, err, "expected to fail decryption")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package provider
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrPayloadTooShort is returned when the payload is too short to be decrypted.
|
||||
// In some situations, the error may instead be io.ErrUnexpectedEOF or a cipher-specific error.
|
||||
var ErrPayloadTooShort = errors.New("payload too short")
|
||||
@@ -0,0 +1,18 @@
|
||||
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{},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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
@@ -0,0 +1,35 @@
|
||||
#!/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))
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Used by ../cipher_aesgcm_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'
|
||||
|
||||
# randReader field
|
||||
salt = "abcdefgh"
|
||||
nonce = "123456789012"
|
||||
|
||||
# inputs to Encrypt
|
||||
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(:GCM).encrypt
|
||||
cipher.iv = nonce
|
||||
cipher.key = key
|
||||
cipher.auth_data = ""
|
||||
encrypted = cipher.update(plaintext)
|
||||
|
||||
def to_hex(s)
|
||||
s.unpack('H*').first
|
||||
end
|
||||
|
||||
# Salt Nonce Encrypted
|
||||
# | | Payload
|
||||
# | | |
|
||||
# | +---------v-------------+ |
|
||||
# +-->SSSSSSSNNNNNNNEEEEEEEEE<--+
|
||||
# +-----------------------+
|
||||
printf("%s%s%s%s%s\n", to_hex(salt), to_hex(nonce), cipher.final, to_hex(encrypted), to_hex(cipher.auth_tag))
|
||||
@@ -0,0 +1,199 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher"
|
||||
encryptionprovider "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher/provider"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
const (
|
||||
encryptionAlgorithmDelimiter = '*'
|
||||
)
|
||||
|
||||
// Service must not be used for cipher.
|
||||
// Use secrets.Service implementing envelope encryption instead.
|
||||
type Service struct {
|
||||
tracer tracing.Tracer
|
||||
log log.Logger
|
||||
|
||||
cfg *setting.Cfg
|
||||
usageMetrics usagestats.Service
|
||||
|
||||
ciphers map[string]cipher.Encrypter
|
||||
deciphers map[string]cipher.Decrypter
|
||||
}
|
||||
|
||||
func NewEncryptionService(
|
||||
tracer tracing.Tracer,
|
||||
usageMetrics usagestats.Service,
|
||||
cfg *setting.Cfg,
|
||||
) (*Service, error) {
|
||||
if cfg.SecretsManagement.SecretKey == "" {
|
||||
return nil, fmt.Errorf("`[secrets_manager]secret_key` is not set")
|
||||
}
|
||||
|
||||
if cfg.SecretsManagement.Encryption.Algorithm == "" {
|
||||
return nil, fmt.Errorf("`[secrets_manager.encryption]algorithm` is not set")
|
||||
}
|
||||
|
||||
s := &Service{
|
||||
tracer: tracer,
|
||||
log: log.New("encryption"),
|
||||
|
||||
ciphers: encryptionprovider.ProvideCiphers(),
|
||||
deciphers: encryptionprovider.ProvideDeciphers(),
|
||||
|
||||
usageMetrics: usageMetrics,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
algorithm := s.cfg.SecretsManagement.Encryption.Algorithm
|
||||
|
||||
if err := s.checkEncryptionAlgorithm(algorithm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.registerUsageMetrics()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Service) checkEncryptionAlgorithm(algorithm string) error {
|
||||
var err error
|
||||
defer func() {
|
||||
if err != nil {
|
||||
s.log.Error("Wrong security encryption configuration", "algorithm", algorithm, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, ok := s.ciphers[algorithm]; !ok {
|
||||
err = fmt.Errorf("no cipher registered for encryption algorithm '%s'", algorithm)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, ok := s.deciphers[algorithm]; !ok {
|
||||
err = fmt.Errorf("no decipher registered for encryption algorithm '%s'", algorithm)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) registerUsageMetrics() {
|
||||
s.usageMetrics.RegisterMetricsFunc(func(context.Context) (map[string]any, error) {
|
||||
algorithm := s.cfg.SecretsManagement.Encryption.Algorithm
|
||||
|
||||
return map[string]any{
|
||||
fmt.Sprintf("stats.%s.encryption.cipher.%s.count", encryption.UsageInsightsPrefix, algorithm): 1,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Decrypt(ctx context.Context, payload []byte, secret string) ([]byte, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "cipher.service.Decrypt")
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
defer func() {
|
||||
if err != nil {
|
||||
s.log.FromContext(ctx).Error("Decryption failed", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var (
|
||||
algorithm string
|
||||
toDecrypt []byte
|
||||
)
|
||||
algorithm, toDecrypt, err = s.deriveEncryptionAlgorithm(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decipher, ok := s.deciphers[algorithm]
|
||||
if !ok {
|
||||
err = fmt.Errorf("no decipher available for algorithm '%s'", algorithm)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.String("cipher.algorithm", algorithm))
|
||||
|
||||
var decrypted []byte
|
||||
decrypted, err = decipher.Decrypt(ctx, toDecrypt, secret)
|
||||
|
||||
return decrypted, err
|
||||
}
|
||||
|
||||
func (s *Service) deriveEncryptionAlgorithm(payload []byte) (string, []byte, error) {
|
||||
if len(payload) == 0 {
|
||||
return "", nil, fmt.Errorf("unable to derive encryption algorithm")
|
||||
}
|
||||
|
||||
if payload[0] != encryptionAlgorithmDelimiter {
|
||||
return cipher.AesCfb, payload, nil // backwards compatibility
|
||||
}
|
||||
|
||||
payload = payload[1:]
|
||||
algorithmDelimiterIdx := bytes.Index(payload, []byte{encryptionAlgorithmDelimiter})
|
||||
if algorithmDelimiterIdx == -1 {
|
||||
return cipher.AesCfb, payload, nil // backwards compatibility
|
||||
}
|
||||
|
||||
algorithmB64 := payload[:algorithmDelimiterIdx]
|
||||
payload = payload[algorithmDelimiterIdx+1:]
|
||||
|
||||
algorithm := make([]byte, base64.RawStdEncoding.DecodedLen(len(algorithmB64)))
|
||||
|
||||
_, err := base64.RawStdEncoding.Decode(algorithm, algorithmB64)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return string(algorithm), payload, nil
|
||||
}
|
||||
|
||||
func (s *Service) Encrypt(ctx context.Context, payload []byte, secret string) ([]byte, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "cipher.service.Encrypt")
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
defer func() {
|
||||
if err != nil {
|
||||
s.log.Error("Encryption failed", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
algorithm := s.cfg.SecretsManagement.Encryption.Algorithm
|
||||
|
||||
cipher, ok := s.ciphers[algorithm]
|
||||
if !ok {
|
||||
err = fmt.Errorf("no cipher available for algorithm '%s'", algorithm)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.String("cipher.algorithm", algorithm))
|
||||
|
||||
var encrypted []byte
|
||||
encrypted, err = cipher.Encrypt(ctx, payload, secret)
|
||||
|
||||
prefix := make([]byte, base64.RawStdEncoding.EncodedLen(len([]byte(algorithm)))+2)
|
||||
base64.RawStdEncoding.Encode(prefix[1:], []byte(algorithm))
|
||||
prefix[0] = encryptionAlgorithmDelimiter
|
||||
prefix[len(prefix)-1] = encryptionAlgorithmDelimiter
|
||||
|
||||
ciphertext := make([]byte, len(prefix)+len(encrypted))
|
||||
copy(ciphertext, prefix)
|
||||
copy(ciphertext[len(prefix):], encrypted)
|
||||
|
||||
return ciphertext, nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func newGcmService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
|
||||
usageStats := &usagestats.UsageStatsMock{}
|
||||
settings := &setting.Cfg{
|
||||
SecretsManagement: setting.SecretsManagerSettings{
|
||||
SecretKey: "SdlklWklckeLS",
|
||||
EncryptionProvider: "secretKey.v1",
|
||||
Encryption: setting.EncryptionSettings{
|
||||
DataKeysCacheTTL: 5 * time.Minute,
|
||||
DataKeysCleanupInterval: 1 * time.Nanosecond,
|
||||
Algorithm: cipher.AesGcm,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc, err := NewEncryptionService(tracing.InitializeTracerForTest(), usageStats, settings)
|
||||
require.NoError(t, err, "failed to set up encryption service")
|
||||
return svc
|
||||
}
|
||||
|
||||
func TestService(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("decrypt empty payload should return error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := newGcmService(t)
|
||||
_, err := svc.Decrypt(t.Context(), []byte(""), "1234")
|
||||
require.Error(t, err)
|
||||
|
||||
assert.Equal(t, "unable to derive encryption algorithm", err.Error())
|
||||
})
|
||||
|
||||
t.Run("encrypt and decrypt with GCM should work", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := newGcmService(t)
|
||||
encrypted, err := svc.Encrypt(t.Context(), []byte("grafana"), "1234")
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := svc.Decrypt(t.Context(), encrypted, "1234")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []byte("grafana"), decrypted)
|
||||
// We'll let the provider deal with testing details.
|
||||
})
|
||||
|
||||
t.Run("decrypting legacy ciphertext should work", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Raw slice of bytes that corresponds to the following ciphertext:
|
||||
// - 'grafana' as payload
|
||||
// - '1234' as secret
|
||||
// - no encryption algorithm metadata
|
||||
ciphertext := []byte{73, 71, 50, 57, 121, 110, 90, 109, 115, 23, 237, 13, 130, 188, 151, 118, 98, 103, 80, 209, 79, 143, 22, 122, 44, 40, 102, 41, 136, 16, 27}
|
||||
|
||||
svc := newGcmService(t)
|
||||
decrypted, err := svc.Decrypt(t.Context(), ciphertext, "1234")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("grafana"), decrypted)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package encryption
|
||||
|
||||
const UsageInsightsPrefix = "secrets_manager"
|
||||
@@ -558,6 +558,9 @@ type Cfg struct {
|
||||
SprinklesApiServerPageLimit int
|
||||
CACertPath string
|
||||
HttpsSkipVerify bool
|
||||
|
||||
// Secrets Management
|
||||
SecretsManagement SecretsManagerSettings
|
||||
}
|
||||
|
||||
type UnifiedStorageConfig struct {
|
||||
@@ -1367,6 +1370,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
|
||||
cfg.readFeatureManagementConfig()
|
||||
cfg.readPublicDashboardsSettings()
|
||||
cfg.readCloudMigrationSettings()
|
||||
cfg.readSecretsManagerSettings()
|
||||
|
||||
// read experimental scopes settings.
|
||||
scopesSection := iniFile.Section("scopes")
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher"
|
||||
"github.com/grafana/grafana/pkg/services/kmsproviders"
|
||||
)
|
||||
|
||||
type EncryptionSettings struct {
|
||||
DataKeysCacheTTL time.Duration
|
||||
DataKeysCleanupInterval time.Duration
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
type SecretsManagerSettings struct {
|
||||
SecretKey string
|
||||
EncryptionProvider string
|
||||
AvailableProviders []string
|
||||
|
||||
Encryption EncryptionSettings
|
||||
}
|
||||
|
||||
func (cfg *Cfg) readSecretsManagerSettings() {
|
||||
secretsMgmt := cfg.Raw.Section("secrets_manager")
|
||||
cfg.SecretsManagement.EncryptionProvider = secretsMgmt.Key("encryption_provider").MustString(kmsproviders.Default)
|
||||
|
||||
// TODO: These are not used yet by the secrets manager because we need to distentagle the dependencies with OSS.
|
||||
cfg.SecretsManagement.SecretKey = secretsMgmt.Key("secret_key").MustString("")
|
||||
cfg.SecretsManagement.AvailableProviders = regexp.MustCompile(`\s*,\s*`).Split(secretsMgmt.Key("available_encryption_providers").MustString(""), -1) // parse comma separated list
|
||||
|
||||
encryption := cfg.Raw.Section("secrets_manager.encryption")
|
||||
cfg.SecretsManagement.Encryption.DataKeysCacheTTL = encryption.Key("data_keys_cache_ttl").MustDuration(15 * time.Minute)
|
||||
cfg.SecretsManagement.Encryption.DataKeysCleanupInterval = encryption.Key("data_keys_cache_cleanup_interval").MustDuration(1 * time.Minute)
|
||||
cfg.SecretsManagement.Encryption.Algorithm = encryption.Key("algorithm").MustString(cipher.AesGcm)
|
||||
}
|
||||
Reference in New Issue
Block a user