From 5f810aba2ea036934b43c57a922b307afdaeff2f Mon Sep 17 00:00:00 2001 From: Sergio Rubio Date: Fri, 1 May 2020 21:00:56 +0200 Subject: [PATCH] Encrypted configuration backend (#295) --- cfg/aesbackend.go | 234 +++++++++++++++++++++++++++++++ cfg/aesbackend_test.go | 122 ++++++++++++++++ cfg/config.go | 15 +- cfg/config_test.go | 9 ++ cfg/testdata/beehive-crypto.conf | Bin 0 -> 235 bytes docs/config_encryption.md | 51 +++++++ go.mod | 2 +- tools/encrypted-config-wrapper | 17 +++ 8 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 cfg/aesbackend.go create mode 100644 cfg/aesbackend_test.go create mode 100644 cfg/testdata/beehive-crypto.conf create mode 100644 docs/config_encryption.md create mode 100755 tools/encrypted-config-wrapper diff --git a/cfg/aesbackend.go b/cfg/aesbackend.go new file mode 100644 index 00000000..687dad0b --- /dev/null +++ b/cfg/aesbackend.go @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2020 Sergio Rubio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Authors: + * Sergio Rubio + */ + +package cfg + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/json" + "errors" + "io/ioutil" + "net/url" + "os" + "path/filepath" + + "golang.org/x/crypto/scrypt" +) + +// PasswordEnvVar defines the environment variable name that should +// contain the configuration password. +const PasswordEnvVar = "BEEHIVE_CONFIG_PASSWORD" + +// EncryptedHeaderPrefix is added to the encrypted configuration +// to make it possible to detect it's an encrypted configuration file +const EncryptedHeaderPrefix = "beehiveconf+" + +// AESBackend symmetrically encrypts the configuration file using AES-GCM +type AESBackend struct{} + +// NewAESBackend creates the backend. +// +// Given the password is required to encrypt/decrypt the configuration, if the +// URL passed doesn't have a password or PasswordEnvVar is not defined, +// it'll return an error. +func NewAESBackend(u *url.URL) (*AESBackend, error) { + if _, err := getPassword(u); err != nil { + return nil, err + } + + return &AESBackend{}, nil +} + +// IsEncrypted returns true and no error if the configuration is encrypted +// +// If the error returned is not nil, an error was returned while opening or +// reading the file. +func IsEncrypted(u *url.URL) (bool, error) { + f, err := os.Open(u.Path) + if err != nil { + return false, err + } + defer f.Close() + + b := make([]byte, 12) + _, err = f.Read(b) + if err != nil { + return false, err + } + + if string(b) != EncryptedHeaderPrefix { + return false, nil + } + + return true, nil +} + +// Load configuration file from the given URL and decrypt it +func (b *AESBackend) Load(u *url.URL) (*Config, error) { + config := &Config{url: u} + + if !exist(u.Path) { + return config, nil + } + + ciphertext, err := ioutil.ReadFile(u.Path) + if err != nil { + return nil, err + } + ftype := ciphertext[0:12] + if string(ftype) != EncryptedHeaderPrefix { + return nil, errors.New("encrypted configuration header not valid") + } + + p, err := getPassword(u) + if err != nil { + return nil, err + } + + plaintext, err := decrypt(ciphertext[12:], []byte(p)) + if err != nil { + return nil, err + } + + err = json.Unmarshal(plaintext, config) + if err != nil { + return nil, err + } + + config.backend = b + config.url = u + + return config, nil + +} + +// Save encrypts then saves the configuration +func (b *AESBackend) Save(config *Config) error { + u := config.URL() + cfgDir := filepath.Dir(u.Path) + if !exist(cfgDir) { + os.MkdirAll(cfgDir, 0755) + } + + j, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + p, err := getPassword(config.URL()) + if err != nil { + return err + } + + ciphertext, err := encrypt(j, []byte(p)) + if err != nil { + return err + } + + marked := []byte(EncryptedHeaderPrefix) + err = ioutil.WriteFile(u.Path, append(marked, ciphertext...), 0644) + + return err +} + +func encrypt(data, key []byte) ([]byte, error) { + key, salt, err := deriveKey(key, nil) + if err != nil { + return nil, err + } + + blockCipher, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(blockCipher) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = rand.Read(nonce); err != nil { + return nil, err + } + + ciphertext := gcm.Seal(nonce, nonce, data, nil) + ciphertext = append(ciphertext, salt...) + + return ciphertext, nil +} + +func decrypt(data, key []byte) ([]byte, error) { + salt, data := data[len(data)-32:], data[:len(data)-32] + key, _, err := deriveKey(key, salt) + if err != nil { + return nil, err + } + + blockCipher, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(blockCipher) + if err != nil { + return nil, err + } + + nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + + return plaintext, nil +} + +func deriveKey(password, salt []byte) ([]byte, []byte, error) { + if salt == nil { + salt = make([]byte, 32) + if _, err := rand.Read(salt); err != nil { + return nil, nil, err + } + } + + key, err := scrypt.Key(password, salt, 32768, 8, 1, 32) + if err != nil { + return nil, nil, err + } + + return key, salt, nil +} + +func getPassword(u *url.URL) (string, error) { + p := os.Getenv(PasswordEnvVar) + if p != "" { + return p, nil + } + + p = u.User.Username() + if p != "" { + return p, nil + } + + return "", errors.New("password to encrypt or decrypt the config file not available") +} diff --git a/cfg/aesbackend_test.go b/cfg/aesbackend_test.go new file mode 100644 index 00000000..90cf6457 --- /dev/null +++ b/cfg/aesbackend_test.go @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2020 Sergio Rubio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * Authors: + * Sergio Rubio + */ + +package cfg + +import ( + "io/ioutil" + "net/url" + "os" + "path/filepath" + "testing" +) + +const testPassword = "foo" + +func TestAESBackendLoad(t *testing.T) { + u, _ := url.Parse("crypt://foo@foobar") + backend, err := NewAESBackend(u) + if err != nil { + t.Error("The backend should return an error if no password specified") + } + + _, err = backend.Load(u) + if err != nil { + t.Error("Loading an non-existing config file should not return an error") + } + + // try to load the config from an absolute path using a URI + cwd, _ := os.Getwd() + u, err = url.Parse("crypto://" + testPassword + "@" + filepath.Join(cwd, "testdata", "beehive-crypto.conf")) + backend, err = NewAESBackend(u) + conf, err := backend.Load(u) + if err != nil { + t.Errorf("Error loading config file fixture from absolute path %s. %v", u, err) + } + if conf.Bees[0].Name != "echo" { + t.Error("The first bee should be an exec bee named echo") + } + + // try to load the config using the password from the environment + os.Setenv(PasswordEnvVar, testPassword) + u, err = url.Parse("crypto://" + filepath.Join(cwd, "testdata", "beehive-crypto.conf")) + backend, err = NewAESBackend(u) + conf, err = backend.Load(u) + if err != nil { + t.Errorf("loading the config file using the environment password should work. %v", err) + } + + // try to load the config with an invalid password + os.Setenv(PasswordEnvVar, "") + u, err = url.Parse("crypto://bar@" + filepath.Join(cwd, "testdata", "beehive-crypto.conf")) + backend, err = NewAESBackend(u) + conf, err = backend.Load(u) + if err == nil || err.Error() != "cipher: message authentication failed" { + t.Errorf("loading the config file with an invalid password should fail. %v", err) + } + + // environment password takes prececence + os.Setenv(PasswordEnvVar, testPassword) + u, _ = url.Parse("crypto://bar@" + filepath.Join(cwd, "testdata", "beehive-crypto.conf")) + backend, _ = NewAESBackend(u) + conf, err = backend.Load(u) + if err != nil { + t.Errorf("the password defined in %s should take precedence. %v", PasswordEnvVar, err) + } + + u, _ = url.Parse("crypto://" + testPassword + "@" + filepath.Join(cwd, "testdata", "beehive.conf")) + conf, err = backend.Load(u) + if err == nil || err.Error() != "encrypted configuration header not valid" { + t.Errorf("the password defined in %s should take precedence. %v", PasswordEnvVar, err) + } +} + +func TestAESBackendSave(t *testing.T) { + tmpdir, err := ioutil.TempDir("", "beehivetest") + if err != nil { + t.Error("Could not create temp directory") + } + + cwd, _ := os.Getwd() + u, err := url.Parse(filepath.Join("crypto://"+testPassword+"@", cwd, "testdata", "beehive-crypto.conf")) + backend, _ := NewAESBackend(u) + c, err := backend.Load(u) + if err != nil { + t.Errorf("Failed to load config fixture from relative path %s: %v", u, err) + } + + // Save the config file to a new absolute path using a URL + p := filepath.Join(tmpdir, "beehive-crypto.conf") + u, err = url.Parse("crypto://" + testPassword + "@" + p) + c.SetURL(u.String()) + backend, _ = NewAESBackend(u) + err = backend.Save(c) + if err != nil { + t.Errorf("cailed to save the config to %s", u) + } + if !exist(p) { + t.Errorf("configuration file wasn't saved to %s", p) + } + + ok, err := IsEncrypted(u) + if !ok { + t.Errorf("encrypted config header not added. %v", err) + } +} diff --git a/cfg/config.go b/cfg/config.go index e8cf0121..4ac94427 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -104,9 +104,22 @@ func New(url string) (*Config, error) { switch config.url.Scheme { case "", "file": - backend = NewFileBackend() + if ok, _ := IsEncrypted(config.url); ok { + log.Debugf("Loading encrypted configuration file") + backend, err = NewAESBackend(config.url) + if err != nil { + log.Fatalf("error loading the AES configuration backend. err: %v", err) + } + } else { + backend = NewFileBackend() + } case "mem": backend = NewMemBackend() + case "crypto": + backend, err = NewAESBackend(config.url) + if err != nil { + log.Fatalf("error loading the AES configuration backend. err: %v", err) + } default: return nil, fmt.Errorf("Configuration backend '%s' not supported", config.url.Scheme) } diff --git a/cfg/config_test.go b/cfg/config_test.go index 061873a0..944a9d05 100644 --- a/cfg/config_test.go +++ b/cfg/config_test.go @@ -1,6 +1,8 @@ package cfg import ( + "os" + "path/filepath" "testing" ) @@ -21,6 +23,13 @@ func TestNew(t *testing.T) { t.Error("Backend for 'file:///foobar' should be a FileBackend") } + cwd, _ := os.Getwd() + p := filepath.Join(cwd, "testdata/beehive-crypto.conf") + conf, err = New(p) + if _, ok := conf.Backend().(*AESBackend); !ok { + t.Errorf("Backend for '%s' should be an AESBackend", p) + } + conf, err = New("mem:") if err != nil { panic(err) diff --git a/cfg/testdata/beehive-crypto.conf b/cfg/testdata/beehive-crypto.conf new file mode 100644 index 0000000000000000000000000000000000000000..42fae2eefed72eee698ca3970a46d6dc803b0761 GIT binary patch literal 235 zcmV&2 + echo "Add it using 'secret-tool store --label "Beehive configuration password" /beehive/secrets/config password'" >&2 + exit 1 +fi +beehive --config crypto://$HOME/.config/beehive/beehive.conf