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 00000000..42fae2ee Binary files /dev/null and b/cfg/testdata/beehive-crypto.conf differ diff --git a/docs/config_encryption.md b/docs/config_encryption.md new file mode 100644 index 00000000..68165e1c --- /dev/null +++ b/docs/config_encryption.md @@ -0,0 +1,51 @@ +# Configuration Encryption + +Beehive's supports encrypting the configuration file using AES+GCM. + +## Usage + +To encrypt the configuration for the first time, simply start Beehive using a `crypto` URL for the configuration: + +``` +./beehive --config crypto://mysecret@$HOME/.config/beehive/beehive.conf` +``` + +You could also use the `BEEHIVE_CONFIG_PASSWORD` environment variable to define the password: + +``` +BEEHIVE_CONFIG_PASSWORD=mysecret ./beehive --config crypto://$HOME/.config/beehive/beehive.conf` +``` + +This will use the key `mysecret` to encrypt/decrypt the configuration file. + +Once the configuration has been encrypted, it's no longer necessary to use a `crypto:` URL, Beehive will automatically detect it's encrypted. +That is, something like: + +``` +BEEHIVE_CONFIG_PASSWORD=mysecret beehive --config /path/to/config +``` + +Will happily detect and load an encrypted configuration file. + +## Using user keyrings to store the password + +A sample wrapper script (Linux only) is provided in [tools/encrypted-config-wrapper] that will read the configuration password from the sessions's keyring. + +Something similar could be written to do it on macOS using Keychain and its `security(1)` CLI. + +## Troubleshooting + +``` +FATA[0000] Error loading user config file /home/rubiojr/.config/beehive/beehive.conf. err: cipher: message authentication failed +``` + +Means the password used to decrypt the configuration file is not valid. + +## Notes + +The encrypted configuration file includes a 12 bytes header (`beehiveconf+`) that makes it possible to identify the file as an encrypted configuration file: + +``` +head -c 12 beehive-encrypted.conf +beehiveconf+ +``` diff --git a/go.mod b/go.mod index a03e5e6b..801bc600 100644 --- a/go.mod +++ b/go.mod @@ -93,7 +93,7 @@ require ( github.com/technoweenie/multipartstreamer v1.0.1 // indirect github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect github.com/xrash/smetrics v0.0.0-20170218160415-a3153f7040e9 // indirect - golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734 // indirect + golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734 golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a golang.org/x/text v0.3.2 // indirect google.golang.org/appengine v1.5.0 // indirect diff --git a/tools/encrypted-config-wrapper b/tools/encrypted-config-wrapper new file mode 100755 index 00000000..6f3e16ce --- /dev/null +++ b/tools/encrypted-config-wrapper @@ -0,0 +1,17 @@ +#!/bin/bash +# Beehive shell wrapper that reads configuration password from the keyring. +# Store the configuration password using secret-tool: +# +# secret-tool store --label "Beehive configuration password" /beehive/secrets/config password +# +# Linux only. +# +set -e + +export BEEHIVE_CONFIG_PASSWORD=$(secret-tool lookup /beehive/secrets/config password) +if [ -z "$BEEHIVE_CONFIG_PASSWORD" ]; then + echo "Beehive's config password not found in keyring." >&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