Skip to content

Commit

Permalink
Encrypted configuration backend (#295)
Browse files Browse the repository at this point in the history
  • Loading branch information
rubiojr committed May 1, 2020
1 parent 59e5fc1 commit 5f810ab
Show file tree
Hide file tree
Showing 8 changed files with 448 additions and 2 deletions.
234 changes: 234 additions & 0 deletions cfg/aesbackend.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*
* Authors:
* Sergio Rubio <sergio@rubio.im>
*/

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")
}
122 changes: 122 additions & 0 deletions cfg/aesbackend_test.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*
* Authors:
* Sergio Rubio <sergio@rubio.im>
*/

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)
}
}
15 changes: 14 additions & 1 deletion cfg/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down

0 comments on commit 5f810ab

Please sign in to comment.