diff --git a/cli/command_repository_connect.go b/cli/command_repository_connect.go index 49b481a1f..893bac488 100644 --- a/cli/command_repository_connect.go +++ b/cli/command_repository_connect.go @@ -32,8 +32,9 @@ func setupConnectOptions(cmd *kingpin.CmdClause) { cmd.Flag("max-list-cache-duration", "Duration of index cache").Default("600s").Hidden().DurationVar(&connectMaxListCacheDuration) } -func connectOptions() repo.ConnectOptions { - return repo.ConnectOptions{ +func connectOptions() *repo.ConnectOptions { + return &repo.ConnectOptions{ + PersistCredentials: connectPersistCredentials, CachingOptions: content.CachingOptions{ CacheDirectory: connectCacheDirectory, MaxCacheSizeBytes: connectMaxCacheSizeMB << 20, //nolint:gomnd @@ -61,14 +62,6 @@ func runConnectCommandWithStorageAndPassword(ctx context.Context, st blob.Storag return err } - if connectPersistCredentials { - if err := persistPassword(configFile, getUserName(), password); err != nil { - return errors.Wrap(err, "unable to persist password") - } - } else { - deletePassword(configFile, getUserName()) - } - printStderr("Connected to repository.\n") return nil diff --git a/cli/command_repository_disconnect.go b/cli/command_repository_disconnect.go index 5ef9ba882..9ec933862 100644 --- a/cli/command_repository_disconnect.go +++ b/cli/command_repository_disconnect.go @@ -15,6 +15,5 @@ func init() { } func runDisconnectCommand(ctx context.Context) error { - deletePassword(repositoryConfigFileName(), getUserName()) return repo.Disconnect(repositoryConfigFileName()) } diff --git a/cli/password.go b/cli/password.go index 038c2dbe9..f63210a71 100644 --- a/cli/password.go +++ b/cli/password.go @@ -1,18 +1,13 @@ package cli import ( - "crypto/sha256" - "encoding/base64" "fmt" - "io" - "io/ioutil" - "os" - "path/filepath" "strings" "github.com/bgentry/speakeasy" "github.com/pkg/errors" - keyring "github.com/zalando/go-keyring" + + "github.com/kopia/kopia/repo" ) var ( @@ -59,7 +54,7 @@ func getPasswordFromFlags(isNew, allowPersistent bool) (string, error) { } if !isNew && allowPersistent { - pass, ok := getPersistedPassword(repositoryConfigFileName(), getUserName()) + pass, ok := repo.GetPersistedPassword(repositoryConfigFileName()) if ok { return pass, nil } @@ -92,70 +87,3 @@ func askPass(prompt string) (string, error) { return "", errors.New("can't get password") } - -func getPersistedPassword(configFile, username string) (string, bool) { - if *keyringEnabled { - kr, err := keyring.Get(getKeyringItemID(configFile), username) - if err == nil { - log.Debugf("password for %v retrieved from OS keyring", configFile) - return kr, true - } - } - - b, err := ioutil.ReadFile(passwordFileName(configFile)) - if err == nil { - s, err := base64.StdEncoding.DecodeString(string(b)) - if err == nil { - log.Debugf("password for %v retrieved from password file", configFile) - return string(s), true - } - } - - log.Debugf("could not find persisted password") - - return "", false -} - -func persistPassword(configFile, username, password string) error { - if *keyringEnabled { - log.Debugf("saving password to OS keyring...") - - err := keyring.Set(getKeyringItemID(configFile), username, password) - if err == nil { - log.Infof("Saved password") - return nil - } - - return err - } - - fn := passwordFileName(configFile) - log.Infof("Saving password to file %v.", fn) - - return ioutil.WriteFile(fn, []byte(base64.StdEncoding.EncodeToString([]byte(password))), 0600) -} - -func deletePassword(configFile, username string) { - // delete from both keyring and a file - if *keyringEnabled { - err := keyring.Delete(getKeyringItemID(configFile), username) - if err == nil { - log.Infof("deleted repository password for %v.", configFile) - } else if err != keyring.ErrNotFound { - log.Warningf("unable to delete keyring item %v: %v", getKeyringItemID(configFile), err) - } - } - - _ = os.Remove(passwordFileName(configFile)) -} - -func getKeyringItemID(configFile string) string { - h := sha256.New() - io.WriteString(h, configFile) //nolint:errcheck - - return fmt.Sprintf("%v-%x", filepath.Base(configFile), h.Sum(nil)[0:8]) -} - -func passwordFileName(configFile string) string { - return configFile + ".kopia-password" -} diff --git a/cli/password_darwin.go b/cli/password_darwin.go index 0901a6d81..76fdabdaa 100644 --- a/cli/password_darwin.go +++ b/cli/password_darwin.go @@ -1,3 +1,7 @@ package cli -var keyringEnabled = app.Flag("use-keychain", "Use macOS Keychain for storing repository password.").Default("true").Bool() +import "github.com/kopia/kopia/repo" + +func init() { + app.Flag("use-keychain", "Use macOS Keychain for storing repository password.").Default("true").BoolVar(&repo.KeyRingEnabled) +} diff --git a/cli/password_linux.go b/cli/password_linux.go index 063a0ee3c..7672f50e2 100644 --- a/cli/password_linux.go +++ b/cli/password_linux.go @@ -1,3 +1,7 @@ package cli -var keyringEnabled = app.Flag("use-keyring", "Use Gnome Keyring for storing repository password.").Default("false").Bool() +import "github.com/kopia/kopia/repo" + +func init() { + app.Flag("use-keyring", "Use Gnome Keyring for storing repository password.").Default("false").BoolVar(&repo.KeyRingEnabled) +} diff --git a/cli/password_windows.go b/cli/password_windows.go index 54cafa933..a828c7a08 100644 --- a/cli/password_windows.go +++ b/cli/password_windows.go @@ -1,3 +1,7 @@ package cli -var keyringEnabled = app.Flag("use-credential-manager", "Use Windows Credential Manager for storing repository password.").Default("true").Bool() +import "github.com/kopia/kopia/repo" + +func init() { + app.Flag("use-credential-manager", "Use Windows Credential Manager for storing repository password.").Default("true").BoolVar(&repo.KeyRingEnabled) +} diff --git a/examples/upload_download/setup_repository.go b/examples/upload_download/setup_repository.go index 7e5be02a2..f304bcaaf 100644 --- a/examples/upload_download/setup_repository.go +++ b/examples/upload_download/setup_repository.go @@ -43,7 +43,7 @@ func setupRepositoryAndConnect(ctx context.Context, password string) error { } // now establish connection to repository and create configuration file. - if err := repo.Connect(ctx, configFile, st, password, repo.ConnectOptions{ + if err := repo.Connect(ctx, configFile, st, password, &repo.ConnectOptions{ CachingOptions: content.CachingOptions{ CacheDirectory: cacheDirectory, MaxCacheSizeBytes: 100000000, diff --git a/internal/repotesting/repotesting.go b/internal/repotesting/repotesting.go index 465aaf463..1e3a872e5 100644 --- a/internal/repotesting/repotesting.go +++ b/internal/repotesting/repotesting.go @@ -68,11 +68,7 @@ func (e *Environment) Setup(t *testing.T, opts ...func(*repo.NewRepositoryOption t.Fatalf("err: %v", err) } - connOpts := repo.ConnectOptions{ - //TraceStorage: log.Printf, - } - - if err = repo.Connect(ctx, e.configFile(), st, masterPassword, connOpts); err != nil { + if err = repo.Connect(ctx, e.configFile(), st, masterPassword, nil); err != nil { t.Fatalf("can't connect: %v", err) } diff --git a/repo/connect.go b/repo/connect.go index 2db1ce3c4..68a88c251 100644 --- a/repo/connect.go +++ b/repo/connect.go @@ -17,11 +17,17 @@ // ConnectOptions specifies options when persisting configuration to connect to a repository. type ConnectOptions struct { + PersistCredentials bool `json:"persistCredentials"` + content.CachingOptions } // Connect connects to the repository in the specified storage and persists the configuration and credentials in the file provided. -func Connect(ctx context.Context, configFile string, st blob.Storage, password string, opt ConnectOptions) error { +func Connect(ctx context.Context, configFile string, st blob.Storage, password string, opt *ConnectOptions) error { + if opt == nil { + opt = &ConnectOptions{} + } + formatBytes, err := st.GetBlob(ctx, FormatBlobID, 0, -1) if err != nil { return errors.Wrap(err, "unable to read format blob") @@ -58,6 +64,14 @@ func Connect(ctx context.Context, configFile string, st blob.Storage, password s return err } + if opt.PersistCredentials { + if err := persistPassword(configFile, password); err != nil { + return errors.Wrap(err, "unable to persist password") + } + } else { + deletePassword(configFile) + } + return r.Close(ctx) } @@ -106,6 +120,8 @@ func Disconnect(configFile string) error { return err } + deletePassword(configFile) + if cfg.Caching.CacheDirectory != "" { if err = os.RemoveAll(cfg.Caching.CacheDirectory); err != nil { log.Warningf("unable to to remove cache directory: %v", err) diff --git a/repo/password.go b/repo/password.go new file mode 100644 index 000000000..3bdb37b72 --- /dev/null +++ b/repo/password.go @@ -0,0 +1,108 @@ +package repo + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "io/ioutil" + "os" + "os/user" + "path/filepath" + "runtime" + "strings" + + "github.com/zalando/go-keyring" +) + +// KeyRingEnabled enables password persistence uses OS-specific keyring. +var KeyRingEnabled = false + +// GetPersistedPassword retrieves persisted password for a given repository config. +func GetPersistedPassword(configFile string) (string, bool) { + if KeyRingEnabled { + kr, err := keyring.Get(getKeyringItemID(configFile), keyringUsername()) + if err == nil { + log.Debugf("password for %v retrieved from OS keyring", configFile) + return kr, true + } + } + + b, err := ioutil.ReadFile(passwordFileName(configFile)) + if err == nil { + s, err := base64.StdEncoding.DecodeString(string(b)) + if err == nil { + log.Debugf("password for %v retrieved from password file", configFile) + return string(s), true + } + } + + log.Debugf("could not find persisted password") + + return "", false +} + +// persistPassword stores password for a given repository config. +func persistPassword(configFile, password string) error { + if KeyRingEnabled { + log.Debugf("saving password to OS keyring...") + + err := keyring.Set(getKeyringItemID(configFile), keyringUsername(), password) + if err == nil { + log.Infof("Saved password") + return nil + } + + return err + } + + fn := passwordFileName(configFile) + log.Infof("Saving password to file %v.", fn) + + return ioutil.WriteFile(fn, []byte(base64.StdEncoding.EncodeToString([]byte(password))), 0600) +} + +// deletePassword removes stored repository password. +func deletePassword(configFile string) { + // delete from both keyring and a file + if KeyRingEnabled { + err := keyring.Delete(getKeyringItemID(configFile), keyringUsername()) + if err == nil { + log.Infof("deleted repository password for %v.", configFile) + } else if err != keyring.ErrNotFound { + log.Warningf("unable to delete keyring item %v: %v", getKeyringItemID(configFile), err) + } + } + + _ = os.Remove(passwordFileName(configFile)) +} + +func getKeyringItemID(configFile string) string { + h := sha256.New() + io.WriteString(h, configFile) //nolint:errcheck + + return fmt.Sprintf("%v-%x", filepath.Base(configFile), h.Sum(nil)[0:8]) +} + +func passwordFileName(configFile string) string { + return configFile + ".kopia-password" +} + +func keyringUsername() string { + currentUser, err := user.Current() + if err != nil { + log.Warningf("Cannot determine keyring username: %s", err) + return "nobody" + } + + u := currentUser.Username + + if runtime.GOOS == "windows" { + if p := strings.Index(u, "\\"); p >= 0 { + // On Windows ignore domain name. + u = u[p+1:] + } + } + + return u +} diff --git a/snapshot/snapshotfs/upload_test.go b/snapshot/snapshotfs/upload_test.go index 8d2b3275a..a12f65fdf 100644 --- a/snapshot/snapshotfs/upload_test.go +++ b/snapshot/snapshotfs/upload_test.go @@ -58,7 +58,7 @@ func newUploadTestHarness() *uploadTestHarness { log.Debugf("repo dir: %v", repoDir) configFile := filepath.Join(repoDir, ".kopia.config") - if conerr := repo.Connect(ctx, configFile, storage, masterPassword, repo.ConnectOptions{}); conerr != nil { + if conerr := repo.Connect(ctx, configFile, storage, masterPassword, nil); conerr != nil { panic("unable to connect to repository: " + conerr.Error()) } diff --git a/tests/repository_stress_test/repository_stress_test.go b/tests/repository_stress_test/repository_stress_test.go index af6f0f46f..9b2e497e8 100644 --- a/tests/repository_stress_test/repository_stress_test.go +++ b/tests/repository_stress_test/repository_stress_test.go @@ -69,7 +69,7 @@ func TestStressRepository(t *testing.T) { } // set up two parallel kopia connections, each with its own config file and cache. - if err := repo.Connect(ctx, configFile1, st, masterPassword, repo.ConnectOptions{ + if err := repo.Connect(ctx, configFile1, st, masterPassword, &repo.ConnectOptions{ CachingOptions: content.CachingOptions{ CacheDirectory: filepath.Join(tmpPath, "cache1"), MaxCacheSizeBytes: 2000000000, @@ -78,7 +78,7 @@ func TestStressRepository(t *testing.T) { t.Fatalf("unable to connect 1: %v", err) } - if err := repo.Connect(ctx, configFile2, st, masterPassword, repo.ConnectOptions{ + if err := repo.Connect(ctx, configFile2, st, masterPassword, &repo.ConnectOptions{ CachingOptions: content.CachingOptions{ CacheDirectory: filepath.Join(tmpPath, "cache2"), MaxCacheSizeBytes: 2000000000,