Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Adding support for age.Plugin identities #2960

Merged
merged 2 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions docs/backends/age.md
Original file line number Diff line number Diff line change
Expand Up @@ -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...]
<if you do not specify an age secret key, you'll be prompted for one>
$ 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.
Expand All @@ -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
<should be empty>
$ age-plugin-yubikey
✨ Let's get your YubiKey set up for age! ✨
<follow instructions to setup a PIV slot>
$ age-plugin-yubikey -i
<should display your PIV slot information now>
$ gopass age identities add
Enter the age identity starting in AGE-:
<paste the `AGE-PLUGIN-YUBIKEY-...` identity from the previous command>
Provide the corresponding age recipient starting in age1:
<paste the `age1yubikey1...` recipient from the previous command>
```

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

Expand All @@ -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

2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
6 changes: 5 additions & 1 deletion internal/action/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <subkey> to use subkeys!'")
if crypto.Name() != "age" {
out.Notice(ctx, "Hint: Use 'gopass init <subkey> 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}
Expand Down
5 changes: 2 additions & 3 deletions internal/backend/crypto/age/age.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"fmt"
"path/filepath"
"runtime"
"time"

"github.com/blang/semver/v4"
Expand Down Expand Up @@ -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
}
44 changes: 44 additions & 0 deletions internal/backend/crypto/age/clientUI.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package age

import (
"context"

"filippo.io/age/plugin"
"github.com/gopasspw/gopass/internal/cui"
"github.com/gopasspw/gopass/internal/out"
"github.com/gopasspw/gopass/pkg/termio"
)

var pluginTerminalUI = &plugin.ClientUI{
DisplayMessage: func(name, message string) error {
out.Printf(context.Background(), "%s plugin: %s", name, message)

return nil
},
RequestValue: func(name, message string, _ bool) (string, error) {
var err error
defer func() {
if err != nil {
out.Warningf(context.Background(), "could not read value for age-plugin-%s: %v", name, err)
}
}()
secret, err := termio.AskForPassword(context.Background(), "secret", false)
if err != nil {
return "", err
}

return secret, nil
},
Confirm: func(name, message, yes, no string) (bool, error) {
rep, _ := cui.GetSelection(context.Background(), message, []string{yes, no})
if rep == yes {
return true, nil
}

return false, nil
},

WaitTimer: func(name string) {
out.Printf(context.Background(), "waiting on %s plugin...", name)
},
}
127 changes: 111 additions & 16 deletions internal/backend/crypto/age/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -41,57 +50,143 @@ 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
},
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)
if err != nil {
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")
}
if strings.HasPrefix(recEncm, "AGE-") {
out.Warning(ctx, "You have provided an identity as a recipient, recipients should start in 'age1', this might not be properly supported and might leak secret data in our identity recipient cache")
}
}

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)
if err != nil {
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))

debug.Log("ranging over %d age identities", 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("will remove 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("will remove 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")
}

// we invalidate our recipient id cache when we remove an identity, if there's one
if err := a.recpCache.Remove(idRecpCacheKey); err != nil {
debug.Log("error invalidating age id recipient cache: %s", err)
}

return a.saveIdentities(ctx, newIds, false)
},
Expand Down
3 changes: 3 additions & 0 deletions internal/backend/crypto/age/decrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand All @@ -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 {
Expand Down
Loading
Loading