mirror of
https://github.com/gopasspw/gopass.git
synced 2025-12-08 19:24:54 +00:00
This change makes the `gopass create` workflow pick up the config settings for pwgen the same way as the `gopass generate` workflows do. This allows more flexibility and users to override the defaults used in the create wizard. Fixes #3141 Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>
444 lines
12 KiB
Go
444 lines
12 KiB
Go
// Package create provides a credential creation wizard.
|
|
package create
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gopasspw/gopass/internal/action/exit"
|
|
"github.com/gopasspw/gopass/internal/backend"
|
|
"github.com/gopasspw/gopass/internal/config"
|
|
"github.com/gopasspw/gopass/internal/cui"
|
|
"github.com/gopasspw/gopass/internal/editor"
|
|
"github.com/gopasspw/gopass/internal/hook"
|
|
"github.com/gopasspw/gopass/internal/out"
|
|
"github.com/gopasspw/gopass/internal/store/root"
|
|
"github.com/gopasspw/gopass/internal/tpl"
|
|
"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/set"
|
|
"github.com/gopasspw/gopass/pkg/termio"
|
|
"github.com/martinhoefling/goxkcdpwgen/xkcdpwgen"
|
|
"github.com/urfave/cli/v2"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
tplPath = ".gopass/create/"
|
|
)
|
|
|
|
// Attribute is a credential attribute that is being asked for
|
|
// when populating a template.
|
|
type Attribute struct {
|
|
Name string `yaml:"name"`
|
|
Type string `yaml:"type"`
|
|
Prompt string `yaml:"prompt"`
|
|
Charset string `yaml:"charset"`
|
|
Min int `yaml:"min"`
|
|
Max int `yaml:"max"`
|
|
AlwaysPrompt bool `yaml:"always_prompt"` // always prompt for the crendentials
|
|
}
|
|
|
|
// 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 []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{}
|
|
|
|
tpls, err := w.parseTemplates(ctx, s)
|
|
if err != nil {
|
|
return w, fmt.Errorf("could not parse templates: %w", err)
|
|
}
|
|
|
|
if len(tpls) < 1 {
|
|
// no templates found, write default templates
|
|
if err := w.writeTemplates(ctx, s); err != nil {
|
|
return w, fmt.Errorf("could not write default templates: %w", err)
|
|
}
|
|
|
|
// then re-parse them
|
|
tpls, err = w.parseTemplates(ctx, s)
|
|
if err != nil {
|
|
return w, fmt.Errorf("could not parse templates: %w", err)
|
|
}
|
|
}
|
|
|
|
// fallback to the default templates if no templates were found
|
|
// even after writing them. this indicates a problem with writing
|
|
// and should be investigated.
|
|
if len(tpls) < 1 {
|
|
out.Error(ctx, "No templates found, falling back to default templates")
|
|
|
|
tpls, err = w.parseTemplatesFallback(ctx)
|
|
if err != nil {
|
|
return w, fmt.Errorf("could not parse fallback templates: %w", err)
|
|
}
|
|
}
|
|
|
|
w.Templates = tpls
|
|
|
|
return w, nil
|
|
}
|
|
|
|
func (w *Wizard) parseTemplatesFallback(_ context.Context) ([]Template, error) {
|
|
parsed := []Template{}
|
|
for _, tpl := range defaultTemplates {
|
|
t := Template{}
|
|
if err := yaml.Unmarshal([]byte(tpl), &t); err != nil {
|
|
return nil, err
|
|
}
|
|
parsed = append(parsed, t)
|
|
}
|
|
|
|
return parsed, nil
|
|
}
|
|
|
|
func (w *Wizard) parseTemplates(ctx context.Context, s backend.Storage) ([]Template, error) {
|
|
tpls, err := s.List(ctx, tplPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parsedTpls := []Template{}
|
|
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 read 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
|
|
}
|
|
|
|
parsedTpls = append(parsedTpls, tpl)
|
|
}
|
|
|
|
sort.Slice(parsedTpls, func(i, j int) bool {
|
|
return parsedTpls[i].Priority < parsedTpls[j].Priority
|
|
})
|
|
|
|
return parsedTpls, 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 { //nolint:cyclop
|
|
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")
|
|
|
|
// select store.
|
|
if store == "" {
|
|
store = cui.AskForStore(ctx, s)
|
|
}
|
|
ctx = config.WithMount(ctx, store)
|
|
|
|
force := c.Bool("force")
|
|
|
|
if err := hook.Invoke(ctx, "create.pre-hook", name); err != nil {
|
|
return err
|
|
}
|
|
|
|
sec := secrets.NewAKV()
|
|
|
|
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 _, v := range tpl.Attributes {
|
|
step++
|
|
k := v.Name
|
|
|
|
// 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 v.Min > 0 && len(sv) < v.Min {
|
|
return fmt.Errorf("%s is too short (needs %d)", v.Name, v.Min)
|
|
}
|
|
if v.Max > 0 && len(sv) > v.Max {
|
|
return fmt.Errorf("%s is too long (at most %d)", v.Name, v.Max)
|
|
}
|
|
if wantForName[k] {
|
|
nameParts = append(nameParts, sv)
|
|
}
|
|
_ = sec.Set(k, sv)
|
|
case "multiline":
|
|
ed := editor.Path(c)
|
|
|
|
content, err := renderTemplate(ctx, k, s)
|
|
if err != nil {
|
|
debug.Log("failed to render template %q: %s", k, err)
|
|
}
|
|
content, err = editor.Invoke(ctx, ed, content)
|
|
if err != nil {
|
|
return exit.Error(exit.Unknown, err, "failed to invoke editor: %s", err)
|
|
}
|
|
n, err := sec.Write(content)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write %d bytes to %s: %w", n, k, err)
|
|
}
|
|
case "hostname":
|
|
var def string
|
|
if k == "username" {
|
|
def = config.String(ctx, "create.default-username")
|
|
}
|
|
sv, err := termio.AskForString(ctx, fmtfn(2, strconv.Itoa(step), v.Prompt), def)
|
|
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(ctx, hostname); u != "" {
|
|
_ = sec.Set("password-change-url", u)
|
|
}
|
|
_ = sec.Set(k, sv)
|
|
case "password":
|
|
var err error
|
|
if !v.AlwaysPrompt {
|
|
genPw, err = termio.AskForBool(ctx, fmtfn(2, strconv.Itoa(step), "Generate Password?"), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if genPw { //nolint:nestif
|
|
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
|
|
}
|
|
if v.Min > 0 && len(password) < v.Min {
|
|
return fmt.Errorf("%s is too short (needs %d)", v.Name, v.Min)
|
|
}
|
|
if v.Max > 0 && len(password) > v.Min {
|
|
return fmt.Errorf("%s is too long (at most %d)", v.Name, v.Max)
|
|
}
|
|
}
|
|
|
|
sec.SetPassword(password)
|
|
}
|
|
}
|
|
|
|
// 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: %w", name, err)
|
|
}
|
|
out.OKf(ctx, "Credentials saved to %q", name)
|
|
|
|
return cb(ctx, c, name, password, genPw)
|
|
}
|
|
}
|
|
|
|
func renderTemplate(ctx context.Context, name string, s *root.Store) ([]byte, error) {
|
|
tName, tmpl, found := s.LookupTemplate(ctx, name)
|
|
if !found {
|
|
debug.Log("No template found for %s", name)
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
tmplStr := strings.TrimSpace(string(tmpl))
|
|
if tmplStr == "" {
|
|
debug.Log("Skipping empty template %q, for %s", tName, name)
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
nc, err := tpl.Execute(ctx, string(tmpl), name, nil, s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute template %q: %w", tName, err)
|
|
}
|
|
|
|
return nc, nil
|
|
}
|
|
|
|
// generatePassword will walk through the password generation steps.
|
|
func generatePassword(ctx context.Context, hostname, charset string) (string, error) {
|
|
defaultLength, _ := config.DefaultPasswordLengthFromEnv(ctx)
|
|
|
|
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(ctx, 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(ctx, length, hostname).Password(), nil
|
|
}
|
|
|
|
xkcd, err := termio.AskForBool(ctx, fmtfn(4, "a", "Human-pronounceable passphrase?"), false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if xkcd {
|
|
return generatePasswordXKCD(ctx)
|
|
}
|
|
|
|
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?"), config.Bool(ctx, "generate.symbols"))
|
|
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
|
|
}
|
|
|
|
func generatePasswordXKCD(ctx context.Context) (string, error) {
|
|
length, err := termio.AskForInt(ctx, fmtfn(4, "b", "How many words?"), config.Int(ctx, "pwgen.xkcd-len"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if length < 1 {
|
|
length = config.DefaultXKCDLength
|
|
}
|
|
|
|
g := xkcdpwgen.NewGenerator()
|
|
g.SetNumWords(length)
|
|
|
|
if sv := config.String(ctx, "pwgen.xkcd-sep"); sv != "" {
|
|
g.SetDelimiter(sv)
|
|
}
|
|
|
|
g.SetCapitalize(config.Bool(ctx, "pwgen.xkcd-capitalize"))
|
|
g.SetRandomNumbers(config.Bool(ctx, "pwgen.xkcd-numbers"))
|
|
|
|
if sv := config.String(ctx, "pwgen.xkcd-lang"); sv != "" {
|
|
if err := g.UseLangWordlist(sv); err != nil {
|
|
return "", fmt.Errorf("failed to use wordlist for lang %s: %w", sv, err)
|
|
}
|
|
}
|
|
|
|
return g.GeneratePasswordString(), nil
|
|
}
|