Skip to content

fix: sweep expired token files and document uninstalling - #151

Merged
leet-c1 merged 7 commits into
mainfrom
fix/sweep-expired-tokens
Oct 9, 2026
Merged

leet-c1 merged 7 commits into
mainfrom
fix/sweep-expired-tokens

Conversation

@leet-c1

@leet-c1 leet-c1 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Why

Without a usable OS keyring, c1i caches one access-token file per set of credentials under <UserConfigDir>/c1i/tokens/. The file is named by a hash of host, client id and secret. When credentials are replaced or removed, their token file stays on disk forever. #150 stopped leftovers piling up in ~/.c1i/cache/. This PR applies the same idea to the token cache, and documents what an uninstall leaves behind.

A c1i clean command was ruled out. go-keyring can't enumerate keyring entries and c1i keeps no list of tenants, so such a command couldn't truthfully claim to remove everything.

What

Token file sweep (internal/tokensource/cache.go): after a token file is renamed into place, pruneTokenDir deletes the other files in tokens/ that no load would serve.

  • What counts as unservable: a token that has expired or expires within the 60s skew, and a file that is corrupt, unreadable, group/other-accessible, or over the 64 KiB bound. A corrupt file is deleted rather than kept, because no load can use it. The worst a transient read error costs is one re-mint.
  • Temp files: .tmp- files older than one minute are deleted. Younger ones are kept, so a concurrent write isn't broken.
  • Only c1i's own files: the sweep removes only regular files named exactly as this package names them: a lowercase 64-hex key plus .json, or the matching .<key>.json.tmp-*. A user's backup.json, an uppercase-hex name, a directory or a symlink is never touched.
  • Trust checks kept: trustedCacheDir on tokens/ and its c1i parent, plus Lstat. The read path is now shared with loadFileCachedToken through readCachedTokenFile. The sweep only reads tokens/ and never credentials/.
  • Keyring tokens aren't swept: they can't be enumerated, so they're only ever overwritten. A code comment says so.

README "Uninstalling" lists what c1i leaves behind:

  • ~/.c1i/
  • ~/.c1i.yaml
  • <UserConfigDir>/c1i/ (credentials and tokens)
  • keyring entries under c1i/<host>, the legacy c1i/<name>, and com.conductorone.c1i.tokens

It explains that c1i auth logout --url <tenant>, once per tenant before removing the binary and with C1I_CLIENT_ID/C1I_CLIENT_SECRET unset, is the only way c1i can remove keyring credentials. It gives the manual removal steps for macOS (security delete-generic-password), Linux (secret-tool clear) and Windows (Credential Manager). It also notes that ~/.sigstore/root/ is shared with other Sigstore tools. cmd/agents.md is unchanged, because agents don't uninstall c1i.

CHANGELOG: one entry. With main merged in, [Unreleased] also carries the entries for #148, #149 and #150. I checked each against its PR's diff.

Verification

  • Tests:

    • TestStoreSweepsExpiredTokenFiles drives the sweep through storeCachedToken. It checks that removed files are gone and that kept files survive.
      • Removed: expired, near-expiry, corrupt, loose-permission, oversized (valid JSON padded past 64 KiB) and stale-temp files.
      • Kept: fresh files, young temp files, an uppercase-hex name, a token-named directory, a token-named symlink with its target, and near-miss names that are expired and old (a short or truncated key, a temp name without its leading dot, its .json.tmp- marker or the marker's dash, and user files).
    • TestStoreSweepKeepsTokenJustWritten, TestStoreSweepLeavesCredentialsDir, and TestTokenSweepSkipsSymlinkedDir, which covers a symlinked tokens/ and a symlinked c1i/.
  • Mutation testing: with the sweep reverted to a no-op, the removal test fails.

    • An independent reviewer ran 48 mutants against the sweep. Every survivor was either equivalent or is now killed by a fixture added here.
    • I also mutated each guard myself, and each one turns a test red. The guards: the trust checks on the dir and its parent, keep-the-written-file, the temp grace period, the regular-file check, the name checks (key length, case, leading dot, marker, dash), the 64 KiB bound, and a sweep pointed at credentials/.
  • Live sweep: a built binary ran against a lab tenant on Linux, using the file cache. Fixtures were seeded into the real tokens/ dir, and one mint was forced by removing the current token file.

    • Deleted: the expired, corrupt, oversized, 0644 and stale-temp fixtures.
    • Kept: the fresh, uppercase-hex, symlink, directory, user-file and near-miss fixtures. The real token was rewritten.
    • Untouched: credentials/ did not change. The fixtures were removed afterwards.
  • Linux keyring commands, tested live: gnome-keyring on a private D-Bus session with a throwaway HOME. Entries were written through go-keyring v0.2.8 under c1i's service and account names. secret-tool clear service … removed both credential entries and both cached tokens, and an unrelated app's entry survived.

  • macOS and Windows steps, not run: there's no Mac or Windows machine available. They come from go-keyring v0.2.8's source: its macOS DeleteAll loops the same security delete-generic-password -s call, and its Windows target names are <service>:<account>.

  • Gates (GOTOOLCHAIN=go1.27.2, at d47fa8c), all passing:

    • go build, go vet, go test, go test -count=1 -shuffle=on
    • golangci-lint, go mod tidy diff, gosec, gitleaks
    • govulncheck for linux, darwin and windows: no vulnerabilities.
  • Dependency scan: no outdated direct dependency, and every workflow action and go install pin is at its latest release.

  • Adversarial review: independent reviewers covered six axes:

    • invariants and correctness
    • every claim checked against the c1i, go-keyring and sigstore-go source
    • test sensitivity
    • the live run
    • publication hygiene
    • each fix round

    Each fix round was re-reviewed and approved. No blocking findings remain.

Known trade-offs

  • Race with a concurrent write: if one process deletes a stale token file just as another renames a fresh token over it for the same credentials, the fresh token is lost. The same happens if a transient read error hits a fresh file. Either way the cost is one extra mint, which is one audit-log event.
  • Machines that gain a keyring: the sweep runs only on a file-cache write. A machine that later gains a usable keyring stops writing token files, so files left from before are never swept. The Uninstalling section covers removing them.

🤖 Generated with Claude Code

leet-c1 and others added 7 commits October 9, 2026 01:00
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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/<name> service older releases used for a *.conductor.one tenant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@leet-c1
leet-c1 merged commit 6c8e04e into main Oct 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant