mirror of
https://github.com/gopasspw/gopass.git
synced 2025-12-08 19:24:54 +00:00
This commit adds yet another config handler for gopass. It is based on the format used by git itself. This has the potential to address a lot of long standing issues, but it also causes a lot of changes to how we handle configuration, so bugs are inevitable. Fixes #1567 Fixes #1764 Fixes #1819 Fixes #1878 Fixes #2387 Fixes #2418 RELEASE_NOTES=[BREAKING] New config format based on git config. Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org> Co-authored-by: Yolan Romailler <AnomalRoil@users.noreply.github.com> address comments Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org> Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>
341 lines
9.3 KiB
Go
341 lines
9.3 KiB
Go
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
|
|
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"`
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
w.Templates = tpls
|
|
|
|
return w, 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 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
|
|
}
|
|
|
|
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")
|
|
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 _, 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.Min {
|
|
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 "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(ctx, 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 { //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)
|
|
}
|
|
}
|
|
|
|
// 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: %w", 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(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 {
|
|
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
|
|
}
|