gopass/internal/config/docs_test.go
google-labs-jules[bot] baad47c7ef
feat(age): add agent for passphrase caching (#3218)
* This change introduces an agent for the age backend to cache passphrases for age identities.

The agent is a long-running process that listens on a Unix domain socket. Gopass communicates with the agent to request decryption of secrets. The agent caches the passphrases for the identities and performs the decryption, so the passphrases never leave the agent process. This addresses the security concerns with the initial implementation.

The agent can be controlled with the following commands:
- `gopass age agent`: starts the agent in the foreground.
- `gopass age lock`: locks the agent, clearing all cached passphrases.

The age backend will automatically start the agent if it's not already running and the `age.agent-enabled` configuration option is set to `true` (the default).

This change includes:
- The implementation of the age agent in `internal/backend/crypto/age/agent/`.
- Modifications to the age backend to communicate with the agent.
- A new configuration option `age.agent-enabled`.
- Unit tests for the agent.
- Updated documentation for the age backend.

The integration test for this feature (`TestAgeAgent`) is currently failing. The issue is that the test environment is non-interactive, and the code path for initializing a new age store requires a password for the identity keyring, which triggers a `pinentry` call that fails without a TTY. I have tried several approaches to work around this, including setting the `GOPASS_PASSWORD` environment variable and providing a custom pinentry script, but none have been successful so far. The core implementation of the agent is believed to be correct, but the integration test needs further work to run in a non-interactive environment.

* This change introduces an agent for the age backend to cache passphrases for age identities.

The agent is a long-running process that listens on a Unix domain socket. Gopass communicates with the agent to request decryption of secrets. The agent caches the passphrases for the identities and performs the decryption, so the passphrases never leave the agent process. This addresses the security concerns with the initial implementation.

The agent can be controlled with the following commands:
- `gopass age agent`: starts the agent in the foreground.
- `gopass age lock`: locks the agent, clearing all cached passphrases.

The age backend will automatically start the agent if it's not already running and the `age.agent-enabled` configuration option is set to `true` (the default).

This change includes:
- The implementation of the age agent in `internal/backend/crypto/age/agent/`.
- Modifications to the age backend to communicate with the agent.
- A new configuration option `age.agent-enabled`.
- Unit tests for the agent.
- Updated documentation for the age backend.

* This change introduces an agent for the age backend to cache passphrases for age identities.

The agent is a long-running process that listens on a Unix domain socket. Gopass communicates with the agent to request decryption of secrets. The agent caches the passphrases for the identities and performs the decryption, so the passphrases never leave the agent process. This addresses the security concerns with the initial implementation.

The agent can be controlled with the following commands:
- `gopass age agent`: starts the agent in the foreground.
- `gopass age lock`: locks the agent, clearing all cached passphrases.

The age backend will automatically start the agent if it's not already running and the `age.agent-enabled` configuration option is set to `true` (the default).

This change includes:
- The implementation of the age agent in `internal/backend/crypto/age/agent/`.
- Modifications to the age backend to communicate with the agent.
- A new configuration option `age.agent-enabled`.
- Unit tests for the agent.
- Updated documentation for the age backend.

* This change introduces an agent for the age backend to cache passphrases for age identities.

The agent is a long-running process that listens on a Unix domain socket. Gopass communicates with the agent to request decryption of secrets. The agent caches the passphrases for the identities and performs the decryption, so the passphrases never leave the agent process. This addresses the security concerns with the initial implementation.

The agent can be controlled with the following commands:
- `gopass age agent`: starts the agent in the foreground.
- `gopass age lock`: locks the agent, clearing all cached passphrases.

The age backend will automatically start the agent if it's not already running and the `age.agent-enabled` configuration option is set to `true` (the default).

This change includes:
- The implementation of the age agent in `internal/backend/crypto/age/agent/`.
- Modifications to the age backend to communicate with the agent.
- A new configuration option `age.agent-enabled`.
- Unit tests for the agent.
- Updated documentation for the age backend.

* Fix some test failures and add more logging.

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>

* Fix lint error

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>

* [fix] Fix integration tests

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>

---------

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Dominik Schulz <dominik.schulz@gauner.org>
2025-09-15 22:03:33 +02:00

312 lines
6.3 KiB
Go

package config
import (
"bufio"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/gopasspw/gopass/pkg/set"
)
// ignoredEnvs is a list of environment variables that are used by gopass
// but originate from elsewhere. They should be well known and properly
// documented already.
var ignoredEnvs = set.Map([]string{
// keep-sorted start
"APPDATA",
"GIT_AUTHOR_EMAIL",
"GIT_AUTHOR_NAME",
"GNUPGHOME",
"GOPASS_CONFIG_NOSYSTEM", // name assembled, tests can't catch it
"GOPASS_DEBUG_FILES", // indirect usage
"GOPASS_DEBUG_FUNCS", // indirect usage
"GOPASS_GPG_OPTS", // indirect usage
"GOPASS_UMASK", // indirect usage
"GOPATH",
"GPG_TTY",
"HOME",
"LOCALAPPDATA",
"PASSWORD_STORE_UMASK", // indirect usage
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_RUNTIME_DIR",
// keep-sorted end
})
// ignoredOptions is a list of config options that are used by gopass
// but may not be covered easily by a regexp.
var ignoredOptions = set.Map([]string{
// keep-sorted start
"core.post-hook",
"core.pre-hook",
"include.path",
"recipients.hash",
"user.email",
"user.name",
// keep-sorted end
})
func TestConfigOptsInDocs(t *testing.T) {
t.Parallel()
documented := documentedOpts(t)
used := usedOpts(t)
t.Logf("Config options documented in doc: %+v", documented)
t.Logf("Config options used in the code: %+v", used)
for _, k := range set.SortedKeys(documented) {
if _, got := migrationOpts[k]; got {
continue
}
if !used[k] {
t.Errorf("Documented but not used: %s", k)
}
}
for _, k := range set.SortedKeys(used) {
if _, got := migrationOpts[k]; got {
t.Errorf("Legacy option still used: %s", k)
}
if !documented[k] {
t.Errorf("Used but not documented: %s", k)
}
}
}
func usedOpts(t *testing.T) map[string]bool {
t.Helper()
optRE := regexp.MustCompile(`(?:\.Get(?:|Int|Bool|All|Global)\(\"([a-z]+\.[a-z-]+)\"\)|\.Get(?:|Int|Bool)M\([^,]+, \"([a-z]+\.[a-z-]+)\"\)|config\.(?:Bool|Int|String)\((?:ctx|c\.Context), \"([a-z]+\.[a-z-]+)\"\)|hook\.Invoke(?:Root)?\(ctx, \"([a-z]+\.[a-z-]+)\")`)
opts := make(map[string]bool, 42)
dir := filepath.Join("..", "..")
if err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() && strings.HasPrefix(info.Name(), ".") && path != dir {
return filepath.SkipDir
}
if info.IsDir() {
return nil
}
if strings.HasSuffix(info.Name(), "_test.go") {
return nil
}
if !strings.HasSuffix(info.Name(), ".go") {
return nil
}
return usedOptsInFile(t, path, opts, optRE)
}); err != nil {
t.Errorf("failed to walk %s: %s", dir, err)
}
return opts
}
func usedOptsInFile(t *testing.T, fn string, opts map[string]bool, re *regexp.Regexp) error {
t.Helper()
fh, err := os.Open(fn)
if err != nil {
return err
}
defer fh.Close() //nolint:errcheck
scanner := bufio.NewScanner(fh)
for scanner.Scan() {
line := scanner.Text()
if !re.MatchString(line) {
continue
}
found := re.FindStringSubmatch(line)
// t.Logf("found: %q", found)
if len(found) < 4 {
continue
}
for i := 1; i < 10; i++ {
if found[i] == "" {
continue
}
if ignoredOptions[found[i]] {
break
}
opts[found[i]] = true
break
}
}
return nil
}
func documentedOpts(t *testing.T) map[string]bool {
t.Helper()
fn := filepath.Join("..", "..", "docs", "config.md")
fh, err := os.Open(fn)
if err != nil {
t.Fatalf("failed to open %s: %s", fn, err)
}
defer fh.Close() //nolint:errcheck
optRE := regexp.MustCompile(`^\| .([a-z]+\.[a-z-]+).`)
opts := make(map[string]bool, 42)
scanner := bufio.NewScanner(fh)
for scanner.Scan() {
line := scanner.Text()
if !optRE.MatchString(line) {
continue
}
found := optRE.FindStringSubmatch(line)
if len(found) < 2 {
continue
}
if _, got := ignoredOptions[found[1]]; got {
continue
}
opts[found[1]] = true
}
return opts
}
func TestEnvVarsInDocs(t *testing.T) {
t.Parallel()
documented := documentedEnvs(t)
used := usedEnvs(t)
t.Logf("env options documented in doc: %+v", documented)
t.Logf("env options used in the code: %+v", used)
for _, k := range set.SortedKeys(documented) {
if !used[k] {
t.Errorf("Documented but not used: %s", k)
}
}
for _, k := range set.SortedKeys(used) {
if !documented[k] {
t.Errorf("Used but not documented: %s", k)
}
}
}
func usedEnvs(t *testing.T) map[string]bool {
t.Helper()
optRE := regexp.MustCompile(`os\.(?:Getenv|LookupEnv)\(\"([^"]+)\"\)`)
opts := make(map[string]bool, 42)
dir := filepath.Join("..", "..")
if err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() && strings.HasPrefix(info.Name(), ".") && path != dir {
return filepath.SkipDir
}
if info.IsDir() && (info.Name() == "helpers" || info.Name() == "tests") {
return filepath.SkipDir
}
if info.IsDir() {
return nil
}
if strings.HasSuffix(info.Name(), "_test.go") {
return nil
}
if !strings.HasSuffix(info.Name(), ".go") {
return nil
}
return usedEnvsInFile(t, path, opts, optRE)
}); err != nil {
t.Errorf("failed to walk %s: %s", dir, err)
}
return opts
}
func usedEnvsInFile(t *testing.T, fn string, opts map[string]bool, re *regexp.Regexp) error {
t.Helper()
fh, err := os.Open(fn)
if err != nil {
return err
}
defer fh.Close() //nolint:errcheck
scanner := bufio.NewScanner(fh)
for scanner.Scan() {
line := scanner.Text()
if !re.MatchString(line) {
continue
}
found := re.FindStringSubmatch(line)
// t.Logf("found: %q", found)
if len(found) < 2 {
continue
}
v := found[1]
if ignoredEnvs[v] {
continue
}
opts[v] = true
}
return nil
}
func documentedEnvs(t *testing.T) map[string]bool {
t.Helper()
fn := filepath.Join("..", "..", "docs", "config.md")
fh, err := os.Open(fn)
if err != nil {
t.Fatalf("failed to open %s: %s", fn, err)
}
defer fh.Close() //nolint:errcheck
optRE := regexp.MustCompile(`^\| .([A-Z0-9_]+).`)
opts := make(map[string]bool, 42)
scanner := bufio.NewScanner(fh)
for scanner.Scan() {
line := scanner.Text()
if !optRE.MatchString(line) {
continue
}
found := optRE.FindStringSubmatch(line)
if len(found) < 2 {
continue
}
v := found[1]
if ignoredEnvs[v] {
continue
}
opts[v] = true
}
return opts
}