Files
rclone/fs/config/crypt.go
T
phatlc 03783be7a5 fs/config: only run --password-command once when using --daemon
Decrypting the config with --daemon runs --password-command twice, which
means two authentications when the command needs one, such as a hardware
key touch for `pass show`.

SetConfigPassword saves the obscured key to the temp file named by
_RCLONE_CONFIG_KEY_FILE so the daemon process can pick it up, but the
process that wrote it then read and deleted that file itself before
daemonizing. The daemon started with the variable pointing at a file that
was already gone, found no key, and ran the password command again.

Skip acquiring a password when _RCLONE_CONFIG_KEY_FILE is set, as the
PassConfigKeyForDaemonization documentation already describes, and only
consume the key file in a process that has no key of its own. The parent
then leaves the key for the daemon, and the daemon uses it.

Fixes #7341
2026-09-08 17:13:23 +01:00

379 lines
11 KiB
Go

package config
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"golang.org/x/crypto/nacl/secretbox"
"golang.org/x/text/unicode/norm"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config/obscure"
)
var (
// Key to use for password en/decryption.
// When nil, no encryption will be used for saving.
configKey []byte
// PasswordPromptOutput is output of prompt for password
PasswordPromptOutput = os.Stderr
// PassConfigKeyForDaemonization if set to true, the configKey
// is obscured with obscure.Obscure and saved to a temp file
// when it is calculated from the password. The path of that
// temp file is then written to the environment variable
// `_RCLONE_CONFIG_KEY_FILE`. If `_RCLONE_CONFIG_KEY_FILE` is
// present, password prompt is skipped and
// `RCLONE_CONFIG_PASS` ignored. For security reasons, the
// temp file is deleted once the configKey is successfully
// loaded. This can be used to pass the configKey to a child
// process.
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()
ci := fs.GetConfig(ctx)
var usingPasswordCommand bool
var usingEnvPassword bool
// Find first non-empty line
r := bufio.NewReader(b)
for {
line, _, err := r.ReadLine()
if err != nil {
if err == io.EOF {
if _, err := b.Seek(0, io.SeekStart); err != nil {
return nil, err
}
return b, nil
}
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, errors.New("unsupported configuration encryption - update rclone for support")
}
// Restore non-seekable plain-text stream to its original state
if _, err := b.Seek(0, io.SeekStart); err != nil {
return io.MultiReader(strings.NewReader(l+"\n"), r), nil
}
return b, nil
}
// A key file named by _RCLONE_CONFIG_KEY_FILE is a key handed over by a
// parent process, so the password must not be asked for again here.
if len(configKey) == 0 && os.Getenv("_RCLONE_CONFIG_KEY_FILE") == "" {
pass, err := GetPasswordCommand(ctx)
if err != nil {
return nil, err
}
if pass != "" {
usingPasswordCommand = true
err = SetConfigPassword(pass)
if err != nil {
return nil, fmt.Errorf("incorrect password: %w", err)
}
if len(configKey) == 0 {
return nil, errors.New("unable to decrypt configuration: incorrect password")
}
} else {
usingPasswordCommand = false
envPassword := os.Getenv("RCLONE_CONFIG_PASS")
if envPassword != "" {
usingEnvPassword = true
err := SetConfigPassword(envPassword)
if err != nil {
fs.Errorf(nil, "Using RCLONE_CONFIG_PASS returned: %v", err)
} else {
fs.Debugf(nil, "Using RCLONE_CONFIG_PASS password.")
}
} else {
usingEnvPassword = false
}
}
}
// Encrypted content is base64 encoded.
dec := base64.NewDecoder(base64.StdEncoding, r)
box, err := io.ReadAll(dec)
if err != nil {
return nil, fmt.Errorf("failed to load base64 encoded data: %w", err)
}
if len(box) < 24+secretbox.Overhead {
return nil, errors.New("configuration data too short")
}
var out []byte
for {
// Only a process without a key of its own consumes the key file, so
// that a process which wrote one for its child leaves it in place.
if envKeyFile := os.Getenv("_RCLONE_CONFIG_KEY_FILE"); len(configKey) == 0 && len(envKeyFile) > 0 {
fs.Debugf(nil, "attempting to obtain configKey from temp file %s", envKeyFile)
obscuredKey, err := os.ReadFile(envKeyFile)
if err != nil {
errRemove := os.Remove(envKeyFile)
if errRemove != nil {
return nil, fmt.Errorf("unable to read obscured config key and unable to delete the temp file: %w", err)
}
return nil, fmt.Errorf("unable to read obscured config key: %w", err)
}
errRemove := os.Remove(envKeyFile)
if errRemove != nil {
return nil, fmt.Errorf("unable to delete temp file with configKey: %w", errRemove)
}
configKey = []byte(obscure.MustReveal(string(obscuredKey)))
fs.Debugf(nil, "using _RCLONE_CONFIG_KEY_FILE for configKey")
} else if len(configKey) == 0 {
if usingPasswordCommand {
return nil, errors.New("using --password-command derived password, unable to decrypt configuration")
}
if usingEnvPassword {
return nil, errors.New("using RCLONE_CONFIG_PASS env password, unable to decrypt configuration")
}
if !ci.AskPassword {
return nil, errors.New("unable to decrypt configuration and not allowed to ask for password - set RCLONE_CONFIG_PASS to your configuration password")
}
getConfigPassword("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
fs.Errorf(nil, "Couldn't decrypt configuration, most likely wrong password.")
configKey = nil
}
return bytes.NewReader(out), nil
}
// GetPasswordCommand gets the password using the --password-command setting
//
// If the --password-command flag was not in use it returns "", nil
func GetPasswordCommand(ctx context.Context) (pass string, err error) {
ci := fs.GetConfig(ctx)
if len(ci.PasswordCommand) == 0 {
return "", nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd := exec.Command(ci.PasswordCommand[0], ci.PasswordCommand[1:]...)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
if err != nil {
// One does not always get the stderr returned in the wrapped error.
fs.Errorf(nil, "Using --password-command returned: %v", err)
if ers := strings.TrimSpace(stderr.String()); ers != "" {
fs.Errorf(nil, "--password-command stderr: %s", ers)
}
return pass, fmt.Errorf("password command failed: %w", err)
}
pass = strings.Trim(stdout.String(), "\r\n")
if pass == "" {
return pass, errors.New("--password-command returned empty string")
}
return pass, nil
}
// Encrypt the config file
func Encrypt(src io.Reader, dst io.Writer) error {
if len(configKey) == 0 {
_, err := io.Copy(dst, src)
return err
}
_, _ = fmt.Fprintln(dst, "# Encrypted rclone configuration File")
_, _ = fmt.Fprintln(dst, "")
_, _ = fmt.Fprintln(dst, "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 {
return fmt.Errorf("nonce short read: %d", n)
}
enc := base64.NewEncoder(base64.StdEncoding, dst)
_, err := enc.Write(nonce[:])
if err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
var key [32]byte
copy(key[:], configKey[:32])
data, err := io.ReadAll(src)
if err != nil {
return err
}
b := secretbox.Seal(nil, data, &nonce, &key)
_, err = enc.Write(b)
if err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return enc.Close()
}
// getConfigPassword will query the user for a password the
// first time it is required.
func getConfigPassword(q string) {
if len(configKey) != 0 {
return
}
for {
password := GetPassword(q)
err := SetConfigPassword(password)
if err == nil {
return
}
_, _ = fmt.Fprintln(os.Stderr, "Error:", err)
}
}
// SetConfigPassword 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 SetConfigPassword(password string) error {
password, err := checkPassword(password)
if err != nil {
return err
}
// Normalize the config encryption password to reduce weird
// variations so that the same password always derives the same
// key. This is safe for the master password as it is only ever
// used to derive the key, never sent anywhere verbatim.
password = norm.NFKC.String(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)
if PassConfigKeyForDaemonization {
tempFile, err := os.CreateTemp("", "rclone")
if err != nil {
return fmt.Errorf("cannot create temp file to store configKey: %w", err)
}
_, err = tempFile.WriteString(obscure.MustObscure(string(configKey)))
if err != nil {
errRemove := os.Remove(tempFile.Name())
if errRemove != nil {
return fmt.Errorf("error writing configKey to temp file and also error deleting it: %w", err)
}
return fmt.Errorf("error writing configKey to temp file: %w", err)
}
err = tempFile.Close()
if err != nil {
errRemove := os.Remove(tempFile.Name())
if errRemove != nil {
return fmt.Errorf("error closing temp file with configKey and also error deleting it: %w", err)
}
return fmt.Errorf("error closing temp file with configKey: %w", err)
}
fs.Debugf(nil, "saving configKey to temp file")
err = os.Setenv("_RCLONE_CONFIG_KEY_FILE", tempFile.Name())
if err != nil {
errRemove := os.Remove(tempFile.Name())
if errRemove != nil {
return fmt.Errorf("unable to set environment variable _RCLONE_CONFIG_KEY_FILE and unable to delete the temp file: %w", err)
}
return fmt.Errorf("unable to set environment variable _RCLONE_CONFIG_KEY_FILE: %w", err)
}
}
return nil
}
// ClearConfigPassword sets the current the password to empty
func ClearConfigPassword() {
configKey = nil
}
// changeConfigPassword 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.
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)
return
}
if pass == "" {
pass = ChangePassword("NEW configuration")
} else {
fmt.Printf("Read password using --password-command\n")
}
err = SetConfigPassword(pass)
if err != nil {
fmt.Printf("Failed to set config password: %v\n", err)
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()
}