1
mirror of https://github.com/rclone/rclone synced 2024-11-27 05:23:40 +01:00

Add configuration file encryption

See #317 for details.

Use `rclone config` to add/change/remove password.

Tests that loads the default configuration will now fail with a better error message, and add a switch that makes it possible to disable password prompts and fail instead.

Make it possible to use the "RCLONE_CONFIG_PASS" environment variable as password for configuration.
This commit is contained in:
klauspost 2016-02-16 16:25:27 +01:00
parent 4676a89963
commit bfd7601cf9
10 changed files with 506 additions and 12 deletions

View File

@ -436,6 +436,72 @@ Very useful for debugging.
Prints the version number Prints the version number
Configuration Encryption
------------------------
Your configuration file contains information for logging in to
your cloud services. This means that you should keep your
`.rclone.conf` file in a secure location.
If you are in an environment where that isn't possible, you can
add a password to your configuration. This means that you will
have to enter the password every time you start rclone.
To add a password to your rclone configuration, execute `rclone config`.
```
>rclone config
Current remotes:
e) Edit existing remote
n) New remote
d) Delete remote
s) Set configuration password
q) Quit config
e/n/d/s/q>
```
Go into `s`, Set configuration password:
```
e/n/d/s/q> s
Your configuration is not encrypted.
If you add a password, you will protect your login information to cloud services.
a) Add Password
q) Quit to main menu
a/q> a
Enter NEW configuration password:
password>
Confirm NEW password:
password>
Password set
Your configuration is encrypted.
c) Change Password
u) Unencrypt configuration
q) Quit to main menu
c/u/q>
```
Your configuration is now encrypted, and every time you start rclone
you will now be asked for the password. In the same menu you can
change the password or completely remove encryption from your
configuration.
There is no way to recover the configuration if you lose your password.
rclone uses [nacl secretbox](https://godoc.org/golang.org/x/crypto/nacl/secretbox)
which in term uses XSalsa20 and Poly1305 to encrypt and authenticate
your configuration with secret-key cryptography.
The password is SHA-256 hashed, which produces the key for secretbox.
The hashed password is not stored.
While this provides very good security, we do not recommend storing
your encrypted rclone configuration in public, if it contains sensitive
information, maybe except if you use a very strong password.
If it is safe in your environment, you can set the `RCLONE_CONFIG_PASS`
environment variable to contain your password, in which case it will be
used for decrypting the configuration.
Developer options Developer options
----------------- -----------------

View File

@ -4,8 +4,14 @@ package fs
import ( import (
"bufio" "bufio"
"bytes"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io"
"io/ioutil"
"log" "log"
"math" "math"
"net/http" "net/http"
@ -16,12 +22,14 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"crypto/tls" "github.com/klauspost/goconfig"
"github.com/Unknwon/goconfig"
"github.com/mreiferson/go-httpclient" "github.com/mreiferson/go-httpclient"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"golang.org/x/crypto/nacl/secretbox"
"golang.org/x/crypto/ssh/terminal"
"golang.org/x/text/unicode/norm"
) )
const ( const (
@ -69,10 +77,15 @@ var (
dumpHeaders = pflag.BoolP("dump-headers", "", false, "Dump HTTP headers - may contain sensitive info") dumpHeaders = pflag.BoolP("dump-headers", "", false, "Dump HTTP headers - may contain sensitive info")
dumpBodies = pflag.BoolP("dump-bodies", "", false, "Dump HTTP headers and bodies - may contain sensitive info") dumpBodies = pflag.BoolP("dump-bodies", "", false, "Dump HTTP headers and bodies - may contain sensitive info")
skipVerify = pflag.BoolP("no-check-certificate", "", false, "Do not verify the server SSL certificate. Insecure.") skipVerify = pflag.BoolP("no-check-certificate", "", false, "Do not verify the server SSL certificate. Insecure.")
AskPassword = pflag.BoolP("ask-password", "", true, "Allow prompt for password for encrypted configuration.")
deleteBefore = pflag.BoolP("delete-before", "", false, "When synchronizing, delete files on destination before transfering") deleteBefore = pflag.BoolP("delete-before", "", false, "When synchronizing, delete files on destination before transfering")
deleteDuring = pflag.BoolP("delete-during", "", false, "When synchronizing, delete files during transfer (default)") deleteDuring = pflag.BoolP("delete-during", "", false, "When synchronizing, delete files during transfer (default)")
deleteAfter = pflag.BoolP("delete-after", "", false, "When synchronizing, delete files on destination after transfering") deleteAfter = pflag.BoolP("delete-after", "", false, "When synchronizing, delete files on destination after transfering")
bwLimit SizeSuffix bwLimit SizeSuffix
// Key to use for password en/decryption.
// When nil, no encryption will be used for saving.
configKey []byte
) )
func init() { func init() {
@ -292,13 +305,9 @@ func LoadConfig() {
// Load configuration file. // Load configuration file.
var err error var err error
ConfigFile, err = goconfig.LoadConfigFile(ConfigPath) ConfigFile, err = loadConfigFile()
if err != nil { if err != nil {
log.Printf("Failed to load config file %v - using defaults: %v", ConfigPath, err) log.Fatalf("Failed to config file \"%s\": %v", ConfigPath, err)
ConfigFile, err = goconfig.LoadConfigFile(os.DevNull)
if err != nil {
log.Fatalf("Failed to read null config file: %v", err)
}
} }
// Load filters // Load filters
@ -311,8 +320,135 @@ func LoadConfig() {
startTokenBucket() startTokenBucket()
} }
// loadConfigFile will load a config file, and
// automatically decrypt it.
func loadConfigFile() (*goconfig.ConfigFile, error) {
b, err := ioutil.ReadFile(ConfigPath)
if err != nil {
log.Printf("Failed to load config file %v - using defaults: %v", ConfigPath, err)
return goconfig.LoadFromData(nil)
}
// Find first non-empty line
r := bufio.NewReader(bytes.NewBuffer(b))
for {
line, _, err := r.ReadLine()
if err != nil {
if err == io.EOF {
return goconfig.LoadFromData(b)
}
return nil, err
}
l := strings.TrimSpace(string(line))
if len(l) == 0 || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "#") {
continue
}
// First non-empty or non-comment must be ENCRYPT_V0
if l == "RCLONE_ENCRYPT_V0:" {
break
}
if strings.HasPrefix(l, "RCLONE_ENCRYPT_V") {
return nil, fmt.Errorf("Unsupported configuration encryption. Update rclone for support.")
}
return goconfig.LoadFromData(b)
}
// Encrypted content is base64 encoded.
dec := base64.NewDecoder(base64.StdEncoding, r)
box, err := ioutil.ReadAll(dec)
if err != nil {
return nil, fmt.Errorf("Failed to load base64 encoded data: %v", err)
}
if len(box) < 24+secretbox.Overhead {
return nil, fmt.Errorf("Configuration data too short")
}
envpw := os.Getenv("RCLONE_CONFIG_PASS")
var out []byte
for {
if len(configKey) == 0 && envpw != "" {
err := setPassword(envpw)
if err != nil {
fmt.Println("Using RCLONE_CONFIG_PASS returned:", err)
envpw = ""
} else {
Debug(nil, "Using RCLONE_CONFIG_PASS password.")
}
}
if len(configKey) == 0 {
if !*AskPassword {
return nil, fmt.Errorf("Unable to decrypt configuration and not allowed to ask for password. Set RCLONE_CONFIG_PASS to your configuration password.")
}
getPassword("Enter configuration password:")
}
// Nonce is first 24 bytes of the ciphertext
var nonce [24]byte
copy(nonce[:], box[:24])
var key [32]byte
copy(key[:], configKey[:32])
// Attempt to decrypt
var ok bool
out, ok = secretbox.Open(nil, box[24:], &nonce, &key)
if ok {
break
}
// Retry
log.Println("Couldn't decrypt configuration, most likely wrong password.")
configKey = nil
envpw = ""
}
return goconfig.LoadFromData(out)
}
// getPassword will query the user for a password the
// first time it is required.
func getPassword(q string) {
if len(configKey) != 0 {
return
}
for {
fmt.Println(q)
fmt.Print("password>")
err := setPassword(ReadPassword())
if err == nil {
return
}
fmt.Println("Error:", err)
}
}
// setPassword will set the configKey to the hash of
// the password. If the length of the password is
// zero after trimming+normalization, an error is returned.
func setPassword(password string) error {
if !utf8.ValidString(password) {
return fmt.Errorf("Password contains invalid utf8 characters")
}
// Remove leading+trailing whitespace
password = strings.TrimSpace(password)
// Normalize to reduce weird variations.
password = norm.NFKC.String(password)
if len(password) == 0 {
return fmt.Errorf("No characters in password")
}
// Create SHA256 has of the password
sha := sha256.New()
_, err := sha.Write([]byte("[" + password + "][rclone-config]"))
if err != nil {
return err
}
configKey = sha.Sum(nil)
return nil
}
// SaveConfig saves configuration file. // SaveConfig saves configuration file.
// if configKey has been set, the file will be encrypted.
func SaveConfig() { func SaveConfig() {
if len(configKey) == 0 {
err := goconfig.SaveConfigFile(ConfigFile, ConfigPath) err := goconfig.SaveConfigFile(ConfigFile, ConfigPath)
if err != nil { if err != nil {
log.Fatalf("Failed to save config file: %v", err) log.Fatalf("Failed to save config file: %v", err)
@ -321,6 +457,53 @@ func SaveConfig() {
if err != nil { if err != nil {
log.Printf("Failed to set permissions on config file: %v", err) log.Printf("Failed to set permissions on config file: %v", err)
} }
return
}
var buf bytes.Buffer
err := goconfig.SaveConfigData(ConfigFile, &buf)
if err != nil {
log.Fatalf("Failed to save config file: %v", err)
}
f, err := os.Create(ConfigPath)
if err != nil {
log.Fatalf("Failed to save config file: %v", err)
}
fmt.Fprintln(f, "# Encrypted rclone configuration File")
fmt.Fprintln(f, "")
fmt.Fprintln(f, "RCLONE_ENCRYPT_V0:")
// Generate new nonce and write it to the start of the ciphertext
var nonce [24]byte
n, _ := rand.Read(nonce[:])
if n != 24 {
log.Fatalf("nonce short read: %d", n)
}
enc := base64.NewEncoder(base64.StdEncoding, f)
_, err = enc.Write(nonce[:])
if err != nil {
log.Fatalf("Failed to write config file: %v", err)
}
var key [32]byte
copy(key[:], configKey[:32])
b := secretbox.Seal(nil, buf.Bytes(), &nonce, &key)
_, err = enc.Write(b)
if err != nil {
log.Fatalf("Failed to write config file: %v", err)
}
_ = enc.Close()
err = f.Close()
if err != nil {
log.Fatalf("Failed to close config file: %v", err)
}
err = os.Chmod(ConfigPath, 0600)
if err != nil {
log.Printf("Failed to set permissions on config file: %v", err)
}
} }
// ShowRemotes shows an overview of the config file // ShowRemotes shows an overview of the config file
@ -354,6 +537,17 @@ func ReadLine() string {
return strings.TrimSpace(line) return strings.TrimSpace(line)
} }
// ReadPassword reads a password
// without echoing it to the terminal.
func ReadPassword() string {
line, err := terminal.ReadPassword(int(os.Stdin.Fd()))
fmt.Println("")
if err != nil {
log.Fatalf("Failed to read password: %v", err)
}
return strings.TrimSpace(string(line))
}
// Command - choose one // Command - choose one
func Command(commands []string) byte { func Command(commands []string) byte {
opts := []string{} opts := []string{}
@ -547,7 +741,7 @@ func DeleteRemote(name string) {
func EditConfig() { func EditConfig() {
for { for {
haveRemotes := len(ConfigFile.GetSectionList()) != 0 haveRemotes := len(ConfigFile.GetSectionList()) != 0
what := []string{"eEdit existing remote", "nNew remote", "dDelete remote", "qQuit config"} what := []string{"eEdit existing remote", "nNew remote", "dDelete remote", "sSet configuration password", "qQuit config"}
if haveRemotes { if haveRemotes {
fmt.Printf("Current remotes:\n\n") fmt.Printf("Current remotes:\n\n")
ShowRemotes() ShowRemotes()
@ -581,9 +775,69 @@ func EditConfig() {
case 'd': case 'd':
name := ChooseRemote() name := ChooseRemote()
DeleteRemote(name) DeleteRemote(name)
case 's':
SetPassword()
case 'q':
return
}
}
}
// SetPassword will allow the user to modify the current
// configuration encryption settings.
func SetPassword() {
for {
if len(configKey) > 0 {
fmt.Println("Your configuration is encrypted.")
what := []string{"cChange Password", "uUnencrypt configuration", "qQuit to main menu"}
switch i := Command(what); i {
case 'c':
changePassword()
SaveConfig()
fmt.Println("Password changed")
continue
case 'u':
configKey = nil
SaveConfig()
continue
case 'q': case 'q':
return return
} }
} else {
fmt.Println("Your configuration is not encrypted.")
fmt.Println("If you add a password, you will protect your login information to cloud services.")
what := []string{"aAdd Password", "qQuit to main menu"}
switch i := Command(what); i {
case 'a':
changePassword()
SaveConfig()
fmt.Println("Password set")
continue
case 'q':
return
}
}
}
}
// changePassword will query the user twice
// for a password. If the same password is entered
// twice the key is updated.
func changePassword() {
for {
configKey = nil
getPassword("Enter NEW configuration password:")
a := configKey
// re-enter password
configKey = nil
getPassword("Confirm NEW password:")
b := configKey
if bytes.Equal(a, b) {
return
}
fmt.Println("Passwords does not match!")
} }
} }

View File

@ -1,6 +1,10 @@
package fs package fs
import "testing" import (
"bytes"
"reflect"
"testing"
)
func TestSizeSuffixString(t *testing.T) { func TestSizeSuffixString(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
@ -73,3 +77,136 @@ func TestReveal(t *testing.T) {
} }
} }
} }
func TestConfigLoad(t *testing.T) {
ConfigPath = "./testdata/plain.conf"
configKey = nil
c, err := loadConfigFile()
if err != nil {
t.Fatal(err)
}
sections := c.GetSectionList()
var expect = []string{"RCLONE_ENCRYPT_V0", "nounc", "unc"}
if !reflect.DeepEqual(sections, expect) {
t.Fatalf("%v != %v", sections, expect)
}
keys := c.GetKeyList("nounc")
expect = []string{"type", "nounc"}
if !reflect.DeepEqual(keys, expect) {
t.Fatalf("%v != %v", keys, expect)
}
}
func TestConfigLoadEncrypted(t *testing.T) {
var err error
ConfigPath = "./testdata/encrypted.conf"
// Set correct password
err = setPassword("asdf")
if err != nil {
t.Fatal(err)
}
c, err := loadConfigFile()
if err != nil {
t.Fatal(err)
}
sections := c.GetSectionList()
var expect = []string{"nounc", "unc"}
if !reflect.DeepEqual(sections, expect) {
t.Fatalf("%v != %v", sections, expect)
}
keys := c.GetKeyList("nounc")
expect = []string{"type", "nounc"}
if !reflect.DeepEqual(keys, expect) {
t.Fatalf("%v != %v", keys, expect)
}
}
func TestConfigLoadEncryptedFailures(t *testing.T) {
var err error
// This file should be too short to be decoded.
ConfigPath = "./testdata/enc-short.conf"
_, err = loadConfigFile()
if err == nil {
t.Fatal("expected error")
}
t.Log("Correctly got:", err)
// This file contains invalid base64 characters.
ConfigPath = "./testdata/enc-invalid.conf"
_, err = loadConfigFile()
if err == nil {
t.Fatal("expected error")
}
t.Log("Correctly got:", err)
// This file contains invalid base64 characters.
ConfigPath = "./testdata/enc-too-new.conf"
_, err = loadConfigFile()
if err == nil {
t.Fatal("expected error")
}
t.Log("Correctly got:", err)
// This file contains invalid base64 characters.
ConfigPath = "./testdata/filenotfound.conf"
c, err := loadConfigFile()
if err != nil {
t.Fatal(err)
}
if len(c.GetSectionList()) != 0 {
t.Fatalf("Expected 0-length section, got %d entries", len(c.GetSectionList()))
}
}
func TestPassword(t *testing.T) {
var err error
// Empty password should give error
err = setPassword(" \t ")
if err == nil {
t.Fatal("expected error")
}
// Test invalid utf8 sequence
err = setPassword(string([]byte{0xff, 0xfe, 0xfd}) + "abc")
if err == nil {
t.Fatal("expected error")
}
// Simple check of wrong passwords
hashedKeyCompare(t, "mis", "match", false)
// Check that passwords match with trimmed whitespace
hashedKeyCompare(t, " abcdef \t", "abcdef", true)
// Check that passwords match after unicode normalization
hashedKeyCompare(t, "ff\u0041\u030A", "ffÅ", true)
// Check that passwords preserves case
hashedKeyCompare(t, "abcdef", "ABCDEF", false)
}
func hashedKeyCompare(t *testing.T, a, b string, shouldMatch bool) {
err := setPassword(a)
if err != nil {
t.Fatal(err)
}
k1 := configKey
err = setPassword(b)
if err != nil {
t.Fatal(err)
}
k2 := configKey
matches := bytes.Equal(k1, k2)
if shouldMatch && !matches {
t.Fatalf("%v != %v", k1, k2)
}
if !shouldMatch && matches {
t.Fatalf("%v == %v", k1, k2)
}
}

View File

@ -94,6 +94,10 @@ func newRun() *Run {
mkdir: make(map[string]bool), mkdir: make(map[string]bool),
} }
// Never ask for passwords, fail instead.
// If your local config is encrypted set environment variable
// "RCLONE_CONFIG_PASS=hunter2" (or your password)
*fs.AskPassword = false
fs.LoadConfig() fs.LoadConfig()
fs.Config.Verbose = *Verbose fs.Config.Verbose = *Verbose
fs.Config.Quiet = !*Verbose fs.Config.Quiet = !*Verbose

4
fs/testdata/enc-invalid.conf vendored Normal file
View File

@ -0,0 +1,4 @@
# Encrypted rclone configuration File
RCLONE_ENCRYPT_V0:
b5Uk6mE3cUn5Wb8xiWYnVBAxXUirAaEG1PO/GIDiO9274AOæøå+Yj790BwJA4d2y7lNkmHt4nJwIsoueFvUYmm7RDyzER8IA3XOCrjzl3OUcczZqcplk5JfBdhxMZpt1aGYWUdle1IgO/kAFne6sLD6IuxPySEb

4
fs/testdata/enc-short.conf vendored Normal file
View File

@ -0,0 +1,4 @@
# Encrypted rclone configuration File
RCLONE_ENCRYPT_V0:
b5Uk6mE3cUn5Wb8xi

4
fs/testdata/enc-too-new.conf vendored Normal file
View File

@ -0,0 +1,4 @@
# Encrypted rclone configuration File
RCLONE_ENCRYPT_V1:
b5Uk6mE3cUn5Wb8xiWYnVBAxXUirAaEG1PO/GIDiO9274AO+Yj790BwJA4d2y7lNkmHt4nJwIsoueFvUYmm7RDyzER8IA3XOCrjzl3OUcczZqcplk5JfBdhxMZpt1aGYWUdle1IgO/kAFne6sLD6IuxPySEb

4
fs/testdata/encrypted.conf vendored Normal file
View File

@ -0,0 +1,4 @@
# Encrypted rclone configuration File
RCLONE_ENCRYPT_V0:
b5Uk6mE3cUn5Wb8xiWYnVBAxXUirAaEG1PO/GIDiO9274AO+Yj790BwJA4d2y7lNkmHt4nJwIsoueFvUYmm7RDyzER8IA3XOCrjzl3OUcczZqcplk5JfBdhxMZpt1aGYWUdle1IgO/kAFne6sLD6IuxPySEb

12
fs/testdata/plain.conf vendored Normal file
View File

@ -0,0 +1,12 @@
[RCLONE_ENCRYPT_V0]
type = local
nounc = true
[nounc]
type = local
nounc = true
[unc]
type = local
nounc = false

View File

@ -50,6 +50,11 @@ func init() {
// TestInit tests basic intitialisation // TestInit tests basic intitialisation
func TestInit(t *testing.T) { func TestInit(t *testing.T) {
var err error var err error
// Never ask for passwords, fail instead.
// If your local config is encrypted set environment variable
// "RCLONE_CONFIG_PASS=hunter2" (or your password)
*fs.AskPassword = false
fs.LoadConfig() fs.LoadConfig()
fs.Config.Verbose = *verbose fs.Config.Verbose = *verbose
fs.Config.Quiet = !*verbose fs.Config.Quiet = !*verbose