From fcedaf794b2161e9f30463cfdcbe9e409af94446 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 14:50:39 -0700 Subject: [PATCH 01/14] credstore: forced file-only construction and a bounded keyring probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewStore probes keyring availability with keyring.Set, which on darwin execs /usr/bin/security -i and Wait()s with no cancellation path. On a locked keychain with no TTY or GUI the child blocks forever and cannot be reclaimed from outside go-keyring, so every credential-touching caller hangs unbounded (basecamp/basecamp-cli#568). Two additions to StoreOptions: * ForceFile forces file-backed storage with no keyring probe — the programmatic equivalent of DisableEnvVar, and the fallback target a caller reaches for after a probe timeout. Like DisableEnvVar, an explicit opt-out carries no fallback warning. * ProbeTimeout bounds the availability probe. Zero keeps today's unbounded probe. When set, the darwin probe mirrors go-keyring's Set (security -i fed add-generic-password over stdin, same escaping and base64 password encoding) via exec.CommandContext, so expiry kills the child instead of leaking it. Non-darwin backends run in-process with no child to reclaim, so the bound there is a plain goroutine timeout. A timed-out probe falls back to file storage with the same warning as a failed probe. Fixes #55 --- credstore/probe.go | 44 ++++++++++++++++++ credstore/probe_darwin.go | 53 ++++++++++++++++++++++ credstore/probe_darwin_test.go | 81 ++++++++++++++++++++++++++++++++++ credstore/probe_other.go | 21 +++++++++ credstore/store.go | 26 +++++------ credstore/store_test.go | 68 ++++++++++++++++++++++++++++ 6 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 credstore/probe.go create mode 100644 credstore/probe_darwin.go create mode 100644 credstore/probe_darwin_test.go create mode 100644 credstore/probe_other.go diff --git a/credstore/probe.go b/credstore/probe.go new file mode 100644 index 0000000..c9b7543 --- /dev/null +++ b/credstore/probe.go @@ -0,0 +1,44 @@ +package credstore + +import ( + "context" + "crypto/rand" + "encoding/hex" + "time" + + "github.com/zalando/go-keyring" +) + +// probeKeyring checks system keyring availability. Overridable in tests. +var probeKeyring = probe + +// probe writes and removes a throwaway keyring entry to check availability. +// A zero timeout probes unbounded, matching historical behavior. A positive +// timeout bounds the probe; on platforms where the probe runs a child +// process (darwin), the child is killed when the timeout expires. +func probe(serviceName string, timeout time.Duration) error { + // Probe with a random key to avoid collisions. + probeKey := probeKeyName() + if timeout <= 0 { + return probeDirect(serviceName, probeKey) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return probeBounded(ctx, serviceName, probeKey) +} + +// probeDirect probes via go-keyring, which has no cancellation path. +func probeDirect(serviceName, probeKey string) error { + if err := keyring.Set(serviceName, probeKey, "probe"); err != nil { + return err + } + _ = keyring.Delete(serviceName, probeKey) + return nil +} + +func probeKeyName() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return "__probe_" + hex.EncodeToString(b) +} diff --git a/credstore/probe_darwin.go b/credstore/probe_darwin.go new file mode 100644 index 0000000..ae2df73 --- /dev/null +++ b/credstore/probe_darwin.go @@ -0,0 +1,53 @@ +package credstore + +import ( + "context" + "encoding/base64" + "fmt" + "os/exec" + "regexp" + "strings" +) + +// securityPath is the macOS keychain tool go-keyring shells out to. +// Variable so tests can substitute a stub. +var securityPath = "/usr/bin/security" + +// probeBounded mirrors go-keyring's darwin Set — `security -i` fed an +// add-generic-password command over stdin — via exec.CommandContext so the +// child is killed when ctx expires. go-keyring's own exec has no +// cancellation path: on a locked keychain with no TTY or GUI it blocks +// forever and the child cannot be reclaimed from outside. +func probeBounded(ctx context.Context, serviceName, probeKey string) error { + // go-keyring base64-encodes every password it stores; mirror that so + // probe success genuinely predicts go-keyring usability. + password := "go-keyring-base64:" + base64.StdEncoding.EncodeToString([]byte("probe")) + command := fmt.Sprintf("add-generic-password -U -s %s -a %s -w %s\n", + quoteSecurityArg(serviceName), quoteSecurityArg(probeKey), quoteSecurityArg(password)) + + cmd := exec.CommandContext(ctx, securityPath, "-i") + cmd.Stdin = strings.NewReader(command) + if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + + _ = exec.CommandContext(ctx, securityPath, "delete-generic-password", "-s", serviceName, "-a", probeKey).Run() + return nil +} + +var securityArgUnsafe = regexp.MustCompile(`[^\w@%+=:,./-]`) + +// quoteSecurityArg mirrors go-keyring's internal shellescape.Quote so the +// probe entry is written exactly as go-keyring would write it. +func quoteSecurityArg(s string) string { + if s == "" { + return "''" + } + if securityArgUnsafe.MatchString(s) { + return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" + } + return s +} diff --git a/credstore/probe_darwin_test.go b/credstore/probe_darwin_test.go new file mode 100644 index 0000000..639fffd --- /dev/null +++ b/credstore/probe_darwin_test.go @@ -0,0 +1,81 @@ +package credstore + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func stubSecurity(t *testing.T, script string) { + t.Helper() + stub := filepath.Join(t.TempDir(), "security") + require.NoError(t, os.WriteFile(stub, []byte(script), 0755)) + restore := securityPath + securityPath = stub + t.Cleanup(func() { securityPath = restore }) +} + +func TestProbeBoundedKillsChildOnTimeout(t *testing.T) { + pidFile := filepath.Join(t.TempDir(), "pid") + // exec replaces the shell so the recorded PID is the hung process itself, + // mirroring a `security -i` blocked on a locked keychain. + stubSecurity(t, "#!/bin/sh\necho $$ > "+pidFile+".tmp && mv "+pidFile+".tmp "+pidFile+"\nexec sleep 60\n") + + // Expire the context once the child is demonstrably hung (PID recorded), + // so the test exercises exactly the timeout path without racing startup. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + for { + if _, err := os.Stat(pidFile); err == nil { + cancel() + return + } + time.Sleep(10 * time.Millisecond) + } + }() + + err := probeBounded(ctx, "test", "__probe_timeout") + assert.ErrorIs(t, err, context.Canceled) + + raw, err := os.ReadFile(pidFile) + require.NoError(t, err) + pid, err := strconv.Atoi(strings.TrimSpace(string(raw))) + require.NoError(t, err) + + assert.Eventually(t, func() bool { + return syscall.Kill(pid, 0) == syscall.ESRCH + }, 5*time.Second, 10*time.Millisecond, "hung child should be killed when the context expires") +} + +func TestProbeBoundedSuccess(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args") + stubSecurity(t, "#!/bin/sh\necho \"$@\" >> "+argsFile+"\ncat > /dev/null\nexit 0\n") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + require.NoError(t, probeBounded(ctx, "test", "__probe_ok")) + + raw, err := os.ReadFile(argsFile) + require.NoError(t, err) + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + require.Len(t, lines, 2, "probe should add then delete the probe entry") + assert.Equal(t, "-i", lines[0]) + assert.Equal(t, "delete-generic-password -s test -a __probe_ok", lines[1]) +} + +func TestQuoteSecurityArg(t *testing.T) { + assert.Equal(t, "basecamp", quoteSecurityArg("basecamp")) + assert.Equal(t, "''", quoteSecurityArg("")) + assert.Equal(t, "'my service'", quoteSecurityArg("my service")) + assert.Equal(t, `'it'"'"'s'`, quoteSecurityArg("it's")) +} diff --git a/credstore/probe_other.go b/credstore/probe_other.go new file mode 100644 index 0000000..bdac135 --- /dev/null +++ b/credstore/probe_other.go @@ -0,0 +1,21 @@ +//go:build !darwin + +package credstore + +import "context" + +// probeBounded runs the go-keyring probe with a deadline. Non-darwin +// backends (dbus secret service, Windows credential manager) run in-process, +// so timing out abandons at most a goroutine — there is no child process to +// reclaim. +func probeBounded(ctx context.Context, serviceName, probeKey string) error { + done := make(chan error, 1) + go func() { done <- probeDirect(serviceName, probeKey) }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/credstore/store.go b/credstore/store.go index fdacb0e..fc5c7b3 100644 --- a/credstore/store.go +++ b/credstore/store.go @@ -1,11 +1,10 @@ package credstore import ( - "crypto/rand" - "encoding/hex" "fmt" "os" "path/filepath" + "time" "github.com/zalando/go-keyring" ) @@ -19,6 +18,15 @@ type StoreOptions struct { // When set to any non-empty value, forces file-based storage. DisableEnvVar string + // ForceFile forces file-based storage without probing the keyring. + // The programmatic equivalent of setting DisableEnvVar. + ForceFile bool + + // ProbeTimeout bounds the keyring availability probe. Zero means no + // bound, matching historical behavior. When the probe times out, the + // store falls back to file storage as if the probe had failed. + ProbeTimeout time.Duration + // FallbackDir is the directory for file-based credential storage. FallbackDir string } @@ -34,15 +42,11 @@ type Store struct { // NewStore creates a credential store. It probes the system keyring // and falls back to file storage if unavailable. func NewStore(opts StoreOptions) *Store { - if opts.DisableEnvVar != "" && os.Getenv(opts.DisableEnvVar) != "" { + if opts.ForceFile || (opts.DisableEnvVar != "" && os.Getenv(opts.DisableEnvVar) != "") { return &Store{serviceName: opts.ServiceName, useKeyring: false, fallbackDir: opts.FallbackDir} } - // Probe keyring with a random key to avoid collisions. - probeKey := probeKeyName() - err := keyring.Set(opts.ServiceName, probeKey, "probe") - if err == nil { - _ = keyring.Delete(opts.ServiceName, probeKey) + if probeKeyring(opts.ServiceName, opts.ProbeTimeout) == nil { return &Store{serviceName: opts.ServiceName, useKeyring: true, fallbackDir: opts.FallbackDir} } @@ -60,12 +64,6 @@ func (s *Store) FallbackWarning() string { return s.fallbackWarning } -func probeKeyName() string { - b := make([]byte, 8) - _, _ = rand.Read(b) - return "__probe_" + hex.EncodeToString(b) -} - func (s *Store) key(name string) string { return fmt.Sprintf("%s::%s", s.serviceName, name) } diff --git a/credstore/store_test.go b/credstore/store_test.go index ebdb579..bb9a833 100644 --- a/credstore/store_test.go +++ b/credstore/store_test.go @@ -1,14 +1,23 @@ package credstore import ( + "context" "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func stubProbe(t *testing.T, fn func(serviceName string, timeout time.Duration) error) { + t.Helper() + restore := probeKeyring + probeKeyring = fn + t.Cleanup(func() { probeKeyring = restore }) +} + func TestFileStore(t *testing.T) { dir := t.TempDir() t.Setenv("TEST_NO_KEYRING", "1") @@ -69,6 +78,65 @@ func TestFileStoreMultipleKeys(t *testing.T) { assert.JSONEq(t, `{"b":2}`, string(d2)) } +func TestForceFileSkipsProbe(t *testing.T) { + dir := t.TempDir() + stubProbe(t, func(string, time.Duration) error { + t.Error("probe should not run when ForceFile is set") + return nil + }) + + store := NewStore(StoreOptions{ + ServiceName: "test", + ForceFile: true, + FallbackDir: dir, + }) + + assert.False(t, store.UsingKeyring()) + assert.Empty(t, store.FallbackWarning()) + + require.NoError(t, store.Save("mykey", []byte(`{"token":"abc123"}`))) + data, err := store.Load("mykey") + require.NoError(t, err) + assert.JSONEq(t, `{"token":"abc123"}`, string(data)) +} + +func TestProbeTimeoutFallsBackToFile(t *testing.T) { + dir := t.TempDir() + stubProbe(t, func(serviceName string, timeout time.Duration) error { + assert.Equal(t, "test", serviceName) + assert.Equal(t, 50*time.Millisecond, timeout) + return context.DeadlineExceeded + }) + + store := NewStore(StoreOptions{ + ServiceName: "test", + ProbeTimeout: 50 * time.Millisecond, + FallbackDir: dir, + }) + + assert.False(t, store.UsingKeyring()) + assert.Contains(t, store.FallbackWarning(), "system keyring unavailable") +} + +func TestZeroValueOptionsProbeUnbounded(t *testing.T) { + dir := t.TempDir() + probed := false + stubProbe(t, func(serviceName string, timeout time.Duration) error { + probed = true + assert.Zero(t, timeout) + return nil + }) + + store := NewStore(StoreOptions{ + ServiceName: "test", + FallbackDir: dir, + }) + + assert.True(t, probed) + assert.True(t, store.UsingKeyring()) + assert.Empty(t, store.FallbackWarning()) +} + func TestLoadNonexistent(t *testing.T) { dir := t.TempDir() t.Setenv("TEST_NO_KEYRING", "1") From b2011ec1f84f7c50ec4d285a69ec53f27b7b83a9 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 15:47:06 -0700 Subject: [PATCH 02/14] credstore: document non-positive ProbeTimeout as unbounded The probe treats any non-positive timeout as unbounded (timeout <= 0), but the ProbeTimeout and probe doc comments claimed only zero did. Align both comments with the behavior. --- credstore/probe.go | 6 +++--- credstore/store.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/credstore/probe.go b/credstore/probe.go index c9b7543..f30ff1d 100644 --- a/credstore/probe.go +++ b/credstore/probe.go @@ -13,9 +13,9 @@ import ( var probeKeyring = probe // probe writes and removes a throwaway keyring entry to check availability. -// A zero timeout probes unbounded, matching historical behavior. A positive -// timeout bounds the probe; on platforms where the probe runs a child -// process (darwin), the child is killed when the timeout expires. +// A zero or negative timeout probes unbounded, matching historical behavior. +// A positive timeout bounds the probe; on platforms where the probe runs a +// child process (darwin), the child is killed when the timeout expires. func probe(serviceName string, timeout time.Duration) error { // Probe with a random key to avoid collisions. probeKey := probeKeyName() diff --git a/credstore/store.go b/credstore/store.go index fc5c7b3..69404e8 100644 --- a/credstore/store.go +++ b/credstore/store.go @@ -22,9 +22,9 @@ type StoreOptions struct { // The programmatic equivalent of setting DisableEnvVar. ForceFile bool - // ProbeTimeout bounds the keyring availability probe. Zero means no - // bound, matching historical behavior. When the probe times out, the - // store falls back to file storage as if the probe had failed. + // ProbeTimeout bounds the keyring availability probe. Zero or negative + // means no bound, matching historical behavior. When the probe times + // out, the store falls back to file storage as if the probe had failed. ProbeTimeout time.Duration // FallbackDir is the directory for file-based credential storage. From aee68900f66362d2d1d3b991d834e8c760696584 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 16:33:26 -0700 Subject: [PATCH 03/14] credstore: run probe cleanup under its own budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful add may consume the probe deadline; the delete then ran under the exhausted probe context, was skipped or killed, and the probe still returned nil — leaving a stray __probe_* entry in the keychain. Cleanup now gets a fresh bounded context (probeCleanupTimeout) and remains best-effort: the add already proved availability, so a failed delete must not fail the probe. afterProbeAdd is a test seam pinning cleanup's independence from the probe context. --- credstore/probe_darwin.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/credstore/probe_darwin.go b/credstore/probe_darwin.go index ae2df73..2e570d0 100644 --- a/credstore/probe_darwin.go +++ b/credstore/probe_darwin.go @@ -7,12 +7,19 @@ import ( "os/exec" "regexp" "strings" + "time" ) // securityPath is the macOS keychain tool go-keyring shells out to. // Variable so tests can substitute a stub. var securityPath = "/usr/bin/security" +// probeCleanupTimeout bounds removal of the probe entry. Cleanup runs under +// its own budget rather than the probe's: a successful add may have consumed +// the probe deadline, and a delete skipped or killed by the exhausted probe +// context would leave a stray __probe_* entry in the keychain. +const probeCleanupTimeout = 5 * time.Second + // probeBounded mirrors go-keyring's darwin Set — `security -i` fed an // add-generic-password command over stdin — via exec.CommandContext so the // child is killed when ctx expires. go-keyring's own exec has no @@ -34,10 +41,21 @@ func probeBounded(ctx context.Context, serviceName, probeKey string) error { return err } - _ = exec.CommandContext(ctx, securityPath, "delete-generic-password", "-s", serviceName, "-a", probeKey).Run() + afterProbeAdd() + + // Best-effort by design: the add already proved availability, and a + // failed delete must not fail the probe. + cleanupCtx, cancel := context.WithTimeout(context.Background(), probeCleanupTimeout) + defer cancel() + _ = exec.CommandContext(cleanupCtx, securityPath, "delete-generic-password", "-s", serviceName, "-a", probeKey).Run() return nil } +// afterProbeAdd runs between the probe's add and its cleanup delete. No-op in +// production; tests use it to expire the probe context in that window and pin +// cleanup's independence from it. +var afterProbeAdd = func() {} + var securityArgUnsafe = regexp.MustCompile(`[^\w@%+=:,./-]`) // quoteSecurityArg mirrors go-keyring's internal shellescape.Quote so the From 3e4f514a0f754b68dc8139c3ba95f10839b3f4de Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 16:33:26 -0700 Subject: [PATCH 04/14] credstore: quote stub paths and bound the PID watcher The generated security stubs embedded pid/args file paths into shell source unquoted, so a space-containing TMPDIR broke the redirections and could hang the kill test's PID watcher indefinitely. Stub paths are now shell-quoted and every stub lives in a dir-with-space so the quoting is pinned on all machines; the watcher observes a deadline and fails fast on fixture breakage. Adds the cleanup-survives-expiry regression for the probe fix. --- credstore/probe_darwin_test.go | 71 +++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/credstore/probe_darwin_test.go b/credstore/probe_darwin_test.go index 639fffd..15d5c20 100644 --- a/credstore/probe_darwin_test.go +++ b/credstore/probe_darwin_test.go @@ -23,24 +23,42 @@ func stubSecurity(t *testing.T, script string) { t.Cleanup(func() { securityPath = restore }) } +// stubDir returns a scratch directory whose path contains a space, so any +// stub script that embeds a path without quoting fails loudly here rather +// than only on machines with space-containing TMPDIRs. +func stubDir(t *testing.T) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "dir with space") + require.NoError(t, os.Mkdir(dir, 0755)) + return dir +} + +// shQuote single-quotes s for safe embedding in generated stub scripts. +func shQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" +} + func TestProbeBoundedKillsChildOnTimeout(t *testing.T) { - pidFile := filepath.Join(t.TempDir(), "pid") + pidFile := filepath.Join(stubDir(t), "pid") // exec replaces the shell so the recorded PID is the hung process itself, // mirroring a `security -i` blocked on a locked keychain. - stubSecurity(t, "#!/bin/sh\necho $$ > "+pidFile+".tmp && mv "+pidFile+".tmp "+pidFile+"\nexec sleep 60\n") + stubSecurity(t, "#!/bin/sh\nPF="+shQuote(pidFile)+"\necho $$ > \"$PF.tmp\" && mv \"$PF.tmp\" \"$PF\"\nexec sleep 60\n") // Expire the context once the child is demonstrably hung (PID recorded), // so the test exercises exactly the timeout path without racing startup. + // The watcher observes a deadline: a broken fixture cancels anyway and + // fails fast on the missing PID file instead of hanging the test. ctx, cancel := context.WithCancel(context.Background()) defer cancel() + deadline := time.Now().Add(5 * time.Second) go func() { - for { + for time.Now().Before(deadline) { if _, err := os.Stat(pidFile); err == nil { - cancel() - return + break } time.Sleep(10 * time.Millisecond) } + cancel() }() err := probeBounded(ctx, "test", "__probe_timeout") @@ -56,23 +74,56 @@ func TestProbeBoundedKillsChildOnTimeout(t *testing.T) { }, 5*time.Second, 10*time.Millisecond, "hung child should be killed when the context expires") } +// argsStub emits a stub security that records each invocation's argv and +// consumes stdin, mirroring a healthy keychain. +func argsStub(t *testing.T) (argsFile string) { + t.Helper() + argsFile = filepath.Join(stubDir(t), "args") + stubSecurity(t, "#!/bin/sh\nAF="+shQuote(argsFile)+"\necho \"$@\" >> \"$AF\"\ncat > /dev/null\nexit 0\n") + return argsFile +} + +func readArgLines(t *testing.T, argsFile string) []string { + t.Helper() + raw, err := os.ReadFile(argsFile) + require.NoError(t, err) + return strings.Split(strings.TrimSpace(string(raw)), "\n") +} + func TestProbeBoundedSuccess(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args") - stubSecurity(t, "#!/bin/sh\necho \"$@\" >> "+argsFile+"\ncat > /dev/null\nexit 0\n") + argsFile := argsStub(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() require.NoError(t, probeBounded(ctx, "test", "__probe_ok")) - raw, err := os.ReadFile(argsFile) - require.NoError(t, err) - lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + lines := readArgLines(t, argsFile) require.Len(t, lines, 2, "probe should add then delete the probe entry") assert.Equal(t, "-i", lines[0]) assert.Equal(t, "delete-generic-password -s test -a __probe_ok", lines[1]) } +// Regression: the probe deadline expiring immediately after a successful add +// must not skip or kill the cleanup delete — cleanup runs under its own +// budget, so no stray __probe_* entry is left in the keychain. +func TestProbeBoundedCleanupSurvivesProbeExpiry(t *testing.T) { + argsFile := argsStub(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + restore := afterProbeAdd + afterProbeAdd = cancel // probe context is dead before cleanup starts + t.Cleanup(func() { afterProbeAdd = restore }) + + require.NoError(t, probeBounded(ctx, "test", "__probe_expiry")) + + lines := readArgLines(t, argsFile) + require.Len(t, lines, 2, "cleanup delete must still run after probe expiry") + assert.Equal(t, "-i", lines[0]) + assert.Equal(t, "delete-generic-password -s test -a __probe_expiry", lines[1]) +} + func TestQuoteSecurityArg(t *testing.T) { assert.Equal(t, "basecamp", quoteSecurityArg("basecamp")) assert.Equal(t, "''", quoteSecurityArg("")) From 47714d1d85807bfbc71eefa5694f346b489c7d1e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 16:38:08 -0700 Subject: [PATCH 05/14] credstore: fall back to PID+time probe key on rand failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit probeKeyName ignored rand.Read errors: a failed or short read yields a predictable (zeroed) key, raising the odds of colliding with an existing entry — which cleanup would then delete. The fallback keys on PID + wall clock, unique enough for a transient probe entry, behind a randRead seam so the failure path is testable. --- credstore/probe.go | 14 +++++++++++++- credstore/probe_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 credstore/probe_test.go diff --git a/credstore/probe.go b/credstore/probe.go index f30ff1d..b443af1 100644 --- a/credstore/probe.go +++ b/credstore/probe.go @@ -4,6 +4,8 @@ import ( "context" "crypto/rand" "encoding/hex" + "fmt" + "os" "time" "github.com/zalando/go-keyring" @@ -37,8 +39,18 @@ func probeDirect(serviceName, probeKey string) error { return nil } +// randRead is crypto/rand.Read, replaceable in tests. +var randRead = rand.Read + +// probeKeyName returns a low-collision key for the throwaway probe entry so +// cleanup never deletes a real credential. crypto/rand is effectively +// infallible on supported platforms, but a failed or short read must not +// yield a predictable key: fall back to PID + wall clock, still unique +// enough for a transient probe entry. func probeKeyName() string { b := make([]byte, 8) - _, _ = rand.Read(b) + if _, err := randRead(b); err != nil { + return fmt.Sprintf("__probe_%d_%d", os.Getpid(), time.Now().UnixNano()) + } return "__probe_" + hex.EncodeToString(b) } diff --git a/credstore/probe_test.go b/credstore/probe_test.go new file mode 100644 index 0000000..a1f8229 --- /dev/null +++ b/credstore/probe_test.go @@ -0,0 +1,28 @@ +package credstore + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProbeKeyName(t *testing.T) { + assert.True(t, strings.HasPrefix(probeKeyName(), "__probe_")) + assert.NotEqual(t, probeKeyName(), probeKeyName(), "keys must not repeat") +} + +// Regression: a failed rand read must not yield a predictable probe key — +// cleanup deletes the probe entry, so a colliding key could delete a real +// credential. +func TestProbeKeyNameRandFailure(t *testing.T) { + restore := randRead + randRead = func([]byte) (int, error) { return 0, errors.New("entropy exhausted") } + t.Cleanup(func() { randRead = restore }) + + key := probeKeyName() + assert.True(t, strings.HasPrefix(key, "__probe_")) + assert.NotEqual(t, "__probe_"+strings.Repeat("0", 16), key, "fallback must not be the zeroed rand buffer") + assert.NotEqual(t, key, probeKeyName(), "fallback keys must not repeat") +} From 04bae05ab87ecacc891bed4df96660211068b4e7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 16:44:16 -0700 Subject: [PATCH 06/14] credstore: run probe cleanup asynchronously A slow cleanup delete under its fresh five-second budget could stretch NewStore past the caller's configured ProbeTimeout, contradicting the documented guarantee that ProbeTimeout bounds the probe. The best-effort delete now runs in a fire-and-forget goroutine: probe latency stays within ProbeTimeout, cleanup keeps its own budget, and the securityPath value is captured synchronously so the goroutine never races the test seam. --- credstore/probe_darwin.go | 25 +++++++++++++-------- credstore/probe_darwin_test.go | 40 +++++++++++++++++++++------------- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/credstore/probe_darwin.go b/credstore/probe_darwin.go index 2e570d0..452eba3 100644 --- a/credstore/probe_darwin.go +++ b/credstore/probe_darwin.go @@ -14,10 +14,12 @@ import ( // Variable so tests can substitute a stub. var securityPath = "/usr/bin/security" -// probeCleanupTimeout bounds removal of the probe entry. Cleanup runs under -// its own budget rather than the probe's: a successful add may have consumed -// the probe deadline, and a delete skipped or killed by the exhausted probe -// context would leave a stray __probe_* entry in the keychain. +// probeCleanupTimeout bounds removal of the probe entry. Cleanup runs +// asynchronously under its own budget rather than the probe's: a successful +// add may have consumed the probe deadline, a delete skipped or killed by +// the exhausted probe context would leave a stray __probe_* entry in the +// keychain, and a slow delete must not stretch construction past the +// caller's ProbeTimeout. const probeCleanupTimeout = 5 * time.Second // probeBounded mirrors go-keyring's darwin Set — `security -i` fed an @@ -43,11 +45,16 @@ func probeBounded(ctx context.Context, serviceName, probeKey string) error { afterProbeAdd() - // Best-effort by design: the add already proved availability, and a - // failed delete must not fail the probe. - cleanupCtx, cancel := context.WithTimeout(context.Background(), probeCleanupTimeout) - defer cancel() - _ = exec.CommandContext(cleanupCtx, securityPath, "delete-generic-password", "-s", serviceName, "-a", probeKey).Run() + // Best-effort, asynchronous, and under its own budget: the add already + // proved availability, a failed delete must not fail the probe, and + // cleanup must neither be starved by the exhausted probe context nor + // stretch construction past the caller's ProbeTimeout. + security := securityPath + go func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), probeCleanupTimeout) + defer cancel() + _ = exec.CommandContext(cleanupCtx, security, "delete-generic-password", "-s", serviceName, "-a", probeKey).Run() + }() return nil } diff --git a/credstore/probe_darwin_test.go b/credstore/probe_darwin_test.go index 15d5c20..420b2ae 100644 --- a/credstore/probe_darwin_test.go +++ b/credstore/probe_darwin_test.go @@ -83,13 +83,30 @@ func argsStub(t *testing.T) (argsFile string) { return argsFile } -func readArgLines(t *testing.T, argsFile string) []string { - t.Helper() +// argLines returns the stub's recorded invocations, or nil before the stub +// has written any. Non-fatal so it can poll inside assert.Eventually — the +// cleanup delete runs asynchronously. +func argLines(argsFile string) []string { raw, err := os.ReadFile(argsFile) - require.NoError(t, err) + if err != nil { + return nil + } return strings.Split(strings.TrimSpace(string(raw)), "\n") } +// requireCleanupDelete waits for the asynchronous cleanup delete to be +// recorded after the synchronous add. +func requireCleanupDelete(t *testing.T, argsFile, probeKey string) { + t.Helper() + lines := argLines(argsFile) + require.NotEmpty(t, lines, "add should be recorded synchronously") + assert.Equal(t, "-i", lines[0]) + assert.Eventually(t, func() bool { + lines := argLines(argsFile) + return len(lines) == 2 && lines[1] == "delete-generic-password -s test -a "+probeKey + }, 5*time.Second, 10*time.Millisecond, "cleanup should delete the probe entry") +} + func TestProbeBoundedSuccess(t *testing.T) { argsFile := argsStub(t) @@ -97,16 +114,13 @@ func TestProbeBoundedSuccess(t *testing.T) { defer cancel() require.NoError(t, probeBounded(ctx, "test", "__probe_ok")) - - lines := readArgLines(t, argsFile) - require.Len(t, lines, 2, "probe should add then delete the probe entry") - assert.Equal(t, "-i", lines[0]) - assert.Equal(t, "delete-generic-password -s test -a __probe_ok", lines[1]) + requireCleanupDelete(t, argsFile, "__probe_ok") } // Regression: the probe deadline expiring immediately after a successful add -// must not skip or kill the cleanup delete — cleanup runs under its own -// budget, so no stray __probe_* entry is left in the keychain. +// must not skip or kill the cleanup delete — cleanup runs asynchronously +// under its own budget, so no stray __probe_* entry is left in the keychain +// and construction is never stretched past the caller's ProbeTimeout. func TestProbeBoundedCleanupSurvivesProbeExpiry(t *testing.T) { argsFile := argsStub(t) @@ -117,11 +131,7 @@ func TestProbeBoundedCleanupSurvivesProbeExpiry(t *testing.T) { t.Cleanup(func() { afterProbeAdd = restore }) require.NoError(t, probeBounded(ctx, "test", "__probe_expiry")) - - lines := readArgLines(t, argsFile) - require.Len(t, lines, 2, "cleanup delete must still run after probe expiry") - assert.Equal(t, "-i", lines[0]) - assert.Equal(t, "delete-generic-password -s test -a __probe_expiry", lines[1]) + requireCleanupDelete(t, argsFile, "__probe_expiry") } func TestQuoteSecurityArg(t *testing.T) { From 779aae6ffc24fd3292d6473d293cf4e1e9be2321 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 16:44:17 -0700 Subject: [PATCH 07/14] credstore: treat short rand reads like rand failures probeKeyName's comment promised protection against failed or short reads but the code only checked the error. A short read now takes the same PID + time fallback, pinned by TestProbeKeyNameShortRead. --- credstore/probe.go | 2 +- credstore/probe_test.go | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/credstore/probe.go b/credstore/probe.go index b443af1..abfa548 100644 --- a/credstore/probe.go +++ b/credstore/probe.go @@ -49,7 +49,7 @@ var randRead = rand.Read // enough for a transient probe entry. func probeKeyName() string { b := make([]byte, 8) - if _, err := randRead(b); err != nil { + if n, err := randRead(b); err != nil || n != len(b) { return fmt.Sprintf("__probe_%d_%d", os.Getpid(), time.Now().UnixNano()) } return "__probe_" + hex.EncodeToString(b) diff --git a/credstore/probe_test.go b/credstore/probe_test.go index a1f8229..f4e73d0 100644 --- a/credstore/probe_test.go +++ b/credstore/probe_test.go @@ -13,16 +13,24 @@ func TestProbeKeyName(t *testing.T) { assert.NotEqual(t, probeKeyName(), probeKeyName(), "keys must not repeat") } -// Regression: a failed rand read must not yield a predictable probe key — -// cleanup deletes the probe entry, so a colliding key could delete a real -// credential. +// Regression: a failed or short rand read must not yield a predictable probe +// key — cleanup deletes the probe entry, so a colliding key could delete a +// real credential. func TestProbeKeyNameRandFailure(t *testing.T) { restore := randRead randRead = func([]byte) (int, error) { return 0, errors.New("entropy exhausted") } t.Cleanup(func() { randRead = restore }) key := probeKeyName() - assert.True(t, strings.HasPrefix(key, "__probe_")) - assert.NotEqual(t, "__probe_"+strings.Repeat("0", 16), key, "fallback must not be the zeroed rand buffer") + assert.Regexp(t, `^__probe_\d+_\d+$`, key, "fallback should key on PID + time, not the zeroed rand buffer") assert.NotEqual(t, key, probeKeyName(), "fallback keys must not repeat") } + +func TestProbeKeyNameShortRead(t *testing.T) { + restore := randRead + randRead = func(b []byte) (int, error) { return len(b) / 2, nil } + t.Cleanup(func() { randRead = restore }) + + key := probeKeyName() + assert.Regexp(t, `^__probe_\d+_\d+$`, key, "a short read must fall back, not leak a half-zeroed key") +} From 5c8ed4638afd1dd17c6e4898581cbd18e69a12c1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 16:51:33 -0700 Subject: [PATCH 08/14] credstore: keep probe cleanup joined; document the bounded tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup returns to synchronous execution under its own budget: Go does not wait for goroutines at process exit, so the detached cleanup could leak one __probe_* entry per short-lived CLI invocation — the primary consumer shape. The honest contract lives in the ProbeTimeout docs now: worst-case construction is ProbeTimeout plus the five-second cleanup bound, typically milliseconds since cleanup only runs when the keyring just proved responsive. Leaking keychain state was judged worse than a theoretical latency tail. Also from the same review round: DisableEnvVar/ForceFile docs now say explicitly that the *named environment variable* being non-empty is what forces file mode; the fallback probe key gains a per-process atomic sequence (UnixNano can repeat on coarse clocks), and the fallback-shape regexes cover the new __probe___ format. --- credstore/probe.go | 11 ++++++++--- credstore/probe_darwin.go | 28 +++++++++++++--------------- credstore/probe_darwin_test.go | 28 +++++++++------------------- credstore/probe_test.go | 4 ++-- credstore/store.go | 18 ++++++++++++++---- 5 files changed, 46 insertions(+), 43 deletions(-) diff --git a/credstore/probe.go b/credstore/probe.go index abfa548..aee4e8d 100644 --- a/credstore/probe.go +++ b/credstore/probe.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "os" + "sync/atomic" "time" "github.com/zalando/go-keyring" @@ -42,15 +43,19 @@ func probeDirect(serviceName, probeKey string) error { // randRead is crypto/rand.Read, replaceable in tests. var randRead = rand.Read +// probeSeq disambiguates fallback probe keys within a process: UnixNano can +// repeat on coarse-resolution clocks. +var probeSeq atomic.Uint64 + // probeKeyName returns a low-collision key for the throwaway probe entry so // cleanup never deletes a real credential. crypto/rand is effectively // infallible on supported platforms, but a failed or short read must not -// yield a predictable key: fall back to PID + wall clock, still unique -// enough for a transient probe entry. +// yield a predictable key: fall back to PID + wall clock + a per-process +// sequence, still unique enough for a transient probe entry. func probeKeyName() string { b := make([]byte, 8) if n, err := randRead(b); err != nil || n != len(b) { - return fmt.Sprintf("__probe_%d_%d", os.Getpid(), time.Now().UnixNano()) + return fmt.Sprintf("__probe_%d_%d_%d", os.Getpid(), time.Now().UnixNano(), probeSeq.Add(1)) } return "__probe_" + hex.EncodeToString(b) } diff --git a/credstore/probe_darwin.go b/credstore/probe_darwin.go index 452eba3..0218b39 100644 --- a/credstore/probe_darwin.go +++ b/credstore/probe_darwin.go @@ -15,11 +15,14 @@ import ( var securityPath = "/usr/bin/security" // probeCleanupTimeout bounds removal of the probe entry. Cleanup runs -// asynchronously under its own budget rather than the probe's: a successful -// add may have consumed the probe deadline, a delete skipped or killed by -// the exhausted probe context would leave a stray __probe_* entry in the -// keychain, and a slow delete must not stretch construction past the -// caller's ProbeTimeout. +// synchronously under its own budget rather than the probe's: a successful +// add may have consumed the probe deadline, and a delete skipped or killed +// by the exhausted probe context would leave a stray __probe_* entry in the +// keychain. Synchronous because Go does not wait for goroutines at process +// exit — an unjoined cleanup in a short-lived CLI would leak the entry. The +// tail is theoretical in practice: cleanup only runs when the keychain +// proved responsive milliseconds earlier. StoreOptions.ProbeTimeout +// documents this additive bound. const probeCleanupTimeout = 5 * time.Second // probeBounded mirrors go-keyring's darwin Set — `security -i` fed an @@ -45,16 +48,11 @@ func probeBounded(ctx context.Context, serviceName, probeKey string) error { afterProbeAdd() - // Best-effort, asynchronous, and under its own budget: the add already - // proved availability, a failed delete must not fail the probe, and - // cleanup must neither be starved by the exhausted probe context nor - // stretch construction past the caller's ProbeTimeout. - security := securityPath - go func() { - cleanupCtx, cancel := context.WithTimeout(context.Background(), probeCleanupTimeout) - defer cancel() - _ = exec.CommandContext(cleanupCtx, security, "delete-generic-password", "-s", serviceName, "-a", probeKey).Run() - }() + // Best-effort by design: the add already proved availability, and a + // failed delete must not fail the probe. + cleanupCtx, cancel := context.WithTimeout(context.Background(), probeCleanupTimeout) + defer cancel() + _ = exec.CommandContext(cleanupCtx, securityPath, "delete-generic-password", "-s", serviceName, "-a", probeKey).Run() return nil } diff --git a/credstore/probe_darwin_test.go b/credstore/probe_darwin_test.go index 420b2ae..2116b07 100644 --- a/credstore/probe_darwin_test.go +++ b/credstore/probe_darwin_test.go @@ -83,28 +83,18 @@ func argsStub(t *testing.T) (argsFile string) { return argsFile } -// argLines returns the stub's recorded invocations, or nil before the stub -// has written any. Non-fatal so it can poll inside assert.Eventually — the -// cleanup delete runs asynchronously. -func argLines(argsFile string) []string { - raw, err := os.ReadFile(argsFile) - if err != nil { - return nil - } - return strings.Split(strings.TrimSpace(string(raw)), "\n") -} - -// requireCleanupDelete waits for the asynchronous cleanup delete to be -// recorded after the synchronous add. +// requireCleanupDelete asserts the stub recorded the add followed by the +// cleanup delete. Both are synchronous: cleanup is deliberately not detached +// (see probeCleanupTimeout), so it has completed by the time probeBounded +// returns. func requireCleanupDelete(t *testing.T, argsFile, probeKey string) { t.Helper() - lines := argLines(argsFile) - require.NotEmpty(t, lines, "add should be recorded synchronously") + raw, err := os.ReadFile(argsFile) + require.NoError(t, err) + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + require.Len(t, lines, 2, "probe should add then delete the probe entry") assert.Equal(t, "-i", lines[0]) - assert.Eventually(t, func() bool { - lines := argLines(argsFile) - return len(lines) == 2 && lines[1] == "delete-generic-password -s test -a "+probeKey - }, 5*time.Second, 10*time.Millisecond, "cleanup should delete the probe entry") + assert.Equal(t, "delete-generic-password -s test -a "+probeKey, lines[1]) } func TestProbeBoundedSuccess(t *testing.T) { diff --git a/credstore/probe_test.go b/credstore/probe_test.go index f4e73d0..2a65cd8 100644 --- a/credstore/probe_test.go +++ b/credstore/probe_test.go @@ -22,7 +22,7 @@ func TestProbeKeyNameRandFailure(t *testing.T) { t.Cleanup(func() { randRead = restore }) key := probeKeyName() - assert.Regexp(t, `^__probe_\d+_\d+$`, key, "fallback should key on PID + time, not the zeroed rand buffer") + assert.Regexp(t, `^__probe_\d+_\d+_\d+$`, key, "fallback should key on PID + time + sequence, not the zeroed rand buffer") assert.NotEqual(t, key, probeKeyName(), "fallback keys must not repeat") } @@ -32,5 +32,5 @@ func TestProbeKeyNameShortRead(t *testing.T) { t.Cleanup(func() { randRead = restore }) key := probeKeyName() - assert.Regexp(t, `^__probe_\d+_\d+$`, key, "a short read must fall back, not leak a half-zeroed key") + assert.Regexp(t, `^__probe_\d+_\d+_\d+$`, key, "a short read must fall back, not leak a half-zeroed key") } diff --git a/credstore/store.go b/credstore/store.go index 69404e8..137a1b6 100644 --- a/credstore/store.go +++ b/credstore/store.go @@ -14,17 +14,27 @@ type StoreOptions struct { // ServiceName is the keyring service name (e.g., "basecamp", "fizzy"). ServiceName string - // DisableEnvVar is the env var name that disables keyring (e.g., "BASECAMP_NO_KEYRING"). - // When set to any non-empty value, forces file-based storage. + // DisableEnvVar names an environment variable (e.g., "BASECAMP_NO_KEYRING"). + // When that variable is set to any non-empty value in the process + // environment, the store uses file-based storage without probing the + // keyring. An empty DisableEnvVar field disables this check entirely. DisableEnvVar string - // ForceFile forces file-based storage without probing the keyring. - // The programmatic equivalent of setting DisableEnvVar. + // ForceFile forces file-based storage without probing the keyring — + // the programmatic equivalent of the DisableEnvVar environment variable + // being set. ForceFile bool // ProbeTimeout bounds the keyring availability probe. Zero or negative // means no bound, matching historical behavior. When the probe times // out, the store falls back to file storage as if the probe had failed. + // After a successful probe, removal of the throwaway probe entry runs + // synchronously with a short budget of its own, so worst-case + // construction is ProbeTimeout plus that cleanup bound (five seconds; + // typically milliseconds, since cleanup only runs when the keyring just + // proved responsive). Cleanup is deliberately not detached: Go does not + // wait for goroutines at process exit, and a short-lived CLI would leak + // a probe entry per invocation. ProbeTimeout time.Duration // FallbackDir is the directory for file-based credential storage. From 5c551d6d757c9beb065a1574bf75d236e0976f61 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 16:54:59 -0700 Subject: [PATCH 09/14] credstore: align expiry-regression comment with the joined cleanup Leftover from the asynchronous interlude: the comment claimed detached cleanup and a hard ProbeTimeout-only bound, contradicting the documented additive bound the code now implements. --- credstore/probe_darwin_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/credstore/probe_darwin_test.go b/credstore/probe_darwin_test.go index 2116b07..c1e72d5 100644 --- a/credstore/probe_darwin_test.go +++ b/credstore/probe_darwin_test.go @@ -108,9 +108,9 @@ func TestProbeBoundedSuccess(t *testing.T) { } // Regression: the probe deadline expiring immediately after a successful add -// must not skip or kill the cleanup delete — cleanup runs asynchronously -// under its own budget, so no stray __probe_* entry is left in the keychain -// and construction is never stretched past the caller's ProbeTimeout. +// must not skip or kill the cleanup delete — cleanup runs synchronously +// under its own budget (the documented additive bound), so no stray +// __probe_* entry is left in the keychain. func TestProbeBoundedCleanupSurvivesProbeExpiry(t *testing.T) { argsFile := argsStub(t) From 9de72abfffd52108b1af53db18dafdf890752820 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 17:10:57 -0700 Subject: [PATCH 10/14] credstore: use a deterministic probe key so leaks self-heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A timed-out non-darwin probe abandons its goroutine; if the blocked Set later succeeds and the process exits before Delete, the probe entry leaks — and go-keyring has no list API, so under a random name the leftover is permanently unfindable. A fixed, reserved __probe__ name makes the leak self-healing: the next probe's Set overwrites it and its Delete removes it. Concurrent probes sharing the name are harmless — Set results are unaffected and the losing Delete's failure is already ignored. Supersedes the random-key machinery (rand fallback, PID+time key, per-process sequence) and its tests. --- credstore/probe.go | 44 +++++++++++++-------------------------- credstore/probe_darwin.go | 6 +++--- credstore/probe_other.go | 9 +++++--- credstore/probe_test.go | 36 -------------------------------- 4 files changed, 23 insertions(+), 72 deletions(-) delete mode 100644 credstore/probe_test.go diff --git a/credstore/probe.go b/credstore/probe.go index aee4e8d..943e531 100644 --- a/credstore/probe.go +++ b/credstore/probe.go @@ -2,11 +2,6 @@ package credstore import ( "context" - "crypto/rand" - "encoding/hex" - "fmt" - "os" - "sync/atomic" "time" "github.com/zalando/go-keyring" @@ -15,13 +10,22 @@ import ( // probeKeyring checks system keyring availability. Overridable in tests. var probeKeyring = probe +// probeKey names the throwaway entry every probe writes and removes. It is +// deliberately deterministic, not random: go-keyring has no list API, so an +// entry leaked by an abandoned probe (a timed-out probe whose blocked Set +// completes after the process exits, or a darwin cleanup cut short) would be +// permanently unfindable under a random name. Under a fixed name, the next +// probe's Set overwrites the leftover and its Delete removes it — leaks +// self-heal on the following run. Concurrent probes sharing the name are +// harmless: Set results are unaffected, and the losing Delete just fails, +// which is ignored. The name is reserved within the service's namespace. +const probeKey = "__probe__" + // probe writes and removes a throwaway keyring entry to check availability. // A zero or negative timeout probes unbounded, matching historical behavior. // A positive timeout bounds the probe; on platforms where the probe runs a // child process (darwin), the child is killed when the timeout expires. func probe(serviceName string, timeout time.Duration) error { - // Probe with a random key to avoid collisions. - probeKey := probeKeyName() if timeout <= 0 { return probeDirect(serviceName, probeKey) } @@ -32,30 +36,10 @@ func probe(serviceName string, timeout time.Duration) error { } // probeDirect probes via go-keyring, which has no cancellation path. -func probeDirect(serviceName, probeKey string) error { - if err := keyring.Set(serviceName, probeKey, "probe"); err != nil { +func probeDirect(serviceName, key string) error { + if err := keyring.Set(serviceName, key, "probe"); err != nil { return err } - _ = keyring.Delete(serviceName, probeKey) + _ = keyring.Delete(serviceName, key) return nil } - -// randRead is crypto/rand.Read, replaceable in tests. -var randRead = rand.Read - -// probeSeq disambiguates fallback probe keys within a process: UnixNano can -// repeat on coarse-resolution clocks. -var probeSeq atomic.Uint64 - -// probeKeyName returns a low-collision key for the throwaway probe entry so -// cleanup never deletes a real credential. crypto/rand is effectively -// infallible on supported platforms, but a failed or short read must not -// yield a predictable key: fall back to PID + wall clock + a per-process -// sequence, still unique enough for a transient probe entry. -func probeKeyName() string { - b := make([]byte, 8) - if n, err := randRead(b); err != nil || n != len(b) { - return fmt.Sprintf("__probe_%d_%d_%d", os.Getpid(), time.Now().UnixNano(), probeSeq.Add(1)) - } - return "__probe_" + hex.EncodeToString(b) -} diff --git a/credstore/probe_darwin.go b/credstore/probe_darwin.go index 0218b39..dae4b14 100644 --- a/credstore/probe_darwin.go +++ b/credstore/probe_darwin.go @@ -30,12 +30,12 @@ const probeCleanupTimeout = 5 * time.Second // child is killed when ctx expires. go-keyring's own exec has no // cancellation path: on a locked keychain with no TTY or GUI it blocks // forever and the child cannot be reclaimed from outside. -func probeBounded(ctx context.Context, serviceName, probeKey string) error { +func probeBounded(ctx context.Context, serviceName, key string) error { // go-keyring base64-encodes every password it stores; mirror that so // probe success genuinely predicts go-keyring usability. password := "go-keyring-base64:" + base64.StdEncoding.EncodeToString([]byte("probe")) command := fmt.Sprintf("add-generic-password -U -s %s -a %s -w %s\n", - quoteSecurityArg(serviceName), quoteSecurityArg(probeKey), quoteSecurityArg(password)) + quoteSecurityArg(serviceName), quoteSecurityArg(key), quoteSecurityArg(password)) cmd := exec.CommandContext(ctx, securityPath, "-i") cmd.Stdin = strings.NewReader(command) @@ -52,7 +52,7 @@ func probeBounded(ctx context.Context, serviceName, probeKey string) error { // failed delete must not fail the probe. cleanupCtx, cancel := context.WithTimeout(context.Background(), probeCleanupTimeout) defer cancel() - _ = exec.CommandContext(cleanupCtx, securityPath, "delete-generic-password", "-s", serviceName, "-a", probeKey).Run() + _ = exec.CommandContext(cleanupCtx, securityPath, "delete-generic-password", "-s", serviceName, "-a", key).Run() return nil } diff --git a/credstore/probe_other.go b/credstore/probe_other.go index bdac135..6d63e80 100644 --- a/credstore/probe_other.go +++ b/credstore/probe_other.go @@ -7,10 +7,13 @@ import "context" // probeBounded runs the go-keyring probe with a deadline. Non-darwin // backends (dbus secret service, Windows credential manager) run in-process, // so timing out abandons at most a goroutine — there is no child process to -// reclaim. -func probeBounded(ctx context.Context, serviceName, probeKey string) error { +// reclaim. An abandoned probe whose blocked Set later succeeds can leak the +// probe entry if the process exits before Delete runs; the deterministic +// probeKey makes that self-healing — the next probe overwrites and removes +// the leftover (see probeKey). +func probeBounded(ctx context.Context, serviceName, key string) error { done := make(chan error, 1) - go func() { done <- probeDirect(serviceName, probeKey) }() + go func() { done <- probeDirect(serviceName, key) }() select { case err := <-done: diff --git a/credstore/probe_test.go b/credstore/probe_test.go deleted file mode 100644 index 2a65cd8..0000000 --- a/credstore/probe_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package credstore - -import ( - "errors" - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestProbeKeyName(t *testing.T) { - assert.True(t, strings.HasPrefix(probeKeyName(), "__probe_")) - assert.NotEqual(t, probeKeyName(), probeKeyName(), "keys must not repeat") -} - -// Regression: a failed or short rand read must not yield a predictable probe -// key — cleanup deletes the probe entry, so a colliding key could delete a -// real credential. -func TestProbeKeyNameRandFailure(t *testing.T) { - restore := randRead - randRead = func([]byte) (int, error) { return 0, errors.New("entropy exhausted") } - t.Cleanup(func() { randRead = restore }) - - key := probeKeyName() - assert.Regexp(t, `^__probe_\d+_\d+_\d+$`, key, "fallback should key on PID + time + sequence, not the zeroed rand buffer") - assert.NotEqual(t, key, probeKeyName(), "fallback keys must not repeat") -} - -func TestProbeKeyNameShortRead(t *testing.T) { - restore := randRead - randRead = func(b []byte) (int, error) { return len(b) / 2, nil } - t.Cleanup(func() { randRead = restore }) - - key := probeKeyName() - assert.Regexp(t, `^__probe_\d+_\d+_\d+$`, key, "a short read must fall back, not leak a half-zeroed key") -} From a91e335629466cdad21ff0024242a86f030c84e3 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 17:18:31 -0700 Subject: [PATCH 11/14] credstore: isolate the probe in its own service namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A private constant does not enforce a name reservation: a service that already held an account named __probe__ would have its password overwritten by the probe's Set and removed by its Delete. The probe now operates in serviceName + ".probe", so it cannot touch any credential in the caller's real service, whatever its name — while keeping the deterministic key's self-healing property within the probe namespace. Also from this round: ProbeTimeout docs scope the additive cleanup bound to darwin (elsewhere cleanup is part of the probe itself), and stale __probe_* wording no longer implies the removed randomized scheme. --- credstore/probe.go | 30 ++++++++++++++++++------------ credstore/probe_darwin.go | 2 +- credstore/probe_darwin_test.go | 21 +++++++++++++++------ credstore/store.go | 15 ++++++++------- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/credstore/probe.go b/credstore/probe.go index 943e531..b98ffa8 100644 --- a/credstore/probe.go +++ b/credstore/probe.go @@ -10,29 +10,35 @@ import ( // probeKeyring checks system keyring availability. Overridable in tests. var probeKeyring = probe -// probeKey names the throwaway entry every probe writes and removes. It is -// deliberately deterministic, not random: go-keyring has no list API, so an -// entry leaked by an abandoned probe (a timed-out probe whose blocked Set -// completes after the process exits, or a darwin cleanup cut short) would be -// permanently unfindable under a random name. Under a fixed name, the next -// probe's Set overwrites the leftover and its Delete removes it — leaks -// self-heal on the following run. Concurrent probes sharing the name are -// harmless: Set results are unaffected, and the losing Delete just fails, -// which is ignored. The name is reserved within the service's namespace. -const probeKey = "__probe__" +// The probe entry lives in its own service namespace — the caller's service +// plus probeServiceSuffix — so probing can never overwrite or delete a +// credential in the real service, whatever its name. Within that namespace +// the key is deliberately deterministic, not random: go-keyring has no list +// API, so an entry leaked by an abandoned probe (a timed-out probe whose +// blocked Set completes after the process exits, or a darwin cleanup cut +// short) would be permanently unfindable under a random name. Under a fixed +// name, the next probe's Set overwrites the leftover and its Delete removes +// it — leaks self-heal on the following run. Concurrent probes sharing the +// name are harmless: Set results are unaffected, and the losing Delete just +// fails, which is ignored. +const ( + probeServiceSuffix = ".probe" + probeKey = "__probe__" +) // probe writes and removes a throwaway keyring entry to check availability. // A zero or negative timeout probes unbounded, matching historical behavior. // A positive timeout bounds the probe; on platforms where the probe runs a // child process (darwin), the child is killed when the timeout expires. func probe(serviceName string, timeout time.Duration) error { + service := serviceName + probeServiceSuffix if timeout <= 0 { - return probeDirect(serviceName, probeKey) + return probeDirect(service, probeKey) } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - return probeBounded(ctx, serviceName, probeKey) + return probeBounded(ctx, service, probeKey) } // probeDirect probes via go-keyring, which has no cancellation path. diff --git a/credstore/probe_darwin.go b/credstore/probe_darwin.go index dae4b14..d687676 100644 --- a/credstore/probe_darwin.go +++ b/credstore/probe_darwin.go @@ -17,7 +17,7 @@ var securityPath = "/usr/bin/security" // probeCleanupTimeout bounds removal of the probe entry. Cleanup runs // synchronously under its own budget rather than the probe's: a successful // add may have consumed the probe deadline, and a delete skipped or killed -// by the exhausted probe context would leave a stray __probe_* entry in the +// by the exhausted probe context would leave a stray probe entry in the // keychain. Synchronous because Go does not wait for goroutines at process // exit — an unjoined cleanup in a short-lived CLI would leak the entry. The // tail is theoretical in practice: cleanup only runs when the keychain diff --git a/credstore/probe_darwin_test.go b/credstore/probe_darwin_test.go index c1e72d5..0ed9ccf 100644 --- a/credstore/probe_darwin_test.go +++ b/credstore/probe_darwin_test.go @@ -87,14 +87,14 @@ func argsStub(t *testing.T) (argsFile string) { // cleanup delete. Both are synchronous: cleanup is deliberately not detached // (see probeCleanupTimeout), so it has completed by the time probeBounded // returns. -func requireCleanupDelete(t *testing.T, argsFile, probeKey string) { +func requireCleanupDelete(t *testing.T, argsFile, service, key string) { t.Helper() raw, err := os.ReadFile(argsFile) require.NoError(t, err) lines := strings.Split(strings.TrimSpace(string(raw)), "\n") require.Len(t, lines, 2, "probe should add then delete the probe entry") assert.Equal(t, "-i", lines[0]) - assert.Equal(t, "delete-generic-password -s test -a "+probeKey, lines[1]) + assert.Equal(t, "delete-generic-password -s "+service+" -a "+key, lines[1]) } func TestProbeBoundedSuccess(t *testing.T) { @@ -104,13 +104,22 @@ func TestProbeBoundedSuccess(t *testing.T) { defer cancel() require.NoError(t, probeBounded(ctx, "test", "__probe_ok")) - requireCleanupDelete(t, argsFile, "__probe_ok") + requireCleanupDelete(t, argsFile, "test", "__probe_ok") +} + +// The probe must operate in its own service namespace so it can never touch +// a credential in the caller's real service, whatever its name. +func TestProbeUsesIsolatedNamespace(t *testing.T) { + argsFile := argsStub(t) + + require.NoError(t, probe("svc", 5*time.Second)) + requireCleanupDelete(t, argsFile, "svc"+probeServiceSuffix, probeKey) } // Regression: the probe deadline expiring immediately after a successful add // must not skip or kill the cleanup delete — cleanup runs synchronously -// under its own budget (the documented additive bound), so no stray -// __probe_* entry is left in the keychain. +// under its own budget (the documented additive bound), so no stray probe +// entry is left in the keychain. func TestProbeBoundedCleanupSurvivesProbeExpiry(t *testing.T) { argsFile := argsStub(t) @@ -121,7 +130,7 @@ func TestProbeBoundedCleanupSurvivesProbeExpiry(t *testing.T) { t.Cleanup(func() { afterProbeAdd = restore }) require.NoError(t, probeBounded(ctx, "test", "__probe_expiry")) - requireCleanupDelete(t, argsFile, "__probe_expiry") + requireCleanupDelete(t, argsFile, "test", "__probe_expiry") } func TestQuoteSecurityArg(t *testing.T) { diff --git a/credstore/store.go b/credstore/store.go index 137a1b6..7b85775 100644 --- a/credstore/store.go +++ b/credstore/store.go @@ -28,13 +28,14 @@ type StoreOptions struct { // ProbeTimeout bounds the keyring availability probe. Zero or negative // means no bound, matching historical behavior. When the probe times // out, the store falls back to file storage as if the probe had failed. - // After a successful probe, removal of the throwaway probe entry runs - // synchronously with a short budget of its own, so worst-case - // construction is ProbeTimeout plus that cleanup bound (five seconds; - // typically milliseconds, since cleanup only runs when the keyring just - // proved responsive). Cleanup is deliberately not detached: Go does not - // wait for goroutines at process exit, and a short-lived CLI would leak - // a probe entry per invocation. + // On darwin, removal of the throwaway probe entry runs synchronously + // after a successful probe with a short budget of its own, so worst-case + // construction there is ProbeTimeout plus that cleanup bound (five + // seconds; typically milliseconds, since cleanup only runs when the + // keyring just proved responsive). That cleanup is deliberately not + // detached: Go does not wait for goroutines at process exit, and a + // short-lived CLI would leak a probe entry per invocation. On other + // platforms cleanup is part of the probe itself and adds no extra time. ProbeTimeout time.Duration // FallbackDir is the directory for file-based credential storage. From d8687b2c242aa31c9d4aadff506ebf07dc4ad7c0 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 17:25:45 -0700 Subject: [PATCH 12/14] credstore: reserve a declared credstore.probe namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare .probe suffix on the caller's service is an ordinary global keyring name, not a reservation. The probe now writes under credstore.probe., and the ProbeTimeout docs publicly declare that namespace as reserved by this package — a colliding consumer would have to deliberately adopt the declared namespace, at which point the deterministic key's self-healing semantics are exactly what they'd share. --- credstore/probe.go | 28 +++++++++++++++------------- credstore/probe_darwin_test.go | 2 +- credstore/store.go | 4 ++++ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/credstore/probe.go b/credstore/probe.go index b98ffa8..554ecb7 100644 --- a/credstore/probe.go +++ b/credstore/probe.go @@ -10,19 +10,21 @@ import ( // probeKeyring checks system keyring availability. Overridable in tests. var probeKeyring = probe -// The probe entry lives in its own service namespace — the caller's service -// plus probeServiceSuffix — so probing can never overwrite or delete a -// credential in the real service, whatever its name. Within that namespace -// the key is deliberately deterministic, not random: go-keyring has no list -// API, so an entry leaked by an abandoned probe (a timed-out probe whose -// blocked Set completes after the process exits, or a darwin cleanup cut -// short) would be permanently unfindable under a random name. Under a fixed -// name, the next probe's Set overwrites the leftover and its Delete removes -// it — leaks self-heal on the following run. Concurrent probes sharing the -// name are harmless: Set results are unaffected, and the losing Delete just -// fails, which is ignored. +// The probe entry lives in this package's own service namespace — +// probeServicePrefix plus the caller's service — publicly documented on +// StoreOptions.ProbeTimeout as reserved by credstore, so probing never +// touches the caller's real service and a colliding consumer would have to +// deliberately adopt this package's declared namespace. Within it, the key +// is deliberately deterministic, not random: go-keyring has no list API, so +// an entry leaked by an abandoned probe (a timed-out probe whose blocked Set +// completes after the process exits, or a darwin cleanup cut short) would be +// permanently unfindable under a random name. Under a fixed name, the next +// probe's Set overwrites the leftover and its Delete removes it — leaks +// self-heal on the following run. Concurrent probes sharing the name are +// harmless: Set results are unaffected, and the losing Delete just fails, +// which is ignored. const ( - probeServiceSuffix = ".probe" + probeServicePrefix = "credstore.probe." probeKey = "__probe__" ) @@ -31,7 +33,7 @@ const ( // A positive timeout bounds the probe; on platforms where the probe runs a // child process (darwin), the child is killed when the timeout expires. func probe(serviceName string, timeout time.Duration) error { - service := serviceName + probeServiceSuffix + service := probeServicePrefix + serviceName if timeout <= 0 { return probeDirect(service, probeKey) } diff --git a/credstore/probe_darwin_test.go b/credstore/probe_darwin_test.go index 0ed9ccf..3792b14 100644 --- a/credstore/probe_darwin_test.go +++ b/credstore/probe_darwin_test.go @@ -113,7 +113,7 @@ func TestProbeUsesIsolatedNamespace(t *testing.T) { argsFile := argsStub(t) require.NoError(t, probe("svc", 5*time.Second)) - requireCleanupDelete(t, argsFile, "svc"+probeServiceSuffix, probeKey) + requireCleanupDelete(t, argsFile, probeServicePrefix+"svc", probeKey) } // Regression: the probe deadline expiring immediately after a successful add diff --git a/credstore/store.go b/credstore/store.go index 7b85775..d4c9825 100644 --- a/credstore/store.go +++ b/credstore/store.go @@ -28,6 +28,10 @@ type StoreOptions struct { // ProbeTimeout bounds the keyring availability probe. Zero or negative // means no bound, matching historical behavior. When the probe times // out, the store falls back to file storage as if the probe had failed. + // Probing writes and removes a throwaway entry under the dedicated + // keyring service "credstore.probe." (account "__probe__") + // — a namespace reserved by this package — never under ServiceName + // itself, so a probe cannot touch real credentials. // On darwin, removal of the throwaway probe entry runs synchronously // after a successful probe with a short budget of its own, so worst-case // construction there is ProbeTimeout plus that cleanup bound (five From 87e7a24f3af4b7321f1e84593d94f4c0723bf507 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 17:34:02 -0700 Subject: [PATCH 13/14] credstore: pin the probe leak-containment contract cross-platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deterministic-name containment for abandoned probes (a timed-out non-darwin probe whose blocked Set completes after process exit leaves at most one known entry, overwritten and removed by the next probe) was only exercised by darwin-local tests — invisible to ubuntu CI. A pure derivation helper and a platform-independent test now pin the reserved service and account names; changing either would orphan entries written by earlier releases. --- credstore/probe.go | 7 ++++++- credstore/probe_test.go | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 credstore/probe_test.go diff --git a/credstore/probe.go b/credstore/probe.go index 554ecb7..6b1d6ec 100644 --- a/credstore/probe.go +++ b/credstore/probe.go @@ -28,12 +28,17 @@ const ( probeKey = "__probe__" ) +// probeService derives the reserved namespace the probe entry lives in. +func probeService(serviceName string) string { + return probeServicePrefix + serviceName +} + // probe writes and removes a throwaway keyring entry to check availability. // A zero or negative timeout probes unbounded, matching historical behavior. // A positive timeout bounds the probe; on platforms where the probe runs a // child process (darwin), the child is killed when the timeout expires. func probe(serviceName string, timeout time.Duration) error { - service := probeServicePrefix + serviceName + service := probeService(serviceName) if timeout <= 0 { return probeDirect(service, probeKey) } diff --git a/credstore/probe_test.go b/credstore/probe_test.go new file mode 100644 index 0000000..1bec2d3 --- /dev/null +++ b/credstore/probe_test.go @@ -0,0 +1,20 @@ +package credstore + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Pins the leak-containment contract on every platform, not just darwin: a +// probe abandoned mid-flight (a timed-out non-darwin probe whose blocked Set +// completes after process exit, or a darwin cleanup cut short) can leave at +// most the one known entry — deterministic account, reserved service — which +// the next probe's Set overwrites and Delete removes. The names below are +// publicly documented on StoreOptions.ProbeTimeout; changing either breaks +// self-healing across versions and orphans entries written by earlier +// releases. +func TestProbeContractIsDeterministicAndReserved(t *testing.T) { + assert.Equal(t, "credstore.probe.svc", probeService("svc")) + assert.Equal(t, "__probe__", probeKey) +} From bddce434cf74365df75f94ce8d71c629f129381b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 17:40:30 -0700 Subject: [PATCH 14/14] credstore: document that bounded darwin probes sit below MockInit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The child-process probe exists precisely to operate beneath go-keyring's uncancellable exec layer, so it cannot observe the mocked provider — go-keyring exports no way to detect MockInit. State the limitation on ProbeTimeout with the test guidance: mocked-keyring tests use a zero timeout (probeDirect honors the mock) or ForceFile. --- credstore/store.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/credstore/store.go b/credstore/store.go index d4c9825..dd2556f 100644 --- a/credstore/store.go +++ b/credstore/store.go @@ -32,6 +32,7 @@ type StoreOptions struct { // keyring service "credstore.probe." (account "__probe__") // — a namespace reserved by this package — never under ServiceName // itself, so a probe cannot touch real credentials. + // // On darwin, removal of the throwaway probe entry runs synchronously // after a successful probe with a short budget of its own, so worst-case // construction there is ProbeTimeout plus that cleanup bound (five @@ -40,6 +41,13 @@ type StoreOptions struct { // detached: Go does not wait for goroutines at process exit, and a // short-lived CLI would leak a probe entry per invocation. On other // platforms cleanup is part of the probe itself and adds no extra time. + // + // Limitation: on darwin, a positive timeout probes via the security + // binary directly — the whole point is operating below go-keyring's + // uncancellable exec layer — so it cannot observe go-keyring's mocked + // provider (keyring.MockInit), which go-keyring does not expose. Tests + // that mock the keyring should use a zero ProbeTimeout (the unbounded + // probe goes through go-keyring and honors the mock) or ForceFile. ProbeTimeout time.Duration // FallbackDir is the directory for file-based credential storage.