diff --git a/docs/security.md b/docs/security.md index e8daf59..4057bff 100644 --- a/docs/security.md +++ b/docs/security.md @@ -41,7 +41,10 @@ focuses on: ## Platform credential-store adapters -The macOS implementation invokes the system `security` utility without a shell -and sends new vault keys through standard input rather than the process argument -vector. Linux similarly sends values to `secret-tool` over standard input. -Windows calls the native Credential Manager API directly. +The macOS implementation invokes the system `security` utility without a shell. +Because that utility treats `-w` without a value as an interactive prompt rather +than reading standard input, the adapter supplies the vault key directly to +`-w`. The value can therefore be visible briefly to processes running as the +same operating-system user, which is inside this project's trust boundary. +Linux sends values to `secret-tool` over standard input. Windows calls the +native Credential Manager API directly. diff --git a/internal/secretstore/store_darwin.go b/internal/secretstore/store_darwin.go index e43ba07..4403a28 100644 --- a/internal/secretstore/store_darwin.go +++ b/internal/secretstore/store_darwin.go @@ -25,14 +25,20 @@ func (darwinStore) Set(key, value string) error { if err := validateKey(key); err != nil { return err } - command := exec.Command("security", "add-generic-password", "-U", "-a", target(key), "-s", serviceName, "-w") - command.Stdin = strings.NewReader(value + "\n") + command := newDarwinSetCommand(key, value) if output, err := command.CombinedOutput(); err != nil { return fmt.Errorf("write macOS Keychain entry: %s: %w", strings.TrimSpace(string(output)), err) } return nil } +func newDarwinSetCommand(key, value string) *exec.Cmd { + // The security CLI treats a trailing -w as a request to prompt twice on a + // terminal; it does not consume a password from stdin. Supply the value as + // the argument to -w so unattended initialization stores the generated key. + return exec.Command("security", "add-generic-password", "-U", "-a", target(key), "-s", serviceName, "-w", value) +} + func (darwinStore) Get(key string) (string, error) { if err := validateKey(key); err != nil { return "", err diff --git a/internal/secretstore/store_darwin_test.go b/internal/secretstore/store_darwin_test.go new file mode 100644 index 0000000..3b98052 --- /dev/null +++ b/internal/secretstore/store_darwin_test.go @@ -0,0 +1,32 @@ +//go:build darwin + +package secretstore + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDarwinSetSuppliesPasswordToWFlag(t *testing.T) { + directory := t.TempDir() + security := filepath.Join(directory, "security") + stub := `#!/bin/sh +test "$#" -eq 8 || exit 64 +test "$1" = "add-generic-password" || exit 65 +test "$2" = "-U" || exit 66 +test "$3" = "-a" || exit 67 +test "$4" = "codex-switch/master-key/test" || exit 68 +test "$5" = "-s" || exit 69 +test "$6" = "codex-switch" || exit 70 +test "$7" = "-w" || exit 71 +test "$8" = "generated-vault-key" || exit 72 +` + if err := os.WriteFile(security, []byte(stub), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", directory) + if err := (darwinStore{}).Set("master-key/test", "generated-vault-key"); err != nil { + t.Fatal(err) + } +}