Skip to content

Commit

Permalink
Add gopass merge (#1979)
Browse files Browse the repository at this point in the history
* Add gopass merge

Add a subcommand to implement a merge workflow.
This command accepts multiple entries to be merged
into one to help deduplicating secrets.

Fixes #1948

RELEASE_NOTES=[ENHACNEMENT] Add gopass merge

Signed-off-by: Dominik Schulz <[email protected]>

* Add subcommand documentation.

RELEASE_NOTES=n/a

Signed-off-by: Dominik Schulz <[email protected]>

* Update tests

RELEASE_NOTES=n/a

Signed-off-by: Dominik Schulz <[email protected]>

* Fix queue bugs.

RELEASE_NOTES=n/a

Signed-off-by: Dominik Schulz <[email protected]>
  • Loading branch information
dominikschulz authored Aug 28, 2021
1 parent 0aded87 commit d81ebf6
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 3 deletions.
1 change: 1 addition & 0 deletions fish.completion
Original file line number Diff line number Diff line change
Expand Up @@ -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"'
Expand Down
27 changes: 27 additions & 0 deletions internal/action/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
105 changes: 105 additions & 0 deletions internal/action/merge.go
Original file line number Diff line number Diff line change
@@ -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 <to> <from> [<from>]", s.Name)
}
if len(from) < 1 {
return ExitError(ExitUsage, nil, "usage: %s merge <to> <from> [<from>]", 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
}
31 changes: 30 additions & 1 deletion internal/queue/background.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ package queue

import (
"context"
"fmt"
"time"

"github.com/gopasspw/gopass/pkg/debug"
)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ var commandsWithError = map[string]struct{}{
".init": {},
".insert": {},
".link": {},
".merge": {},
".mounts.add": {},
".mounts.remove": {},
".move": {},
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion zsh.completion
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit d81ebf6

Please sign in to comment.