From c90e2e8e5e2f4507612a8782544c230d8376f7e4 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Mon, 5 May 2025 15:26:52 +0200 Subject: [PATCH] SecretsManager: Add (en/de)cryption packages (#104923) Merging the code as-is from the feature branch: secret-service/feature-branch Co-authored-by: PoorlyDefinedBehaviour Co-authored-by: Dana Axinte <53751979+dana-axinte@users.noreply.github.com> Co-authored-by: Leandro Deveikis Co-authored-by: Mariell Hoversholm Co-authored-by: Michael Mandrus --- .../apis/secret/encryption/cipher/cipher.go | 28 +++ .../encryption/cipher/provider/aes256.go | 14 ++ .../encryption/cipher/provider/aes256_test.go | 44 ++++ .../cipher/provider/cipher_aesgcm.go | 118 +++++++++++ .../cipher/provider/cipher_aesgcm_test.go | 144 +++++++++++++ .../cipher/provider/decipher_aescfb.go | 52 +++++ .../cipher/provider/decipher_aescfb_test.go | 70 ++++++ .../encryption/cipher/provider/errors.go | 7 + .../encryption/cipher/provider/provider.go | 18 ++ .../cipher/provider/provider_test.go | 17 ++ .../aescfb_encrypt_correct_output.rb | 35 +++ .../aesgcm_encrypt_correct_output.rb | 38 ++++ .../encryption/cipher/service/service.go | 199 ++++++++++++++++++ .../encryption/cipher/service/service_test.go | 78 +++++++ .../apis/secret/encryption/secrets.go | 3 + pkg/setting/setting.go | 4 + pkg/setting/setting_secrets_manager.go | 37 ++++ 17 files changed, 906 insertions(+) create mode 100644 pkg/registry/apis/secret/encryption/cipher/cipher.go create mode 100644 pkg/registry/apis/secret/encryption/cipher/provider/aes256.go create mode 100644 pkg/registry/apis/secret/encryption/cipher/provider/aes256_test.go create mode 100644 pkg/registry/apis/secret/encryption/cipher/provider/cipher_aesgcm.go create mode 100644 pkg/registry/apis/secret/encryption/cipher/provider/cipher_aesgcm_test.go create mode 100644 pkg/registry/apis/secret/encryption/cipher/provider/decipher_aescfb.go create mode 100644 pkg/registry/apis/secret/encryption/cipher/provider/decipher_aescfb_test.go create mode 100644 pkg/registry/apis/secret/encryption/cipher/provider/errors.go create mode 100644 pkg/registry/apis/secret/encryption/cipher/provider/provider.go create mode 100644 pkg/registry/apis/secret/encryption/cipher/provider/provider_test.go create mode 100644 pkg/registry/apis/secret/encryption/cipher/provider/test_fixtures/aescfb_encrypt_correct_output.rb create mode 100644 pkg/registry/apis/secret/encryption/cipher/provider/test_fixtures/aesgcm_encrypt_correct_output.rb create mode 100644 pkg/registry/apis/secret/encryption/cipher/service/service.go create mode 100644 pkg/registry/apis/secret/encryption/cipher/service/service_test.go create mode 100644 pkg/registry/apis/secret/encryption/secrets.go create mode 100644 pkg/setting/setting_secrets_manager.go diff --git a/pkg/registry/apis/secret/encryption/cipher/cipher.go b/pkg/registry/apis/secret/encryption/cipher/cipher.go new file mode 100644 index 00000000000..73a8b245d54 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/cipher.go @@ -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 +} diff --git a/pkg/registry/apis/secret/encryption/cipher/provider/aes256.go b/pkg/registry/apis/secret/encryption/cipher/provider/aes256.go new file mode 100644 index 00000000000..d645671de3a --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/provider/aes256.go @@ -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) +} diff --git a/pkg/registry/apis/secret/encryption/cipher/provider/aes256_test.go b/pkg/registry/apis/secret/encryption/cipher/provider/aes256_test.go new file mode 100644 index 00000000000..dfa517b3633 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/provider/aes256_test.go @@ -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") + }) +} diff --git a/pkg/registry/apis/secret/encryption/cipher/provider/cipher_aesgcm.go b/pkg/registry/apis/secret/encryption/cipher/provider/cipher_aesgcm.go new file mode 100644 index 00000000000..c5072b60092 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/provider/cipher_aesgcm.go @@ -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 +} diff --git a/pkg/registry/apis/secret/encryption/cipher/provider/cipher_aesgcm_test.go b/pkg/registry/apis/secret/encryption/cipher/provider/cipher_aesgcm_test.go new file mode 100644 index 00000000000..c09c66d9b9e --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/provider/cipher_aesgcm_test.go @@ -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") + }) +} diff --git a/pkg/registry/apis/secret/encryption/cipher/provider/decipher_aescfb.go b/pkg/registry/apis/secret/encryption/cipher/provider/decipher_aescfb.go new file mode 100644 index 00000000000..8d1c77a2c82 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/provider/decipher_aescfb.go @@ -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 +} diff --git a/pkg/registry/apis/secret/encryption/cipher/provider/decipher_aescfb_test.go b/pkg/registry/apis/secret/encryption/cipher/provider/decipher_aescfb_test.go new file mode 100644 index 00000000000..c6e8f0f58df --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/provider/decipher_aescfb_test.go @@ -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") + }) +} diff --git a/pkg/registry/apis/secret/encryption/cipher/provider/errors.go b/pkg/registry/apis/secret/encryption/cipher/provider/errors.go new file mode 100644 index 00000000000..037cb842f53 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/provider/errors.go @@ -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") diff --git a/pkg/registry/apis/secret/encryption/cipher/provider/provider.go b/pkg/registry/apis/secret/encryption/cipher/provider/provider.go new file mode 100644 index 00000000000..a299b5d20e1 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/provider/provider.go @@ -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{}, + } +} diff --git a/pkg/registry/apis/secret/encryption/cipher/provider/provider_test.go b/pkg/registry/apis/secret/encryption/cipher/provider/provider_test.go new file mode 100644 index 00000000000..b3977b74dc6 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/provider/provider_test.go @@ -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") +} diff --git a/pkg/registry/apis/secret/encryption/cipher/provider/test_fixtures/aescfb_encrypt_correct_output.rb b/pkg/registry/apis/secret/encryption/cipher/provider/test_fixtures/aescfb_encrypt_correct_output.rb new file mode 100644 index 00000000000..002a1d3d1f1 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/provider/test_fixtures/aescfb_encrypt_correct_output.rb @@ -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)) diff --git a/pkg/registry/apis/secret/encryption/cipher/provider/test_fixtures/aesgcm_encrypt_correct_output.rb b/pkg/registry/apis/secret/encryption/cipher/provider/test_fixtures/aesgcm_encrypt_correct_output.rb new file mode 100644 index 00000000000..1b100f3f027 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/provider/test_fixtures/aesgcm_encrypt_correct_output.rb @@ -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)) diff --git a/pkg/registry/apis/secret/encryption/cipher/service/service.go b/pkg/registry/apis/secret/encryption/cipher/service/service.go new file mode 100644 index 00000000000..4c3c0103769 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/service/service.go @@ -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 +} diff --git a/pkg/registry/apis/secret/encryption/cipher/service/service_test.go b/pkg/registry/apis/secret/encryption/cipher/service/service_test.go new file mode 100644 index 00000000000..462b1011508 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/cipher/service/service_test.go @@ -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) + }) +} diff --git a/pkg/registry/apis/secret/encryption/secrets.go b/pkg/registry/apis/secret/encryption/secrets.go new file mode 100644 index 00000000000..d8f89151a75 --- /dev/null +++ b/pkg/registry/apis/secret/encryption/secrets.go @@ -0,0 +1,3 @@ +package encryption + +const UsageInsightsPrefix = "secrets_manager" diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 7f76c2b8612..db397c3ddb9 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -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") diff --git a/pkg/setting/setting_secrets_manager.go b/pkg/setting/setting_secrets_manager.go new file mode 100644 index 00000000000..646acd77546 --- /dev/null +++ b/pkg/setting/setting_secrets_manager.go @@ -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) +}