Skip to content

Commit

Permalink
add support for passphrase for age
Browse files Browse the repository at this point in the history
  • Loading branch information
tomaszduda23 committed Dec 30, 2023
1 parent 0bceaf4 commit cb6262a
Show file tree
Hide file tree
Showing 2 changed files with 241 additions and 4 deletions.
149 changes: 145 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,10 +10,12 @@ import (
"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"
)
Expand Down Expand Up @@ -284,11 +287,64 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {

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)
}
ids := []age.Identity{&EncryptedIdentity{
Contents: contents,
Passphrase: func() (string, error) {
fmt.Fprintf(os.Stderr, "Enter passphrase for identity '%s':", n)

var pass string
if term.IsTerminal(syscall.Stdin) {
p, err = term.ReadPassword(syscall.Stdin)
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
},
NoMatchWarning: func() {
warningf("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 +373,88 @@ func parseIdentities(identity ...string) (ParsedIdentities, error) {
}
return identities, nil
}

type EncryptedIdentity struct {
Contents []byte
Passphrase func() (string, error)
NoMatchWarning 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) {
return fmt.Errorf("identity file is encrypted with age but not with a 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)
if errors.Is(err, age.ErrIncorrectIdentity) {
// 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.
return nil, fmt.Errorf("incorrect passphrase")
}
return fileKey, err
}

func warningf(format string, v ...interface{}) {
log.Warnf("age: warning: "+format, v...)
}
96 changes: 96 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,86 @@ 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)

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)

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)

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
}

0 comments on commit cb6262a

Please sign in to comment.