feat: add support for password commands in crypt backend

crypt backend to allow passwords to be retrieved from the command.
This means that passwords can be dynamically retrieved from standard input, secret manager, gpg, etc.
This commit is contained in:
Takanori Hirano 2025-03-15 02:13:40 +09:00
parent 6a7e68aaf2
commit ffdf140bbd
2 changed files with 101 additions and 12 deletions

View File

@ -2,11 +2,15 @@
package crypt
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path"
"runtime"
"strings"
"time"
@ -69,13 +73,20 @@ NB If filename_encryption is "off" then this option will do nothing.`,
},
}, {
Name: "password",
Help: "Password or pass phrase for encryption.",
Help: "Password or pass phrase for encryption.\n\npassword or password command is required.",
IsPassword: true,
Required: true,
}, {
Name: "password_command",
Help: "Command to retrieve the password or pass phrase for encryption.\n\npassword or password command is required.",
IsPassword: false,
}, {
Name: "password2",
Help: "Password or pass phrase for salt.\n\nOptional but recommended.\nShould be different to the previous password.",
IsPassword: true,
}, {
Name: "password2_command",
Help: "Command to retrieve the password or pass phrase for salt.\n\nOptional but recommended.\nShould be different to the previous password.",
IsPassword: false,
}, {
Name: "server_side_across_configs",
Default: false,
@ -181,19 +192,20 @@ func newCipherForConfig(opt *Options) (*Cipher, error) {
if err != nil {
return nil, err
}
if opt.Password == "" {
return nil, errors.New("password not set in config file")
}
password, err := obscure.Reveal(opt.Password)
password, err := evalPassword(opt.Password, opt.PasswordCommand)
if err != nil {
return nil, fmt.Errorf("failed to decrypt password: %w", err)
return nil, fmt.Errorf("failed to eval password: %w", err)
}
if password == "" {
return nil, errors.New("password or password_command not set in config file")
}
var salt string
if opt.Password2 != "" {
salt, err = obscure.Reveal(opt.Password2)
if err != nil {
return nil, fmt.Errorf("failed to decrypt password2: %w", err)
}
password2, err := evalPassword(opt.Password2, opt.Password2Command)
if err != nil {
return nil, fmt.Errorf("failed to eval password2: %w", err)
}
if password2 != "" {
salt = password2
}
enc, err := NewNameEncoding(opt.FilenameEncoding)
if err != nil {
@ -208,6 +220,38 @@ func newCipherForConfig(opt *Options) (*Cipher, error) {
return cipher, nil
}
func evalPassword(password string, passwordCommand string) (string, error) {
if password != "" {
revealed, err := obscure.Reveal(password)
if err != nil {
return "", fmt.Errorf("failed to decrypt password: %w", err)
}
return revealed, nil
}
if passwordCommand != "" {
var stdout bytes.Buffer
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("cmd", "/c", passwordCommand)
default:
cmd = exec.Command("sh", "-c", passwordCommand)
}
cmd.Stdout = &stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to run password command: %w", err)
}
pass := strings.TrimSpace(stdout.String())
if pass == "" {
return "", errors.New("password command returned empty string")
}
return pass, nil
}
return "", nil
}
// NewCipher constructs a Cipher for the given config
func NewCipher(m configmap.Mapper) (*Cipher, error) {
// Parse config into Options struct
@ -303,7 +347,9 @@ type Options struct {
DirectoryNameEncryption bool `config:"directory_name_encryption"`
NoDataEncryption bool `config:"no_data_encryption"`
Password string `config:"password"`
PasswordCommand string `config:"password_command"`
Password2 string `config:"password2"`
Password2Command string `config:"password2_command"`
ServerSideAcrossConfigs bool `config:"server_side_across_configs"`
ShowMapping bool `config:"show_mapping"`
PassBadBlocks bool `config:"pass_bad_blocks"`

View File

@ -95,6 +95,49 @@ func TestStandardBase32768(t *testing.T) {
})
}
func TestPasswordCommand(t *testing.T) {
if *fstest.RemoteName != "" {
t.Skip("Skipping as -remote set")
}
tempdir := filepath.Join(os.TempDir(), "rclone-crypt-test-standard")
name := "TestCrypt"
fstests.Run(t, &fstests.Opt{
RemoteName: name + ":",
NilObject: (*crypt.Object)(nil),
ExtraConfig: []fstests.ExtraConfigItem{
{Name: name, Key: "type", Value: "crypt"},
{Name: name, Key: "remote", Value: tempdir},
{Name: name, Key: "password_command", Value: "echo potato"},
{Name: name, Key: "filename_encryption", Value: "standard"},
},
UnimplementableFsMethods: []string{"OpenWriterAt", "OpenChunkWriter"},
UnimplementableObjectMethods: []string{"MimeType"},
QuickTestOK: true,
})
}
func TestPassword2Command(t *testing.T) {
if *fstest.RemoteName != "" {
t.Skip("Skipping as -remote set")
}
tempdir := filepath.Join(os.TempDir(), "rclone-crypt-test-standard")
name := "TestCrypt"
fstests.Run(t, &fstests.Opt{
RemoteName: name + ":",
NilObject: (*crypt.Object)(nil),
ExtraConfig: []fstests.ExtraConfigItem{
{Name: name, Key: "type", Value: "crypt"},
{Name: name, Key: "remote", Value: tempdir},
{Name: name, Key: "password_command", Value: "echo potato"},
{Name: name, Key: "password2_command", Value: "echo potato"},
{Name: name, Key: "filename_encryption", Value: "standard"},
},
UnimplementableFsMethods: []string{"OpenWriterAt", "OpenChunkWriter"},
UnimplementableObjectMethods: []string{"MimeType"},
QuickTestOK: true,
})
}
// TestOff runs integration tests against the remote
func TestOff(t *testing.T) {
if *fstest.RemoteName != "" {