diff --git a/fish.completion b/fish.completion index f3c6b85ac4..d45d2c33ac 100644 --- a/fish.completion +++ b/fish.completion @@ -208,6 +208,7 @@ complete -c $PROG -f -n '__fish_gopass_uses_command insert' -a "(__fish_gopass_p complete -c $PROG -f -n '__fish_gopass_needs_command' -a link -d 'Command: Create a symlink' complete -c $PROG -f -n '__fish_gopass_needs_command' -a list -d 'Command: List existing secrets' complete -c $PROG -f -n '__fish_gopass_uses_command list' -a "(__fish_gopass_print_dir)" +complete -c $PROG -f -n '__fish_gopass_needs_command' -a merge -d 'Command: Merge multiple secrets into one' complete -c $PROG -f -n '__fish_gopass_needs_command' -a mounts -d 'Command: Edit mounted stores' complete -c $PROG -f -n '__fish_gopass_uses_command mounts' -a add -d 'Subcommand: Mount a password store' complete -c $PROG -f -n '__fish_gopass_uses_command mounts add -l yes -d "Always answer yes to yes/no questions"' diff --git a/internal/action/commands.go b/internal/action/commands.go index c0cda48a12..4bf9fd089b 100644 --- a/internal/action/commands.go +++ b/internal/action/commands.go @@ -673,6 +673,33 @@ func (s *Action) GetCommands() []*cli.Command { }, }, }, + { + Name: "merge", + Usage: "Merge multiple secrets into one", + ArgsUsage: "[to] [from]...", + Description: "" + + "This command implements a merge workflow to help deduplicate " + + "secrets. It requires exactly one destination (may already exist) " + + "and at least one source (must exist, can be multiple). gopass will " + + "then merge all entries into one, drop into an editor, save the result " + + "and remove all merged entries.", + Before: s.IsInitialized, + Action: s.Merge, + BashComplete: s.Complete, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "delete", + Aliases: []string{"d"}, + Usage: "Remove merged entries", + Value: true, + }, + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "Skip editor, merge entries unattended", + }, + }, + }, { Name: "move", Aliases: []string{"mv"}, diff --git a/internal/action/merge.go b/internal/action/merge.go new file mode 100644 index 0000000000..f9cc3ffdc2 --- /dev/null +++ b/internal/action/merge.go @@ -0,0 +1,105 @@ +package action + +import ( + "bytes" + "fmt" + "time" + + "github.com/gopasspw/gopass/internal/audit" + "github.com/gopasspw/gopass/internal/editor" + "github.com/gopasspw/gopass/internal/out" + "github.com/gopasspw/gopass/internal/queue" + "github.com/gopasspw/gopass/pkg/ctxutil" + "github.com/gopasspw/gopass/pkg/debug" + "github.com/gopasspw/gopass/pkg/gopass/secrets" + "github.com/urfave/cli/v2" +) + +// Merge implements the merge subcommand that allows merging multiple entries. +func (s *Action) Merge(c *cli.Context) error { + ctx := ctxutil.WithGlobalFlags(c) + to := c.Args().First() + from := c.Args().Tail() + + if to == "" { + return ExitError(ExitUsage, nil, "usage: %s merge []", s.Name) + } + if len(from) < 1 { + return ExitError(ExitUsage, nil, "usage: %s merge []", s.Name) + } + + ed := editor.Path(c) + if err := editor.Check(ctx, ed); err != nil { + out.Warningf(ctx, "Failed to check editor config: %s", err) + } + + content := &bytes.Buffer{} + for _, k := range c.Args().Slice() { + if !s.Store.Exists(ctx, k) { + continue + } + sec, err := s.Store.Get(ctxutil.WithShowParsing(ctx, false), k) + if err != nil { + return ExitError(ExitDecrypt, err, "failed to decrypt: %s: %s", k, err) + } + _, err = content.WriteString("\n# Secret: " + k + "\n") + if err != nil { + return ExitError(ExitUnknown, err, "failed to write: %s", err) + } + _, err = content.Write(sec.Bytes()) + if err != nil { + return ExitError(ExitUnknown, err, "failed to write: %s", err) + } + } + + newContent := content.Bytes() + if !c.Bool("force") { + var err error + // invoke the editor to let the user edit the content + newContent, err = editor.Invoke(ctx, ed, content.Bytes()) + if err != nil { + return ExitError(ExitUnknown, err, "failed to invoke editor: %s", err) + } + + // If content is equal, nothing changed, exiting + if bytes.Equal(content.Bytes(), newContent) { + return nil + } + } + + nSec := secrets.ParsePlain(newContent) + + // if the secret has a password, we check it's strength + if pw := nSec.Password(); pw != "" && !c.Bool("force") { + audit.Single(ctx, pw) + } + + // write result (back) to store + if err := s.Store.Set(ctxutil.WithCommitMessage(ctx, fmt.Sprintf("Merged %+v", c.Args().Slice())), to, nSec); err != nil { + return ExitError(ExitEncrypt, err, "failed to encrypt secret %s: %s", to, err) + } + + if !c.Bool("delete") { + return nil + } + + // wait until the previous commit is done + // TODO: This wouldn't be necessary if we could handle merging and deleting + // in a single commit, but then we'd need to expose additional implementation + // details of the underlying VCS. Or create some kind of transaction on top + // of the Git wrapper. + if err := queue.GetQueue(ctx).Idle(time.Minute); err != nil { + return err + } + + for _, old := range from { + if !s.Store.Exists(ctx, old) { + continue + } + debug.Log("deleting merged entry %s", old) + if err := s.Store.Delete(ctx, old); err != nil { + return ExitError(ExitUnknown, err, "failed to delete %s: %s", old, err) + } + } + return nil +} diff --git a/internal/queue/background.go b/internal/queue/background.go index 189c9f2292..ee497e1ef9 100644 --- a/internal/queue/background.go +++ b/internal/queue/background.go @@ -9,6 +9,8 @@ package queue import ( "context" + "fmt" + "time" "github.com/gopasspw/gopass/pkg/debug" ) @@ -23,6 +25,7 @@ const ( type Queuer interface { Add(Task) Task Wait(context.Context) error + Idle(time.Duration) error } // WithQueue adds the given queue to the context @@ -51,6 +54,11 @@ func (n *noop) Wait(_ context.Context) error { return nil } +// Idle always returns nil +func (n *noop) Idle(_ time.Duration) error { + return nil +} + // Task is a background task type Task func(ctx context.Context) error @@ -88,7 +96,28 @@ func (q *Queue) Add(t Task) Task { return func(_ context.Context) error { return nil } } -// Wait waits for all tasks to be processed +// Idle returns nil the next time the queue is empty +func (q *Queue) Idle(maxWait time.Duration) error { + done := make(chan struct{}) + go func() { + for { + if len(q.work) < 1 { + done <- struct{}{} + } + time.Sleep(20 * time.Millisecond) + } + }() + select { + case <-done: + return nil + case <-time.After(maxWait): + return fmt.Errorf("timed out waiting for empty queue") + } +} + +// Wait waits for all tasks to be processed. Must only be called once on +// shutdown. +// TODO: This should be called Close func (q *Queue) Wait(ctx context.Context) error { close(q.work) select { diff --git a/main_test.go b/main_test.go index 1d4ae2c513..3294ae5fb2 100644 --- a/main_test.go +++ b/main_test.go @@ -76,6 +76,7 @@ var commandsWithError = map[string]struct{}{ ".init": {}, ".insert": {}, ".link": {}, + ".merge": {}, ".mounts.add": {}, ".mounts.remove": {}, ".move": {}, @@ -122,7 +123,7 @@ func TestGetCommands(t *testing.T) { c.Context = ctx commands := getCommands(act, app) - assert.Equal(t, 37, len(commands)) + assert.Equal(t, 38, len(commands)) prefix := "" testCommands(t, c, commands, prefix) diff --git a/zsh.completion b/zsh.completion index 54f88035cc..610d13a276 100644 --- a/zsh.completion +++ b/zsh.completion @@ -173,6 +173,12 @@ WARNING: This will update the secret content to the latest format. This might be _describe -t commands "gopass list" subcommands _gopass_complete_folders + ;; + merge) + _arguments : "--delete[Remove merged entries]" "--force[Skip editor, merge entries unattended]" + _describe -t commands "gopass merge" subcommands + + ;; mounts) local -a subcommands @@ -198,7 +204,7 @@ WARNING: This will update the secret content to the latest format. This might be ;; pwgen) - _arguments : "--no-numerals[Do not include numerals in the generated passwords.]" "--no-capitalize[Do not include capital letter in the generated passwords.]" "--ambiguous[Do not include characters that could be easily confused with each other, like '1' and 'l' or '0' and 'O']" "--one-per-line[Print one password per line]" "--xkcd[Use multiple random english words combined to a password. By default, space is used as separator and all words are lowercase]" "--sep[Word separator for generated xkcd style password. If no separator is specified, the words are combined without spaces/separator and the first character of words is capitalised. This flag implies -xkcd]" "--lang[Language to generate password from, currently de (german) and en (english, default) are supported]" + _arguments : "--no-numerals[Do not include numerals in the generated passwords.]" "--no-capitalize[Do not include capital letter in the generated passwords.]" "--ambiguous[Do not include characters that could be easily confused with each other, like '1' and 'l' or '0' and 'O']" "--symbols[Include at least one symbol in the password.]" "--one-per-line[Print one password per line]" "--xkcd[Use multiple random english words combined to a password. By default, space is used as separator and all words are lowercase]" "--sep[Word separator for generated xkcd style password. If no separator is specified, the words are combined without spaces/separator and the first character of words is capitalised. This flag implies -xkcd]" "--lang[Language to generate password from, currently de (german) and en (english, default) are supported]" _describe -t commands "gopass pwgen" subcommands @@ -305,6 +311,7 @@ WARNING: This will update the secret content to the latest format. This might be "insert:Insert a new secret" "link:Create a symlink" "list:List existing secrets" + "merge:Merge multiple secrets into one" "mounts:Edit mounted stores" "move:Move secrets from one location to another" "otp:Generate time- or hmac-based tokens"