Skip to content

Commit beb0d87

Browse files
authored
Merge pull request #1400 from tomaszduda23/pass
support for age identity with passphrase
2 parents 063e054 + 1d3eefa commit beb0d87

File tree

4 files changed

+262
-2
lines changed

4 files changed

+262
-2
lines changed

age/encrypted_keys.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// These functions have been copied from the age project
2+
// https://github.com/FiloSottile/age/blob/101cc8676386b0503571a929a88618cae2f0b1cd/cmd/age/encrypted_keys.go
3+
// https://github.com/FiloSottile/age/blob/101cc8676386b0503571a929a88618cae2f0b1cd/cmd/age/parse.go
4+
//
5+
// Copyright 2021 The age Authors. All rights reserved.
6+
// Use of this source code is governed by a BSD-style
7+
// license that can be found in age's LICENSE file at
8+
// https://github.com/FiloSottile/age/blob/v1.0.0/LICENSE
9+
//
10+
// SPDX-License-Identifier: BSD-3-Clause
11+
12+
package age
13+
14+
import (
15+
"bufio"
16+
"bytes"
17+
"errors"
18+
"fmt"
19+
"io"
20+
21+
"filippo.io/age"
22+
"filippo.io/age/armor"
23+
24+
gpgagent "github.com/getsops/gopgagent"
25+
)
26+
27+
type EncryptedIdentity struct {
28+
Contents []byte
29+
Passphrase func() (string, error)
30+
NoMatchWarning func()
31+
IncorrectPassphrase func()
32+
33+
identities []age.Identity
34+
}
35+
36+
var _ age.Identity = &EncryptedIdentity{}
37+
38+
func (i *EncryptedIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
39+
if i.identities == nil {
40+
if err := i.decrypt(); err != nil {
41+
return nil, err
42+
}
43+
}
44+
45+
for _, id := range i.identities {
46+
fileKey, err = id.Unwrap(stanzas)
47+
if errors.Is(err, age.ErrIncorrectIdentity) {
48+
continue
49+
}
50+
if err != nil {
51+
return nil, err
52+
}
53+
return fileKey, nil
54+
}
55+
i.NoMatchWarning()
56+
return nil, age.ErrIncorrectIdentity
57+
}
58+
59+
func (i *EncryptedIdentity) decrypt() error {
60+
d, err := age.Decrypt(bytes.NewReader(i.Contents), &LazyScryptIdentity{i.Passphrase})
61+
if e := new(age.NoIdentityMatchError); errors.As(err, &e) {
62+
// ScryptIdentity returns ErrIncorrectIdentity for an incorrect
63+
// passphrase, which would lead Decrypt to returning "no identity
64+
// matched any recipient". That makes sense in the API, where there
65+
// might be multiple configured ScryptIdentity. Since in cmd/age there
66+
// can be only one, return a better error message.
67+
i.IncorrectPassphrase()
68+
return fmt.Errorf("incorrect passphrase")
69+
}
70+
if err != nil {
71+
return fmt.Errorf("failed to decrypt identity file: %v", err)
72+
}
73+
i.identities, err = age.ParseIdentities(d)
74+
return err
75+
}
76+
77+
// LazyScryptIdentity is an age.Identity that requests a passphrase only if it
78+
// encounters an scrypt stanza. After obtaining a passphrase, it delegates to
79+
// ScryptIdentity.
80+
type LazyScryptIdentity struct {
81+
Passphrase func() (string, error)
82+
}
83+
84+
var _ age.Identity = &LazyScryptIdentity{}
85+
86+
func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
87+
for _, s := range stanzas {
88+
if s.Type == "scrypt" && len(stanzas) != 1 {
89+
return nil, errors.New("an scrypt recipient must be the only one")
90+
}
91+
}
92+
if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
93+
return nil, age.ErrIncorrectIdentity
94+
}
95+
pass, err := i.Passphrase()
96+
if err != nil {
97+
return nil, fmt.Errorf("could not read passphrase: %v", err)
98+
}
99+
ii, err := age.NewScryptIdentity(pass)
100+
if err != nil {
101+
return nil, err
102+
}
103+
fileKey, err = ii.Unwrap(stanzas)
104+
return fileKey, err
105+
}
106+
107+
func unwrapIdentities(key string, reader io.Reader) (ParsedIdentities, error){
108+
b := bufio.NewReader(reader)
109+
p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE"
110+
peeked := string(p)
111+
112+
switch {
113+
// An age encrypted file, plain or armored.
114+
case peeked == "age-encryption" || peeked == "-----BEGIN AGE":
115+
var r io.Reader = b
116+
if peeked == "-----BEGIN AGE" {
117+
r = armor.NewReader(r)
118+
}
119+
const privateKeySizeLimit = 1 << 24 // 16 MiB
120+
contents, err := io.ReadAll(io.LimitReader(r, privateKeySizeLimit))
121+
if err != nil {
122+
return nil, fmt.Errorf("failed to read '%s': %w", key, err)
123+
}
124+
if len(contents) == privateKeySizeLimit {
125+
return nil, fmt.Errorf("failed to read '%s': file too long", key)
126+
}
127+
IncorrectPassphrase := func() {
128+
conn, err := gpgagent.NewConn()
129+
if err != nil {
130+
return
131+
}
132+
defer func(conn *gpgagent.Conn) {
133+
if err := conn.Close(); err != nil {
134+
log.Errorf("failed to close connection with gpg-agent: %s", err)
135+
}
136+
}(conn)
137+
err = conn.RemoveFromCache(key)
138+
if err != nil {
139+
log.Warnf("gpg-agent remove cache request errored: %s", err)
140+
return
141+
}
142+
}
143+
ids := []age.Identity{&EncryptedIdentity{
144+
Contents: contents,
145+
Passphrase: func() (string, error) {
146+
conn, err := gpgagent.NewConn()
147+
if err != nil {
148+
passphrase, err := readPassphrase("Enter passphrase for identity " + key + ":")
149+
if err != nil {
150+
return "", err
151+
}
152+
return string(passphrase), nil
153+
}
154+
defer func(conn *gpgagent.Conn) {
155+
if err := conn.Close(); err != nil {
156+
log.Errorf("failed to close connection with gpg-agent: %s", err)
157+
}
158+
}(conn)
159+
160+
req := gpgagent.PassphraseRequest{
161+
// TODO is the cachekey good enough?
162+
CacheKey: key,
163+
Prompt: "Passphrase",
164+
Desc: fmt.Sprintf("Enter passphrase for identity '%s':", key),
165+
}
166+
pass, err := conn.GetPassphrase(&req)
167+
if err != nil {
168+
return "", fmt.Errorf("gpg-agent passphrase request errored: %s", err)
169+
}
170+
//make sure that we won't store empty pass
171+
if len(pass) == 0 {
172+
IncorrectPassphrase()
173+
}
174+
return pass, nil
175+
},
176+
IncorrectPassphrase: IncorrectPassphrase,
177+
NoMatchWarning: func() {
178+
log.Warnf("encrypted identity '%s' didn't match file's recipients", key)
179+
},
180+
}}
181+
return ids, nil
182+
// An unencrypted age identity file.
183+
default:
184+
ids, err := age.ParseIdentities(b)
185+
if err != nil {
186+
return nil, fmt.Errorf("failed to parse '%s' age identities: %w", key, err)
187+
}
188+
return ids, nil
189+
}
190+
}

age/keysource.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,9 +326,9 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
326326
}
327327

328328
for n, r := range readers {
329-
ids, err := age.ParseIdentities(r)
329+
ids, err := unwrapIdentities(n, r)
330330
if err != nil {
331-
return nil, fmt.Errorf("failed to parse '%s' age identities: %w", n, err)
331+
return nil, err
332332
}
333333
identities = append(identities, ids...)
334334
}

age/keysource_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ EylloI7MNGbadPGb
2828
-----END AGE ENCRYPTED FILE-----`
2929
// mockEncryptedKeyPlain is the plain value of mockEncryptedKey.
3030
mockEncryptedKeyPlain string = "data"
31+
// passphrase used to encrypt age identity.
32+
mockIdentityPassphrase string = "passphrase"
33+
mockEncryptedIdentity string = `-----BEGIN AGE ENCRYPTED FILE-----
34+
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNjcnlwdCBMN2FXZW9xSFViYjdNeW5D
35+
dy9iSHFnIDE4Ck9zV0ZoNldmci9rL3VXd3BtZmQvK3VZWEpBQjdhZ0UrcmhqR2lF
36+
YThFMzAKLS0tIGVEQ0xwODI1TlNYeHNHaHZKWHoyLzYwMTMvTGhaZG1oa203cSs0
37+
VUpBL1kKsaTnt+H/z8mkL21UYKIt3YMpWSV/oYqTm1cSSUnF9InZEYU9HndK9rc8
38+
ni+MTJCmYf4mgvvGPMf7oIQvs6ijaTdlQb+zeQsL4eif20w+CWgvPNrS6iXUIs8W
39+
w5/fHsxwmrkG96nDkMErJKhmjmLpC+YdbiMe6P/KIpas09m08RTIqcz7ua0Xm3ey
40+
ndU+8ILJOhcnWV55W43nTw/UUFse7f+qY61n7kcd1sGd7ZfSEdEIqS3K2vEtA3ER
41+
fn0s3cyXVEBxL9OZqcAk45bCFVOl13Fp/DBfquHEjvAyeg0=
42+
-----END AGE ENCRYPTED FILE-----`
3143
// mockSshRecipient is a mock age ssh recipient, it matches mockSshIdentity
3244
mockSshRecipient string = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID+Wi8WZw2bXfBpcs/WECttCzP39OkenS6pHWHWGFJvN Test"
3345
// mockSshIdentity is a mock age identity based on an OpenSSH private key (ed25519)
@@ -502,3 +514,49 @@ func TestUserConfigDir(t *testing.T) {
502514
assert.Equal(t, home, dir)
503515
}
504516
}
517+
518+
func TestMasterKey_Identities_Passphrase(t *testing.T) {
519+
t.Run(SopsAgeKeyEnv, func(t *testing.T) {
520+
key := &MasterKey{EncryptedKey: mockEncryptedKey}
521+
t.Setenv(SopsAgeKeyEnv, mockEncryptedIdentity)
522+
//blocks calling gpg-agent
523+
os.Unsetenv("XDG_RUNTIME_DIR")
524+
t.Setenv(SopsAgePasswordEnv, mockIdentityPassphrase)
525+
got, err := key.Decrypt()
526+
527+
assert.NoError(t, err)
528+
assert.EqualValues(t, mockEncryptedKeyPlain, got)
529+
})
530+
531+
t.Run(SopsAgeKeyFileEnv, func(t *testing.T) {
532+
tmpDir := t.TempDir()
533+
// Overwrite to ensure local config is not picked up by tests
534+
overwriteUserConfigDir(t, tmpDir)
535+
536+
keyPath := filepath.Join(tmpDir, "keys.txt")
537+
assert.NoError(t, os.WriteFile(keyPath, []byte(mockEncryptedIdentity), 0o644))
538+
539+
key := &MasterKey{EncryptedKey: mockEncryptedKey}
540+
t.Setenv(SopsAgeKeyFileEnv, keyPath)
541+
//blocks calling gpg-agent
542+
os.Unsetenv("XDG_RUNTIME_DIR")
543+
t.Setenv(SopsAgePasswordEnv, mockIdentityPassphrase)
544+
545+
got, err := key.Decrypt()
546+
assert.NoError(t, err)
547+
assert.EqualValues(t, mockEncryptedKeyPlain, got)
548+
})
549+
550+
t.Run("invalid encrypted key", func(t *testing.T) {
551+
key := &MasterKey{EncryptedKey: "invalid"}
552+
t.Setenv(SopsAgeKeyEnv, mockEncryptedIdentity)
553+
//blocks calling gpg-agent
554+
os.Unsetenv("XDG_RUNTIME_DIR")
555+
t.Setenv(SopsAgePasswordEnv, mockIdentityPassphrase)
556+
557+
got, err := key.Decrypt()
558+
assert.Error(t, err)
559+
assert.ErrorContains(t, err, "failed to create reader for decrypting sops data key with age")
560+
assert.Nil(t, got)
561+
})
562+
}

age/tui.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,25 @@ import (
1414
"fmt"
1515
"os"
1616
"runtime"
17+
"testing"
1718

1819
"golang.org/x/term"
1920
)
2021

22+
const (
23+
SopsAgePasswordEnv = "SOPS_AGE_PASSWORD"
24+
)
25+
2126
// readPassphrase reads a passphrase from the terminal. It does not read from a
2227
// non-terminal stdin, so it does not check stdinInUse.
2328
func readPassphrase(prompt string) ([]byte, error) {
29+
if testing.Testing() {
30+
password := os.Getenv(SopsAgePasswordEnv)
31+
if password != "" {
32+
return []byte(password), nil
33+
}
34+
}
35+
2436
var in, out *os.File
2537
if runtime.GOOS == "windows" {
2638
var err error

0 commit comments

Comments
 (0)