From 86ef72d610060c9ec80be848a3e89e57d5aca2b3 Mon Sep 17 00:00:00 2001 From: Dominik Schulz Date: Thu, 23 Dec 2021 21:05:29 +0100 Subject: [PATCH] Create wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RELEASE_NOTES=[ENHANCEMENT] Template support for the create wizard Signed-off-by: Dominik Schulz --- docs/commands/create.md | 62 +++++ internal/action/config.go | 6 + internal/action/config_test.go | 8 +- internal/action/create.go | 371 +-------------------------- internal/action/create_test.go | 183 ------------- internal/backend/storage/fs/store.go | 6 +- internal/create/helpers.go | 41 +++ internal/create/wizard.go | 346 +++++++++++++++++++++++++ internal/create/wizard_test.go | 95 +++++++ internal/set/map.go | 9 + tests/config_test.go | 8 +- 11 files changed, 577 insertions(+), 558 deletions(-) create mode 100644 docs/commands/create.md create mode 100644 internal/create/helpers.go create mode 100644 internal/create/wizard.go create mode 100644 internal/create/wizard_test.go create mode 100644 internal/set/map.go diff --git a/docs/commands/create.md b/docs/commands/create.md new file mode 100644 index 0000000000..6a647c8ba1 --- /dev/null +++ b/docs/commands/create.md @@ -0,0 +1,62 @@ +# `create` command + +The `create` command creates a new secret using a set of built-in or custom templates. +It implements a wizard that guides inexperienced users through the secret creating. + +The main design goal of this command was to guide users through the creation of a secret +and asking for the necessary information to create a reasonable secret location. + +## Synopsis + +``` +$ gopass create +$ gopass create --store=foo +``` + +## Modes of operation + +* Create a new secret using a wizard + +## Templates + +`gopass create` will look for files ending in `.yml` in the folder `.gopass/create` inside +the selected store (by default the root store). + +To add new templates to the wizard add templates to this folder. + +Example: + +``` +$ cat $(gopass config path)/.gopass/create/aws.yml +--- +priority: 5 +name: "AWS" +prefix: "aws" +name_from: + - "org" + - "user" +welcome: "🧪 Creating AWS credentials" +attributes: + org: + type: "string" + prompt: "Organization" + min: 1 + user: + type: "string" + prompt: "User" + min: 1 + password: + type: "password" + prompt: "Password" + comment: + type: "string" + prompt: "Comments" +``` + +## Flags + +Flag | Aliases | Description +---- | ------- | ----------- +`--store` | `-s` | Select the store to use. Will be used to look up user templates. +`--force` | `-f` | For overwriting existing entries. +`--print` | `-p` | Print the password to STDOUT. diff --git a/internal/action/config.go b/internal/action/config.go index 7fd394b551..702fca1217 100644 --- a/internal/action/config.go +++ b/internal/action/config.go @@ -37,6 +37,12 @@ func (s *Action) Config(c *cli.Context) error { func (s *Action) printConfigValues(ctx context.Context, needles ...string) { m := s.cfg.ConfigMap() for _, k := range filterMap(m, needles) { + // if only a single key is requested, print only the value + // useful for scriping, e.g. `$ cd $(gopass config path)` + if len(needles) == 1 { + out.Printf(ctx, "%s", m[k]) + continue + } out.Printf(ctx, "%s: %s", k, m[k]) } for alias, path := range s.cfg.Mounts { diff --git a/internal/action/config_test.go b/internal/action/config_test.go index 00a97a062a..3c6a8b4448 100644 --- a/internal/action/config_test.go +++ b/internal/action/config_test.go @@ -56,7 +56,7 @@ parsing: true defer buf.Reset() assert.NoError(t, act.setConfigValue(ctx, "nopager", "true")) - assert.Equal(t, "nopager: true", strings.TrimSpace(buf.String()), "action.setConfigValue") + assert.Equal(t, "true", strings.TrimSpace(buf.String()), "action.setConfigValue") }) t.Run("set invalid config value", func(t *testing.T) { @@ -70,7 +70,7 @@ parsing: true act.printConfigValues(ctx, "nopager") - want := "nopager: true" + want := "true" assert.Equal(t, want, strings.TrimSpace(buf.String()), "action.printConfigValues") }) @@ -98,7 +98,7 @@ parsing: true c := gptest.CliCtx(ctx, t, "autoimport") assert.NoError(t, act.Config(c)) - assert.Equal(t, "autoimport: true", strings.TrimSpace(buf.String())) + assert.Equal(t, "true", strings.TrimSpace(buf.String())) }) t.Run("disable autoimport", func(t *testing.T) { @@ -106,7 +106,7 @@ parsing: true c := gptest.CliCtx(ctx, t, "autoimport", "false") assert.NoError(t, act.Config(c)) - assert.Equal(t, "autoimport: false", strings.TrimSpace(buf.String())) + assert.Equal(t, "false", strings.TrimSpace(buf.String())) }) t.Run("complete config items", func(t *testing.T) { diff --git a/internal/action/create.go b/internal/action/create.go index 4e9d0346c1..da24f0a662 100644 --- a/internal/action/create.go +++ b/internal/action/create.go @@ -3,33 +3,15 @@ package action import ( "context" "fmt" - "net/url" - "strconv" - "strings" - "github.com/fatih/color" + "github.com/gopasspw/gopass/internal/create" "github.com/gopasspw/gopass/internal/cui" "github.com/gopasspw/gopass/internal/out" "github.com/gopasspw/gopass/pkg/clipboard" "github.com/gopasspw/gopass/pkg/ctxutil" - "github.com/gopasspw/gopass/pkg/debug" - "github.com/gopasspw/gopass/pkg/fsutil" - "github.com/gopasspw/gopass/pkg/gopass/secrets" - "github.com/gopasspw/gopass/pkg/pwgen" - "github.com/gopasspw/gopass/pkg/pwgen/pwrules" - "github.com/gopasspw/gopass/pkg/termio" - "github.com/martinhoefling/goxkcdpwgen/xkcdpwgen" "github.com/urfave/cli/v2" ) -func fmtfn(d int, n string, t string) string { - strlen := 40 - d - // indent - [N] - text (trailing spaces) - fmtStr := "%" + strconv.Itoa(d) + "s%s %-" + strconv.Itoa(strlen) + "s" - debug.Log("d: %d, n: %q, t: %q, strlen: %d, fmtStr: %q", d, n, t, strlen, fmtStr) - return fmt.Sprintf(fmtStr, "", color.GreenString("["+n+"]"), t) -} - // Create displays the password creation wizard func (s *Action) Create(c *cli.Context) error { ctx := ctxutil.WithGlobalFlags(c) @@ -37,10 +19,11 @@ func (s *Action) Create(c *cli.Context) error { out.Printf(ctx, "🌟 Welcome to the secret creation wizard (gopass create)!") out.Printf(ctx, "🧪 Hint: Use 'gopass edit -c' for more control!") - acts := make(cui.Actions, 0, 5) - acts = append(acts, cui.Action{Name: "Website Login", Fn: s.createWebsite}) - acts = append(acts, cui.Action{Name: "PIN Code (numerical)", Fn: s.createPIN}) - acts = append(acts, cui.Action{Name: "Generic", Fn: s.createGeneric}) + wiz, err := create.New(ctx, s.Store.Storage(ctx, c.String("store"))) + if err != nil { + return err + } + acts := wiz.Actions(s.Store, s.createPrintOrCopy) act, sel := cui.GetSelection(ctx, "Please select the type of secret you would like to create", acts.Selection()) switch act { @@ -53,118 +36,6 @@ func (s *Action) Create(c *cli.Context) error { } } -// extractHostname tries to extract the hostname from a URL in a filepath-safe -// way for use in the name of a secret -func extractHostname(in string) string { - if in == "" { - return "" - } - // help url.Parse by adding a scheme if one is missing. This should still - // allow for any scheme, but by default we assume http (only for parsing) - urlStr := in - if !strings.Contains(urlStr, "://") { - urlStr = "http://" + urlStr - } - u, err := url.Parse(urlStr) - if err == nil { - if ch := fsutil.CleanFilename(u.Hostname()); ch != "" { - return ch - } - } - return fsutil.CleanFilename(in) -} - -// createWebsite walks through the website credential creation wizard -func (s *Action) createWebsite(ctx context.Context, c *cli.Context) error { - name := c.Args().First() - store := c.String("store") - force := c.Bool("force") - - out.Print(ctx, "🧪 Creating Website login") - urlStr, err := termio.AskForString(ctx, fmtfn(2, "1", "URL"), "") - if err != nil { - return err - } - // the hostname is used as part of the name - hostname := extractHostname(urlStr) - if hostname == "" { - return ExitError(ExitUnknown, err, "Can not parse URL %q. Please use 'gopass edit' to manually create the secret", urlStr) - } - - username, err := termio.AskForString(ctx, fmtfn(2, "2", "Login"), "") - if err != nil { - return err - } - - genPw, err := termio.AskForBool(ctx, fmtfn(2, "3", "Generate Password?"), true) - if err != nil { - return err - } - - var password string - if genPw { - password, err = s.createGeneratePassword(ctx, hostname) - if err != nil { - return err - } - } else { - password, err = termio.AskForPassword(ctx, fmt.Sprintf("password for %s", username), true) - if err != nil { - return err - } - } - - comment, err := termio.AskForString(ctx, fmtfn(2, "4", "Comments"), "") - if err != nil { - debug.Log("failed to read comment input: %s", err) - // ignore the error, comments are considered optional - } - - // select store - if store == "" { - store = cui.AskForStore(ctx, s.Store) - } - - // generate name, ask for override if already taken - if store != "" { - store += "/" - } - - // by default create will generate a name for the secret based on the user - // input. Only when the force flag is given it will accept a secrets path - // as the first argument. - if name == "" || !force { - name = fmt.Sprintf("%swebsites/%s/%s", store, fsutil.CleanFilename(hostname), fsutil.CleanFilename(username)) - } - if force && !strings.HasPrefix(name, store) { - out.Warningf(ctx, "User supplied secret name %q does not match requested mount %q. Ignoring store flag.", name, store) - } - - // force will also override the check for existing entries - if s.Store.Exists(ctx, name) && !force { - name, err = termio.AskForString(ctx, fmtfn(2, "5", "Secret already exists. Choose another path or enter to overwrite"), name) - if err != nil { - return err - } - } - - // populate a new secret with the gathered information - sec := secrets.New() - sec.SetPassword(password) - sec.Set("url", urlStr) - sec.Set("username", username) - sec.Set("comment", comment) - if u := pwrules.LookupChangeURL(hostname); u != "" { - sec.Set("password-change-url", u) - } - if err := s.Store.Set(ctxutil.WithCommitMessage(ctx, "Created new entry"), name, sec); err != nil { - return ExitError(ExitEncrypt, err, "failed to set %q: %s", name, err) - } - out.OKf(ctx, "Credentials saved to %q", name) - - return s.createPrintOrCopy(ctx, c, name, password, genPw) -} - // createPrintOrCopy will display the created password (or copy to clipboard) func (s *Action) createPrintOrCopy(ctx context.Context, c *cli.Context, name, password string, genPw bool) error { if !genPw { @@ -181,233 +52,3 @@ func (s *Action) createPrintOrCopy(ctx context.Context, c *cli.Context, name, pa } return nil } - -// createPIN will walk through the numerical password (PIN) wizard -func (s *Action) createPIN(ctx context.Context, c *cli.Context) error { - name := c.Args().First() - store := c.String("store") - force := c.Bool("force") - - out.Printf(ctx, "🧪 Creating numerical PIN ...") - authority, err := termio.AskForString(ctx, fmtfn(2, "1", "Authority"), "") - if err != nil { - return err - } - if authority == "" { - return ExitError(ExitUnknown, nil, "Authority must not be empty") - } - - application, err := termio.AskForString(ctx, fmtfn(2, "2", "Entity"), "") - if err != nil { - return err - } - if application == "" { - return ExitError(ExitUnknown, nil, "Application must not be empty") - } - - genPw, err := termio.AskForBool(ctx, fmtfn(2, "3", "Generate PIN?"), false) - if err != nil { - return err - } - - var password string - if genPw { - password, err = s.createGeneratePIN(ctx) - if err != nil { - return err - } - } else { - password, err = termio.AskForPassword(ctx, "PIN", true) - if err != nil { - return err - } - } - - comment, err := termio.AskForString(ctx, fmtfn(2, "4", "Comments"), "") - if err != nil { - debug.Log("failed to read comment input: %s", err) - // ignore the error, comments are considered optional - } - - // select store - if store == "" { - store = cui.AskForStore(ctx, s.Store) - } - - // generate name, ask for override if already taken - if store != "" { - store += "/" - } - - // by default create will generate a name for the secret based on the user - // input. Only when the force flag is given it will accept a secrets path - // as the first argument. - if name == "" || !force { - name = fmt.Sprintf("%spins/%s/%s", store, fsutil.CleanFilename(authority), fsutil.CleanFilename(application)) - } - if force && !strings.HasPrefix(name, store) { - out.Warningf(ctx, "User supplied secret name %q does not match requested mount %q. Ignoring store flag.", name, store) - } - - // force will also override the check for existing entries - if s.Store.Exists(ctx, name) && !force { - name, err = termio.AskForString(ctx, fmtfn(2, "5", "Secret already exists. Choose another path or enter to overwrite"), name) - if err != nil { - return err - } - } - - sec := secrets.New() - sec.SetPassword(password) - sec.Set("application", application) - sec.Set("comment", comment) - if err := s.Store.Set(ctxutil.WithCommitMessage(ctx, "Created new entry"), name, sec); err != nil { - return ExitError(ExitEncrypt, err, "failed to set %q: %s", name, err) - } - out.OKf(ctx, "Credentials saved to %q", name) - - return s.createPrintOrCopy(ctx, c, name, password, genPw) -} - -// createGeneric will walk through the generic secret wizard -func (s *Action) createGeneric(ctx context.Context, c *cli.Context) error { - name := c.Args().Get(0) - store := c.String("store") - force := c.Bool("force") - - out.Printf(ctx, "🧪 Creating generic secret ...") - shortname, err := termio.AskForString(ctx, fmtfn(2, "1", "Name"), "") - if err != nil { - return err - } - if shortname == "" { - return ExitError(ExitUnknown, nil, "Name must not be empty") - } - - genPw, err := termio.AskForBool(ctx, fmtfn(2, "2", "Generate password?"), true) - if err != nil { - return err - } - - var password string - if genPw { - password, err = s.createGeneratePassword(ctx, "") - if err != nil { - return err - } - } else { - password, err = termio.AskForPassword(ctx, fmt.Sprintf("password for %s", shortname), true) - if err != nil { - return err - } - } - - // select store - if store == "" { - store = cui.AskForStore(ctx, s.Store) - } - - // generate name, ask for override if already taken - if store != "" { - store += "/" - } - - // by default create will generate a name for the secret based on the user - // input. Only when the force flag is given it will accept a secrets path - // as the first argument. - if name == "" || !force { - name = fmt.Sprintf("%smisc/%s", store, fsutil.CleanFilename(shortname)) - } - if force && !strings.HasPrefix(name, store) { - out.Warningf(ctx, "User supplied secret name %q does not match requested mount %q. Ignoring store flag.", name, store) - } - - // force will also override the check for existing entries - if s.Store.Exists(ctx, name) && !force { - name, err = termio.AskForString(ctx, fmtfn(2, "5", "Secret already exists. Choose another path or enter to overwrite"), name) - if err != nil { - return err - } - } - - sec := secrets.New() - sec.SetPassword(password) - out.Printf(ctx, fmtfn(2, "3", "Enter zero or more key value pairs for this secret:")) - for { - key, err := termio.AskForString(ctx, fmtfn(4, "a", "Name (enter to quit)"), "") - if err != nil { - return err - } - if key == "" { - break - } - val, err := termio.AskForString(ctx, fmtfn(4, "b", "Value for Key '"+key+"'"), "") - if err != nil { - return err - } - sec.Set(key, val) - } - if err := s.Store.Set(ctxutil.WithCommitMessage(ctx, "Created new entry"), name, sec); err != nil { - return ExitError(ExitEncrypt, err, "failed to set %q: %s", name, err) - } - out.OKf(ctx, "Credentials saved to %q", name) - - return s.createPrintOrCopy(ctx, c, name, password, genPw) -} - -// createGeneratePasssword will walk through the password generation steps -func (s *Action) createGeneratePassword(ctx context.Context, hostname string) (string, error) { - if _, found := pwrules.LookupRule(hostname); found { - out.Noticef(ctx, "Using password rules for %s ...", hostname) - length, err := termio.AskForInt(ctx, fmtfn(4, "b", "How long?"), defaultLength) - if err != nil { - return "", err - } - return pwgen.NewCrypticForDomain(length, hostname).Password(), nil - } - xkcd, err := termio.AskForBool(ctx, fmtfn(4, "a", "Human-pronounceable passphrase?"), false) - if err != nil { - return "", err - } - if xkcd { - length, err := termio.AskForInt(ctx, fmtfn(4, "b", "How many words?"), 4) - if err != nil { - return "", err - } - g := xkcdpwgen.NewGenerator() - g.SetNumWords(length) - g.SetDelimiter(" ") - g.SetCapitalize(true) - return string(g.GeneratePassword()), nil - } - - length, err := termio.AskForInt(ctx, fmtfn(4, "b", "How long?"), defaultLength) - if err != nil { - return "", err - } - - symbols, err := termio.AskForBool(ctx, fmtfn(4, "c", "Include symbols?"), false) - if err != nil { - return "", err - } - - corp, err := termio.AskForBool(ctx, fmtfn(4, "d", "Strict rules?"), false) - if err != nil { - return "", err - } - if corp { - return pwgen.GeneratePasswordWithAllClasses(length, symbols) - } - - return pwgen.GeneratePassword(length, symbols), nil -} - -// createGeneratePIN will walk through the PIN generation steps -func (s *Action) createGeneratePIN(ctx context.Context) (string, error) { - length, err := termio.AskForInt(ctx, fmtfn(4, "a", "How long?"), 4) - if err != nil { - return "", err - } - - return pwgen.GeneratePasswordCharset(length, "0123456789"), nil -} diff --git a/internal/action/create_test.go b/internal/action/create_test.go index d70e7eae2d..a8a4b88771 100644 --- a/internal/action/create_test.go +++ b/internal/action/create_test.go @@ -3,33 +3,17 @@ package action import ( "bytes" "context" - "flag" "os" - "strings" "testing" aclip "github.com/atotto/clipboard" "github.com/gopasspw/gopass/internal/out" "github.com/gopasspw/gopass/pkg/ctxutil" - "github.com/gopasspw/gopass/pkg/termio" "github.com/gopasspw/gopass/tests/gptest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/urfave/cli/v2" ) -func TestExtractHostname(t *testing.T) { - for in, out := range map[string]string{ - "": "", - "http://www.example.org/": "www.example.org", - "++#+++#jhlkadsrezu 33 553q ++++##$§&": "jhlkadsrezu_33_553q", - "www.example.org/?foo=bar#abc": "www.example.org", - "a test": "a_test", - } { - assert.Equal(t, out, extractHostname(in)) - } -} - func TestCreate(t *testing.T) { u := gptest.NewUnitTester(t) defer u.Remove() @@ -58,170 +42,3 @@ func TestCreate(t *testing.T) { assert.Error(t, act.Create(c)) buf.Reset() } - -func TestCreateWebsite(t *testing.T) { - u := gptest.NewUnitTester(t) - defer u.Remove() - - aclip.Unsupported = true - - ctx := context.Background() - ctx = ctxutil.WithInteractive(ctx, true) - ctx = ctxutil.WithNotifications(ctx, false) - - act, err := newMock(ctx, u) - require.NoError(t, err) - require.NotNil(t, act) - - act.cfg.ClipTimeout = 1 - - buf := &bytes.Buffer{} - out.Stderr = buf - termio.Stderr = buf - defer func() { - out.Stderr = os.Stderr - termio.Stderr = os.Stderr - }() - - // provide values on redirected stdin - input := `https://www.example.org/ -foobar -y -y -5 -` - termio.Stdin = strings.NewReader(input) - ctx = ctxutil.WithAlwaysYes(ctx, false) - defer func() { - termio.Stdin = os.Stdin - }() - - app := cli.NewApp() - // create - fs := flag.NewFlagSet("default", flag.ContinueOnError) - sf := cli.BoolFlag{ - Name: "print", - Usage: "print", - } - assert.NoError(t, sf.Apply(fs)) - assert.NoError(t, fs.Parse([]string{"--print=true"})) - c := cli.NewContext(app, fs, nil) - - assert.NoError(t, act.createWebsite(ctx, c)) - buf.Reset() - - // try to create the same entry twice - input = `https://www.example.org/ -foobar -y -y -5 -` - termio.Stdin = strings.NewReader(input) - - fs = flag.NewFlagSet("default", flag.ContinueOnError) - c = cli.NewContext(app, fs, nil) - - assert.NoError(t, act.createWebsite(ctx, c)) - buf.Reset() -} - -func TestCreatePIN(t *testing.T) { - u := gptest.NewUnitTester(t) - defer u.Remove() - - aclip.Unsupported = true - - ctx := context.Background() - ctx = ctxutil.WithInteractive(ctx, true) - ctx = ctxutil.WithNotifications(ctx, false) - - act, err := newMock(ctx, u) - require.NoError(t, err) - require.NotNil(t, act) - - act.cfg.ClipTimeout = 1 - - buf := &bytes.Buffer{} - out.Stderr = buf - termio.Stderr = buf - defer func() { - out.Stderr = os.Stderr - termio.Stderr = os.Stderr - }() - - ctx = ctxutil.WithAlwaysYes(ctx, true) - - pw, err := act.createGeneratePIN(ctx) - assert.NoError(t, err) - if len(pw) < 4 || len(pw) > 4 { - t.Errorf("PIN should have 4 characters") - } - buf.Reset() - - // provide values on redirected stdin - input := `MyBank -FooCard -y -8 -` - termio.Stdin = strings.NewReader(input) - ctx = ctxutil.WithAlwaysYes(ctx, false) - defer func() { - termio.Stdin = os.Stdin - }() - - app := cli.NewApp() - // create - fs := flag.NewFlagSet("default", flag.ContinueOnError) - c := cli.NewContext(app, fs, nil) - - assert.NoError(t, act.createPIN(ctx, c)) - buf.Reset() -} - -func TestCreateGeneric(t *testing.T) { - u := gptest.NewUnitTester(t) - defer u.Remove() - - aclip.Unsupported = true - - ctx := context.Background() - ctx = ctxutil.WithAlwaysYes(ctx, true) - ctx = ctxutil.WithNotifications(ctx, false) - - act, err := newMock(ctx, u) - require.NoError(t, err) - require.NotNil(t, act) - - act.cfg.ClipTimeout = 1 - - buf := &bytes.Buffer{} - out.Stderr = buf - termio.Stderr = buf - defer func() { - out.Stderr = os.Stderr - termio.Stderr = os.Stderr - }() - - // provide values on redirected stdin - input := `foobar -y -y -8 - -` - termio.Stdin = strings.NewReader(input) - ctx = ctxutil.WithAlwaysYes(ctx, false) - defer func() { - termio.Stdin = os.Stdin - }() - - app := cli.NewApp() - // create - fs := flag.NewFlagSet("default", flag.ContinueOnError) - c := cli.NewContext(app, fs, nil) - - assert.NoError(t, act.createGeneric(ctx, c)) - buf.Reset() -} diff --git a/internal/backend/storage/fs/store.go b/internal/backend/storage/fs/store.go index 42e2cc2adb..b8068305d2 100644 --- a/internal/backend/storage/fs/store.go +++ b/internal/backend/storage/fs/store.go @@ -115,13 +115,15 @@ func (s *Store) Exists(ctx context.Context, name string) bool { // directory separator are normalized using `/` func (s *Store) List(ctx context.Context, prefix string) ([]string, error) { prefix = strings.TrimPrefix(prefix, "/") - debug.Log("Listing %s", prefix) + debug.Log("Listing %s/%s", s.path, prefix) files := make([]string, 0, 100) if err := filepath.Walk(s.path, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - if info.IsDir() && strings.HasPrefix(info.Name(), ".") && path != s.path { + relPath := strings.TrimPrefix(path, s.path+string(filepath.Separator)) + string(filepath.Separator) + if info.IsDir() && strings.HasPrefix(info.Name(), ".") && path != s.path && !strings.HasPrefix(prefix, relPath) { + debug.Log("skipping dot dir (relPath: %s, prefix: %s)", relPath, prefix) return filepath.SkipDir } if info.IsDir() { diff --git a/internal/create/helpers.go b/internal/create/helpers.go new file mode 100644 index 0000000000..b318e36854 --- /dev/null +++ b/internal/create/helpers.go @@ -0,0 +1,41 @@ +package create + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/fatih/color" + "github.com/gopasspw/gopass/pkg/debug" + "github.com/gopasspw/gopass/pkg/fsutil" +) + +func fmtfn(d int, n string, t string) string { + strlen := 40 - d + // indent - [N] - text (trailing spaces) + fmtStr := "%" + strconv.Itoa(d) + "s%s %-" + strconv.Itoa(strlen) + "s" + debug.Log("d: %d, n: %q, t: %q, strlen: %d, fmtStr: %q", d, n, t, strlen, fmtStr) + return fmt.Sprintf(fmtStr, "", color.GreenString("["+n+"]"), t) +} + +// extractHostname tries to extract the hostname from a URL in a filepath-safe +// way for use in the name of a secret +func extractHostname(in string) string { + if in == "" { + return "" + } + // help url.Parse by adding a scheme if one is missing. This should still + // allow for any scheme, but by default we assume http (only for parsing) + urlStr := in + if !strings.Contains(urlStr, "://") { + urlStr = "http://" + urlStr + } + u, err := url.Parse(urlStr) + if err == nil { + if ch := fsutil.CleanFilename(u.Hostname()); ch != "" { + return ch + } + } + return fsutil.CleanFilename(in) +} diff --git a/internal/create/wizard.go b/internal/create/wizard.go new file mode 100644 index 0000000000..6bc93fc36f --- /dev/null +++ b/internal/create/wizard.go @@ -0,0 +1,346 @@ +package create + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/gopasspw/gopass/internal/backend" + "github.com/gopasspw/gopass/internal/cui" + "github.com/gopasspw/gopass/internal/out" + "github.com/gopasspw/gopass/internal/set" + "github.com/gopasspw/gopass/internal/store/root" + "github.com/gopasspw/gopass/pkg/ctxutil" + "github.com/gopasspw/gopass/pkg/debug" + "github.com/gopasspw/gopass/pkg/fsutil" + "github.com/gopasspw/gopass/pkg/gopass/secrets" + "github.com/gopasspw/gopass/pkg/pwgen" + "github.com/gopasspw/gopass/pkg/pwgen/pwrules" + "github.com/gopasspw/gopass/pkg/termio" + "github.com/martinhoefling/goxkcdpwgen/xkcdpwgen" + "github.com/urfave/cli/v2" + "gopkg.in/yaml.v3" +) + +const ( + defaultLength = 24 + defaultXKCDLength = 4 +) + +// Attribute is a credential attribute that is being asked for +// when populating a template. +type Attribute struct { + Type string `yaml:"type"` + Prompt string `yaml:"prompt"` + Charset string `yaml:"charset"` + Min int `yaml:"min"` + Max int `yaml:"max"` +} + +// Template is an action template for the create wizard. +type Template struct { + Name string `yaml:"name"` + Priority int `yaml:"priority"` + Prefix string `yaml:"prefix"` + NameFrom []string `yaml:"name_from"` + Welcome string `yaml:"welcome"` + Attributes map[string]Attribute `yaml:"attributes"` +} + +// Wizard is the templateable credential creation wizard. +type Wizard struct { + Templates []Template +} + +// New creates a new instance of the wizard. It will parse the user +// supplied templates and add the default templates. +func New(ctx context.Context, s backend.Storage) (*Wizard, error) { + w := &Wizard{ + Templates: []Template{ + { + Name: "Website login", + Priority: 0, + Prefix: "websites", + NameFrom: []string{"url", "username"}, + Welcome: "🧪 Creating Website login", + Attributes: map[string]Attribute{ + "url": { + Type: "hostname", + Prompt: "Website name", + Min: 1, + Max: 255, + }, + "username": { + Type: "string", + Prompt: "Login", + Min: 1, + }, + "password": { + Type: "password", + Prompt: "Password for the Website", + }, + }, + }, + { + Name: "PIN Code (numerical)", + Priority: 1, + Prefix: "pins", + NameFrom: []string{ + "authority", + "application", + }, + Welcome: "🔑 Creating PIN Code", + Attributes: map[string]Attribute{ + "authority": { + Type: "string", + Prompt: "Authority (Issuer)", + Min: 1, + }, + "application": { + Type: "string", + Prompt: "Entity (e.g. debit, credit card, etc.)", + Min: 1, + }, + "password": { + Type: "password", + Prompt: "PIN Code", + Min: 1, + Max: 64, + Charset: "0123456789", + }, + "comment": { + Type: "string", + Prompt: "Comment", + }, + }, + }, + }, + } + tpls, err := s.List(ctx, ".gopass/create/") + if err != nil { + return nil, err + } + for _, f := range tpls { + if !strings.HasSuffix(f, ".yml") && !strings.HasSuffix(f, ".yaml") { + debug.Log("ignoring unknown file extension: %s", f) + continue + } + buf, err := s.Get(ctx, f) + if err != nil { + debug.Log("failed to parse template %s: %s", f, err) + continue + } + tpl := Template{} + if err := yaml.Unmarshal(buf, &tpl); err != nil { + debug.Log("failed to parse template %s: %s", f, err) + out.Errorf(ctx, "Bad template %s: %s\n%s", f, err, string(buf)) + continue + } + w.Templates = append(w.Templates, tpl) + } + sort.Slice(w.Templates, func(i, j int) bool { + return w.Templates[i].Priority < w.Templates[j].Priority + }) + return w, nil +} + +// ActionCallback is the callback for the creation calls to print and copy the credentials. +type ActionCallback func(context.Context, *cli.Context, string, string, bool) error + +// Actions returns a list of actions that can be performed on the wizard. The actions directly +// interact with the underlying storage. +func (w *Wizard) Actions(s *root.Store, cb ActionCallback) cui.Actions { + sort.Slice(w.Templates, func(i, j int) bool { + return w.Templates[i].Priority < w.Templates[j].Priority + }) + acts := make(cui.Actions, 0, len(w.Templates)) + for _, tpl := range w.Templates { + acts = append(acts, cui.Action{ + Name: tpl.Name, + Fn: mkActFunc(tpl, s, cb), + }) + } + return acts +} + +func mkActFunc(tpl Template, s *root.Store, cb ActionCallback) func(context.Context, *cli.Context) error { + debug.Log("creating action func for %+v, cb: %p", tpl, cb) + return func(ctx context.Context, c *cli.Context) error { + name := c.Args().First() + store := c.String("store") + force := c.Bool("force") + + sec := secrets.New() + + out.Print(ctx, tpl.Welcome) + + // genPW is needed for the callback + var genPw bool + // password is needed for the callback + var password string + // hostname is needed in later iterations (e.g. password rule lookup) + var hostname string + // wantForName is a list of attributes that will be used to build the name + wantForName := set.Map(tpl.NameFrom) + // nameParts are the components the name will be built from + var nameParts []string + // step is only used for printing the progress + var step int + for k, v := range tpl.Attributes { + step++ + + // if no prompt is set default to the key + if v.Prompt == "" { + v.Prompt = strings.ToTitle(k) + } + + switch v.Type { + case "string": + sv, err := termio.AskForString(ctx, fmtfn(2, strconv.Itoa(step), v.Prompt), "") + if err != nil { + return err + } + if wantForName[k] { + nameParts = append(nameParts, sv) + } + sec.Set(k, sv) + case "hostname": + sv, err := termio.AskForString(ctx, fmtfn(2, strconv.Itoa(step), v.Prompt), "") + if err != nil { + return err + } + hostname = extractHostname(sv) + if hostname == "" { + return fmt.Errorf("can not parse URL %s", sv) + } + if wantForName[k] { + nameParts = append(nameParts, hostname) + } + if u := pwrules.LookupChangeURL(hostname); u != "" { + sec.Set("password-change-url", u) + } + sec.Set(k, sv) + case "password": + var err error + genPw, err = termio.AskForBool(ctx, fmtfn(2, strconv.Itoa(step), "Generate Password?"), true) + if err != nil { + return err + } + + if genPw { + password, err = generatePassword(ctx, hostname, v.Charset) + if err != nil { + return err + } + } else { + password, err = termio.AskForPassword(ctx, v.Prompt, true) + if err != nil { + return err + } + } + + sec.SetPassword(password) + } + } + + // select store + if store == "" { + store = cui.AskForStore(ctx, s) + } + + // now we can generate a name. If it's already take we can the user for an alternative + // name. + + // make sure the store is properly separated from the name + if store != "" { + store += "/" + } + + // by default create will generate a name for the secret based on the user + // input. Only when the force flag is given it will accept a secrets path + // as the first argument. + if name == "" || !force { + for i, s := range nameParts { + nameParts[i] = fsutil.CleanFilename(s) + } + name = fmt.Sprintf("%s%s/%s", store, tpl.Prefix, filepath.Join(nameParts...)) + } + if force && !strings.HasPrefix(name, store) { + out.Warningf(ctx, "User supplied secret name %q does not match requested mount %q. Ignoring store flag.", name, store) + } + + // force will also override the check for existing entries + if s.Exists(ctx, name) && !force { + step++ + var err error + name, err = termio.AskForString(ctx, fmtfn(2, strconv.Itoa(step), "Secret already exists. Choose another path or enter to overwrite"), name) + if err != nil { + return err + } + } + + if err := s.Set(ctxutil.WithCommitMessage(ctx, "Created new entry"), name, sec); err != nil { + return fmt.Errorf("failed to set %q: %s", name, err) + } + out.OKf(ctx, "Credentials saved to %q", name) + return cb(ctx, c, name, password, genPw) + } + +} + +// generatePasssword will walk through the password generation steps +func generatePassword(ctx context.Context, hostname, charset string) (string, error) { + if charset != "" { + length, err := termio.AskForInt(ctx, fmtfn(4, "a", "How long?"), 4) + if err != nil { + return "", err + } + return pwgen.GeneratePasswordCharset(length, charset), nil + } + if _, found := pwrules.LookupRule(hostname); found { + out.Noticef(ctx, "Using password rules for %s ...", hostname) + length, err := termio.AskForInt(ctx, fmtfn(4, "b", "How long?"), defaultLength) + if err != nil { + return "", err + } + return pwgen.NewCrypticForDomain(length, hostname).Password(), nil + } + xkcd, err := termio.AskForBool(ctx, fmtfn(4, "a", "Human-pronounceable passphrase?"), false) + if err != nil { + return "", err + } + if xkcd { + length, err := termio.AskForInt(ctx, fmtfn(4, "b", "How many words?"), defaultXKCDLength) + if err != nil { + return "", err + } + g := xkcdpwgen.NewGenerator() + g.SetNumWords(length) + g.SetDelimiter(" ") + g.SetCapitalize(true) + return string(g.GeneratePassword()), nil + } + + length, err := termio.AskForInt(ctx, fmtfn(4, "b", "How long?"), defaultLength) + if err != nil { + return "", err + } + + symbols, err := termio.AskForBool(ctx, fmtfn(4, "c", "Include symbols?"), false) + if err != nil { + return "", err + } + + corp, err := termio.AskForBool(ctx, fmtfn(4, "d", "Strict rules?"), false) + if err != nil { + return "", err + } + if corp { + return pwgen.GeneratePasswordWithAllClasses(length, symbols) + } + + return pwgen.GeneratePassword(length, symbols), nil +} diff --git a/internal/create/wizard_test.go b/internal/create/wizard_test.go new file mode 100644 index 0000000000..281b523511 --- /dev/null +++ b/internal/create/wizard_test.go @@ -0,0 +1,95 @@ +package create + +import ( + "context" + "testing" + + "github.com/gopasspw/gopass/internal/store/mockstore/inmem" + "github.com/stretchr/testify/assert" +) + +func TestNew(t *testing.T) { + ctx := context.Background() + s := inmem.New() + s.Set(ctx, ".create/pin.yml", []byte(`--- +priority: 1 +name: "PIN Code (numerical)" +prefix: "pin" +name_from: + - "authority" + - "application" +welcome: "🧪 Creating numerical PIN" +attributes: + authority: + type: "string" + prompt: "Authority" + min: 1 + application: + type: "string" + prompt: "Entity" + min: 1 + password: + type: "password" + prompt: "Pin" + charset: "0123456789" + min: 1 + max: 64 + comment: + type: "string" +`)) + w, err := New(ctx, s) + if err != nil { + t.Fatal(err) + } + if w.Templates == nil { + t.Fatal("no templates") + } + if len(w.Templates) != 3 { + t.Fatal("wrong number of templates") + } + if w.Templates[0].Prefix != "websites" { + t.Fatal("wrong prefix") + } + if w.Templates[0].Welcome != "🧪 Creating Website login" { + t.Fatal("wrong welcome") + } + if l := len(w.Templates[0].Attributes); l != 3 { + t.Fatalf("wrong number of attributes. want(%d), got(%d)", 3, l) + } + if w.Templates[0].Attributes["url"].Type != "hostname" { + t.Fatal("wrong type") + } + if w.Templates[0].Attributes["url"].Prompt != "Website name" { + t.Fatal("wrong prompt") + } + if w.Templates[0].Attributes["url"].Min != 1 { + t.Fatal("wrong min") + } + if w.Templates[0].Attributes["url"].Max != 255 { + t.Fatal("wrong max") + } + if w.Templates[0].Attributes["username"].Type != "string" { + t.Fatal("wrong type") + } + if w.Templates[0].Attributes["username"].Prompt != "Login" { + t.Fatal("wrong prompt") + } + if w.Templates[0].Attributes["username"].Min != 1 { + t.Fatal("wrong min") + } + if w.Templates[0].Attributes["password"].Type != "password" { + t.Fatal("wrong type") + } +} + +func TestExtractHostname(t *testing.T) { + for in, out := range map[string]string{ + "": "", + "http://www.example.org/": "www.example.org", + "++#+++#jhlkadsrezu 33 553q ++++##$§&": "jhlkadsrezu_33_553q", + "www.example.org/?foo=bar#abc": "www.example.org", + "a test": "a_test", + } { + assert.Equal(t, out, extractHostname(in)) + } +} diff --git a/internal/set/map.go b/internal/set/map.go new file mode 100644 index 0000000000..c51db42bd6 --- /dev/null +++ b/internal/set/map.go @@ -0,0 +1,9 @@ +package set + +func Map[K comparable](in []K) map[K]bool { + m := make(map[K]bool, len(in)) + for _, i := range in { + m[i] = true + } + return m +} \ No newline at end of file diff --git a/tests/config_test.go b/tests/config_test.go index 4370b8512f..d23b33d1c8 100644 --- a/tests/config_test.go +++ b/tests/config_test.go @@ -38,22 +38,22 @@ parsing: true t.Run("invert "+invert, func(t *testing.T) { out, err = ts.run("config " + invert + " false") assert.NoError(t, err) - assert.Equal(t, invert+": false", out) + assert.Equal(t, "false", out) out, err = ts.run("config " + invert) assert.NoError(t, err) - assert.Equal(t, invert+": false", out) + assert.Equal(t, "false", out) }) } t.Run("cliptimeout", func(t *testing.T) { out, err = ts.run("config cliptimeout 120") assert.NoError(t, err) - assert.Equal(t, "cliptimeout: 120", out) + assert.Equal(t, "120", out) out, err = ts.run("config cliptimeout") assert.NoError(t, err) - assert.Equal(t, "cliptimeout: 120", out) + assert.Equal(t, "120", out) }) }