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

Check existing recipients before trying to add a new one #2487

Merged
merged 5 commits into from
Dec 24, 2022
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
7 changes: 7 additions & 0 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ This can be fixed by setting `export TMPDIR=/tmp` (or any other suiteable locati

Old version of `gpg` may fail to decode message encrypted with newer version without any message. The encrypted secret in such case is just empty and gopass will warn you about this. One case of such behaviour we have seen so far is when the encryption key generated with `gpg` version 2.3.x encrypt a password that is then decrypted on `gpg` version 2.2.x (default on Ubuntu 18.04). In this particular case old `gpg` does not understand `AEAD` encryption extension, and it fails without any error. If it is your case then follw the instructions in listed in #2283.

## Expired recipients

`gopass` will refuse to add new recipients when any invalid (e.g. expired) recipients are present in a password store.
In such cases manual intervention is required. Expired keys can either be removed or extended. Unknown keys that
can not be automatically imported need to be obtained and manually imported first. These are restrictions from the underlying
crypto implementation (GPG) and we can not easily work around these.

## API Stability

gopass is provided as an CLI program, not as a library. While we try to make the packages usable as libraries we make no guarantees whatsoever with respect to the API stability. The gopass version only reflects changes in the CLI commands.
Expand Down
6 changes: 6 additions & 0 deletions internal/action/recipients.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ func (s *Action) RecipientsAdd(c *cli.Context) error {
store = cui.AskForStore(ctx, s.Store)
}

if err := s.Store.CheckRecipients(ctx, store); err != nil && !force {
out.Errorf(ctx, "%s. Please remove expired keys or extend their validity. See https://go.gopass.pw/faq#expired-recipients", err.Error())

return exit.Error(exit.Recipients, err, "recipients invalid: %q", err)
}

crypto := s.Store.Crypto(ctx, store)

// select recipient.
Expand Down
2 changes: 1 addition & 1 deletion internal/action/recipients_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func TestRecipientsGpg(t *testing.T) {

t.Run("add recipient 0xBEEFFEED", func(t *testing.T) {
defer buf.Reset()
assert.NoError(t, act.RecipientsAdd(gptest.CliCtx(ctx, t, "0xBEEFFEED")))
assert.NoError(t, act.RecipientsAdd(gptest.CliCtxWithFlags(ctx, t, map[string]string{"force": "true"}, "0xBEEFFEED")))
})

t.Run("remove recipient 0x82EBD945BE73F104", func(t *testing.T) {
Expand Down
6 changes: 5 additions & 1 deletion internal/backend/crypto/gpg/cli/recipients.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
Expand Down Expand Up @@ -33,9 +34,12 @@ func (g *GPG) ListRecipients(ctx context.Context) ([]string, error) {
// FindRecipients searches for the given public keys.
func (g *GPG) FindRecipients(ctx context.Context, search ...string) ([]string, error) {
kl, err := g.listKeys(ctx, "public", search...)
if err != nil || kl == nil {
if err != nil {
return nil, err
}
if kl == nil {
return nil, fmt.Errorf("no keys found for %v", search)
}

recp := kl.UseableKeys(gpg.IsAlwaysTrust(ctx)).Recipients()
if gpg.IsAlwaysTrust(ctx) {
Expand Down
9 changes: 9 additions & 0 deletions internal/recipients/recipients.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ func New() *Recipients {
}
}

// Len returns the number of recipients.
func (r *Recipients) Len() int {
if r == nil {
return 0
}

return len(r.r)
}

// IDs returns the key IDs.
func (r *Recipients) IDs() []string {
res := maps.Keys(r.r)
Expand Down
2 changes: 1 addition & 1 deletion internal/store/leaf/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestFsckFunc(t *testing.T) {
assert.True(t, HasFsckFunc(WithFsckFunc(ctx, ffunc)))
}

func TestCheckRecipients(t *testing.T) {
func TestCheckRecipientsCtx(t *testing.T) {
t.Parallel()

ctx := context.Background()
Expand Down
62 changes: 62 additions & 0 deletions internal/store/leaf/recipients.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/gopasspw/gopass/internal/config"
"github.com/gopasspw/gopass/internal/out"
"github.com/gopasspw/gopass/internal/recipients"
"github.com/gopasspw/gopass/internal/set"
"github.com/gopasspw/gopass/internal/store"
"github.com/gopasspw/gopass/pkg/ctxutil"
"github.com/gopasspw/gopass/pkg/debug"
Expand All @@ -26,6 +27,31 @@ const (
// ErrInvalidHash indicates an outdated value of `recipients.hash`.
var ErrInvalidHash = fmt.Errorf("recipients.hash invalid")

// InvalidRecipientsError is a custom error type that contains a
// list of invalid recipients with their check failures.
type InvalidRecipientsError struct {
Invalid map[string]error
}

func (e InvalidRecipientsError) Error() string {
var sb strings.Builder

sb.WriteString("Invalid Recipients: ")
for _, k := range set.SortedKeys(e.Invalid) {
sb.WriteString(k)
sb.WriteString(": ")
sb.WriteString(e.Invalid[k].Error())
sb.WriteString(", ")
}

return sb.String()
}

// IsError returns true if this multi error contains any underlying errors.
func (e InvalidRecipientsError) IsError() bool {
return len(e.Invalid) > 0
}

// Recipients returns the list of recipients of this store.
func (s *Store) Recipients(ctx context.Context) []string {
rs, err := s.GetRecipients(ctx, "")
Expand Down Expand Up @@ -71,6 +97,42 @@ func (s *Store) RecipientsTree(ctx context.Context) map[string][]string {
return out
}

// CheckRecipients makes sure all existing recipients are valid.
func (s *Store) CheckRecipients(ctx context.Context) error {
rs, err := s.GetRecipients(ctx, "")
if err != nil {
return fmt.Errorf("failed to read recipient list: %w", err)
}

er := InvalidRecipientsError{
Invalid: make(map[string]error, len(rs.IDs())),
}
for _, k := range rs.IDs() {
validKeys, err := s.crypto.FindRecipients(ctx, k)
if err != nil {
debug.Log("no GPG key info (unexpected) for %s: %s", k, err)
er.Invalid[k] = err

continue
}

if len(validKeys) < 1 {
debug.Log("no valid keys (expired?) for %s", k)
er.Invalid[k] = fmt.Errorf("no valid keys (expired?)")

continue
}

debug.Log("valid keys found for %s", k)
}

if er.IsError() {
return er
}

return nil
}

// AddRecipient adds a new recipient to the list.
func (s *Store) AddRecipient(ctx context.Context, id string) error {
rs, err := s.GetRecipients(ctx, "")
Expand Down
29 changes: 29 additions & 0 deletions internal/store/leaf/recipients_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
Expand All @@ -16,6 +17,7 @@ import (
"github.com/gopasspw/gopass/internal/out"
"github.com/gopasspw/gopass/internal/recipients"
"github.com/gopasspw/gopass/pkg/ctxutil"
"github.com/gopasspw/gopass/tests/gptest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -250,3 +252,30 @@ func TestListRecipients(t *testing.T) {

assert.Equal(t, "0xDEADBEEF", s.OurKeyID(ctx))
}

func TestCheckRecipients(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test setup not supported on Windows")
}

u := gptest.NewGUnitTester(t)

ctx := context.Background()
ctx = ctxutil.WithTerminal(ctx, false)
ctx = backend.WithCryptoBackend(ctx, backend.GPGCLI)

obuf := &bytes.Buffer{}
out.Stdout = obuf

defer func() {
out.Stdout = os.Stdout
}()

s, err := New(ctx, "", u.StoreDir(""))
require.NoError(t, err)

assert.NoError(t, s.CheckRecipients(ctx))

u.AddExpiredRecipient()
assert.Error(t, s.CheckRecipients(ctx))
}
8 changes: 8 additions & 0 deletions internal/store/root/recipients.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ func (r *Store) ListRecipients(ctx context.Context, store string) []string {
return sub.Recipients(ctx)
}

// CheckRecipients checks all current recipients to make sure that they are
// valid, e.g. not expired.
func (r *Store) CheckRecipients(ctx context.Context, store string) error {
sub, _ := r.getStore(store)

return sub.CheckRecipients(ctx)
}

// AddRecipient adds a single recipient to the given store.
func (r *Store) AddRecipient(ctx context.Context, store, rec string) error {
sub, _ := r.getStore(store)
Expand Down
55 changes: 53 additions & 2 deletions tests/gptest/gunit.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
aclip "github.com/atotto/clipboard"
"github.com/gopasspw/gopass/tests/can"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -100,8 +102,7 @@ func (u GUnit) recipients() []byte {
return []byte(strings.Join(u.Recipients, "\n"))
}

// InitStore initializes the test store.
func (u GUnit) InitStore(name string) error {
func (u GUnit) writeRecipients(name string) error {
dir := u.StoreDir(name)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("failed to create store dir %s: %w", dir, err)
Expand All @@ -114,10 +115,21 @@ func (u GUnit) InitStore(name string) error {
return fmt.Errorf("failed to write IDFile %s: %w", fn, err)
}

return nil
}

// InitStore initializes the test store.
func (u GUnit) InitStore(name string) error {
if err := u.writeRecipients(name); err != nil {
return fmt.Errorf("failed to write recipients: %w", err)
}

if err := can.WriteTo(u.GPGHome()); err != nil {
return err
}

dir := u.StoreDir(name)

// write embedded public keys to the store so we can import them
el := can.EmbeddedKeyRing()
for _, e := range el {
Expand Down Expand Up @@ -145,3 +157,42 @@ func (u GUnit) InitStore(name string) error {

return nil
}

func (u *GUnit) AddExpiredRecipient() string {
u.t.Helper()

e, err := openpgp.NewEntity("Expired", "", "expired@example.com", &packet.Config{
RSABits: 4096,
})
require.NoError(u.t, err)

for _, id := range e.Identities {
err := id.SelfSignature.SignUserId(id.UserId.Id, e.PrimaryKey, e.PrivateKey, &packet.Config{
SigLifetimeSecs: 1, // we can not use negative or zero here
})
require.NoError(u.t, err)
}

el := can.EmbeddedKeyRing()
el = append(el, e)

fn := filepath.Join(u.GPGHome(), "pubring.gpg")
fh, err := os.Create(fn)
require.NoError(u.t, err)

for _, e := range el {
require.NoError(u.t, e.Serialize(fh))
// u.t.Logf("wrote %X to %s", e.PrimaryKey.Fingerprint, fn)
}
require.NoError(u.t, fh.Close())

// wait for the key to expire
time.Sleep(time.Second)

id := fmt.Sprintf("%X", e.PrimaryKey.Fingerprint)
u.Recipients = append(u.Recipients, id)

require.NoError(u.t, u.writeRecipients(""))

return id
}