Skip to content

Commit

Permalink
Feat: support age plugin identities, including age-plugin-yubikey ones.
Browse files Browse the repository at this point in the history
Signed-off-by: Yolan Romailler <[email protected]>
  • Loading branch information
AnomalRoil committed Oct 2, 2024
1 parent 486c165 commit e8c173a
Show file tree
Hide file tree
Showing 15 changed files with 380 additions and 81 deletions.
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
}
118 changes: 102 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,134 @@ 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")
}
}

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))

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)
},
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
17 changes: 17 additions & 0 deletions internal/backend/crypto/age/encrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"os"
"slices"

"filippo.io/age"
"github.com/gopasspw/gopass/pkg/ctxutil"
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit e8c173a

Please sign in to comment.