From e8c173ae62b90437dbf73146eb8e65d86b1010c2 Mon Sep 17 00:00:00 2001 From: Yolan Romailler Date: Wed, 2 Oct 2024 23:30:46 +0200 Subject: [PATCH] Feat: support age plugin identities, including age-plugin-yubikey ones. Signed-off-by: Yolan Romailler --- docs/backends/age.md | 36 +++- go.mod | 2 +- go.sum | 4 +- internal/action/init.go | 6 +- internal/backend/crypto/age/age.go | 5 +- internal/backend/crypto/age/commands.go | 118 +++++++++++-- internal/backend/crypto/age/decrypt.go | 3 + internal/backend/crypto/age/encrypt.go | 17 ++ internal/backend/crypto/age/identities.go | 192 ++++++++++++++++++---- internal/backend/crypto/age/recipients.go | 48 ++++-- internal/backend/crypto/age/ssh.go | 5 + internal/cache/disk.go | 2 + internal/cache/ghssh/github.go | 2 +- internal/store/leaf/fsck.go | 18 +- main_test.go | 3 + 15 files changed, 380 insertions(+), 81 deletions(-) diff --git a/docs/backends/age.md b/docs/backends/age.md index 3033747719..3dd110f19b 100644 --- a/docs/backends/age.md +++ b/docs/backends/age.md @@ -13,8 +13,14 @@ WARNING: This backend is experimental and the on-disk format likely to change. To start using the `age` backend initialize a new (sub) store with the `--crypto=age` flag: ``` -gopass init --crypto age -gopass recipients add github:user +$ gopass age identity add [AGE-... age1...] + +$ gopass init --crypto age +``` + +or use the wizard that will help you create a new age key: +``` +$ gopass setup --crypto age ``` This will automatically create a new age keypair and initialize the new store. @@ -29,6 +35,31 @@ Existing stores can be migrated using `gopass convert --crypto age`. * Support for using GitHub users' private keys, e.g. `github:user` as recipient * Automatic downloading and caching of SSH keys from GitHub * Encrypted keyring for age keypairs +* Support for age plugins + +## Usage with a yubikey + +To use with a Yubikey, `age` requires the usage of the [age-plugin-yubikey plugin](https://github.com/str4d/age-plugin-yubikey/). + +Assuming you have Rust installed: +```bash +$ cargo install age-plugin-yubikey +$ age-plugin-yubikey -i + +$ age-plugin-yubikey +✨ Let's get your YubiKey set up for age! ✨ + +$ age-plugin-yubikey -i + +$ gopass age identities add +Enter the age identity starting in AGE-: + +Provide the corresponding age recipient starting in age1: + +``` + +If gopass tells you `waiting on yubikey plugin...` when decrypting secrets, it probably is waiting for you to touch +your Yubikey because you've set a Touch policy when setting up your PIV slot. ## Roadmap @@ -39,4 +70,3 @@ Assuming `age` is supporting this, we'd like to: * Finalize GitHub recipient support * Add Hardware token support * Make age the default gopass backend - diff --git a/go.mod b/go.mod index f344dee7cd..505c8ddea2 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/gopasspw/gopass go 1.22.1 require ( - filippo.io/age v1.2.0 + filippo.io/age v1.2.1-0.20240618131852-7eedd929a6cf github.com/ProtonMail/go-crypto v1.0.0 github.com/atotto/clipboard v0.1.4 github.com/blang/semver/v4 v4.0.0 diff --git a/go.sum b/go.sum index 94c52348b4..81ecdd17d4 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3I c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= code.rocketnine.space/tslocum/cbind v0.1.5 h1:i6NkeLLNPNMS4NWNi3302Ay3zSU6MrqOT+yJskiodxE= code.rocketnine.space/tslocum/cbind v0.1.5/go.mod h1:LtfqJTzM7qhg88nAvNhx+VnTjZ0SXBJtxBObbfBWo/M= -filippo.io/age v1.2.0 h1:vRDp7pUMaAJzXNIWJVAZnEf/Dyi4Vu4wI8S1LBzufhE= -filippo.io/age v1.2.0/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= +filippo.io/age v1.2.1-0.20240618131852-7eedd929a6cf h1:3hBTgZCvtC31eCc8CWH0w+55Yn/R/HI3Of4Zb5TAuWU= +filippo.io/age v1.2.1-0.20240618131852-7eedd929a6cf/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= diff --git a/internal/action/init.go b/internal/action/init.go index f1d87cb96e..60df646c70 100644 --- a/internal/action/init.go +++ b/internal/action/init.go @@ -138,9 +138,13 @@ func (s *Action) init(ctx context.Context, alias, path string, keys ...string) e } if len(keys) < 1 { - out.Notice(ctx, "Hint: Use 'gopass init to use subkeys!'") + if crypto.Name() != "age" { + out.Notice(ctx, "Hint: Use 'gopass init to use subkeys!'") + } nk, err := cui.AskForPrivateKey(ctx, crypto, "🎮 Please select a private key for encrypting secrets:") if err != nil { + out.Noticef(ctx, "Hint: Use 'gopass setup --crypto %s' to be guided through an initial setup instead of 'gopass init'", crypto.Name()) + return fmt.Errorf("failed to read user input: %w", err) } keys = []string{nk} diff --git a/internal/backend/crypto/age/age.go b/internal/backend/crypto/age/age.go index 94093b3b83..c8d20ac582 100644 --- a/internal/backend/crypto/age/age.go +++ b/internal/backend/crypto/age/age.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "path/filepath" - "runtime" "time" "github.com/blang/semver/v4" @@ -82,7 +81,7 @@ func (a *Age) IDFile() string { return IDFile } -// Concurrency returns the number of CPUs. +// Concurrency returns 1 for `age` since otherwise it prompts for the identity password for each worker. func (a *Age) Concurrency() int { - return runtime.NumCPU() + return 1 } diff --git a/internal/backend/crypto/age/commands.go b/internal/backend/crypto/age/commands.go index a3ca17858a..69eece8184 100644 --- a/internal/backend/crypto/age/commands.go +++ b/internal/backend/crypto/age/commands.go @@ -2,27 +2,36 @@ package age import ( "fmt" + "strings" "filippo.io/age" "github.com/gopasspw/gopass/internal/action/exit" "github.com/gopasspw/gopass/internal/out" "github.com/gopasspw/gopass/pkg/ctxutil" + "github.com/gopasspw/gopass/pkg/debug" + "github.com/gopasspw/gopass/pkg/termio" "github.com/urfave/cli/v2" ) +//nolint:cyclop func (l loader) Commands() []*cli.Command { return []*cli.Command{ { Name: name, - Hidden: true, + Hidden: false, Usage: "age commands", Description: "" + "Built-in commands for the age backend.\n" + - "These allow limited interactions with the gopass specific age identities.", + "These allow limited interactions with the gopass specific age identities.\n " + + "Added identities are automatically added as recipient to your secrets when encrypting, but not to" + + "your recipients, make sure to keep your recipients and identities in sync as you want to.\n" + + "All age identities, including plugin ones should be supported. We also still support github" + + "identities despite them being deprecated by age, we do so by falling back to the ssh identities" + + "for these and keeping a local cache of ssh keys for a given github identity.", Subcommands: []*cli.Command{ { Name: "identities", - Usage: "List identities", + Usage: "List age identities used for decryption and encryption", Description: "" + "List identities", Action: func(c *cli.Context) error { @@ -41,8 +50,8 @@ func (l loader) Commands() []*cli.Command { out.Notice(ctx, "No identities found") } - for _, id := range recipientsToBech32(ids) { - out.Printf(ctx, id) + for _, id := range recipientsToString(ids) { + out.Print(ctx, out.Secret(id)) } return nil @@ -50,9 +59,9 @@ func (l loader) Commands() []*cli.Command { Subcommands: []*cli.Command{ { Name: "add", - Usage: "Add an identity", + Usage: "Add an existing age identity", Description: "" + - "Add an identity", + "Add an existing age identity, interactively", Action: func(c *cli.Context) error { ctx := ctxutil.WithGlobalFlags(c) a, err := New(ctx) @@ -60,18 +69,67 @@ func (l loader) Commands() []*cli.Command { return exit.Error(exit.Unknown, err, "failed to create age backend") } - if err := a.GenerateIdentity(ctx, "", "", ""); err != nil { + idS, recEncm := c.Args().Get(0), c.Args().Get(1) + + if len(idS) < 1 { + idS, err = termio.AskForPassword(ctx, "the age identity starting in AGE-", false) + if err != nil { + return exit.Error(exit.Unknown, err, "failed to read age identity") + } + } + if len(recEncm) < 1 && !strings.HasPrefix(idS, "AGE-SECRET-KEY-1") { + recEncm, err = termio.AskForString(ctx, "Provide the corresponding age recipient", "") + if err != nil || recEncm == "" { + return exit.Error(exit.Unknown, err, "failed to read corresponding age recipient") + } + } + + id, err := parseIdentity(idS + "|" + recEncm) + if err != nil { + return exit.Error(exit.Unknown, err, "failed to parse age identity") + } + + err = a.addIdentity(ctx, id) + if err != nil { + return exit.Error(exit.Unknown, err, "failed to save age identity") + } + + rec := IdentityToRecipient(id) + out.Noticef(ctx, "New age identities are not automatically added to your recipient list, consider adding it using 'gopass recipients add %s'", rec) + out.Warning(ctx, "If you do not add this recipient to the recipient list, make sure to re-encrypt using 'gopass fsck --decrypt' to properly support this identity") + + return nil + }, + }, + { + Name: "keygen", + Usage: "Generate a new age identity", + Description: "" + + "Generate a new age identity", + Action: func(c *cli.Context) error { + ctx := ctxutil.WithGlobalFlags(c) + a, err := New(ctx) + if err != nil { + return exit.Error(exit.Unknown, err, "failed to create age backend") + } + + err = a.GenerateIdentity(ctx, "", "", "") + if err != nil { return exit.Error(exit.Unknown, err, "failed to generate age identity") } + out.Notice(ctx, "New age identities are not automatically added to your recipient list, consider adding it using 'gopass recipients add age1...'") + out.Warning(ctx, "If you do not add this recipient to the recipient list, make sure to re-encrypt using 'gopass fsck --decrypt' to properly support this identity") + return nil }, }, { - Name: "remove", - Usage: "Remove an identity", + Name: "remove", + Aliases: []string{"rm"}, + Usage: "Remove an identity", Description: "" + - "Remove an identity", + "Remove all identity matching the argument", Action: func(c *cli.Context) error { ctx := ctxutil.WithGlobalFlags(c) a, err := New(ctx) @@ -79,19 +137,47 @@ func (l loader) Commands() []*cli.Command { return exit.Error(exit.Unknown, err, "failed to create age backend") } victim := c.Args().First() + if len(victim) == 0 { + return exit.Error(exit.Usage, err, "missing argument to remove") + } ids, _ := a.Identities(ctx) newIds := make([]string, 0, len(ids)) for _, id := range ids { - // we only need to care about X25519 identities here because SSH identities are - // considered external and are not managed by gopass. users should use ssh-keygen - // and such to deal with them. At least we definitely don't want to remove them. - if x, ok := id.(*age.X25519Identity); ok && x.Recipient().String() == victim { - continue + // we only need to care about X25519 and plugin/wrapped identities here because + // SSH identities are considered external and are not managed by gopass. + // Users should use ssh-keygen and such to deal with them. + // At least we definitely don't want to remove them. + switch x := id.(type) { + case *age.X25519Identity: + if x.Recipient().String() == victim { + debug.Log("removed X25519Identity %s", x.Recipient()) + + continue + } + case *wrappedIdentity: + skip := false + // to avoid fuzzy matching, let's match on entire parts + for _, part := range strings.Split(x.String(), "|") { + if part == victim { + skip = true + } + } + if skip { + debug.Log("removed Plugin Identity %s", x) + + continue + } } + newIds = append(newIds, fmt.Sprintf("%s", id)) } + if len(newIds) != len(ids) { + out.Warning(ctx, "Make sure to run 'gopass fsck --decrypt' to re-encrypt your secrets without including that identity if it's not in your recipient list.") + } else { + out.Notice(ctx, "no matching identity found in list") + } return a.saveIdentities(ctx, newIds, false) }, diff --git a/internal/backend/crypto/age/decrypt.go b/internal/backend/crypto/age/decrypt.go index 709c2eb6b8..7389d5b5b7 100644 --- a/internal/backend/crypto/age/decrypt.go +++ b/internal/backend/crypto/age/decrypt.go @@ -33,6 +33,8 @@ func (a *Age) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error) { } func (a *Age) decrypt(ciphertext []byte, ids ...age.Identity) ([]byte, error) { + debug.Log("decrypting with %d ids", len(ids)) + out := &bytes.Buffer{} f := bytes.NewReader(ciphertext) r, err := age.Decrypt(f, ids...) @@ -48,6 +50,7 @@ func (a *Age) decrypt(ciphertext []byte, ids ...age.Identity) ([]byte, error) { return out.Bytes(), nil } +// decryptFile is used to decrypt a scrypt encrypted age keyring/identity file. func (a *Age) decryptFile(ctx context.Context, filename string) ([]byte, error) { ciphertext, err := os.ReadFile(filename) if err != nil { diff --git a/internal/backend/crypto/age/encrypt.go b/internal/backend/crypto/age/encrypt.go index 007e215f93..45d5c6bf9e 100644 --- a/internal/backend/crypto/age/encrypt.go +++ b/internal/backend/crypto/age/encrypt.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "os" + "slices" "filippo.io/age" "github.com/gopasspw/gopass/pkg/ctxutil" @@ -48,6 +49,22 @@ func dedupe(recp []age.Recipient) []age.Recipient { for _, r := range set { out = append(out, r) } + + // we make sure they are sorted so that age1 identities are first + slices.SortFunc(out, func(a, b age.Recipient) int { + i, oka := a.(fmt.Stringer) + j, okb := b.(fmt.Stringer) + + // handle non-native recipients such as SSH, we want them at the bottom + if !oka { + return -1 + } + if !okb { + return -1 + } + // yubikey identities are typically longer + return len(i.String()) - len(j.String()) + }) debug.Log("in: %+v - out: %+v", recp, out) return out diff --git a/internal/backend/crypto/age/identities.go b/internal/backend/crypto/age/identities.go index 9ccc1435c8..b5a3cf07e2 100644 --- a/internal/backend/crypto/age/identities.go +++ b/internal/backend/crypto/age/identities.go @@ -1,10 +1,13 @@ package age import ( + "bufio" "bytes" "context" "errors" "fmt" + "io" + "io/fs" "os" "path/filepath" "sort" @@ -12,6 +15,8 @@ import ( "time" "filippo.io/age" + "filippo.io/age/agessh" + "filippo.io/age/plugin" "github.com/gopasspw/gopass/pkg/appdir" "github.com/gopasspw/gopass/pkg/ctxutil" "github.com/gopasspw/gopass/pkg/debug" @@ -19,6 +24,31 @@ import ( var idRecpCacheKey = "identity" +type wrappedIdentity struct { + id age.Identity + rec age.Recipient + encoding string +} + +func (w *wrappedIdentity) String() string { return w.encoding } +func (w *wrappedIdentity) SafeStr() string { return w.encoding[:12] } +func (w *wrappedIdentity) Recipient() age.Recipient { return w.rec } + +func (w *wrappedIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { + return w.id.Unwrap(stanzas) +} + +type wrappedRecipient struct { + rec age.Recipient + encoding string +} + +func (w *wrappedRecipient) String() string { return w.encoding } + +func (w *wrappedRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { + return w.rec.Wrap(fileKey) +} + // Identities returns all identities, used for decryption. func (a *Age) Identities(ctx context.Context) ([]age.Identity, error) { if !ctxutil.HasPasswordCallback(ctx) { @@ -35,14 +65,14 @@ func (a *Age) Identities(ctx context.Context) ([]age.Identity, error) { buf, err := a.decryptFile(ctx, a.identity) if err != nil { debug.Log("failed to decrypt existing identities from %s: %s", a.identity, err) - if !os.IsNotExist(err) { + if !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("failed to decrypt %s: %w", a.identity, err) } return nil, nil } - ids, err := age.ParseIdentities(bytes.NewReader(buf)) + ids, err := parseIdentities(bytes.NewReader(buf)) if err != nil { return nil, err } @@ -52,11 +82,71 @@ func (a *Age) Identities(ctx context.Context) ([]age.Identity, error) { return ids, nil } -// IdentityRecipients returns a slice of recipients dervied from our identities. +func parseIdentity(s string) (age.Identity, error) { + switch { + case strings.HasPrefix(s, "AGE-PLUGIN-"): + sp := strings.Split(s, "|") + id, err := plugin.NewIdentity(sp[0], pluginTerminalUI) + var rec age.Recipient + if len(sp) == 2 { + rec = &wrappedRecipient{ + rec: id.Recipient(), + encoding: sp[1], + } + } else { + rec = id.Recipient() + } + + return &wrappedIdentity{ + id: id, + encoding: s, + rec: rec, + }, err + case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): + sp := strings.Split(s, "|") + + return age.ParseX25519Identity(sp[0]) + default: + return nil, fmt.Errorf("unknown identity type") + } +} + +// parseIdentities is like age.ParseIdentities, but supports plugin identities. +func parseIdentities(f io.Reader) ([]age.Identity, error) { + const privateKeySizeLimit = 1 << 24 // 16 MiB + var ids []age.Identity + scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit)) + var n int + for scanner.Scan() { + n++ + line := scanner.Text() + if strings.HasPrefix(line, "#") || line == "" { + continue + } + + i, err := parseIdentity(line) + if err != nil { + return nil, fmt.Errorf("error at line %d: %w", n, err) + } + ids = append(ids, i) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read secret keys file: %w", err) + } + if len(ids) == 0 { + return nil, fmt.Errorf("no secret keys found") + } + + return ids, nil +} + +// IdentityRecipients returns a slice of recipients derived from our identities. // Since the identity file is encrypted we try to use a cached copy of the recipients -// dervied from the identities. +// derived from the identities. func (a *Age) IdentityRecipients(ctx context.Context) ([]age.Recipient, error) { - if ids := a.cachedIDRecpipients(); len(ids) > 0 { + if ids := a.cachedIDRecipients(); len(ids) > 0 { + debug.Log("successfully retrieved identities from cache") + return ids, nil } @@ -71,18 +161,52 @@ func (a *Age) IdentityRecipients(ctx context.Context) ([]age.Recipient, error) { var r []age.Recipient for _, id := range ids { - if x, ok := id.(*age.X25519Identity); ok { - r = append(r, x.Recipient()) + if rec := IdentityToRecipient(id); rec != nil { + r = append(r, rec) } } + debug.Log("got %d recipients from %d age identities", len(r), len(ids)) - if err := a.recpCache.Set(idRecpCacheKey, recipientsToBech32(r)); err != nil { + if err := a.recpCache.Set(idRecpCacheKey, recipientsToString(r)); err != nil { debug.Log("failed to cache identity recipients: %s", err) } return r, nil } +func IdentityToRecipient(id age.Identity) age.Recipient { + switch id := id.(type) { + case *age.X25519Identity: + debug.Log("parsed age identity as X25519Identity") + + return id.Recipient() + case *wrappedIdentity: + debug.Log("parsed age identity as wrappedIdentity") + + return id.Recipient() + case *plugin.Identity: + debug.Log("parsed age identity as plugin.Identity") + + return id.Recipient() + case *agessh.RSAIdentity: + debug.Log("parsed age identity as RSAIdentity") + + return id.Recipient() + case *agessh.Ed25519Identity: + debug.Log("parsed age identity as Ed25519Identity") + + return id.Recipient() + case *agessh.EncryptedSSHIdentity: + debug.Log("parsed age identity as encrypted SSHIdentity") + + return id.Recipient() + default: + debug.Log("unexpected age identity type: %T", id) + + return nil + } +} + // GenerateIdentity creates a new identity. func (a *Age) GenerateIdentity(ctx context.Context, _ string, _ string, pw string) error { if pw != "" { @@ -91,9 +215,12 @@ func (a *Age) GenerateIdentity(ctx context.Context, _ string, _ string, pw strin }) } - _, err := a.addIdentity(ctx) + id, err := age.GenerateX25519Identity() + if err != nil { + return err + } - return err + return a.addIdentity(ctx, id) } // ListIdentities lists all identities. @@ -123,7 +250,7 @@ func (a *Age) FindIdentities(ctx context.Context, keys ...string) ([]string, err matches := make([]string, 0, len(ids)) OUTER: for _, k := range keys { - for _, r := range recipientsToBech32(ids) { + for _, r := range recipientsToString(ids) { if r == k { matches = append(matches, k) debug.Log("found matching recipient %s", k) @@ -139,7 +266,7 @@ OUTER: return matches, nil } -func (a *Age) cachedIDRecpipients() []age.Recipient { +func (a *Age) cachedIDRecipients() []age.Recipient { if a.recpCache.ModTime(idRecpCacheKey).Before(modTime(a.identity)) { debug.Log("identity cache expired") _ = a.recpCache.Remove(idRecpCacheKey) @@ -154,33 +281,25 @@ func (a *Age) cachedIDRecpipients() []age.Recipient { return nil } - rs := make([]age.Recipient, 0, len(recps)) - for _, recp := range recps { - r, err := age.ParseX25519Recipient(recp) - if err != nil { - debug.Log("failed to parse recipient %s: %s", recp, err) - - continue - } - rs = append(rs, r) + rs, err := a.parseRecipients(context.Background(), recps) + if err != nil { + debug.Log("cachedIDRecipients failed to parse some age recipients: %s", err) } return rs } -func (a *Age) addIdentity(ctx context.Context) ([]age.Identity, error) { - ids, _ := a.Identities(ctx) - id, err := age.GenerateX25519Identity() - if err != nil { - return nil, err +func (a *Age) addIdentity(ctx context.Context, id age.Identity) error { + // we invalidate our recipient id cache when we add a new identity, if there's one + if err := a.recpCache.Remove(idRecpCacheKey); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err } + ids, _ := a.Identities(ctx) + ids = append(ids, id) - if err := a.saveIdentities(ctx, identitiesToString(ids), true); err != nil { - return nil, err - } - return ids, nil + return a.saveIdentities(ctx, identitiesToString(ids), true) } func (a *Age) saveIdentities(ctx context.Context, ids []string, newFile bool) error { @@ -299,18 +418,23 @@ func (a *Age) getNativeIdentities(ctx context.Context) (map[string]age.Identity, func idMap(ids []age.Identity) map[string]age.Identity { m := make(map[string]age.Identity) for _, id := range ids { - if x, ok := id.(*age.X25519Identity); ok { - m[x.Recipient().String()] = id + switch i := id.(type) { + case *age.X25519Identity: + m[i.Recipient().String()] = id continue + case *wrappedIdentity: + m[i.String()] = id + + default: + debug.Log("unknown Identity type: %T", id) } - debug.Log("unknown Identity type: %T", id) } return m } -func recipientsToBech32(recps []age.Recipient) []string { +func recipientsToString(recps []age.Recipient) []string { r := make([]string, 0, len(recps)) for _, recp := range recps { r = append(r, fmt.Sprintf("%s", recp)) diff --git a/internal/backend/crypto/age/recipients.go b/internal/backend/crypto/age/recipients.go index 89524add3f..5908c85676 100644 --- a/internal/backend/crypto/age/recipients.go +++ b/internal/backend/crypto/age/recipients.go @@ -6,6 +6,8 @@ import ( "filippo.io/age" "filippo.io/age/agessh" + "filippo.io/age/plugin" + "github.com/gopasspw/gopass/internal/out" "github.com/gopasspw/gopass/internal/set" "github.com/gopasspw/gopass/pkg/debug" ) @@ -45,46 +47,62 @@ func (a *Age) FindRecipients(ctx context.Context, search ...string) ([]string, e } func (a *Age) parseRecipients(ctx context.Context, recipients []string) ([]age.Recipient, error) { - out := make([]age.Recipient, 0, len(recipients)) + ret := make([]age.Recipient, 0, len(recipients)) for _, r := range recipients { - if strings.HasPrefix(r, "age1") { + switch { + case strings.HasPrefix(r, "age1"): id, err := age.ParseX25519Recipient(r) if err != nil { debug.Log("Failed to parse recipient %q as X25519: %s", r, err) + pid, err := plugin.NewRecipient(r, pluginTerminalUI) + if err != nil { + debug.Log("Failed to parse recipient %q as an age plugin: %s", out.Secret(r), err) + + continue + } + ret = append(ret, &wrappedRecipient{rec: pid, encoding: r}) + continue } - out = append(out, id) + ret = append(ret, id) - continue - } - if strings.HasPrefix(r, "ssh-") { + case strings.HasPrefix(r, "ssh-"): id, err := agessh.ParseRecipient(r) if err != nil { debug.Log("Failed to parse recipient %q as SSH: %s", r, err) continue } - out = append(out, id) + ret = append(ret, id) - continue - } - if strings.HasPrefix(r, "github:") { + case strings.HasPrefix(r, "github:"): + out.Warning(ctx, "github recipient support has been removed from age, consider switching to native keys") pks, err := a.ghCache.ListKeys(ctx, strings.TrimPrefix(r, "github:")) if err != nil { - return out, err + return ret, err } for _, pk := range pks { - id, err := agessh.ParseRecipient(r) + id, err := agessh.ParseRecipient(pk) if err != nil { - debug.Log("Failed to parse GitHub recipient %q: %q: %s", r, pk, err) + debug.Log("Failed to parse GitHub recipient %q for key %q: %s", r, pk, err) continue } - out = append(out, id) + ret = append(ret, id) + } + case strings.HasPrefix(r, "AGE-PLUGIN"): + pid, err := plugin.NewIdentity(r, pluginTerminalUI) + if err != nil { + debug.Log("Failed to parse identity as an age plugin: %s", err) + + continue } + ret = append(ret, &wrappedRecipient{rec: pid.Recipient(), encoding: r}) + default: + debug.Log("Unknown age recipient %q failed parsing", out.Secret(r)) } } - return out, nil + return ret, nil } diff --git a/internal/backend/crypto/age/ssh.go b/internal/backend/crypto/age/ssh.go index 09ad4842e8..6b0655e0c2 100644 --- a/internal/backend/crypto/age/ssh.go +++ b/internal/backend/crypto/age/ssh.go @@ -27,9 +27,13 @@ var ( // getSSHIdentities returns all SSH identities available for the current user. func (a *Age) getSSHIdentities(ctx context.Context) (map[string]age.Identity, error) { if sshCache != nil { + debug.Log("using sshCache") + return sshCache, nil } + // notice that this respects the GOPASS_HOMEDIR env variable, and won't + // find a .ssh folder in your home directory if you set GOPASS_HOMEDIR uhd := appdir.UserHome() sshDir := filepath.Join(uhd, ".ssh") if !fsutil.IsDir(sshDir) { @@ -60,6 +64,7 @@ func (a *Age) getSSHIdentities(ctx context.Context) (map[string]age.Identity, er ids[recp] = id } sshCache = ids + debug.Log("returned %d SSH Identities", len(ids)) return ids, nil } diff --git a/internal/cache/disk.go b/internal/cache/disk.go index 0bc2411340..de9417799e 100644 --- a/internal/cache/disk.go +++ b/internal/cache/disk.go @@ -75,6 +75,8 @@ func (o *OnDisk) Get(key string) ([]string, error) { // Set adds an entry to the cache. func (o *OnDisk) Set(key string, value []string) error { + // we need to make sure not to log things here as plugin Identities' recipients + // can contain secret data if err := o.ensureDir(); err != nil { return err } diff --git a/internal/cache/ghssh/github.go b/internal/cache/ghssh/github.go index b8241756f7..ad73fd25bf 100644 --- a/internal/cache/ghssh/github.go +++ b/internal/cache/ghssh/github.go @@ -22,7 +22,7 @@ var httpClient = &http.Client{ } // ListKeys returns the public keys for a github user. It will -// cache results up the a configurable amount of time (default: 6h). +// cache results up to a configurable amount of time (default: 6h). func (c *Cache) ListKeys(ctx context.Context, user string) ([]string, error) { pk, err := c.disk.Get(user) if err != nil { diff --git a/internal/store/leaf/fsck.go b/internal/store/leaf/fsck.go index 2b64978191..bea7346f4c 100644 --- a/internal/store/leaf/fsck.go +++ b/internal/store/leaf/fsck.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/gopasspw/gopass/internal/backend" + "github.com/gopasspw/gopass/internal/backend/crypto/age" "github.com/gopasspw/gopass/internal/config" "github.com/gopasspw/gopass/internal/diff" "github.com/gopasspw/gopass/internal/out" @@ -253,8 +254,8 @@ func (s *Store) fsckCheckEntry(ctx context.Context, name string) (string, error) if merr.Severity == errsFatal { return "", errs.Append(errsFatal, fmt.Errorf("Checking recipients for %s failed:\n %w", name, merr)).ErrorOrNil() } - // the only errsNonFatal error from that function are missing/extra recipients, - // which isn't much of an error since we have yet to correct that. + // the only errsNonFatal error from that function are missing/extra recipients, or unsupported recipient checks + // all of which aren't much of an issue since we have yet to correct that by re-encrypting. recpNeedFix = true _ = errs.Append(merr.Severity, merr) } @@ -266,7 +267,7 @@ func (s *Store) fsckCheckEntry(ctx context.Context, name string) (string, error) return "", nil } - return "", errs.Append(errsFatal, fmt.Errorf("Run fsck with the --decrypt flag to re-encrypt it automatically, or edit the secret %s yourself.", name)).ErrorOrNil() + return "", errs.Append(errsFatal, fmt.Errorf("secret %s needs re-encryption", name)).ErrorOrNil() } // we need to make sure Parsing is enabled in order to parse old Mime secrets @@ -322,6 +323,13 @@ func (s *Store) fsckCheckRecipients(ctx context.Context, name string) *fsckMulti return e.Append(errsFatal, fmt.Errorf("failed to get raw secret: %w", err)) } + if _, ok := s.crypto.(*age.Age); ok { + debug.Log("RecipientIDs not supported yet by age") + _ = e.Append(errsNonFatal, fmt.Errorf("recipients check not supported by age backend for now")) + + return e + } + itemRecps, err := s.crypto.RecipientIDs(ctx, ciphertext) if err != nil { return e.Append(errsFatal, fmt.Errorf("failed to read recipient IDs from raw secret: %w", err)) @@ -339,10 +347,10 @@ func (s *Store) fsckCheckRecipients(ctx context.Context, name string) *fsckMulti // check itemRecps matches storeRecps extra, missing := diff.List(perItemStoreRecps, itemRecps) if len(missing) > 0 { - _ = e.Append(errsNonFatal, fmt.Errorf("Missing recipients on %s: %+v\nRun fsck with the --decrypt flag to re-encrypt it automatically, or edit this secret yourself.", name, missing)) + _ = e.Append(errsNonFatal, fmt.Errorf("Missing recipients on %s: %+v\n", name, missing)) } if len(extra) > 0 { - _ = e.Append(errsNonFatal, fmt.Errorf("Extra recipients on %s: %+v\nRun fsck with the --decrypt flag to re-encrypt it automatically, or edit this secret yourself.", name, extra)) + _ = e.Append(errsNonFatal, fmt.Errorf("Extra recipients on %s: %+v\n", name, extra)) } return e diff --git a/main_test.go b/main_test.go index 331c3856e4..a54c477b2f 100644 --- a/main_test.go +++ b/main_test.go @@ -121,6 +121,9 @@ func TestGetCommands(t *testing.T) { ctx = ctxutil.WithTerminal(ctx, false) ctx = ctxutil.WithHidden(ctx, true) ctx = backend.WithCryptoBackendString(ctx, "plain") + ctx = ctxutil.WithPasswordCallback(ctx, func(_ string, _ bool) ([]byte, error) { + return []byte("foobar"), nil + }) act, err := action.New(cfg, semver.Version{}) require.NoError(t, err)