Skip to content

Commit

Permalink
Merge pull request #1743 from getsops/dependabot/go_modules/go-dfc8ce…
Browse files Browse the repository at this point in the history
…ccf9

build(deps): Bump the go group with 7 updates
  • Loading branch information
felixfontein authored and tomaszduda23 committed Feb 1, 2025
2 parents c1dc448 + 4c04f42 commit 5381112
Show file tree
Hide file tree
Showing 4 changed files with 343 additions and 64 deletions.
185 changes: 181 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 @@ -13,7 +14,9 @@ import (
"filippo.io/age"
"filippo.io/age/armor"
"github.com/sirupsen/logrus"
"golang.org/x/term"

gpgagent "github.com/getsops/gopgagent"
"github.com/getsops/sops/v3/logging"
)

Expand Down Expand Up @@ -284,11 +287,105 @@ 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)
}
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(int(os.Stdout.Fd())) {
p, err = term.ReadPassword(int(os.Stdout.Fd()))
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 +414,83 @@ func parseIdentities(identity ...string) (ParsedIdentities, error) {
}
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
}
40 changes: 20 additions & 20 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.1
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0
github.com/ProtonMail/go-crypto v1.1.5
github.com/aws/aws-sdk-go-v2 v1.33.0
github.com/aws/aws-sdk-go-v2/config v1.29.1
github.com/aws/aws-sdk-go-v2/credentials v1.17.54
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.53
github.com/aws/aws-sdk-go-v2/service/kms v1.37.13
github.com/aws/aws-sdk-go-v2/service/s3 v1.74.0
github.com/aws/aws-sdk-go-v2/service/sts v1.33.9
github.com/aws/aws-sdk-go-v2 v1.34.0
github.com/aws/aws-sdk-go-v2/config v1.29.2
github.com/aws/aws-sdk-go-v2/credentials v1.17.55
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.54
github.com/aws/aws-sdk-go-v2/service/kms v1.37.14
github.com/aws/aws-sdk-go-v2/service/s3 v1.74.1
github.com/aws/aws-sdk-go-v2/service/sts v1.33.10
github.com/blang/semver v3.5.1+incompatible
github.com/fatih/color v1.18.0
github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e
Expand Down Expand Up @@ -64,19 +64,19 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.49.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.28 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10 // indirect
github.com/aws/smithy-go v1.22.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.29 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.11 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
Expand Down
Loading

0 comments on commit 5381112

Please sign in to comment.