-
Notifications
You must be signed in to change notification settings - Fork 1
credstore: forced file-only construction and a bounded keyring probe #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
fcedaf7
credstore: forced file-only construction and a bounded keyring probe
jeremy b2011ec
credstore: document non-positive ProbeTimeout as unbounded
jeremy aee6890
credstore: run probe cleanup under its own budget
jeremy 3e4f514
credstore: quote stub paths and bound the PID watcher
jeremy 47714d1
credstore: fall back to PID+time probe key on rand failure
jeremy 04bae05
credstore: run probe cleanup asynchronously
jeremy 779aae6
credstore: treat short rand reads like rand failures
jeremy 5c8ed46
credstore: keep probe cleanup joined; document the bounded tail
jeremy 5c551d6
credstore: align expiry-regression comment with the joined cleanup
jeremy 9de72ab
credstore: use a deterministic probe key so leaks self-heal
jeremy a91e335
credstore: isolate the probe in its own service namespace
jeremy d8687b2
credstore: reserve a declared credstore.probe namespace
jeremy 87e7a24
credstore: pin the probe leak-containment contract cross-platform
jeremy bddce43
credstore: document that bounded darwin probes sit below MockInit
jeremy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| package credstore | ||
|
|
||
| import ( | ||
| "context" | ||
| "time" | ||
|
|
||
| "github.com/zalando/go-keyring" | ||
| ) | ||
|
|
||
| // probeKeyring checks system keyring availability. Overridable in tests. | ||
| var probeKeyring = probe | ||
|
|
||
| // 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 ( | ||
| probeServicePrefix = "credstore.probe." | ||
| 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 := probeService(serviceName) | ||
| if timeout <= 0 { | ||
| return probeDirect(service, probeKey) | ||
| } | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), timeout) | ||
| defer cancel() | ||
| return probeBounded(ctx, service, probeKey) | ||
| } | ||
|
|
||
| // probeDirect probes via go-keyring, which has no cancellation path. | ||
| func probeDirect(serviceName, key string) error { | ||
| if err := keyring.Set(serviceName, key, "probe"); err != nil { | ||
| return err | ||
| } | ||
| _ = keyring.Delete(serviceName, key) | ||
| return nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| package credstore | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/base64" | ||
| "fmt" | ||
| "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" | ||
|
jeremy marked this conversation as resolved.
|
||
|
|
||
| // 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 | ||
| // 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 | ||
| // 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, 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(key), quoteSecurityArg(password)) | ||
|
|
||
| cmd := exec.CommandContext(ctx, securityPath, "-i") | ||
|
jeremy marked this conversation as resolved.
|
||
| cmd.Stdin = strings.NewReader(command) | ||
| if err := cmd.Run(); err != nil { | ||
| if ctx.Err() != nil { | ||
| return ctx.Err() | ||
| } | ||
| return err | ||
| } | ||
|
|
||
| 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", key).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 | ||
| // 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| 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 }) | ||
| } | ||
|
|
||
| // 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(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\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 time.Now().Before(deadline) { | ||
| if _, err := os.Stat(pidFile); err == nil { | ||
| break | ||
| } | ||
| time.Sleep(10 * time.Millisecond) | ||
| } | ||
| cancel() | ||
| }() | ||
|
|
||
| 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") | ||
| } | ||
|
|
||
| // 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 | ||
| } | ||
|
|
||
| // 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, 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 "+service+" -a "+key, lines[1]) | ||
| } | ||
|
|
||
| func TestProbeBoundedSuccess(t *testing.T) { | ||
| argsFile := argsStub(t) | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| defer cancel() | ||
|
|
||
| require.NoError(t, probeBounded(ctx, "test", "__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, probeServicePrefix+"svc", 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. | ||
| 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")) | ||
| requireCleanupDelete(t, argsFile, "test", "__probe_expiry") | ||
| } | ||
|
|
||
| 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")) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| //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. 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, key) }() | ||
|
|
||
| select { | ||
| case err := <-done: | ||
| return err | ||
| case <-ctx.Done(): | ||
| return ctx.Err() | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.