From 2d1c2b1f76f44b4db5369bacdd4e5f76feecc128 Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Thu, 5 Sep 2024 11:52:15 +0100 Subject: [PATCH] config encryption: set, remove and check to manage config file encryption #7859 --- cmd/config/config.go | 89 ++++++++++++++++++++++++++++++++ docs/content/docs.md | 15 +++++- fs/config/crypt.go | 29 +++++++++++ fs/config/crypt_internal_test.go | 31 ++++++++++- fs/config/crypt_test.go | 31 +++++++++++ fs/config/ui.go | 9 ++-- 6 files changed, 194 insertions(+), 10 deletions(-) diff --git a/cmd/config/config.go b/cmd/config/config.go index 0300fd76e..4aefe62c0 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -36,6 +36,7 @@ func init() { configCommand.AddCommand(configReconnectCommand) configCommand.AddCommand(configDisconnectCommand) configCommand.AddCommand(configUserInfoCommand) + configCommand.AddCommand(configEncryptionCommand) } var configCommand = &cobra.Command{ @@ -518,3 +519,91 @@ system. return nil }, } + +func init() { + configEncryptionCommand.AddCommand(configEncryptionSetCommand) + configEncryptionCommand.AddCommand(configEncryptionRemoveCommand) + configEncryptionCommand.AddCommand(configEncryptionCheckCommand) +} + +var configEncryptionCommand = &cobra.Command{ + Use: "encryption", + Short: `set, remove and check the encryption for the config file`, + Long: `This command sets, clears and checks the encryption for the config file using +the subcommands below. +`, +} + +var configEncryptionSetCommand = &cobra.Command{ + Use: "set", + Short: `Set or change the config file encryption password`, + Long: strings.ReplaceAll(`This command sets or changes the config file encryption password. + +If there was no config password set then it sets a new one, otherwise +it changes the existing config password. + +Note that if you are changing an encryption password using +|--password-command| then this will be called once to decrypt the +config using the old password and then again to read the new +password to re-encrypt the config. + +When |--password-command| is called to change the password then the +environment variable |RCLONE_PASSWORD_CHANGE=1| will be set. So if +changing passwords programatically you can use the environment +variable to distinguish which password you must supply. + +Alternatively you can remove the password first (with |rclone config +encryption remove|), then set it again with this command which may be +easier if you don't mind the unecrypted config file being on the disk +briefly. +`, "|", "`"), + RunE: func(command *cobra.Command, args []string) error { + cmd.CheckArgs(0, 0, command, args) + config.LoadedData() + config.ChangeConfigPasswordAndSave() + return nil + }, +} + +var configEncryptionRemoveCommand = &cobra.Command{ + Use: "remove", + Short: `Remove the config file encryption password`, + Long: strings.ReplaceAll(`Remove the config file encryption password + +This removes the config file encryption, returning it to un-encrypted. + +If |--password-command| is in use, this will be called to supply the old config +password. + +If the config was not encrypted then no error will be returned and +this command will do nothing. +`, "|", "`"), + RunE: func(command *cobra.Command, args []string) error { + cmd.CheckArgs(0, 0, command, args) + config.LoadedData() + config.RemoveConfigPasswordAndSave() + return nil + }, +} + +var configEncryptionCheckCommand = &cobra.Command{ + Use: "check", + Short: `Check that the config file is encrypted`, + Long: strings.ReplaceAll(`This checks the config file is encrypted and that you can decrypt it. + +It will attempt to decrypt the config using the password you supply. + +If decryption fails it will return a non-zero exit code if using +|--password-command|, otherwise it will prompt again for the password. + +If the config file is not encrypted it will return a non zero exit code. +`, "|", "`"), + RunE: func(command *cobra.Command, args []string) error { + cmd.CheckArgs(0, 0, command, args) + config.LoadedData() + if !config.IsEncrypted() { + return errors.New("config file is NOT encrypted") + } + return nil + }, +} diff --git a/docs/content/docs.md b/docs/content/docs.md index 629bd84e8..f8f5153d9 100644 --- a/docs/content/docs.md +++ b/docs/content/docs.md @@ -1924,7 +1924,7 @@ Suffix length limit is 16 characters. The default is `.partial`. -### --password-command SpaceSepList ### +### --password-command SpaceSepList {#password-command} This flag supplies a program which should supply the config password when run. This is an alternative to rclone prompting for the password @@ -1943,6 +1943,11 @@ Eg --password-command 'echo "hello with space"' --password-command 'echo "hello with ""quotes"" and space"' +Note that when changing the configuration password the environment +variable `RCLONE_PASSWORD_CHANGE=1` will be set. This can be used to +distinguish initial decryption of the config file from the new +password. + See the [Configuration Encryption](#configuration-encryption) for more info. See a [Windows PowerShell example on the Wiki](https://github.com/rclone/rclone/wiki/Windows-Powershell-use-rclone-password-command-for-Config-file-password). @@ -2546,6 +2551,12 @@ encryption from your configuration. There is no way to recover the configuration if you lose your password. +You can also use + +- [rclone config encryption set](/commands/rclone_config_encryption_set/) to set the config encryption directly +- [rclone config encryption remove](/commands/rclone_config_encryption_remove/) to remove it +- [rclone config encryption check](/commands/rclone_config_encryption_check/) to check that it is encrypted properly. + rclone uses [nacl secretbox](https://godoc.org/golang.org/x/crypto/nacl/secretbox) which in turn uses XSalsa20 and Poly1305 to encrypt and authenticate your configuration with secret-key cryptography. @@ -2578,7 +2589,7 @@ An alternate means of supplying the password is to provide a script which will retrieve the password and print on standard output. This script should have a fully specified path name and not rely on any environment variables. The script is supplied either via -`--password-command="..."` command line argument or via the +[`--password-command="..."`](#password-command) command line argument or via the `RCLONE_PASSWORD_COMMAND` environment variable. One useful example of this is using the `passwordstore` application diff --git a/fs/config/crypt.go b/fs/config/crypt.go index f84b3dda1..4a68d4159 100644 --- a/fs/config/crypt.go +++ b/fs/config/crypt.go @@ -41,6 +41,11 @@ var ( PassConfigKeyForDaemonization = false ) +// IsEncrypted returns true if the config file is encrypted +func IsEncrypted() bool { + return len(configKey) > 0 +} + // Decrypt will automatically decrypt a reader func Decrypt(b io.ReadSeeker) (io.Reader, error) { ctx := context.Background() @@ -313,6 +318,11 @@ func ClearConfigPassword() { // // This will use --password-command if configured to read the password. func changeConfigPassword() { + // Set RCLONE_PASSWORD_CHANGE to "1" when calling the --password-command tool + _ = os.Setenv("RCLONE_PASSWORD_CHANGE", "1") + defer func() { + _ = os.Unsetenv("RCLONE_PASSWORD_CHANGE") + }() pass, err := GetPasswordCommand(context.Background()) if err != nil { fmt.Printf("Failed to read new password with --password-command: %v\n", err) @@ -329,3 +339,22 @@ func changeConfigPassword() { return } } + +// ChangeConfigPasswordAndSave will query the user twice +// for a password. If the same password is entered +// twice the key is updated. +// +// This will use --password-command if configured to read the password. +// +// It will then save the config +func ChangeConfigPasswordAndSave() { + changeConfigPassword() + SaveConfig() +} + +// RemoveConfigPasswordAndSave will clear the config password and save +// the unencrypted config file. +func RemoveConfigPasswordAndSave() { + configKey = nil + SaveConfig() +} diff --git a/fs/config/crypt_internal_test.go b/fs/config/crypt_internal_test.go index 9aa813cb3..2d19643c5 100644 --- a/fs/config/crypt_internal_test.go +++ b/fs/config/crypt_internal_test.go @@ -2,6 +2,8 @@ package config import ( "context" + "os" + "path/filepath" "testing" "github.com/rclone/rclone/fs" @@ -64,8 +66,33 @@ func TestChangeConfigPassword(t *testing.T) { // Get rid of any config password ClearConfigPassword() - // Set correct password using --password command - ci.PasswordCommand = fs.SpaceSepList{"echo", "asdf"} + // Return the password, checking the state of the environment variable + checkCode := ` +package main + +import ( + "fmt" + "os" + "log" +) + +func main() { + v := os.Getenv("RCLONE_PASSWORD_CHANGE") + if v == "" { + log.Fatal("Env var not found") + } else if v != "1" { + log.Fatal("Env var wrong value") + } else { + fmt.Println("asdf") + } +} +` + dir := t.TempDir() + code := filepath.Join(dir, "file.go") + require.NoError(t, os.WriteFile(code, []byte(checkCode), 0777)) + + // Set correct password using --password-command + ci.PasswordCommand = fs.SpaceSepList{"go", "run", code} changeConfigPassword() err = Data().Load() require.NoError(t, err) diff --git a/fs/config/crypt_test.go b/fs/config/crypt_test.go index 5cb3fa016..73c3bf6f6 100644 --- a/fs/config/crypt_test.go +++ b/fs/config/crypt_test.go @@ -6,6 +6,8 @@ package config_test import ( "context" + "os" + "path/filepath" "testing" "github.com/rclone/rclone/fs" @@ -24,8 +26,10 @@ func TestConfigLoadEncrypted(t *testing.T) { }() // Set correct password + assert.False(t, config.IsEncrypted()) err = config.SetConfigPassword("asdf") require.NoError(t, err) + assert.True(t, config.IsEncrypted()) err = config.Data().Load() require.NoError(t, err) sections := config.Data().GetSectionList() @@ -138,4 +142,31 @@ func TestGetPasswordCommand(t *testing.T) { ci.PasswordCommand = fs.SpaceSepList{"XXX non-existent command XXX", ""} _, err = config.GetPasswordCommand(ctx) assert.ErrorContains(t, err, "not found") + + // Check the state of the environment variable in --password-command + checkCode := ` +package main + +import ( + "fmt" + "os" +) + +func main() { + if _, found := os.LookupEnv("RCLONE_PASSWORD_CHANGE"); found { + fmt.Println("Env var set") + } else { + fmt.Println("OK") + } +} +` + dir := t.TempDir() + code := filepath.Join(dir, "file.go") + require.NoError(t, os.WriteFile(code, []byte(checkCode), 0777)) + + // Check the environment variable unset when called directly + ci.PasswordCommand = fs.SpaceSepList{"go", "run", code} + pass, err = config.GetPasswordCommand(ctx) + require.NoError(t, err) + assert.Equal(t, "OK", pass) } diff --git a/fs/config/ui.go b/fs/config/ui.go index ff8acda0f..b08e9481c 100644 --- a/fs/config/ui.go +++ b/fs/config/ui.go @@ -797,13 +797,11 @@ func SetPassword() { what := []string{"cChange Password", "uUnencrypt configuration", "qQuit to main menu"} switch i := Command(what); i { case 'c': - changeConfigPassword() - SaveConfig() + ChangeConfigPasswordAndSave() fmt.Println("Password changed") continue case 'u': - configKey = nil - SaveConfig() + RemoveConfigPasswordAndSave() continue case 'q': return @@ -815,8 +813,7 @@ func SetPassword() { what := []string{"aAdd Password", "qQuit to main menu"} switch i := Command(what); i { case 'a': - changeConfigPassword() - SaveConfig() + ChangeConfigPasswordAndSave() fmt.Println("Password set") continue case 'q':