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:
Dana Axinte
2025-07-03 11:29:14 +01:00
committed by GitHub
co-authored by Michael Mandrus Matheus Macabu
parent 93c14c52da
commit 4d8678c7f2
28 changed files with 1173 additions and 431 deletions
@@ -4,11 +4,6 @@ import (
"context"
)
const (
AesCfb = "aes-cfb"
AesGcm = "aes-gcm"
)
type Cipher interface {
Encrypter
Decrypter
@@ -21,8 +16,3 @@ type Encrypter interface {
type Decrypter interface {
Decrypt(ctx context.Context, payload []byte, secret string) ([]byte, error)
}
type Provider interface {
ProvideCiphers() map[string]Encrypter
ProvideDeciphers() map[string]Decrypter
}
@@ -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")
}
@@ -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))
@@ -13,7 +13,7 @@ import (
"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/registry/apis/secret/encryption/cipher/provider"
"github.com/grafana/grafana/pkg/setting"
)
@@ -30,8 +30,9 @@ type Service struct {
cfg *setting.Cfg
usageMetrics usagestats.Service
ciphers map[string]cipher.Encrypter
deciphers map[string]cipher.Decrypter
cipher cipher.Encrypter
decipher cipher.Decrypter
algorithm string
}
func NewEncryptionService(
@@ -43,59 +44,29 @@ func NewEncryptionService(
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(),
// Use the AES-GCM cipher for encryption and decryption.
// This is the only cipher supported by the secrets management system.
cipher: provider.NewAesGcmCipher(),
decipher: provider.NewAesGcmCipher(),
algorithm: provider.AesGcm,
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,
fmt.Sprintf("stats.%s.encryption.cipher.%s.count", encryption.UsageInsightsPrefix, s.algorithm): 1,
}, nil
})
}
@@ -120,16 +91,10 @@ func (s *Service) Decrypt(ctx context.Context, payload []byte, secret string) ([
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)
decrypted, err = s.decipher.Decrypt(ctx, toDecrypt, secret)
return decrypted, err
}
@@ -139,15 +104,8 @@ func (s *Service) deriveEncryptionAlgorithm(payload []byte) (string, []byte, err
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:]
@@ -173,21 +131,13 @@ func (s *Service) Encrypt(ctx context.Context, payload []byte, secret string) ([
}
}()
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))
span.SetAttributes(attribute.String("cipher.algorithm", s.algorithm))
var encrypted []byte
encrypted, err = cipher.Encrypt(ctx, payload, secret)
encrypted, err = s.cipher.Encrypt(ctx, payload, secret)
prefix := make([]byte, base64.RawStdEncoding.EncodedLen(len([]byte(algorithm)))+2)
base64.RawStdEncoding.Encode(prefix[1:], []byte(algorithm))
prefix := make([]byte, base64.RawStdEncoding.EncodedLen(len([]byte(s.algorithm)))+2)
base64.RawStdEncoding.Encode(prefix[1:], []byte(s.algorithm))
prefix[0] = encryptionAlgorithmDelimiter
prefix[len(prefix)-1] = encryptionAlgorithmDelimiter
@@ -2,14 +2,12 @@ package service
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace/noop"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher"
"github.com/grafana/grafana/pkg/setting"
)
@@ -21,11 +19,6 @@ func newGcmService(t *testing.T) *Service {
SecretsManagement: setting.SecretsManagerSettings{
SecretKey: "SdlklWklckeLS",
EncryptionProvider: "secretKey.v1",
Encryption: setting.EncryptionSettings{
DataKeysCacheTTL: 5 * time.Minute,
DataKeysCleanupInterval: 1 * time.Nanosecond,
Algorithm: cipher.AesGcm,
},
},
}
@@ -60,19 +53,4 @@ func TestService(t *testing.T) {
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)
})
}