feat: keep the OpenAI API key in the OS secret store [minor] - #44
Conversation
The key is a billable bearer credential, and it was written in plaintext to ktsu.AppDataStorage's JSON file under the user profile, readable by anything running as the same user and picked up by any backup or file-sync tool covering that directory. It now goes to the platform-native secret store through ktsu.CredentialCache (Windows Credential Manager, macOS Keychain, libsecret on Linux), under a fixed well-known persona and a service name scoped to OAICLI so it cannot collide with another ktsu tool's credentials on a shared host. A key left by an earlier version is migrated on the next run: written to the secret store first, so a store that throws cannot lose it, and only then blanked in the old file. Clearing matters as much as migrating — a key that merely stops being written is still a key sitting in an unencrypted file. On a machine with no usable secret store, common on Linux over SSH and in containers, the tool now fails with an explanation naming the remedy rather than falling back to a plain file. Request.Send built its own client and read the plaintext field directly; it now goes through Auth.GetClient like everything else. Fixes #42 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AnVpPWKjVtXnAvd4bnzUL
SonarCloud's quality gate failed the PR on new-code coverage: 64.8% against a required 80%. The uncovered lines were real gaps rather than noise. The prompt loop in EnsureHasApiKey had no test at all, so nothing pinned that a blank answer is rejected and asked again rather than stored as an empty token. Spectre.Console.Testing was already referenced; driving AnsiConsole through a TestConsole covers it and states the behaviour. AppDataLegacyApiKeyStore was likewise untested, which left the one piece of the migration that touches the real settings file unexercised — including that Clear blanks the persisted copy and not just the in-memory one. Uncovered new lines drop from 30 to 11. What is left is the no-argument overloads that bind to the real OS secret store, and the catch for a platform that has none; neither is reachable from a test without standing up a machine that lacks a credential store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AnVpPWKjVtXnAvd4bnzUL
CI statusTwo checks went red. One is mine and is fixed; the other is not this PR's and cannot be fixed from here.
Uncovered new lines drop from 30 to 11. What remains is the no-argument overloads that bind to the real OS secret store and the catch for a platform that has none — neither reachable from a test without a machine that genuinely lacks a credential store.
This is an account-level Copilot quota, not a finding about this diff. It failed identically and within the same few minutes on two unrelated PRs in other repositories (ktsu-dev/BuildMonitor#285 and ktsu-dev/ProjectDirector#424), which is the reproduction — the three share nothing but the account. Every other scanner is green here: There is no fix to port: a 402 clears when the quota resets or is raised, and nothing in a diff changes it. I also cannot re-run it to confirm — the dynamic workflow is not retryable, and GitHub answers Nothing here needs action from a reviewer; I'm noting it so the red mark is not mistaken for a security finding against this change. I'll keep watching the PR until it's green and mergeable. Generated by Claude Code |
|



Fixes #42.
The exposure
The OpenAI API key — a billable bearer credential — was a plaintext string on an
ktsu.AppDataStorage-backed settings object, written to%APPDATA%/ktsu/OAICLI/app_data.json. Readable by anything running as the same user, and picked up by any backup or file-sync tool covering that directory.AppDataStorage's general-purpose JSON persistence was never meant to hold secrets.It now goes to the platform-native secret store through
ktsu.CredentialCache: Windows Credential Manager, macOS Keychain, libsecret (Secret Service) on Linux.Design
Persona.
CredentialCachekeys every secret byPersonaGUIDbecause it is built for the multi-persona case. OAICLI only ever holds one secret, soAuth.OpenAiPersonaIdis a named constant, not an inline literal and not something generated per install — regenerating it would orphan every key already in the store.PersonaIsTheFixedWellKnownIdentifierpins the value so a later tidy-up cannot quietly change it.Service name. The store is scoped to
ktsu.OAICLIrather than the library default, so OAICLI's entries cannot collide with another ktsu tool's on a shared host. That is whyAuthbuilds its ownCredentialCacheinstead of takingCredentialCache.Instance— the process-wide singleton can only ever use the library default service name. It is built lazily, so a machine with no secret store fails when a key is actually needed rather than at class load.Migration. A key left behind by an earlier version is moved on the next run. Order is load-bearing: the key is written to the secret store first, so a store that throws cannot lose it, and only then blanked in the old file. Clearing is not a tidy-up — a key that merely stops being written is still a key sitting in an unencrypted file. When both copies exist the secret store wins, so a stale key in the old file cannot overwrite a current one, and the stale copy is still cleared.
No secret store. The issue's triage asked for a decision here, and it matters more for a CLI than for the other two repos in this cluster: a command-line tool genuinely does get run over SSH and in containers, where libsecret is often absent.
PlatformNotSupportedException,DllNotFoundExceptionandEntryPointNotFoundExceptionare all turned into aCredentialStoreExceptionwhose message names the cause and the remedy. There is deliberately no plaintext fallback.Request.Sendbuilt its ownHttpClientand read the plaintext field directly, duplicatingAuth.GetClient; it now goes throughAuth.GetClientlike everything else.GetClientthrows rather than sending a request with an empty bearer token.The
AppData.ApiKeyfield is retained, documented as migration-only — it is how the old key is found and cleared.Tests
OAICLI.Test/AuthTests.cs, 15 new tests on top of the existing 34. The migration path is exercised against anInMemoryCredentialStoreand a fake legacy store, so nothing touches the real user profile; the no-secret-store path uses a store whose every operation throwsDllNotFoundException, which is what a missinglibsecret-1.so.0looks like.Proven to fail without the fix, by two mutations on an otherwise unchanged tree:
legacy.Clear()removed — key migrated but the plaintext copy left behindMigrateClearsThePlaintextCopy,MigrateKeepsTheStoredKeyAndStillClearsTheStaleOne,EnsureHasApiKeyIsSatisfiedByTheMigratedKeyGetClientreadingAppData.Get().ApiKeyagainGetClientCarriesTheStoredKeyAsABearerToken,GetClientThrowsWhenNoKeyIsStoredWith the implementation in place all 49 pass, and the solution builds clean with no warnings.
Docs
README's Auth section now describes the secret store, the one-time migration, and the fail-loud behaviour where no store exists.
DESCRIPTION.mdno longer says the key is stored viaktsu.AppDataStorage.Note for the rest of the cluster
The same plaintext-secret pattern is filed as ktsu-dev/BuildMonitor#278 and ktsu-dev/ProjectDirector#411. This is the single-credential case, so the two decisions those will also need are settled here: a named persona constant rather than a generated one, and failing loudly rather than falling back. Both of those repos hold several credentials, so they will need a persona per credential rather than one constant.
🤖 Generated with Claude Code
https://claude.ai/code/session_018AnVpPWKjVtXnAvd4bnzUL
Generated by Claude Code