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