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

support for age identity with passphrase #1400

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
186 changes: 182 additions & 4 deletions age/keysource.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package age

import (
"bufio"
"bytes"
"errors"
"fmt"
Expand All @@ -9,12 +10,15 @@
"path/filepath"
"runtime"
"strings"
"syscall"

"filippo.io/age"
"filippo.io/age/armor"
"github.com/sirupsen/logrus"
"golang.org/x/term"

"github.com/getsops/sops/v3/logging"
gpgagent "github.com/tomaszduda23/gopgagent"
tomaszduda23 marked this conversation as resolved.
Show resolved Hide resolved
)

const (
Expand Down Expand Up @@ -284,11 +288,105 @@

var identities ParsedIdentities
for n, r := range readers {
ids, err := age.ParseIdentities(r)
if err != nil {
return nil, fmt.Errorf("failed to parse '%s' age identities: %w", n, err)

b := bufio.NewReader(r)
p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE"
peeked := string(p)

switch {
// An age encrypted file, plain or armored.
case peeked == "age-encryption" || peeked == "-----BEGIN AGE":
var r io.Reader = b
if peeked == "-----BEGIN AGE" {
r = armor.NewReader(r)
}
const privateKeySizeLimit = 1 << 24 // 16 MiB
contents, err := io.ReadAll(io.LimitReader(r, privateKeySizeLimit))
if err != nil {
return nil, fmt.Errorf("failed to read '%s': %w", n, err)
}
if len(contents) == privateKeySizeLimit {
return nil, fmt.Errorf("failed to read '%s': file too long", n)
}
IncorrectPassphrase := func() {
conn, err := gpgagent.NewConn()
if err != nil {
return
}
defer func(conn *gpgagent.Conn) {
if err := conn.Close(); err != nil {
log.Errorf("failed to close connection with gpg-agent: %s", err)
}
}(conn)
err = conn.RemoveFromCache(n)
if err != nil {
log.Warnf("gpg-agent remove cache request errored: %s", err)
return
}
}
ids := []age.Identity{&EncryptedIdentity{
Contents: contents,
Passphrase: func() (string, error) {
conn, err := gpgagent.NewConn()
if err != nil {
fmt.Fprintf(os.Stderr, "Enter passphrase for identity '%s':", n)

var pass string
if term.IsTerminal(syscall.Stdin) {

Check failure on line 335 in age/keysource.go

View workflow job for this annotation

GitHub Actions / Build and test windows amd64

cannot use syscall.Stdin (variable of type syscall.Handle) as int value in argument to term.IsTerminal
p, err = term.ReadPassword(syscall.Stdin)

Check failure on line 336 in age/keysource.go

View workflow job for this annotation

GitHub Actions / Build and test windows amd64

cannot use syscall.Stdin (variable of type syscall.Handle) as int value in argument to term.ReadPassword
if err == nil {
pass = string(p)
}
} else {
reader := bufio.NewReader(os.Stdin)
pass, err = reader.ReadString('\n')
if err == io.EOF {
err = nil
}
}
if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err)
}

fmt.Fprintln(os.Stderr)

return pass, nil
}
defer func(conn *gpgagent.Conn) {
if err := conn.Close(); err != nil {
log.Errorf("failed to close connection with gpg-agent: %s", err)
}
}(conn)

req := gpgagent.PassphraseRequest{
// TODO is the cachekey good enough?
CacheKey: n,
Prompt: "Passphrase",
Desc: fmt.Sprintf("Enter passphrase for identity '%s':", n),
}
pass, err := conn.GetPassphrase(&req)
if err != nil {
return "", fmt.Errorf("gpg-agent passphrase request errored: %s", err)
}
//make sure that we won't store empty pass
if len(pass) == 0 {
IncorrectPassphrase()
}
return pass, nil
},
IncorrectPassphrase: IncorrectPassphrase,
NoMatchWarning: func() {
log.Warnf("encrypted identity '%s' didn't match file's recipients", n)
},
}}
identities = append(identities, ids...)
default:
ids, err := age.ParseIdentities(b)
if err != nil {
return nil, fmt.Errorf("failed to parse '%s' age identities: %w", n, err)
}
identities = append(identities, ids...)
}
identities = append(identities, ids...)
}
return identities, nil
}
Expand Down Expand Up @@ -317,3 +415,83 @@
}
return identities, nil
}

type EncryptedIdentity struct {
Contents []byte
Passphrase func() (string, error)
NoMatchWarning func()
IncorrectPassphrase func()

identities []age.Identity
}

var _ age.Identity = &EncryptedIdentity{}

func (i *EncryptedIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
if i.identities == nil {
if err := i.decrypt(); err != nil {
return nil, err
}
}

for _, id := range i.identities {
fileKey, err = id.Unwrap(stanzas)
if errors.Is(err, age.ErrIncorrectIdentity) {
continue
}
if err != nil {
return nil, err
}
return fileKey, nil
}
i.NoMatchWarning()
return nil, age.ErrIncorrectIdentity
}

func (i *EncryptedIdentity) decrypt() error {
d, err := age.Decrypt(bytes.NewReader(i.Contents), &LazyScryptIdentity{i.Passphrase})
if e := new(age.NoIdentityMatchError); errors.As(err, &e) {
// ScryptIdentity returns ErrIncorrectIdentity for an incorrect
// passphrase, which would lead Decrypt to returning "no identity
// matched any recipient". That makes sense in the API, where there
// might be multiple configured ScryptIdentity. Since in cmd/age there
// can be only one, return a better error message.
i.IncorrectPassphrase()
return fmt.Errorf("incorrect passphrase")
}
if err != nil {
return fmt.Errorf("failed to decrypt identity file: %v", err)
}
i.identities, err = age.ParseIdentities(d)
return err
}

// LazyScryptIdentity is an age.Identity that requests a passphrase only if it
// encounters an scrypt stanza. After obtaining a passphrase, it delegates to
// ScryptIdentity.
type LazyScryptIdentity struct {
Passphrase func() (string, error)
}

var _ age.Identity = &LazyScryptIdentity{}

func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
for _, s := range stanzas {
if s.Type == "scrypt" && len(stanzas) != 1 {
return nil, errors.New("an scrypt recipient must be the only one")
}
}
if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
return nil, age.ErrIncorrectIdentity
}
pass, err := i.Passphrase()
if err != nil {
return nil, fmt.Errorf("could not read passphrase: %v", err)
}
ii, err := age.NewScryptIdentity(pass)
if err != nil {
return nil, err
}
fileKey, err = ii.Unwrap(stanzas)
return fileKey, err
}
102 changes: 102 additions & 0 deletions age/keysource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package age

import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"runtime"
Expand All @@ -28,6 +29,18 @@ EylloI7MNGbadPGb
-----END AGE ENCRYPTED FILE-----`
// mockEncryptedKeyPlain is the plain value of mockEncryptedKey.
mockEncryptedKeyPlain string = "data"
// passphrase used to encrypt age identity.
mockIdentityPassphrase string = "passphrase"
mockEncryptedIdentity string = `-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNjcnlwdCBMN2FXZW9xSFViYjdNeW5D
dy9iSHFnIDE4Ck9zV0ZoNldmci9rL3VXd3BtZmQvK3VZWEpBQjdhZ0UrcmhqR2lF
YThFMzAKLS0tIGVEQ0xwODI1TlNYeHNHaHZKWHoyLzYwMTMvTGhaZG1oa203cSs0
VUpBL1kKsaTnt+H/z8mkL21UYKIt3YMpWSV/oYqTm1cSSUnF9InZEYU9HndK9rc8
ni+MTJCmYf4mgvvGPMf7oIQvs6ijaTdlQb+zeQsL4eif20w+CWgvPNrS6iXUIs8W
w5/fHsxwmrkG96nDkMErJKhmjmLpC+YdbiMe6P/KIpas09m08RTIqcz7ua0Xm3ey
ndU+8ILJOhcnWV55W43nTw/UUFse7f+qY61n7kcd1sGd7ZfSEdEIqS3K2vEtA3ER
fn0s3cyXVEBxL9OZqcAk45bCFVOl13Fp/DBfquHEjvAyeg0=
-----END AGE ENCRYPTED FILE-----`
)

func TestMasterKeysFromRecipients(t *testing.T) {
Expand Down Expand Up @@ -400,3 +413,92 @@ func TestUserConfigDir(t *testing.T) {
assert.Equal(t, home, dir)
}
}

func TestMasterKey_Identities_Passphrase(t *testing.T) {
t.Run(SopsAgeKeyEnv, func(t *testing.T) {
key := &MasterKey{EncryptedKey: mockEncryptedKey}
t.Setenv(SopsAgeKeyEnv, mockEncryptedIdentity)
//blocks calling gpg-agent
os.Unsetenv("XDG_RUNTIME_DIR")

funcDefer, _ := mockStdin(t, mockIdentityPassphrase)
defer funcDefer()

got, err := key.Decrypt()

assert.NoError(t, err)
assert.EqualValues(t, mockEncryptedKeyPlain, got)
})

t.Run(SopsAgeKeyFileEnv, func(t *testing.T) {
tmpDir := t.TempDir()
// Overwrite to ensure local config is not picked up by tests
overwriteUserConfigDir(t, tmpDir)

keyPath := filepath.Join(tmpDir, "keys.txt")
assert.NoError(t, os.WriteFile(keyPath, []byte(mockEncryptedIdentity), 0o644))

key := &MasterKey{EncryptedKey: mockEncryptedKey}
t.Setenv(SopsAgeKeyFileEnv, keyPath)
//blocks calling gpg-agent
os.Unsetenv("XDG_RUNTIME_DIR")

funcDefer, _ := mockStdin(t, mockIdentityPassphrase)
defer funcDefer()

got, err := key.Decrypt()
assert.NoError(t, err)
assert.EqualValues(t, mockEncryptedKeyPlain, got)
})

t.Run("invalid encrypted key", func(t *testing.T) {
key := &MasterKey{EncryptedKey: "invalid"}
t.Setenv(SopsAgeKeyEnv, mockEncryptedIdentity)
//blocks calling gpg-agent
os.Unsetenv("XDG_RUNTIME_DIR")

funcDefer, _ := mockStdin(t, mockIdentityPassphrase)
defer funcDefer()

got, err := key.Decrypt()
assert.Error(t, err)
assert.ErrorContains(t, err, "failed to create reader for decrypting sops data key with age")
assert.Nil(t, got)
})
}

// mockStdin is a helper function that lets the test pretend dummyInput as os.Stdin.
// It will return a function for `defer` to clean up after the test.
//
// Note: `ioutil.TempFile` should be replaced to `os.CreateTemp` for Go v1.16 or higher.
func mockStdin(t *testing.T, dummyInput string) (funcDefer func(), err error) {
t.Helper()

oldOsStdin := os.Stdin

fmt.Println(t.TempDir(), t.Name())

tmpfile, err := ioutil.TempFile(t.TempDir(), strings.Replace(t.Name(), "/", "_", -1))
if err != nil {
return nil, err
}

content := []byte(dummyInput)

if _, err := tmpfile.Write(content); err != nil {
return nil, err
}

if _, err := tmpfile.Seek(0, 0); err != nil {
return nil, err
}

// Set stdin to the temp file
os.Stdin = tmpfile

return func() {
// clean up
os.Stdin = oldOsStdin
os.Remove(tmpfile.Name())
}, nil
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ require (
github.com/pkg/errors v0.9.1
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4
github.com/tomaszduda23/gopgagent v0.0.0-20231231125842-0d2facc3788a
github.com/urfave/cli v1.22.14
golang.org/x/net v0.19.0
golang.org/x/sys v0.15.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/tomaszduda23/gopgagent v0.0.0-20231231125842-0d2facc3788a h1:SZ7D6UJ8uJF9gxlwJ3MiUtlZHzLCQFxQdtv2iEO05Oc=
github.com/tomaszduda23/gopgagent v0.0.0-20231231125842-0d2facc3788a/go.mod h1:x7Du2Z5KcZyMghacsl2dYqxiqjfyQUJwLMdOZpXWbLE=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk=
github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA=
Expand Down