diff --git a/cmd/restic/cmd_backup.go b/cmd/restic/cmd_backup.go index 19b96e9b0..4890f82ff 100644 --- a/cmd/restic/cmd_backup.go +++ b/cmd/restic/cmd_backup.go @@ -257,7 +257,7 @@ func readFilenamesRaw(r io.Reader) (names []string, err error) { // Check returns an error when an invalid combination of options was set. func (opts BackupOptions) Check(gopts GlobalOptions, args []string) error { - if gopts.password == "" { + if gopts.password == "" && !gopts.InsecureNoPassword { if opts.Stdin { return errors.Fatal("cannot read both password and data from stdin") } diff --git a/cmd/restic/cmd_backup_integration_test.go b/cmd/restic/cmd_backup_integration_test.go index 75de1341c..f7372851f 100644 --- a/cmd/restic/cmd_backup_integration_test.go +++ b/cmd/restic/cmd_backup_integration_test.go @@ -627,3 +627,17 @@ func TestStdinFromCommandFailNoOutputAndExitCode(t *testing.T) { testRunCheck(t, env.gopts) } + +func TestBackupEmptyPassword(t *testing.T) { + // basic sanity test that empty passwords work + env, cleanup := withTestEnvironment(t) + defer cleanup() + + env.gopts.password = "" + env.gopts.InsecureNoPassword = true + + testSetupBackupData(t, env) + testRunBackup(t, filepath.Dir(env.testdata), []string{"testdata"}, BackupOptions{}, env.gopts) + testListSnapshots(t, env.gopts, 1) + testRunCheck(t, env.gopts) +} diff --git a/cmd/restic/cmd_copy_integration_test.go b/cmd/restic/cmd_copy_integration_test.go index 1c8837690..704615870 100644 --- a/cmd/restic/cmd_copy_integration_test.go +++ b/cmd/restic/cmd_copy_integration_test.go @@ -13,10 +13,12 @@ func testRunCopy(t testing.TB, srcGopts GlobalOptions, dstGopts GlobalOptions) { gopts := srcGopts gopts.Repo = dstGopts.Repo gopts.password = dstGopts.password + gopts.InsecureNoPassword = dstGopts.InsecureNoPassword copyOpts := CopyOptions{ secondaryRepoOptions: secondaryRepoOptions{ - Repo: srcGopts.Repo, - password: srcGopts.password, + Repo: srcGopts.Repo, + password: srcGopts.password, + InsecureNoPassword: srcGopts.InsecureNoPassword, }, } @@ -134,3 +136,22 @@ func TestCopyUnstableJSON(t *testing.T) { testRunCheck(t, env2.gopts) testListSnapshots(t, env2.gopts, 1) } + +func TestCopyToEmptyPassword(t *testing.T) { + env, cleanup := withTestEnvironment(t) + defer cleanup() + env2, cleanup2 := withTestEnvironment(t) + defer cleanup2() + env2.gopts.password = "" + env2.gopts.InsecureNoPassword = true + + testSetupBackupData(t, env) + testRunBackup(t, "", []string{filepath.Join(env.testdata, "0", "0", "9")}, BackupOptions{}, env.gopts) + + testRunInit(t, env2.gopts) + testRunCopy(t, env.gopts, env2.gopts) + + testListSnapshots(t, env.gopts, 1) + testListSnapshots(t, env2.gopts, 1) + testRunCheck(t, env2.gopts) +} diff --git a/cmd/restic/cmd_key_add.go b/cmd/restic/cmd_key_add.go index c87a99a5e..9e50aa67d 100644 --- a/cmd/restic/cmd_key_add.go +++ b/cmd/restic/cmd_key_add.go @@ -28,12 +28,14 @@ Exit status is 0 if the command is successful, and non-zero if there was any err type KeyAddOptions struct { NewPasswordFile string + InsecureNoPassword bool Username string Hostname string } func (opts *KeyAddOptions) Add(flags *pflag.FlagSet) { flags.StringVarP(&opts.NewPasswordFile, "new-password-file", "", "", "`file` from which to read the new password") + flags.BoolVar(&opts.InsecureNoPassword, "new-insecure-no-password", false, "add an empty password for the repository (insecure)") flags.StringVarP(&opts.Username, "user", "", "", "the username for new key") flags.StringVarP(&opts.Hostname, "host", "", "", "the hostname for new key") } @@ -63,7 +65,7 @@ func runKeyAdd(ctx context.Context, gopts GlobalOptions, opts KeyAddOptions, arg } func addKey(ctx context.Context, repo *repository.Repository, gopts GlobalOptions, opts KeyAddOptions) error { - pw, err := getNewPassword(ctx, gopts, opts.NewPasswordFile) + pw, err := getNewPassword(ctx, gopts, opts.NewPasswordFile, opts.InsecureNoPassword) if err != nil { return err } @@ -86,19 +88,35 @@ func addKey(ctx context.Context, repo *repository.Repository, gopts GlobalOption // testKeyNewPassword is used to set a new password during integration testing. var testKeyNewPassword string -func getNewPassword(ctx context.Context, gopts GlobalOptions, newPasswordFile string) (string, error) { +func getNewPassword(ctx context.Context, gopts GlobalOptions, newPasswordFile string, insecureNoPassword bool) (string, error) { if testKeyNewPassword != "" { return testKeyNewPassword, nil } + if insecureNoPassword { + if newPasswordFile != "" { + return "", fmt.Errorf("only either --new-password-file or --new-insecure-no-password may be specified") + } + return "", nil + } + if newPasswordFile != "" { - return loadPasswordFromFile(newPasswordFile) + password, err := loadPasswordFromFile(newPasswordFile) + if err != nil { + return "", err + } + if password == "" { + return "", fmt.Errorf("an empty password is not allowed by default. Pass the flag `--new-insecure-no-password` to restic to disable this check") + } + return password, nil } // Since we already have an open repository, temporary remove the password // to prompt the user for the passwd. newopts := gopts newopts.password = "" + // empty passwords are already handled above + newopts.InsecureNoPassword = false return ReadPasswordTwice(ctx, newopts, "enter new password: ", diff --git a/cmd/restic/cmd_key_integration_test.go b/cmd/restic/cmd_key_integration_test.go index 16cc1bdad..0b4533887 100644 --- a/cmd/restic/cmd_key_integration_test.go +++ b/cmd/restic/cmd_key_integration_test.go @@ -3,6 +3,8 @@ package main import ( "bufio" "context" + "os" + "path/filepath" "regexp" "strings" "testing" @@ -109,6 +111,43 @@ func TestKeyAddRemove(t *testing.T) { testRunKeyAddNewKeyUserHost(t, env.gopts) } +func TestKeyAddInvalid(t *testing.T) { + env, cleanup := withTestEnvironment(t) + defer cleanup() + testRunInit(t, env.gopts) + + err := runKeyAdd(context.TODO(), env.gopts, KeyAddOptions{ + NewPasswordFile: "some-file", + InsecureNoPassword: true, + }, []string{}) + rtest.Assert(t, strings.Contains(err.Error(), "only either"), "unexpected error message, got %q", err) + + pwfile := filepath.Join(t.TempDir(), "pwfile") + rtest.OK(t, os.WriteFile(pwfile, []byte{}, 0o666)) + + err = runKeyAdd(context.TODO(), env.gopts, KeyAddOptions{ + NewPasswordFile: pwfile, + }, []string{}) + rtest.Assert(t, strings.Contains(err.Error(), "an empty password is not allowed by default"), "unexpected error message, got %q", err) +} + +func TestKeyAddEmpty(t *testing.T) { + env, cleanup := withTestEnvironment(t) + // must list keys more than once + env.gopts.backendTestHook = nil + defer cleanup() + testRunInit(t, env.gopts) + + rtest.OK(t, runKeyAdd(context.TODO(), env.gopts, KeyAddOptions{ + InsecureNoPassword: true, + }, []string{})) + + env.gopts.password = "" + env.gopts.InsecureNoPassword = true + + testRunCheck(t, env.gopts) +} + type emptySaveBackend struct { backend.Backend } diff --git a/cmd/restic/cmd_key_passwd.go b/cmd/restic/cmd_key_passwd.go index 32822a0ba..1a1200109 100644 --- a/cmd/restic/cmd_key_passwd.go +++ b/cmd/restic/cmd_key_passwd.go @@ -53,7 +53,7 @@ func runKeyPasswd(ctx context.Context, gopts GlobalOptions, opts KeyPasswdOption } func changePassword(ctx context.Context, repo *repository.Repository, gopts GlobalOptions, opts KeyPasswdOptions) error { - pw, err := getNewPassword(ctx, gopts, opts.NewPasswordFile) + pw, err := getNewPassword(ctx, gopts, opts.NewPasswordFile, opts.InsecureNoPassword) if err != nil { return err } diff --git a/cmd/restic/global.go b/cmd/restic/global.go index c954a4270..9671f2a26 100644 --- a/cmd/restic/global.go +++ b/cmd/restic/global.go @@ -52,22 +52,23 @@ type backendWrapper func(r backend.Backend) (backend.Backend, error) // GlobalOptions hold all global options for restic. type GlobalOptions struct { - Repo string - RepositoryFile string - PasswordFile string - PasswordCommand string - KeyHint string - Quiet bool - Verbose int - NoLock bool - RetryLock time.Duration - JSON bool - CacheDir string - NoCache bool - CleanupCache bool - Compression repository.CompressionMode - PackSize uint - NoExtraVerify bool + Repo string + RepositoryFile string + PasswordFile string + PasswordCommand string + KeyHint string + Quiet bool + Verbose int + NoLock bool + RetryLock time.Duration + JSON bool + CacheDir string + NoCache bool + CleanupCache bool + Compression repository.CompressionMode + PackSize uint + NoExtraVerify bool + InsecureNoPassword bool backend.TransportOptions limiter.Limits @@ -125,6 +126,7 @@ func init() { f.BoolVar(&globalOptions.NoCache, "no-cache", false, "do not use a local cache") f.StringSliceVar(&globalOptions.RootCertFilenames, "cacert", nil, "`file` to load root certificates from (default: use system certificates or $RESTIC_CACERT)") f.StringVar(&globalOptions.TLSClientCertKeyFilename, "tls-client-cert", "", "path to a `file` containing PEM encoded TLS client certificate and private key (default: $RESTIC_TLS_CLIENT_CERT)") + f.BoolVar(&globalOptions.InsecureNoPassword, "insecure-no-password", false, "use an empty password for the repository, must be passed to every restic command (insecure)") f.BoolVar(&globalOptions.InsecureTLS, "insecure-tls", false, "skip TLS certificate verification when connecting to the repository (insecure)") f.BoolVar(&globalOptions.CleanupCache, "cleanup-cache", false, "auto remove old cache directories") f.Var(&globalOptions.Compression, "compression", "compression mode (only available for repository format version 2), one of (auto|off|max) (default: $RESTIC_COMPRESSION)") @@ -327,6 +329,13 @@ func readPasswordTerminal(ctx context.Context, in *os.File, out *os.File, prompt // variable RESTIC_PASSWORD or prompts the user. If the context is canceled, // the function leaks the password reading goroutine. func ReadPassword(ctx context.Context, opts GlobalOptions, prompt string) (string, error) { + if opts.InsecureNoPassword { + if opts.password != "" { + return "", errors.Fatal("--insecure-no-password must not be specified together with providing a password via a cli option or environment variable") + } + return "", nil + } + if opts.password != "" { return opts.password, nil } @@ -348,7 +357,7 @@ func ReadPassword(ctx context.Context, opts GlobalOptions, prompt string) (strin } if len(password) == 0 { - return "", errors.Fatal("an empty password is not a password") + return "", errors.Fatal("an empty password is not allowed by default. Pass the flag `--insecure-no-password` to restic to disable this check") } return password, nil @@ -445,7 +454,7 @@ func OpenRepository(ctx context.Context, opts GlobalOptions) (*repository.Reposi } passwordTriesLeft := 1 - if stdinIsTerminal() && opts.password == "" { + if stdinIsTerminal() && opts.password == "" && !opts.InsecureNoPassword { passwordTriesLeft = 3 } diff --git a/cmd/restic/global_test.go b/cmd/restic/global_test.go index 4f5c29e9a..ce59bba49 100644 --- a/cmd/restic/global_test.go +++ b/cmd/restic/global_test.go @@ -1,8 +1,10 @@ package main import ( + "context" "os" "path/filepath" + "strings" "testing" rtest "github.com/restic/restic/internal/test" @@ -50,3 +52,14 @@ func TestReadRepo(t *testing.T) { t.Fatal("must not read repository path from invalid file path") } } + +func TestReadEmptyPassword(t *testing.T) { + opts := GlobalOptions{InsecureNoPassword: true} + password, err := ReadPassword(context.TODO(), opts, "test") + rtest.OK(t, err) + rtest.Equals(t, "", password, "got unexpected password") + + opts.password = "invalid" + _, err = ReadPassword(context.TODO(), opts, "test") + rtest.Assert(t, strings.Contains(err.Error(), "must not be specified together with providing a password via a cli option or environment variable"), "unexpected error message, got %v", err) +} diff --git a/cmd/restic/secondary_repo.go b/cmd/restic/secondary_repo.go index 2afd36a81..9a3eb5fe2 100644 --- a/cmd/restic/secondary_repo.go +++ b/cmd/restic/secondary_repo.go @@ -11,11 +11,12 @@ import ( type secondaryRepoOptions struct { password string // from-repo options - Repo string - RepositoryFile string - PasswordFile string - PasswordCommand string - KeyHint string + Repo string + RepositoryFile string + PasswordFile string + PasswordCommand string + KeyHint string + InsecureNoPassword bool // repo2 options LegacyRepo string LegacyRepositoryFile string @@ -49,6 +50,7 @@ func initSecondaryRepoOptions(f *pflag.FlagSet, opts *secondaryRepoOptions, repo f.StringVarP(&opts.PasswordFile, "from-password-file", "", "", "`file` to read the source repository password from (default: $RESTIC_FROM_PASSWORD_FILE)") f.StringVarP(&opts.KeyHint, "from-key-hint", "", "", "key ID of key to try decrypting the source repository first (default: $RESTIC_FROM_KEY_HINT)") f.StringVarP(&opts.PasswordCommand, "from-password-command", "", "", "shell `command` to obtain the source repository password from (default: $RESTIC_FROM_PASSWORD_COMMAND)") + f.BoolVar(&opts.InsecureNoPassword, "from-insecure-no-password", false, "use an empty password for the source repository, must be passed to every restic command (insecure)") opts.Repo = os.Getenv("RESTIC_FROM_REPOSITORY") opts.RepositoryFile = os.Getenv("RESTIC_FROM_REPOSITORY_FILE") @@ -63,7 +65,7 @@ func fillSecondaryGlobalOpts(ctx context.Context, opts secondaryRepoOptions, gop } hasFromRepo := opts.Repo != "" || opts.RepositoryFile != "" || opts.PasswordFile != "" || - opts.KeyHint != "" || opts.PasswordCommand != "" + opts.KeyHint != "" || opts.PasswordCommand != "" || opts.InsecureNoPassword hasRepo2 := opts.LegacyRepo != "" || opts.LegacyRepositoryFile != "" || opts.LegacyPasswordFile != "" || opts.LegacyKeyHint != "" || opts.LegacyPasswordCommand != "" @@ -85,6 +87,7 @@ func fillSecondaryGlobalOpts(ctx context.Context, opts secondaryRepoOptions, gop dstGopts.PasswordFile = opts.PasswordFile dstGopts.PasswordCommand = opts.PasswordCommand dstGopts.KeyHint = opts.KeyHint + dstGopts.InsecureNoPassword = opts.InsecureNoPassword pwdEnv = "RESTIC_FROM_PASSWORD" repoPrefix = "source" @@ -98,6 +101,8 @@ func fillSecondaryGlobalOpts(ctx context.Context, opts secondaryRepoOptions, gop dstGopts.PasswordFile = opts.LegacyPasswordFile dstGopts.PasswordCommand = opts.LegacyPasswordCommand dstGopts.KeyHint = opts.LegacyKeyHint + // keep existing bevhaior for legacy options + dstGopts.InsecureNoPassword = false pwdEnv = "RESTIC_PASSWORD2" }