rclone/fs/config/crypt.go
Nick Craig-Wood ffb2e2a6de config: use --password-command to set config file password if supplied
Before this change, rclone ignored the --password-command on the
rclone config setting except when decrypting an existing config file.

This change allows for offloading the password storage/generation into
external hardware key or other protected password storage.

Fixes #7859
2024-09-06 10:34:29 +01:00

332 lines
9.2 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"
"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
)
// Decrypt will automatically decrypt a reader
func Decrypt(b io.ReadSeeker) (io.Reader, error) {
ctx := context.Background()
ci := fs.GetConfig(ctx)
var usingPasswordCommand 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")
}
if _, err := b.Seek(0, io.SeekStart); err != nil {
return nil, err
}
return b, nil
}
if len(configKey) == 0 {
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
envpw := os.Getenv("RCLONE_CONFIG_PASS")
if envpw != "" {
err := SetConfigPassword(envpw)
if err != nil {
fs.Errorf(nil, "Using RCLONE_CONFIG_PASS returned: %v", err)
} else {
fs.Debugf(nil, "Using RCLONE_CONFIG_PASS password.")
}
}
}
}
// 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 {
if envKeyFile := os.Getenv("_RCLONE_CONFIG_KEY_FILE"); 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 !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 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
}
// 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() {
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
}
}