From ead0cdb66f149db7e2a6fe179de4cff16f9e89dc Mon Sep 17 00:00:00 2001 From: leet-c1 <264029741+leet-c1@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:00:03 +0000 Subject: [PATCH 1/6] Sweep expired token files from the file cache Without a usable OS keyring, c1i caches one access-token file per set of credentials, so a token minted from credentials since replaced or removed stayed on disk forever. After a token file is written, delete the other token files there that no load would serve, and temp files over a minute old. Only regular files named as this package names them are touched, and the sweep keeps the cache's existing trust checks. Add a README section listing what c1i leaves behind on uninstall. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++ README.md | 26 +++++- internal/tokensource/cache.go | 68 ++++++++++++++- internal/tokensource/cache_test.go | 130 +++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca05b20..e9fd1a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). file in `~/.c1i/cache/` that the running version doesn't use. It leaves subdirectories alone, and skips the sweep when the cache dir is a symlink or, with no home dir, a path relative to the working directory. +- **Expired access-token files stayed on disk forever.** Without a usable OS + keyring, c1i caches tokens as files under `/c1i/tokens/`, one + per set of credentials. Writing one now deletes the others that can't be read + or whose token expires within a minute. It touches only files named as c1i + names them, skips the sweep when that dir fails the cache's trust checks, and + spares a temp file under a minute old. Cached tokens + in the OS keyring can't be listed, so they aren't swept. The README's new + "Uninstalling" section lists everything c1i leaves on disk and in the keyring. - **`requests create grant --duration` documented a format the server refuses.** Its help and the README suggested `24h` or `7d`, which the API rejects with `invalid google.protobuf.Duration value` (exit 2). They now say diff --git a/README.md b/README.md index 8885274..4e75b93 100644 --- a/README.md +++ b/README.md @@ -1310,7 +1310,9 @@ token and reuses it until it nears expiry. It uses the OS keyring when available. On Unix-like hosts without a usable keyring, it instead uses a hardened `0600` file under the config directory (`~/.config/c1i/tokens/` on Linux). A cached token the server rejects (clock skew, or a revoked credential) -is dropped and re-minted once automatically. +is dropped and re-minted once automatically. Each file-cache write also deletes +the other token files there that can't be read or whose token expires within a +minute. The token is strictly shorter-lived than the client secret already stored beside it. Set `C1I_NO_TOKEN_CACHE=1` to disable caching and mint per invocation. @@ -1379,6 +1381,28 @@ compromised distribution origin could withhold upgrades or offer any signed release newer than yours, yanked or prerelease, but never an unsigned build or one older than the one you run. +## Uninstalling + +Removing the binary leaves these behind: + +- `~/.c1i/`, the OpenAPI spec cache. +- `~/.c1i.yaml`, the config file. +- `/c1i/`, holding the file-fallback credentials + (`credentials/`) and the access-token file cache (`tokens/`). See + [Credential sources](#credential-sources) for each OS's config directory. +- OS keyring entries: credentials under `c1i/`, and cached access tokens + under `com.conductorone.c1i.tokens`. + +c1i can't list keyring entries or keep track of the tenants you've logged in to. +The only way it can remove keyring credentials is `c1i auth logout --url `. +Run it once per tenant, before you remove the binary. Logout also drops the +cached token for the credentials stored now. A token cached under credentials +you since replaced stays in the keyring until you delete it with your OS's +keyring tool. + +`~/.sigstore/root/`, which `upgrade` uses to verify releases, is shared with +other Sigstore tools. It isn't c1i's to delete. + ## License Apache 2.0 diff --git a/internal/tokensource/cache.go b/internal/tokensource/cache.go index 08665f4..4e2a311 100644 --- a/internal/tokensource/cache.go +++ b/internal/tokensource/cache.go @@ -36,6 +36,9 @@ const ( maxCachedTokenBytes = 64 << 10 noCacheEnv = "C1I_NO_TOKEN_CACHE" + + // tmpFileGrace spares a temp file a concurrent store may still be renaming. + tmpFileGrace = time.Minute ) const tokenKeychainService = "com.conductorone.c1i.tokens" @@ -112,11 +115,16 @@ func loadFileCachedToken(key string) *oauth2.Token { if !trustedCacheDir(filepath.Dir(filepath.Dir(p))) || !trustedCacheDir(filepath.Dir(p)) { return nil } + return readCachedTokenFile(p) +} + +// readCachedTokenFile assumes the caller has checked p's directories. +func readCachedTokenFile(p string) *oauth2.Token { info, err := os.Lstat(p) if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { return nil } - f, err := os.Open(p) // #nosec G304 -- p contains only a locally-derived cache key + f, err := os.Open(p) // #nosec G304 -- p is a local cache key or an entry of the checked cache dir if err != nil { return nil } @@ -187,7 +195,63 @@ func storeFileCachedToken(key string, b []byte) { if err := tmp.Close(); err != nil { return } - _ = os.Rename(tmpName, p) + if os.Rename(tmpName, p) == nil { + pruneTokenDir(dir, filepath.Base(p)) + } +} + +// pruneTokenDir deletes the token files in dir that no load would serve, so a +// token minted from credentials since replaced or removed doesn't stay on disk +// forever. It is best-effort and touches only regular files named exactly as +// this package names them. Keyring entries can't be enumerated, so expired +// tokens stored there are only ever overwritten, never swept. +func pruneTokenDir(dir, keep string) { + if !trustedCacheDir(filepath.Dir(dir)) || !trustedCacheDir(dir) { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + name := e.Name() + p := filepath.Join(dir, name) + info, err := os.Lstat(p) + if err != nil || !info.Mode().IsRegular() || name == keep { + continue + } + isToken, isTemp := tokenFileName(name) + switch { + case isToken: + // Corrupt or unreadable goes too: no load serves it; a transient error costs one re-mint. + if readCachedTokenFile(p) != nil { + continue + } + case isTemp: + if time.Since(info.ModTime()) < tmpFileGrace { + continue + } + default: + continue + } + _ = os.Remove(p) + } +} + +// tokenFileName reports whether name is shaped like a file cachePath or +// storeFileCachedToken's temp file would create. +func tokenFileName(name string) (isToken, isTemp bool) { + if key, ok := strings.CutSuffix(name, ".json"); ok { + return isCacheKey(key), false + } + rest, dotted := strings.CutPrefix(name, ".") + key, _, found := strings.Cut(rest, ".json.tmp-") + return false, dotted && found && isCacheKey(key) +} + +func isCacheKey(s string) bool { + _, err := hex.DecodeString(s) + return err == nil && len(s) == sha256.Size*2 } func invalidateFileCachedToken(key string) { diff --git a/internal/tokensource/cache_test.go b/internal/tokensource/cache_test.go index 87e3c23..a33f572 100644 --- a/internal/tokensource/cache_test.go +++ b/internal/tokensource/cache_test.go @@ -444,3 +444,133 @@ func TestCacheKeyChangeForcesMint(t *testing.T) { t.Errorf("token=%q mints=%d, want fresh token from exactly one mint", got.AccessToken, m.n) } } + +func writeTokenFile(t *testing.T, p string, expiry time.Time) { + t.Helper() + body := `{"access_token":"t","token_type":"Bearer","expiry":"` + expiry.UTC().Format(time.RFC3339) + `"}` + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestStoreSweepsExpiredTokenFiles(t *testing.T) { + useTempConfig(t) + keep, _ := cachePath(testCacheKey("host", "client")) + dir := filepath.Dir(keep) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + expired := filepath.Join(dir, cacheKey("h", "c", "old-secret")+".json") + nearExpiry := filepath.Join(dir, cacheKey("h", "c", "skew-secret")+".json") + fresh := filepath.Join(dir, cacheKey("h", "c", "other-secret")+".json") + corrupt := filepath.Join(dir, cacheKey("h", "c", "corrupt-secret")+".json") + staleTmp := filepath.Join(dir, "."+cacheKey("h", "c", "old-secret")+".json.tmp-1") + youngTmp := filepath.Join(dir, "."+cacheKey("h", "c", "other-secret")+".json.tmp-2") + notOurs := filepath.Join(dir, "notes.txt") + userJSON := filepath.Join(dir, "backup.json") + userTmp := filepath.Join(dir, ".backup.json.tmp-old") + subdir := filepath.Join(dir, cacheKey("h", "c", "dir-secret")+".json") + if err := os.Mkdir(subdir, 0o700); err != nil { + t.Fatal(err) + } + writeTokenFile(t, expired, time.Now().Add(-time.Hour)) + writeTokenFile(t, nearExpiry, time.Now().Add(30*time.Second)) + writeTokenFile(t, fresh, time.Now().Add(time.Hour)) + writeTokenFile(t, userJSON, time.Now().Add(-time.Hour)) + for _, p := range []string{corrupt, staleTmp, youngTmp, notOurs, userTmp} { + if err := os.WriteFile(p, []byte("not json"), 0o600); err != nil { + t.Fatal(err) + } + } + old := time.Now().Add(-2 * tmpFileGrace) + for _, p := range []string{staleTmp, userTmp} { + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + } + + storeCachedToken(testCacheKey("host", "client"), freshToken(30*time.Minute)) + + for _, p := range []string{expired, nearExpiry, corrupt, staleTmp} { + if _, err := os.Lstat(p); !errors.Is(err, os.ErrNotExist) { + t.Errorf("%s survived the sweep: %v", filepath.Base(p), err) + } + } + for _, p := range []string{keep, fresh, youngTmp, notOurs, subdir, userJSON, userTmp} { + if _, err := os.Lstat(p); err != nil { + t.Errorf("%s was removed: %v", filepath.Base(p), err) + } + } +} + +// The token just written is kept even when it is already too close to expiry +// for a load to serve it. +func TestStoreSweepKeepsTokenJustWritten(t *testing.T) { + useTempConfig(t) + key := testCacheKey("host", "client") + storeCachedToken(key, freshToken(30*time.Second)) + p, _ := cachePath(key) + if _, err := os.Lstat(p); err != nil { + t.Errorf("token just written was swept: %v", err) + } +} + +func TestStoreSweepLeavesCredentialsDir(t *testing.T) { + dir := useTempConfig(t) + creds := filepath.Join(dir, "c1i", "credentials") + if err := os.MkdirAll(creds, 0o700); err != nil { + t.Fatal(err) + } + cred := filepath.Join(creds, cacheKey("h", "c", "cred-secret")+".json") + writeTokenFile(t, cred, time.Now().Add(-time.Hour)) + + key := testCacheKey("host", "client") + storeCachedToken(key, freshToken(30*time.Minute)) + + if p, _ := cachePath(key); loadFileCachedToken(key) == nil { + t.Fatalf("store did not write %s, so the sweep never ran", p) + } + if _, err := os.Lstat(cred); err != nil { + t.Errorf("credentials file was removed: %v", err) + } +} + +// Store refuses a symlinked tokens dir or c1i parent before the sweep runs, so +// this calls the sweep directly to hold its own guard. +func TestTokenSweepSkipsSymlinkedDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink privileges are not portable on Windows") + } + for _, linked := range []string{"tokens", "c1i"} { + t.Run(linked, func(t *testing.T) { + dir := useTempConfig(t) + target := filepath.Join(dir, "untrusted") + tokens := filepath.Join(dir, "c1i", "tokens") + strayDir := target + if linked == "c1i" { + strayDir = filepath.Join(target, "tokens") + } + if err := os.MkdirAll(strayDir, 0o700); err != nil { + t.Fatal(err) + } + stray := filepath.Join(strayDir, cacheKey("h", "c", "stray-secret")+".json") + writeTokenFile(t, stray, time.Now().Add(-time.Hour)) + link := filepath.Join(dir, "c1i") + if linked == "tokens" { + link = tokens + if err := os.MkdirAll(filepath.Dir(tokens), 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("creating symlink: %v", err) + } + + pruneTokenDir(tokens, "") + + if _, err := os.Lstat(stray); err != nil { + t.Errorf("file behind a symlinked %s dir was removed: %v", linked, err) + } + }) + } +} From 22d6ddc0af96e401198aa61670cbf1c0804c8f1a Mon Sep 17 00:00:00 2001 From: leet-c1 <264029741+leet-c1@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:06:23 +0000 Subject: [PATCH 2/6] Give the keyring cleanup commands for each OS Logout removes only one tenant's credentials and its current token, and c1i can't list keyring entries, so the Uninstalling section now gives the macOS, Linux and Windows steps that remove the rest by hand. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- README.md | 31 ++++++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9fd1a9..167e896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). names them, skips the sweep when that dir fails the cache's trust checks, and spares a temp file under a minute old. Cached tokens in the OS keyring can't be listed, so they aren't swept. The README's new - "Uninstalling" section lists everything c1i leaves on disk and in the keyring. + "Uninstalling" section lists everything c1i leaves on disk and in the keyring, + and gives the commands that remove the keyring entries on each OS. - **`requests create grant --duration` documented a format the server refuses.** Its help and the README suggested `24h` or `7d`, which the API rejects with `invalid google.protobuf.Duration value` (exit 2). They now say diff --git a/README.md b/README.md index 4e75b93..84db8f0 100644 --- a/README.md +++ b/README.md @@ -1394,11 +1394,32 @@ Removing the binary leaves these behind: under `com.conductorone.c1i.tokens`. c1i can't list keyring entries or keep track of the tenants you've logged in to. -The only way it can remove keyring credentials is `c1i auth logout --url `. -Run it once per tenant, before you remove the binary. Logout also drops the -cached token for the credentials stored now. A token cached under credentials -you since replaced stays in the keyring until you delete it with your OS's -keyring tool. +The only way it can remove keyring credentials is `auth logout`, once per +tenant, before you remove the binary: + +```sh +c1i auth logout --url example.conductor.one +``` + +Logout also drops the cached token for the credentials stored now. A token +cached under credentials you since replaced stays in the keyring. To remove +keyring entries by hand, where `` is a tenant's host such as +`example.conductor.one`: + +```sh +# macOS: each call deletes one item; repeat until it reports "could not be found" +security delete-generic-password -s 'c1i/' +security delete-generic-password -s com.conductorone.c1i.tokens + +# Linux (Secret Service): each call deletes every matching item +secret-tool clear service 'c1i/' +secret-tool clear service com.conductorone.c1i.tokens +``` + +On Windows, open Credential Manager → Windows Credentials and remove +`c1i/:client_id`, `c1i/:client_secret`, and every +`com.conductorone.c1i.tokens:` entry. To find which hosts you have, search for +`c1i` in Keychain Access (macOS), Seahorse (Linux) or Credential Manager. `~/.sigstore/root/`, which `upgrade` uses to verify releases, is shared with other Sigstore tools. It isn't c1i's to delete. From d8b195c99b238869f513294d86e1a4a6d857fb26 Mon Sep 17 00:00:00 2001 From: leet-c1 <264029741+leet-c1@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:30:41 +0000 Subject: [PATCH 3/6] Match only lowercase token names and name the legacy keyring service hex.DecodeString accepts uppercase, so a user file with an uppercase hex name could be swept; cacheKey only writes lowercase. Cover loose perms, oversized files and a token-named symlink in the sweep test, and list the c1i/ service older releases used for a *.conductor.one tenant. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++-- internal/tokensource/cache.go | 5 +++-- internal/tokensource/cache_test.go | 23 +++++++++++++++++++++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e47175f..0e95c9a 100644 --- a/README.md +++ b/README.md @@ -1393,7 +1393,8 @@ Removing the binary leaves these behind: (`credentials/`) and the access-token file cache (`tokens/`). See [Credential sources](#credential-sources) for each OS's config directory. - OS keyring entries: credentials under `c1i/`, and cached access tokens - under `com.conductorone.c1i.tokens`. + under `com.conductorone.c1i.tokens`. Older releases stored a + `.conductor.one` tenant's credentials under `c1i/`. c1i can't list keyring entries or keep track of the tenants you've logged in to. The only way it can remove keyring credentials is `auth logout`, once per @@ -1406,7 +1407,7 @@ c1i auth logout --url example.conductor.one Logout also drops the cached token for the credentials stored now. A token cached under credentials you since replaced stays in the keyring. To remove keyring entries by hand, where `` is a tenant's host such as -`example.conductor.one`: +`example.conductor.one`, or its `` for an entry from an older release: ```sh # macOS: each call deletes one item; repeat until it reports "could not be found" diff --git a/internal/tokensource/cache.go b/internal/tokensource/cache.go index 4e2a311..941a142 100644 --- a/internal/tokensource/cache.go +++ b/internal/tokensource/cache.go @@ -249,9 +249,10 @@ func tokenFileName(name string) (isToken, isTemp bool) { return false, dotted && found && isCacheKey(key) } +// isCacheKey matches cacheKey's output exactly, lowercase included. func isCacheKey(s string) bool { - _, err := hex.DecodeString(s) - return err == nil && len(s) == sha256.Size*2 + b, err := hex.DecodeString(s) + return err == nil && len(b) == sha256.Size && hex.EncodeToString(b) == s } func invalidateFileCachedToken(key string) { diff --git a/internal/tokensource/cache_test.go b/internal/tokensource/cache_test.go index a33f572..8ecf3f3 100644 --- a/internal/tokensource/cache_test.go +++ b/internal/tokensource/cache_test.go @@ -470,6 +470,9 @@ func TestStoreSweepsExpiredTokenFiles(t *testing.T) { userJSON := filepath.Join(dir, "backup.json") userTmp := filepath.Join(dir, ".backup.json.tmp-old") subdir := filepath.Join(dir, cacheKey("h", "c", "dir-secret")+".json") + upperJSON := filepath.Join(dir, strings.ToUpper(cacheKey("h", "c", "upper-secret"))+".json") + loosePerms := filepath.Join(dir, cacheKey("h", "c", "perm-secret")+".json") + oversized := filepath.Join(dir, cacheKey("h", "c", "big-secret")+".json") if err := os.Mkdir(subdir, 0o700); err != nil { t.Fatal(err) } @@ -477,6 +480,22 @@ func TestStoreSweepsExpiredTokenFiles(t *testing.T) { writeTokenFile(t, nearExpiry, time.Now().Add(30*time.Second)) writeTokenFile(t, fresh, time.Now().Add(time.Hour)) writeTokenFile(t, userJSON, time.Now().Add(-time.Hour)) + writeTokenFile(t, upperJSON, time.Now().Add(-time.Hour)) + writeTokenFile(t, loosePerms, time.Now().Add(time.Hour)) + if err := os.Chmod(loosePerms, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(oversized, []byte(strings.Repeat("x", maxCachedTokenBytes+1)), 0o600); err != nil { + t.Fatal(err) + } + // A link is never followed or removed, even when its target is an expired token. + link := filepath.Join(dir, cacheKey("h", "c", "link-secret")+".json") + target := filepath.Join(t.TempDir(), "target.json") + writeTokenFile(t, target, time.Now().Add(-time.Hour)) + survivors := []string{keep, fresh, youngTmp, notOurs, subdir, userJSON, userTmp, upperJSON} + if err := os.Symlink(target, link); err == nil { + survivors = append(survivors, link, target) + } for _, p := range []string{corrupt, staleTmp, youngTmp, notOurs, userTmp} { if err := os.WriteFile(p, []byte("not json"), 0o600); err != nil { t.Fatal(err) @@ -491,12 +510,12 @@ func TestStoreSweepsExpiredTokenFiles(t *testing.T) { storeCachedToken(testCacheKey("host", "client"), freshToken(30*time.Minute)) - for _, p := range []string{expired, nearExpiry, corrupt, staleTmp} { + for _, p := range []string{expired, nearExpiry, corrupt, staleTmp, loosePerms, oversized} { if _, err := os.Lstat(p); !errors.Is(err, os.ErrNotExist) { t.Errorf("%s survived the sweep: %v", filepath.Base(p), err) } } - for _, p := range []string{keep, fresh, youngTmp, notOurs, subdir, userJSON, userTmp} { + for _, p := range survivors { if _, err := os.Lstat(p); err != nil { t.Errorf("%s was removed: %v", filepath.Base(p), err) } From b78e092c34e2c74cbcc7d17b683e662607fb4099 Mon Sep 17 00:00:00 2001 From: leet-c1 <264029741+leet-c1@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:34:20 +0000 Subject: [PATCH 4/6] Say to unset the credential env vars before logging out to uninstall With C1I_CLIENT_ID/C1I_CLIENT_SECRET set, logout resolves those instead, so it drops their cached token and leaves the keyring credentials' one. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0e95c9a..355c6d0 100644 --- a/README.md +++ b/README.md @@ -1398,7 +1398,8 @@ Removing the binary leaves these behind: c1i can't list keyring entries or keep track of the tenants you've logged in to. The only way it can remove keyring credentials is `auth logout`, once per -tenant, before you remove the binary: +tenant, before you remove the binary. Unset `C1I_CLIENT_ID` and +`C1I_CLIENT_SECRET` first, or logout drops the cached token for those instead: ```sh c1i auth logout --url example.conductor.one From 8ab1468c52c818fab6e8e484e8f346daaa991ac6 Mon Sep 17 00:00:00 2001 From: leet-c1 <264029741+leet-c1@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:46:39 +0000 Subject: [PATCH 5/6] Pin the sweep's name and size checks and tighten the uninstall docs Near-miss names (short or truncated keys, a temp name without its leading dot or marker) now must survive the sweep, and the oversized fixture is valid JSON so only the 64 KiB bound rejects it. The README says logout still removes stored credentials when the env vars are set, covers the legacy name on Windows, and lists every file the sweep deletes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++++-------- README.md | 18 +++++++++-------- internal/tokensource/cache.go | 2 +- internal/tokensource/cache_test.go | 32 +++++++++++++++++++++++------- 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d969b0..794bbd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,15 +20,12 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). skips the sweep when the cache dir is a symlink or, with no home dir, a path relative to the working directory. -- **Expired access-token files stayed on disk forever.** Without a usable OS - keyring, c1i caches tokens as files under `/c1i/tokens/`, one - per set of credentials. Writing one now deletes the others that can't be read - or whose token expires within a minute. It touches only files named as c1i - names them, skips the sweep when that dir fails the cache's trust checks, and - spares a temp file under a minute old. Cached tokens in the OS keyring can't - be listed, so they aren't swept. The README's new "Uninstalling" section lists - everything c1i leaves on disk and in the keyring, and gives the commands that - remove the keyring entries on each OS. +- **Stale access-token files stayed on disk forever.** Without a usable OS + keyring, c1i caches one token file per set of credentials under + `/c1i/tokens/`. Writing one now deletes the others it would + refuse to load, such as expired or corrupt ones, and temp files over a minute + old. Keyring tokens can't be listed, so they aren't swept. README gains an + "Uninstalling" section. - **`requests create grant --duration` documented a format the server refuses.** Its help and the README suggested `24h` or `7d`, which the API diff --git a/README.md b/README.md index 355c6d0..d13d428 100644 --- a/README.md +++ b/README.md @@ -1313,8 +1313,9 @@ available. On Unix-like hosts without a usable keyring, it instead uses a hardened `0600` file under the config directory (`~/.config/c1i/tokens/` on Linux). A cached token the server rejects (clock skew, or a revoked credential) is dropped and re-minted once automatically. Each file-cache write also deletes -the other token files there that can't be read or whose token expires within a -minute. +the other token files there that c1i would refuse to load (expired or expiring +within a minute, corrupt, or accessible to other users) and temp files over a +minute old. The token is strictly shorter-lived than the client secret already stored beside it. Set `C1I_NO_TOKEN_CACHE=1` to disable caching and mint per invocation. @@ -1397,9 +1398,10 @@ Removing the binary leaves these behind: `.conductor.one` tenant's credentials under `c1i/`. c1i can't list keyring entries or keep track of the tenants you've logged in to. -The only way it can remove keyring credentials is `auth logout`, once per -tenant, before you remove the binary. Unset `C1I_CLIENT_ID` and -`C1I_CLIENT_SECRET` first, or logout drops the cached token for those instead: +To remove its keyring credentials, run `auth logout` once per tenant before you +remove the binary. If `C1I_CLIENT_ID` and `C1I_CLIENT_SECRET` are both set, unset +them first: logout still removes the stored credentials, but drops the cached +token for the env-var credentials instead of theirs. ```sh c1i auth logout --url example.conductor.one @@ -1407,8 +1409,8 @@ c1i auth logout --url example.conductor.one Logout also drops the cached token for the credentials stored now. A token cached under credentials you since replaced stays in the keyring. To remove -keyring entries by hand, where `` is a tenant's host such as -`example.conductor.one`, or its `` for an entry from an older release: +keyring entries by hand, replace `` below with a tenant's host, such as +`example.conductor.one`, or with `` for an entry an older release stored: ```sh # macOS: each call deletes one item; repeat until it reports "could not be found" @@ -1426,7 +1428,7 @@ On Windows, open Credential Manager → Windows Credentials and remove `c1i` in Keychain Access (macOS), Seahorse (Linux) or Credential Manager. `~/.sigstore/root/`, which `upgrade` uses to verify releases, is shared with -other Sigstore tools. It isn't c1i's to delete. +other Sigstore tools, so leave it in place. ## License diff --git a/internal/tokensource/cache.go b/internal/tokensource/cache.go index 941a142..eae785a 100644 --- a/internal/tokensource/cache.go +++ b/internal/tokensource/cache.go @@ -223,7 +223,7 @@ func pruneTokenDir(dir, keep string) { isToken, isTemp := tokenFileName(name) switch { case isToken: - // Corrupt or unreadable goes too: no load serves it; a transient error costs one re-mint. + // Delete corrupt or unreadable files too: no load serves them, and a transient error costs one re-mint. if readCachedTokenFile(p) != nil { continue } diff --git a/internal/tokensource/cache_test.go b/internal/tokensource/cache_test.go index 8ecf3f3..df493a8 100644 --- a/internal/tokensource/cache_test.go +++ b/internal/tokensource/cache_test.go @@ -467,8 +467,15 @@ func TestStoreSweepsExpiredTokenFiles(t *testing.T) { staleTmp := filepath.Join(dir, "."+cacheKey("h", "c", "old-secret")+".json.tmp-1") youngTmp := filepath.Join(dir, "."+cacheKey("h", "c", "other-secret")+".json.tmp-2") notOurs := filepath.Join(dir, "notes.txt") - userJSON := filepath.Join(dir, "backup.json") - userTmp := filepath.Join(dir, ".backup.json.tmp-old") + key := cacheKey("h", "c", "near-miss-secret") + // Each is expired and old, so only its name keeps it. + var nearMisses []string + for _, name := range []string{ + "backup.json", "abcd.json", key[:32] + ".json", + ".backup.json.tmp-old", ".abcd.json.tmp-old", key + ".json.tmp-old", "." + key, + } { + nearMisses = append(nearMisses, filepath.Join(dir, name)) + } subdir := filepath.Join(dir, cacheKey("h", "c", "dir-secret")+".json") upperJSON := filepath.Join(dir, strings.ToUpper(cacheKey("h", "c", "upper-secret"))+".json") loosePerms := filepath.Join(dir, cacheKey("h", "c", "perm-secret")+".json") @@ -479,30 +486,41 @@ func TestStoreSweepsExpiredTokenFiles(t *testing.T) { writeTokenFile(t, expired, time.Now().Add(-time.Hour)) writeTokenFile(t, nearExpiry, time.Now().Add(30*time.Second)) writeTokenFile(t, fresh, time.Now().Add(time.Hour)) - writeTokenFile(t, userJSON, time.Now().Add(-time.Hour)) writeTokenFile(t, upperJSON, time.Now().Add(-time.Hour)) writeTokenFile(t, loosePerms, time.Now().Add(time.Hour)) if err := os.Chmod(loosePerms, 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(oversized, []byte(strings.Repeat("x", maxCachedTokenBytes+1)), 0o600); err != nil { + // Valid JSON once the padding is trimmed, so only the size bound rejects it. + writeTokenFile(t, oversized, time.Now().Add(time.Hour)) + f, err := os.OpenFile(oversized, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(strings.Repeat(" ", maxCachedTokenBytes)); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { t.Fatal(err) } // A link is never followed or removed, even when its target is an expired token. link := filepath.Join(dir, cacheKey("h", "c", "link-secret")+".json") target := filepath.Join(t.TempDir(), "target.json") writeTokenFile(t, target, time.Now().Add(-time.Hour)) - survivors := []string{keep, fresh, youngTmp, notOurs, subdir, userJSON, userTmp, upperJSON} + survivors := append([]string{keep, fresh, youngTmp, notOurs, subdir, upperJSON}, nearMisses...) if err := os.Symlink(target, link); err == nil { survivors = append(survivors, link, target) } - for _, p := range []string{corrupt, staleTmp, youngTmp, notOurs, userTmp} { + for _, p := range []string{corrupt, staleTmp, youngTmp, notOurs} { if err := os.WriteFile(p, []byte("not json"), 0o600); err != nil { t.Fatal(err) } } old := time.Now().Add(-2 * tmpFileGrace) - for _, p := range []string{staleTmp, userTmp} { + for _, p := range nearMisses { + writeTokenFile(t, p, time.Now().Add(-time.Hour)) + } + for _, p := range append([]string{staleTmp}, nearMisses...) { if err := os.Chtimes(p, old, old); err != nil { t.Fatal(err) } From d47fa8cadd4c5cec7ec94ba719f2957678ff950f Mon Sep 17 00:00:00 2001 From: leet-c1 <264029741+leet-c1@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:48:32 +0000 Subject: [PATCH 6/6] Pin the temp marker's dash and name every file the sweep deletes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- README.md | 4 ++-- internal/tokensource/cache_test.go | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 794bbd6..4e219e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,8 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). keyring, c1i caches one token file per set of credentials under `/c1i/tokens/`. Writing one now deletes the others it would refuse to load, such as expired or corrupt ones, and temp files over a minute - old. Keyring tokens can't be listed, so they aren't swept. README gains an - "Uninstalling" section. + old. Keyring tokens can't be listed, so they aren't swept. The README now + lists what an uninstall leaves behind and how to remove it. - **`requests create grant --duration` documented a format the server refuses.** Its help and the README suggested `24h` or `7d`, which the API diff --git a/README.md b/README.md index d13d428..5d8d656 100644 --- a/README.md +++ b/README.md @@ -1314,8 +1314,8 @@ hardened `0600` file under the config directory (`~/.config/c1i/tokens/` on Linux). A cached token the server rejects (clock skew, or a revoked credential) is dropped and re-minted once automatically. Each file-cache write also deletes the other token files there that c1i would refuse to load (expired or expiring -within a minute, corrupt, or accessible to other users) and temp files over a -minute old. +within a minute, corrupt or unreadable, or open to group or other users) and +temp files over a minute old. The token is strictly shorter-lived than the client secret already stored beside it. Set `C1I_NO_TOKEN_CACHE=1` to disable caching and mint per invocation. diff --git a/internal/tokensource/cache_test.go b/internal/tokensource/cache_test.go index df493a8..7ac9054 100644 --- a/internal/tokensource/cache_test.go +++ b/internal/tokensource/cache_test.go @@ -473,6 +473,7 @@ func TestStoreSweepsExpiredTokenFiles(t *testing.T) { for _, name := range []string{ "backup.json", "abcd.json", key[:32] + ".json", ".backup.json.tmp-old", ".abcd.json.tmp-old", key + ".json.tmp-old", "." + key, + "." + key + ".json.tmpx", } { nearMisses = append(nearMisses, filepath.Join(dir, name)) }