Merge pull request #4808 from MichaelEischer/insecure-no-password

Implement `--insecure-no-password` option.
This commit is contained in:
Michael Eischer 2024-05-24 22:58:25 +02:00 committed by GitHub
commit 80132e71d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 211 additions and 51 deletions

View file

@ -0,0 +1,19 @@
Enhancement: Support repositories with empty password
Restic refused to create or operate on repositories with an emtpy password.
Using the new option `--insecure-no-password` it is now possible to disable
this check. Restic will not prompt for a password when using this option.
For security reasons, the option must always be specified when operating on
repositories with an empty password.
Specifying `--insecure-no-password` while also passing a password to restic
via a CLI option or via environment variable results in an error.
The `init` and `copy` command also support the option `--from-insecure-no-password`
which applies to the source repository. The `key add` and `key passwd` comands
include the `--new-insecure-no-password` option to add or set an emtpy password.
https://github.com/restic/restic/issues/1786
https://github.com/restic/restic/issues/4326
https://github.com/restic/restic/pull/4698
https://github.com/restic/restic/pull/4808

View file

@ -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. // Check returns an error when an invalid combination of options was set.
func (opts BackupOptions) Check(gopts GlobalOptions, args []string) error { func (opts BackupOptions) Check(gopts GlobalOptions, args []string) error {
if gopts.password == "" { if gopts.password == "" && !gopts.InsecureNoPassword {
if opts.Stdin { if opts.Stdin {
return errors.Fatal("cannot read both password and data from stdin") return errors.Fatal("cannot read both password and data from stdin")
} }

View file

@ -627,3 +627,17 @@ func TestStdinFromCommandFailNoOutputAndExitCode(t *testing.T) {
testRunCheck(t, env.gopts) 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)
}

View file

@ -13,10 +13,12 @@ func testRunCopy(t testing.TB, srcGopts GlobalOptions, dstGopts GlobalOptions) {
gopts := srcGopts gopts := srcGopts
gopts.Repo = dstGopts.Repo gopts.Repo = dstGopts.Repo
gopts.password = dstGopts.password gopts.password = dstGopts.password
gopts.InsecureNoPassword = dstGopts.InsecureNoPassword
copyOpts := CopyOptions{ copyOpts := CopyOptions{
secondaryRepoOptions: secondaryRepoOptions{ secondaryRepoOptions: secondaryRepoOptions{
Repo: srcGopts.Repo, Repo: srcGopts.Repo,
password: srcGopts.password, password: srcGopts.password,
InsecureNoPassword: srcGopts.InsecureNoPassword,
}, },
} }
@ -134,3 +136,22 @@ func TestCopyUnstableJSON(t *testing.T) {
testRunCheck(t, env2.gopts) testRunCheck(t, env2.gopts)
testListSnapshots(t, env2.gopts, 1) 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)
}

View file

@ -9,6 +9,7 @@ import (
"github.com/restic/restic/internal/errors" "github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/repository" "github.com/restic/restic/internal/repository"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
var cmdKeyAdd = &cobra.Command{ var cmdKeyAdd = &cobra.Command{
@ -23,26 +24,30 @@ EXIT STATUS
Exit status is 0 if the command is successful, and non-zero if there was any error. Exit status is 0 if the command is successful, and non-zero if there was any error.
`, `,
DisableAutoGenTag: true, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runKeyAdd(cmd.Context(), globalOptions, keyAddOpts, args)
},
} }
type KeyAddOptions struct { type KeyAddOptions struct {
NewPasswordFile string NewPasswordFile string
Username string InsecureNoPassword bool
Hostname string Username string
Hostname string
} }
var keyAddOpts KeyAddOptions 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")
}
func init() { func init() {
cmdKey.AddCommand(cmdKeyAdd) cmdKey.AddCommand(cmdKeyAdd)
flags := cmdKeyAdd.Flags() var keyAddOpts KeyAddOptions
flags.StringVarP(&keyAddOpts.NewPasswordFile, "new-password-file", "", "", "`file` from which to read the new password") keyAddOpts.Add(cmdKeyAdd.Flags())
flags.StringVarP(&keyAddOpts.Username, "user", "", "", "the username for new key") cmdKeyAdd.RunE = func(cmd *cobra.Command, args []string) error {
flags.StringVarP(&keyAddOpts.Hostname, "host", "", "", "the hostname for new key") return runKeyAdd(cmd.Context(), globalOptions, keyAddOpts, args)
}
} }
func runKeyAdd(ctx context.Context, gopts GlobalOptions, opts KeyAddOptions, args []string) error { func runKeyAdd(ctx context.Context, gopts GlobalOptions, opts KeyAddOptions, args []string) error {
@ -60,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 { 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 { if err != nil {
return err return err
} }
@ -83,19 +88,35 @@ func addKey(ctx context.Context, repo *repository.Repository, gopts GlobalOption
// testKeyNewPassword is used to set a new password during integration testing. // testKeyNewPassword is used to set a new password during integration testing.
var testKeyNewPassword string 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 != "" { if testKeyNewPassword != "" {
return testKeyNewPassword, nil 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 != "" { 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 // Since we already have an open repository, temporary remove the password
// to prompt the user for the passwd. // to prompt the user for the passwd.
newopts := gopts newopts := gopts
newopts.password = "" newopts.password = ""
// empty passwords are already handled above
newopts.InsecureNoPassword = false
return ReadPasswordTwice(ctx, newopts, return ReadPasswordTwice(ctx, newopts,
"enter new password: ", "enter new password: ",

View file

@ -3,6 +3,8 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"os"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
@ -109,6 +111,43 @@ func TestKeyAddRemove(t *testing.T) {
testRunKeyAddNewKeyUserHost(t, env.gopts) 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 { type emptySaveBackend struct {
backend.Backend backend.Backend
} }

View file

@ -22,24 +22,20 @@ EXIT STATUS
Exit status is 0 if the command is successful, and non-zero if there was any error. Exit status is 0 if the command is successful, and non-zero if there was any error.
`, `,
DisableAutoGenTag: true, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runKeyPasswd(cmd.Context(), globalOptions, keyPasswdOpts, args)
},
} }
type KeyPasswdOptions struct { type KeyPasswdOptions struct {
KeyAddOptions KeyAddOptions
} }
var keyPasswdOpts KeyPasswdOptions
func init() { func init() {
cmdKey.AddCommand(cmdKeyPasswd) cmdKey.AddCommand(cmdKeyPasswd)
flags := cmdKeyPasswd.Flags() var keyPasswdOpts KeyPasswdOptions
flags.StringVarP(&keyPasswdOpts.NewPasswordFile, "new-password-file", "", "", "`file` from which to read the new password") keyPasswdOpts.KeyAddOptions.Add(cmdKeyPasswd.Flags())
flags.StringVarP(&keyPasswdOpts.Username, "user", "", "", "the username for new key") cmdKeyPasswd.RunE = func(cmd *cobra.Command, args []string) error {
flags.StringVarP(&keyPasswdOpts.Hostname, "host", "", "", "the hostname for new key") return runKeyPasswd(cmd.Context(), globalOptions, keyPasswdOpts, args)
}
} }
func runKeyPasswd(ctx context.Context, gopts GlobalOptions, opts KeyPasswdOptions, args []string) error { func runKeyPasswd(ctx context.Context, gopts GlobalOptions, opts KeyPasswdOptions, args []string) error {
@ -57,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 { 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 { if err != nil {
return err return err
} }

View file

@ -52,22 +52,23 @@ type backendWrapper func(r backend.Backend) (backend.Backend, error)
// GlobalOptions hold all global options for restic. // GlobalOptions hold all global options for restic.
type GlobalOptions struct { type GlobalOptions struct {
Repo string Repo string
RepositoryFile string RepositoryFile string
PasswordFile string PasswordFile string
PasswordCommand string PasswordCommand string
KeyHint string KeyHint string
Quiet bool Quiet bool
Verbose int Verbose int
NoLock bool NoLock bool
RetryLock time.Duration RetryLock time.Duration
JSON bool JSON bool
CacheDir string CacheDir string
NoCache bool NoCache bool
CleanupCache bool CleanupCache bool
Compression repository.CompressionMode Compression repository.CompressionMode
PackSize uint PackSize uint
NoExtraVerify bool NoExtraVerify bool
InsecureNoPassword bool
backend.TransportOptions backend.TransportOptions
limiter.Limits limiter.Limits
@ -125,6 +126,7 @@ func init() {
f.BoolVar(&globalOptions.NoCache, "no-cache", false, "do not use a local cache") 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.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.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.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.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)") 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, // variable RESTIC_PASSWORD or prompts the user. If the context is canceled,
// the function leaks the password reading goroutine. // the function leaks the password reading goroutine.
func ReadPassword(ctx context.Context, opts GlobalOptions, prompt string) (string, error) { 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 != "" { if opts.password != "" {
return opts.password, nil return opts.password, nil
} }
@ -348,7 +357,7 @@ func ReadPassword(ctx context.Context, opts GlobalOptions, prompt string) (strin
} }
if len(password) == 0 { 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 return password, nil
@ -445,7 +454,7 @@ func OpenRepository(ctx context.Context, opts GlobalOptions) (*repository.Reposi
} }
passwordTriesLeft := 1 passwordTriesLeft := 1
if stdinIsTerminal() && opts.password == "" { if stdinIsTerminal() && opts.password == "" && !opts.InsecureNoPassword {
passwordTriesLeft = 3 passwordTriesLeft = 3
} }

View file

@ -1,8 +1,10 @@
package main package main
import ( import (
"context"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
rtest "github.com/restic/restic/internal/test" 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") 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)
}

View file

@ -11,11 +11,12 @@ import (
type secondaryRepoOptions struct { type secondaryRepoOptions struct {
password string password string
// from-repo options // from-repo options
Repo string Repo string
RepositoryFile string RepositoryFile string
PasswordFile string PasswordFile string
PasswordCommand string PasswordCommand string
KeyHint string KeyHint string
InsecureNoPassword bool
// repo2 options // repo2 options
LegacyRepo string LegacyRepo string
LegacyRepositoryFile 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.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.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.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.Repo = os.Getenv("RESTIC_FROM_REPOSITORY")
opts.RepositoryFile = os.Getenv("RESTIC_FROM_REPOSITORY_FILE") 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 != "" || hasFromRepo := opts.Repo != "" || opts.RepositoryFile != "" || opts.PasswordFile != "" ||
opts.KeyHint != "" || opts.PasswordCommand != "" opts.KeyHint != "" || opts.PasswordCommand != "" || opts.InsecureNoPassword
hasRepo2 := opts.LegacyRepo != "" || opts.LegacyRepositoryFile != "" || opts.LegacyPasswordFile != "" || hasRepo2 := opts.LegacyRepo != "" || opts.LegacyRepositoryFile != "" || opts.LegacyPasswordFile != "" ||
opts.LegacyKeyHint != "" || opts.LegacyPasswordCommand != "" opts.LegacyKeyHint != "" || opts.LegacyPasswordCommand != ""
@ -85,6 +87,7 @@ func fillSecondaryGlobalOpts(ctx context.Context, opts secondaryRepoOptions, gop
dstGopts.PasswordFile = opts.PasswordFile dstGopts.PasswordFile = opts.PasswordFile
dstGopts.PasswordCommand = opts.PasswordCommand dstGopts.PasswordCommand = opts.PasswordCommand
dstGopts.KeyHint = opts.KeyHint dstGopts.KeyHint = opts.KeyHint
dstGopts.InsecureNoPassword = opts.InsecureNoPassword
pwdEnv = "RESTIC_FROM_PASSWORD" pwdEnv = "RESTIC_FROM_PASSWORD"
repoPrefix = "source" repoPrefix = "source"
@ -98,6 +101,8 @@ func fillSecondaryGlobalOpts(ctx context.Context, opts secondaryRepoOptions, gop
dstGopts.PasswordFile = opts.LegacyPasswordFile dstGopts.PasswordFile = opts.LegacyPasswordFile
dstGopts.PasswordCommand = opts.LegacyPasswordCommand dstGopts.PasswordCommand = opts.LegacyPasswordCommand
dstGopts.KeyHint = opts.LegacyKeyHint dstGopts.KeyHint = opts.LegacyKeyHint
// keep existing bevhaior for legacy options
dstGopts.InsecureNoPassword = false
pwdEnv = "RESTIC_PASSWORD2" pwdEnv = "RESTIC_PASSWORD2"
} }

View file

@ -852,3 +852,26 @@ and then grants read/write permissions for group access.
.. note:: To manage who has access to the repository you can use .. note:: To manage who has access to the repository you can use
``usermod`` on Linux systems, to change which group controls ``usermod`` on Linux systems, to change which group controls
repository access ``chgrp -R`` is your friend. repository access ``chgrp -R`` is your friend.
Repositories with empty password
********************************
Restic by default refuses to create or operate on repositories that use an
empty password. Since restic 0.17.0, the option ``--insecure-no-password`` allows
disabling this check. Restic will not prompt for a password when using this option.
Specifying ``--insecure-no-password`` while also passing a password to restic
via a CLI option or via environment variable results in an error.
For security reasons, the option must always be specified when operating on
repositories with an empty password. For example to create a new repository
with an empty password, use the following command.
.. code-block:: console
restic init --insecure-no-password
The ``init`` and ``copy`` command also support the option ``--from-insecure-no-password``
which applies to the source repository. The ``key add`` and ``key passwd`` comands
include the ``--new-insecure-no-password`` option to add or set and emtpy password.