Skip to content

feat: keep the OpenAI API key in the OS secret store [minor] - #44

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/oaicli-credentialcache
Sep 22, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/oaicli-credentialcache

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

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. CredentialCache keys every secret by PersonaGUID because it is built for the multi-persona case. OAICLI only ever holds one secret, so Auth.OpenAiPersonaId is a named constant, not an inline literal and not something generated per install — regenerating it would orphan every key already in the store. PersonaIsTheFixedWellKnownIdentifier pins the value so a later tidy-up cannot quietly change it.

Service name. The store is scoped to ktsu.OAICLI rather than the library default, so OAICLI's entries cannot collide with another ktsu tool's on a shared host. That is why Auth builds its own CredentialCache instead of taking CredentialCache.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, DllNotFoundException and EntryPointNotFoundException are all turned into a CredentialStoreException whose message names the cause and the remedy. There is deliberately no plaintext fallback.

Request.Send built its own HttpClient and read the plaintext field directly, duplicating Auth.GetClient; it now goes through Auth.GetClient like everything else. GetClient throws rather than sending a request with an empty bearer token.

The AppData.ApiKey field 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 an InMemoryCredentialStore and a fake legacy store, so nothing touches the real user profile; the no-secret-store path uses a store whose every operation throws DllNotFoundException, which is what a missing libsecret-1.so.0 looks like.

Proven to fail without the fix, by two mutations on an otherwise unchanged tree:

Mutation Result
legacy.Clear() removed — key migrated but the plaintext copy left behind 3 failures: MigrateClearsThePlaintextCopy, MigrateKeepsTheStoredKeyAndStillClearsTheStaleOne, EnsureHasApiKeyIsSatisfiedByTheMigratedKey
GetClient reading AppData.Get().ApiKey again 2 failures: GetClientCarriesTheStoredKeyAsABearerToken, GetClientThrowsWhenNoKeyIsStored

With 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.md no longer says the key is stored via ktsu.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

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

Copy link
Copy Markdown
Contributor Author

CI status

Two checks went red. One is mine and is fixed; the other is not this PR's and cannot be fixed from here.

SonarCloud Code Analysis — mine, fixed in 202e044. The quality gate failed on new-code coverage: 64.8% against a required 80%. The uncovered lines were real gaps, not noise, so this is covered rather than waived:

  • 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, so driving AnsiConsole through a TestConsole covers it and states the behaviour;
  • AppDataLegacyApiKeyStore was untested, which left the one part 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 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.

github-advanced-security — not this PR's. The "Code scanning AI findings" agent failed before analysing anything, on a billing quota:

_t [SessionModelError]: You have exceeded your monthly quota
  errorType: 'quota',
  statusCode: 402,

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: .NET Workflow, Code Quality (CodeQL) and the code-scanning CodeQL run all passed.

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 403 This workflow run cannot be retried.

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

@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit b1ee94b into main Sep 22, 2026
11 of 12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/oaicli-credentialcache branch September 22, 2026 00:32
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.

Delegate API key storage to ktsu.CredentialCache

2 participants