diff --git a/docs/content/docs.md b/docs/content/docs.md index 762dda3d1..eeb317bce 100644 --- a/docs/content/docs.md +++ b/docs/content/docs.md @@ -436,6 +436,72 @@ Very useful for debugging. 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 ----------------- diff --git a/fs/config.go b/fs/config.go index 0a1184106..54291de22 100644 --- a/fs/config.go +++ b/fs/config.go @@ -4,8 +4,14 @@ package fs import ( "bufio" + "bytes" + "crypto/rand" + "crypto/sha256" + "crypto/tls" "encoding/base64" "fmt" + "io" + "io/ioutil" "log" "math" "net/http" @@ -16,12 +22,14 @@ import ( "strconv" "strings" "time" + "unicode/utf8" - "crypto/tls" - - "github.com/Unknwon/goconfig" + "github.com/klauspost/goconfig" "github.com/mreiferson/go-httpclient" "github.com/spf13/pflag" + "golang.org/x/crypto/nacl/secretbox" + "golang.org/x/crypto/ssh/terminal" + "golang.org/x/text/unicode/norm" ) const ( @@ -69,10 +77,15 @@ var ( 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") 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") 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") bwLimit SizeSuffix + + // Key to use for password en/decryption. + // When nil, no encryption will be used for saving. + configKey []byte ) func init() { @@ -292,13 +305,9 @@ func LoadConfig() { // Load configuration file. var err error - ConfigFile, err = goconfig.LoadConfigFile(ConfigPath) + ConfigFile, err = loadConfigFile() if err != nil { - log.Printf("Failed to load config file %v - using defaults: %v", ConfigPath, err) - ConfigFile, err = goconfig.LoadConfigFile(os.DevNull) - if err != nil { - log.Fatalf("Failed to read null config file: %v", err) - } + log.Fatalf("Failed to config file \"%s\": %v", ConfigPath, err) } // Load filters @@ -311,12 +320,186 @@ func LoadConfig() { 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. +// if configKey has been set, the file will be encrypted. func SaveConfig() { - err := goconfig.SaveConfigFile(ConfigFile, ConfigPath) + if len(configKey) == 0 { + err := goconfig.SaveConfigFile(ConfigFile, ConfigPath) + if err != nil { + log.Fatalf("Failed to save config file: %v", err) + } + err = os.Chmod(ConfigPath, 0600) + if err != nil { + 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) @@ -354,6 +537,17 @@ func ReadLine() string { 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 func Command(commands []string) byte { opts := []string{} @@ -547,7 +741,7 @@ func DeleteRemote(name string) { func EditConfig() { for { 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 { fmt.Printf("Current remotes:\n\n") ShowRemotes() @@ -581,12 +775,72 @@ func EditConfig() { case 'd': name := ChooseRemote() 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': + 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!") + } +} + // Authorize is for remote authorization of headless machines. // // It expects 1 or 3 arguments diff --git a/fs/config_test.go b/fs/config_test.go index d71f21c60..a43b6f043 100644 --- a/fs/config_test.go +++ b/fs/config_test.go @@ -1,6 +1,10 @@ package fs -import "testing" +import ( + "bytes" + "reflect" + "testing" +) func TestSizeSuffixString(t *testing.T) { 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) + } +} diff --git a/fs/operations_test.go b/fs/operations_test.go index 4c0654114..d0f30412e 100644 --- a/fs/operations_test.go +++ b/fs/operations_test.go @@ -94,6 +94,10 @@ func newRun() *Run { 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.Config.Verbose = *Verbose fs.Config.Quiet = !*Verbose diff --git a/fs/testdata/enc-invalid.conf b/fs/testdata/enc-invalid.conf new file mode 100644 index 000000000..f932d5cea --- /dev/null +++ b/fs/testdata/enc-invalid.conf @@ -0,0 +1,4 @@ +# Encrypted rclone configuration File + +RCLONE_ENCRYPT_V0: +b5Uk6mE3cUn5Wb8xiWYnVBAxXUirAaEG1PO/GIDiO9274AOæøå+Yj790BwJA4d2y7lNkmHt4nJwIsoueFvUYmm7RDyzER8IA3XOCrjzl3OUcczZqcplk5JfBdhxMZpt1aGYWUdle1IgO/kAFne6sLD6IuxPySEb \ No newline at end of file diff --git a/fs/testdata/enc-short.conf b/fs/testdata/enc-short.conf new file mode 100644 index 000000000..27f4baa60 --- /dev/null +++ b/fs/testdata/enc-short.conf @@ -0,0 +1,4 @@ +# Encrypted rclone configuration File + +RCLONE_ENCRYPT_V0: +b5Uk6mE3cUn5Wb8xi \ No newline at end of file diff --git a/fs/testdata/enc-too-new.conf b/fs/testdata/enc-too-new.conf new file mode 100644 index 000000000..08afd5639 --- /dev/null +++ b/fs/testdata/enc-too-new.conf @@ -0,0 +1,4 @@ +# Encrypted rclone configuration File + +RCLONE_ENCRYPT_V1: +b5Uk6mE3cUn5Wb8xiWYnVBAxXUirAaEG1PO/GIDiO9274AO+Yj790BwJA4d2y7lNkmHt4nJwIsoueFvUYmm7RDyzER8IA3XOCrjzl3OUcczZqcplk5JfBdhxMZpt1aGYWUdle1IgO/kAFne6sLD6IuxPySEb \ No newline at end of file diff --git a/fs/testdata/encrypted.conf b/fs/testdata/encrypted.conf new file mode 100644 index 000000000..8504421fa --- /dev/null +++ b/fs/testdata/encrypted.conf @@ -0,0 +1,4 @@ +# Encrypted rclone configuration File + +RCLONE_ENCRYPT_V0: +b5Uk6mE3cUn5Wb8xiWYnVBAxXUirAaEG1PO/GIDiO9274AO+Yj790BwJA4d2y7lNkmHt4nJwIsoueFvUYmm7RDyzER8IA3XOCrjzl3OUcczZqcplk5JfBdhxMZpt1aGYWUdle1IgO/kAFne6sLD6IuxPySEb \ No newline at end of file diff --git a/fs/testdata/plain.conf b/fs/testdata/plain.conf new file mode 100644 index 000000000..f76f59566 --- /dev/null +++ b/fs/testdata/plain.conf @@ -0,0 +1,12 @@ +[RCLONE_ENCRYPT_V0] +type = local +nounc = true + +[nounc] +type = local +nounc = true + + +[unc] +type = local +nounc = false diff --git a/fstest/fstests/fstests.go b/fstest/fstests/fstests.go index c2b5ef045..006cae764 100644 --- a/fstest/fstests/fstests.go +++ b/fstest/fstests/fstests.go @@ -50,6 +50,11 @@ func init() { // TestInit tests basic intitialisation func TestInit(t *testing.T) { 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.Config.Verbose = *verbose fs.Config.Quiet = !*verbose