diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go index 6ad41f79b53..169c5c6d15c 100644 --- a/pkg/api/app_routes.go +++ b/pkg/api/app_routes.go @@ -94,8 +94,15 @@ func NewApiPluginProxy(ctx *middleware.Context, proxyPath string, route *plugins ctx.JsonApiErr(500, "failed to get AppSettings.", err) return } - - err = t.Execute(&contentBuf, query.Result.JsonData) + type templateData struct { + JsonData map[string]interface{} + SecureJsonData map[string]string + } + data := templateData{ + JsonData: query.Result.JsonData, + SecureJsonData: query.Result.SecureJsonData.Decrypt(), + } + err = t.Execute(&contentBuf, data) if err != nil { ctx.JsonApiErr(500, fmt.Sprintf("failed to execute header content template for header %s.", header.Name), err) return diff --git a/pkg/models/app_settings.go b/pkg/models/app_settings.go index f3b60502cb0..78d4c483f2b 100644 --- a/pkg/models/app_settings.go +++ b/pkg/models/app_settings.go @@ -3,6 +3,9 @@ package models import ( "errors" "time" + + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) var ( @@ -10,25 +13,37 @@ var ( ) type AppSettings struct { - Id int64 - AppId string - OrgId int64 - Enabled bool - Pinned bool - JsonData map[string]interface{} + Id int64 + AppId string + OrgId int64 + Enabled bool + Pinned bool + JsonData map[string]interface{} + SecureJsonData SecureJsonData Created time.Time Updated time.Time } +type SecureJsonData map[string][]byte + +func (s SecureJsonData) Decrypt() map[string]string { + decrypted := make(map[string]string) + for key, data := range s { + decrypted[key] = string(util.Decrypt(data, setting.SecretKey)) + } + return decrypted +} + // ---------------------- // COMMANDS // Also acts as api DTO type UpdateAppSettingsCmd struct { - Enabled bool `json:"enabled"` - Pinned bool `json:"pinned"` - JsonData map[string]interface{} `json:"jsonData"` + Enabled bool `json:"enabled"` + Pinned bool `json:"pinned"` + JsonData map[string]interface{} `json:"jsonData"` + SecureJsonData map[string]string `json:"secureJsonData"` AppId string `json:"-"` OrgId int64 `json:"-"` diff --git a/pkg/services/sqlstore/app_settings.go b/pkg/services/sqlstore/app_settings.go index 7d9482e7b22..f454d2cc5ff 100644 --- a/pkg/services/sqlstore/app_settings.go +++ b/pkg/services/sqlstore/app_settings.go @@ -5,6 +5,8 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) func init() { @@ -40,18 +42,27 @@ func UpdateAppSettings(cmd *m.UpdateAppSettingsCmd) error { sess.UseBool("enabled") sess.UseBool("pinned") if !exists { + // encrypt secureJsonData + secureJsonData := make(map[string][]byte) + for key, data := range cmd.SecureJsonData { + secureJsonData[key] = util.Encrypt([]byte(data), setting.SecretKey) + } app = m.AppSettings{ - AppId: cmd.AppId, - OrgId: cmd.OrgId, - Enabled: cmd.Enabled, - Pinned: cmd.Pinned, - JsonData: cmd.JsonData, - Created: time.Now(), - Updated: time.Now(), + AppId: cmd.AppId, + OrgId: cmd.OrgId, + Enabled: cmd.Enabled, + Pinned: cmd.Pinned, + JsonData: cmd.JsonData, + SecureJsonData: secureJsonData, + Created: time.Now(), + Updated: time.Now(), } _, err = sess.Insert(&app) return err } else { + for key, data := range cmd.SecureJsonData { + app.SecureJsonData[key] = util.Encrypt([]byte(data), setting.SecretKey) + } app.Updated = time.Now() app.Enabled = cmd.Enabled app.JsonData = cmd.JsonData diff --git a/pkg/services/sqlstore/migrations/app_settings.go b/pkg/services/sqlstore/migrations/app_settings.go index 437debbe95b..885dbbf9f05 100644 --- a/pkg/services/sqlstore/migrations/app_settings.go +++ b/pkg/services/sqlstore/migrations/app_settings.go @@ -4,7 +4,7 @@ import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" func addAppSettingsMigration(mg *Migrator) { - appSettingsV1 := Table{ + appSettingsV2 := Table{ Name: "app_settings", Columns: []*Column{ {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, @@ -13,6 +13,7 @@ func addAppSettingsMigration(mg *Migrator) { {Name: "enabled", Type: DB_Bool, Nullable: false}, {Name: "pinned", Type: DB_Bool, Nullable: false}, {Name: "json_data", Type: DB_Text, Nullable: true}, + {Name: "secure_json_data", Type: DB_Text, Nullable: true}, {Name: "created", Type: DB_DateTime, Nullable: false}, {Name: "updated", Type: DB_DateTime, Nullable: false}, }, @@ -21,8 +22,10 @@ func addAppSettingsMigration(mg *Migrator) { }, } - mg.AddMigration("create app_settings table v1", NewAddTableMigration(appSettingsV1)) + mg.AddMigration("Drop old table app_settings v1", NewDropTableMigration("app_settings")) + + mg.AddMigration("create app_settings table v2", NewAddTableMigration(appSettingsV2)) //------- indexes ------------------ - addTableIndicesMigrations(mg, "v3", appSettingsV1) + addTableIndicesMigrations(mg, "v3", appSettingsV2) } diff --git a/pkg/util/encryption.go b/pkg/util/encryption.go new file mode 100644 index 00000000000..42586ddac8b --- /dev/null +++ b/pkg/util/encryption.go @@ -0,0 +1,66 @@ +package util + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "io" + + "github.com/grafana/grafana/pkg/log" +) + +const saltLength = 8 + +func Decrypt(payload []byte, secret string) []byte { + salt := payload[:saltLength] + key := encryptionKeyToBytes(secret, string(salt)) + + block, err := aes.NewCipher(key) + if err != nil { + log.Fatal(4, err.Error()) + } + + // 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 { + log.Fatal(4, "payload too short") + } + iv := payload[saltLength : saltLength+aes.BlockSize] + payload = payload[saltLength+aes.BlockSize:] + + stream := cipher.NewCFBDecrypter(block, iv) + + // XORKeyStream can work in-place if the two arguments are the same. + stream.XORKeyStream(payload, payload) + return payload +} + +func Encrypt(payload []byte, secret string) []byte { + salt := GetRandomString(saltLength) + + key := encryptionKeyToBytes(secret, salt) + block, err := aes.NewCipher(key) + if err != nil { + log.Fatal(4, err.Error()) + } + + // 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], []byte(salt)) + iv := ciphertext[saltLength : saltLength+aes.BlockSize] + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + log.Fatal(4, err.Error()) + } + + stream := cipher.NewCFBEncrypter(block, iv) + stream.XORKeyStream(ciphertext[saltLength+aes.BlockSize:], payload) + + return ciphertext +} + +// Key needs to be 32bytes +func encryptionKeyToBytes(secret, salt string) []byte { + return PBKDF2([]byte(secret), []byte(salt), 10000, 32, sha256.New) +} diff --git a/pkg/util/encryption_test.go b/pkg/util/encryption_test.go new file mode 100644 index 00000000000..5f1dc18fea3 --- /dev/null +++ b/pkg/util/encryption_test.go @@ -0,0 +1,27 @@ +package util + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestEncryption(t *testing.T) { + + Convey("When getting encryption key", t, func() { + + key := encryptionKeyToBytes("secret", "salt") + So(len(key), ShouldEqual, 32) + + key = encryptionKeyToBytes("a very long secret key that is larger then 32bytes", "salt") + So(len(key), ShouldEqual, 32) + }) + + Convey("When decrypting basic payload", t, func() { + encrypted := Encrypt([]byte("grafana"), "1234") + decrypted := Decrypt(encrypted, "1234") + + So(string(decrypted), ShouldEqual, "grafana") + }) + +} diff --git a/public/app/features/apps/edit_ctrl.ts b/public/app/features/apps/edit_ctrl.ts index dfbce64df5e..ccdaf529b5e 100644 --- a/public/app/features/apps/edit_ctrl.ts +++ b/public/app/features/apps/edit_ctrl.ts @@ -24,6 +24,7 @@ export class AppEditCtrl { enabled: this.appModel.enabled, pinned: this.appModel.pinned, jsonData: this.appModel.jsonData, + secureJsonData: this.appModel.secureJsonData, }, options); this.backendSrv.post(`/api/org/apps/${this.$routeParams.appId}/settings`, updateCmd).then(function() {