Skip to content
Open
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
42 changes: 41 additions & 1 deletion age/keysource.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"

"filippo.io/age"
"filippo.io/age/agessh"
Expand Down Expand Up @@ -43,6 +45,10 @@ const (
// SopsAgeSshPrivateKeyFileEnv can be set as an environment variable pointing to
// a private SSH key file.
SopsAgeSshPrivateKeyFileEnv = "SOPS_AGE_SSH_PRIVATE_KEY_FILE"
// SopsAgeKeyCmdCacheEnv can be set to a value considered true by
// strconv.ParseBool to execute each key command at most once per process,
// reusing its first output for every recipient.
SopsAgeKeyCmdCacheEnv = "SOPS_AGE_KEY_CMD_CACHE"
// SopsAgeKeyUserConfigPath is the default age keys file path in
// getUserConfigDir().
SopsAgeKeyUserConfigPath = "sops/age/keys.txt"
Expand Down Expand Up @@ -294,10 +300,44 @@ func (key *MasterKey) TypeToIdentifier() string {
return KeyTypeIdentifier
}

// cmdOutputCache memoises successful key command outputs per command string
// when SopsAgeKeyCmdCacheEnv is set. Errors are never cached, and the lock is
// not held while the command runs, so a slow or failing command cannot block
// or poison other decrypts in long-lived processes such as the keyservice.
var cmdOutputCache = struct {
sync.Mutex
results map[string][]byte
}{results: make(map[string][]byte)}

// getOutputFromCmd executes a shell command provided in param 'cmdString',
// optionally adding env vars provided in param 'envVars',
// and returns the command's output and error
// and returns the command's output and error. If SopsAgeKeyCmdCacheEnv is
// set to a true value, successful output is cached per command string for
// the lifetime of the process, ignoring envVars on cache hits.
func getOutputFromCmd(cmdString string, envVars []string) ([]byte, error) {
cache, err := strconv.ParseBool(os.Getenv(SopsAgeKeyCmdCacheEnv))
if err != nil || !cache {
return runCmd(cmdString, envVars)
}
cmdOutputCache.Lock()
out, ok := cmdOutputCache.results[cmdString]
cmdOutputCache.Unlock()
if ok {
return out, nil
}
out, err = runCmd(cmdString, envVars)
if err == nil {
cmdOutputCache.Lock()
cmdOutputCache.results[cmdString] = out
cmdOutputCache.Unlock()
}
return out, err
}

// runCmd executes a shell command provided in param 'cmdString',
// optionally adding env vars provided in param 'envVars',
// and returns the command's output and error
func runCmd(cmdString string, envVars []string) ([]byte, error) {
var out []byte

args, err := shlex.Split(cmdString)
Expand Down
73 changes: 73 additions & 0 deletions age/keysource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,79 @@ func TestMasterKey_loadIdentities(t *testing.T) {
assert.Nil(t, got)
assert.Len(t, unusedLocations, 7)
})

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

countFile := filepath.Join(tmpDir, "count")
t.Setenv(SopsAgeKeyCmdEnv, fmt.Sprintf("bash -c 'echo x >> %s; echo %s'", countFile, mockIdentity))
t.Setenv(SopsAgeKeyCmdCacheEnv, "true")

// The command runs once; the second recipient is served from the cache.
for _, recipient := range []string{mockRecipient, mockRecipient + "abc"} {
key := &MasterKey{Recipient: recipient}
got, unusedLocations, errs := key.loadIdentities()
assert.Len(t, errs, 0)
assert.Len(t, got, 1)
assert.Len(t, unusedLocations, 6)
}
count, err := os.ReadFile(countFile)
assert.NoError(t, err)
assert.Equal(t, "x\n", string(count))
})

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

flagFile := filepath.Join(tmpDir, "flag")
countFile := filepath.Join(tmpDir, "count")
t.Setenv(SopsAgeKeyCmdEnv, fmt.Sprintf(
"bash -c 'echo x >> %s; if [ ! -f %s ]; then touch %s; exit 1; fi; echo %s'",
countFile, flagFile, flagFile, mockIdentity))
t.Setenv(SopsAgeKeyCmdCacheEnv, "true")

// First run fails and must not be cached.
key := &MasterKey{Recipient: mockRecipient}
got, _, errs := key.loadIdentities()
assert.Len(t, errs, 1)
assert.Nil(t, got)

// Second run succeeds and is cached; the third is served from cache.
for range 2 {
key = &MasterKey{Recipient: mockRecipient}
got, _, errs = key.loadIdentities()
assert.Len(t, errs, 0)
assert.Len(t, got, 1)
}
count, err := os.ReadFile(countFile)
assert.NoError(t, err)
assert.Equal(t, "x\nx\n", string(count))
})

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

countFile := filepath.Join(tmpDir, "count")
t.Setenv(SopsAgeKeyCmdEnv, fmt.Sprintf("bash -c 'echo x >> %s; echo %s'", countFile, mockIdentity))

// Without the cache flag the command runs once per recipient.
for _, recipient := range []string{mockRecipient, mockRecipient + "abc"} {
key := &MasterKey{Recipient: recipient}
got, unusedLocations, errs := key.loadIdentities()
assert.Len(t, errs, 0)
assert.Len(t, got, 1)
assert.Len(t, unusedLocations, 6)
}
count, err := os.ReadFile(countFile)
assert.NoError(t, err)
assert.Equal(t, "x\nx\n", string(count))
})
}

// overwriteUserConfigDir sets the user config directory and the user home directory
Expand Down