diff --git a/pkg/components/securejsondata/securejsondata.go b/pkg/components/securejsondata/securejsondata.go index 015290a6624..304aa3c064f 100644 --- a/pkg/components/securejsondata/securejsondata.go +++ b/pkg/components/securejsondata/securejsondata.go @@ -38,7 +38,7 @@ func (s SecureJsonData) Decrypt() map[string]string { return decrypted } -// GetEncryptedJsonData returns map where all keys are encrypted. +// GetEncryptedJsonData returns map where all values are encrypted. func GetEncryptedJsonData(sjd map[string]string) SecureJsonData { encrypted := make(SecureJsonData) for key, data := range sjd { diff --git a/pkg/models/data_key.go b/pkg/models/data_key.go new file mode 100644 index 00000000000..f0daa530a28 --- /dev/null +++ b/pkg/models/data_key.go @@ -0,0 +1,19 @@ +package models + +import ( + "errors" + "time" +) + +var ( + ErrDataKeyNotFound = errors.New("data key not found") +) + +type DataKey struct { + Active bool `json:"active"` + Name string `json:"name"` + Provider string `json:"provider"` + EncryptedData []byte `json:"-"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} diff --git a/pkg/services/secrets/json.go b/pkg/services/secrets/json.go new file mode 100644 index 00000000000..9d41110489e --- /dev/null +++ b/pkg/services/secrets/json.go @@ -0,0 +1,55 @@ +package secrets + +// EncryptedJSON is used to store encrypted data (for example in data_source table). Only values are separately +// encrypted. +type EncryptedJSON struct { + s *Secrets + Map map[string][]byte +} + +// DecryptedValue returns single decrypted value from EncryptedJSON. Similar to normal map access second return value +// is true if the key exists and false if not. +func (j EncryptedJSON) DecryptedValue(key string) (string, bool) { + if value, ok := j.Map[key]; ok { + decryptedData, err := j.s.Decrypt(value, "") + if err != nil { + logger.Error("could not decrypt value", "key", key, "err", err.Error()) + return "", false + } + return string(decryptedData), true + } + return "", false +} + +// Decrypt returns map of the same type but where the all the values are decrypted. Opposite of what +// EncryptJSONMap is doing. +func (j EncryptedJSON) DecryptJSONMap() (map[string]string, error) { + decrypted := make(map[string]string) + for key, data := range j.Map { + decryptedData, err := j.s.Decrypt(data, "") + if err != nil { + logger.Error("could not decrypt map", "err", err.Error()) + return nil, err + } + + decrypted[key] = string(decryptedData) + } + return decrypted, nil +} + +// EncryptJSONMap returns map where all values are encrypted. +func (s *Secrets) EncryptJSONMap(m map[string]string) (EncryptedJSON, error) { + encrypted := EncryptedJSON{ + s: s, + Map: make(map[string][]byte), + } + for key, data := range m { + encryptedData, err := s.Encrypt([]byte(data), "") + if err != nil { + return EncryptedJSON{}, err + } + + encrypted.Map[key] = encryptedData + } + return encrypted, nil +} diff --git a/pkg/services/secrets/secretkey.go b/pkg/services/secrets/secretkey.go new file mode 100644 index 00000000000..ced1f78465d --- /dev/null +++ b/pkg/services/secrets/secretkey.go @@ -0,0 +1,93 @@ +package secrets + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "errors" + "io" + + "golang.org/x/crypto/pbkdf2" + + "github.com/grafana/grafana/pkg/util" +) + +type secretKey struct { + key func() []byte +} + +func (s *secretKey) Encrypt(blob []byte) ([]byte, error) { + return encrypt(blob, s.key()) +} + +func (s *secretKey) Decrypt(blob []byte) ([]byte, error) { + return decrypt(blob, s.key()) +} + +const saltLength = 8 + +// Decrypt decrypts a payload with a given secret. +func decrypt(payload, secret []byte) ([]byte, error) { + salt := payload[:saltLength] + key, err := encryptionKeyToBytes(secret, salt) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // The IV needs to be unique, but not secure. Therefore it's common to + // include it at the beginning of the ciphertext. + if len(payload) < aes.BlockSize { + return nil, errors.New("payload too short") + } + iv := payload[saltLength : saltLength+aes.BlockSize] + payload = payload[saltLength+aes.BlockSize:] + payloadDst := make([]byte, len(payload)) + + stream := cipher.NewCFBDecrypter(block, iv) + + // XORKeyStream can work in-place if the two arguments are the same. + stream.XORKeyStream(payloadDst, payload) + return payloadDst, nil +} + +// Encrypt encrypts a payload with a given secret. +func encrypt(payload, secret []byte) ([]byte, error) { + salt, err := util.GetRandomString(saltLength) + if err != nil { + return nil, err + } + + key, err := encryptionKeyToBytes(secret, []byte(salt)) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // The IV needs to be unique, but not secure. Therefore it's common to + // include it at the beginning of the ciphertext. + ciphertext := make([]byte, saltLength+aes.BlockSize+len(payload)) + copy(ciphertext[:saltLength], salt) + iv := ciphertext[saltLength : saltLength+aes.BlockSize] + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return nil, err + } + + stream := cipher.NewCFBEncrypter(block, iv) + stream.XORKeyStream(ciphertext[saltLength+aes.BlockSize:], payload) + + return ciphertext, nil +} + +// Key needs to be 32bytes +func encryptionKeyToBytes(secret, salt []byte) ([]byte, error) { + return pbkdf2.Key(secret, salt, 10000, 32, sha256.New), nil +} diff --git a/pkg/services/secrets/secrets.go b/pkg/services/secrets/secrets.go new file mode 100644 index 00000000000..f5434815257 --- /dev/null +++ b/pkg/services/secrets/secrets.go @@ -0,0 +1,110 @@ +package secrets + +import ( + "encoding/base64" + "fmt" + "time" + + "github.com/grafana/grafana/pkg/services/sqlstore" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/setting" +) + +var logger = log.New("secrets") + +type Secrets struct { + store *sqlstore.SqlStore `inject:""` + + providers map[string]Provider + dataKeyCache map[string]dataKeyCacheItem +} + +type dataKeyCacheItem struct { + expiry time.Time + dataKey []byte +} + +type Provider interface { + Encrypt(blob []byte) ([]byte, error) + Decrypt(blob []byte) ([]byte, error) +} + +func (s *Secrets) Init() error { + s.providers = map[string]Provider{ + "": &secretKey{ + key: func() []byte { + return []byte(setting.SecretKey) + }, + }, + } + + return nil +} + +func (s *Secrets) Encrypt(payload []byte, key string) ([]byte, error) { + dataKey, err := s.dataKey(key) + if err != nil { + return nil, err + } + + b64 := base64.StdEncoding + prefix := make([]byte, b64.EncodedLen(len(key))+2) + b64.Encode(prefix[1:], []byte(key)) + prefix[0] = '#' + prefix[len(prefix)-1] = '#' + + blob := make([]byte, len(prefix)+len(payload)) + copy(blob, prefix) + copy(blob[len(prefix):], payload) + + return encrypt(blob, dataKey) +} + +func (s *Secrets) Decrypt(payload []byte, key string) ([]byte, error) { + dataKey, err := s.dataKey(key) + if err != nil { + return nil, err + } + + return decrypt(payload, dataKey) +} + +func (s *Secrets) dataKey(key string) ([]byte, error) { + if key == "" { + return []byte(setting.SecretKey), nil + } + + if item, exists := s.dataKeyCache[key]; exists { + if item.expiry.Before(time.Now()) && !item.expiry.IsZero() { + delete(s.dataKeyCache, key) + } else { + return item.dataKey, nil + } + } + + // 1. get encrypted data key from database + dataKey, err := s.store.GetDataKey(key) + if err != nil { + return nil, err + } + + // 2. decrypt data key + provider, exists := s.providers[dataKey.Provider] + if !exists { + return nil, fmt.Errorf("could not find encryption provider '%s'", dataKey.Provider) + } + + decrypted, err := provider.Decrypt(dataKey.EncryptedData) + if err != nil { + return nil, err + } + + // 3. cache data key + s.dataKeyCache[key] = dataKeyCacheItem{ + expiry: time.Now().Add(15 * time.Minute), + dataKey: decrypted, + } + + return decrypted, nil +} diff --git a/pkg/services/sqlstore/data_keys.go b/pkg/services/sqlstore/data_keys.go new file mode 100644 index 00000000000..e4097afcaf6 --- /dev/null +++ b/pkg/services/sqlstore/data_keys.go @@ -0,0 +1,27 @@ +package sqlstore + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/models" + "xorm.io/xorm" +) + +func (ss *SqlStore) GetDataKey(name string) (*models.DataKey, error) { + return getDataKey(name, ss.engine) +} + +func getDataKey(name string, engine *xorm.Engine) (*models.DataKey, error) { + dataKey := &models.DataKey{Name: name} + exists, err := engine.Get(dataKey) + + if err != nil { + sqlog.Error("Failed getting data key", "err", err, "name", name) + return nil, fmt.Errorf("failed getting data key: %w", err) + } + if !exists { + return nil, models.ErrDataKeyNotFound + } + + return dataKey, nil +} diff --git a/pkg/services/sqlstore/migrations/data_keys_mig.go b/pkg/services/sqlstore/migrations/data_keys_mig.go new file mode 100644 index 00000000000..178d58d7ef4 --- /dev/null +++ b/pkg/services/sqlstore/migrations/data_keys_mig.go @@ -0,0 +1,20 @@ +package migrations + +import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addDataKeysMigrations(mg *migrator.Migrator) { + dataKeysV1 := migrator.Table{ + Name: "data_keys", + Columns: []*migrator.Column{ + {Name: "name", Type: migrator.DB_NVarchar, Length: 50, IsPrimaryKey: true}, + {Name: "active", Type: migrator.DB_Bool}, + {Name: "provider", Type: migrator.DB_NVarchar, Length: 50, Nullable: true}, + {Name: "encrypted_data", Type: migrator.DB_Blob, Nullable: false}, + {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, + }, + Indices: []*migrator.Index{}, + } + + mg.AddMigration("create data keys table", migrator.NewAddTableMigration(dataKeysV1)) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 3e40c749f37..fec2bbb0f29 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -34,6 +34,7 @@ func AddMigrations(mg *Migrator) { addServerlockMigrations(mg) addUserAuthTokenMigrations(mg) addCacheMigration(mg) + addDataKeysMigrations(mg) } func addMigrationLogMigrations(mg *Migrator) {