Skip to content

feat(auth): the keychain setting starts doing something, on every platform including Windows - #1532

Merged
diillson merged 1 commit into
mainfrom
feat/keychain-windows
Sep 5, 2026
Merged

feat(auth): the keychain setting starts doing something, on every platform including Windows#1532
diillson merged 1 commit into
mainfrom
feat/keychain-windows

Conversation

@diillson

@diillson diillson commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Fourth PR from the features/security audit. The finding here turned out to be larger than the one I set out to fix.

What the audit found

The page documents CHATCLI_KEYCHAIN_BACKEND with a per-OS table, and says Windows uses the Credential Manager. useNative() had no Windows branch, so it returned false there and silently used the file.

Chasing that turned up the bigger problem: NewKeychainStore is never called anywhere in the codebase. grep -rn "NewKeychainStore" --include='*.go' . returns only its own definition. The variable was read, a store was built, and nothing ever asked it for anything — the key was a file on every platform, regardless of the setting, while /config displayed a preference the runtime never acted on.

What this delivers

The setting is wired. loadOrCreateKey — the key that encrypts ~/.chatcli/auth-profiles.json — now resolves through the backend:

  • file: the file, always. The keychain is never consulted.
  • keychain: the keychain. A key already on disk is migrated once, and the file is removed only after the keychain has handed that key back. A write that appears to succeed and a read that returns nothing would otherwise leave credentials no future process can decrypt.
  • auto (default): an existing file key keeps being used, untouched. Only a first-time key goes to the keychain, and only where one is available. Moving a working installation's key without being asked is not a default's business.

Every failure keeps the file. Unreachable keychain, refused write, lost write, or a stored value that is not a 32-byte key — the on-disk key stays where it is, with one warning per process rather than one per credential read.

Windows Credential Manager, through the advapi32 credential API (CredReadW/CredWriteW/CredDeleteW). There is no CLI route: cmdkey can create and list credentials but never reveals a secret. Entries are CRED_PERSIST_LOCAL_MACHINE rather than roaming, and a machine where advapi32 cannot be loaded falls back to the file — the same shape as a Linux box with no secret-tool. No new dependency: golang.org/x/sys is already direct.

The store takes its platform behind an interface. Which backend a key ends up in is the decision worth testing, and it is untestable on any single machine while the platform is reached directly.

Verification

9 tests over the backend-selection logic: the default not migrating, a new key going to the keychain with no copy left on disk, the file backend never reading the keychain, the verified migration, and the three failure modes each keeping the file.

The macOS path was exercised against the real keychain on a development machine (round-trip under a throwaway account, deleted after).

⚠️ The Windows path has not been run on Windows

It compiles and vets under GOOS=windows, and there is no Windows job in CI. Given the terminal-rendering post-mortem, I am not willing to call an unrun Windows path verified. It is designed so the worst case is today's behaviour — any failure falls back to the file — but please run it on a Windows box before we let the docs claim it. I will hold that line in the docs PR until you confirm.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Quality Gate

Result: ✅ all floors passed

Floor Status Result Δ vs main Budget
1 · Build & Static go build / vet / fmt / lint
2 · Coverage 59.2% (bootstrap) 0 ≥ baseline
3 · Patch coverage 80.2% (req ≥ 60%) ≥ 60%
4 · AI smells diff scanned
5 · Scope budget ⚠️ 14 files / 955 LOC (code 955 + tooling 0) warn 800·25
6 · E2E go test -race ./e2e/... ≤ 15min
7 · Commit lint conventional commits
8 · Cyclo (new code) 9 file(s) under threshold ≤ 30
9 · Secrets scan gitleaks
10 · i18n parity missing 0, unknown 0
11 · CRD drift drifted: 0
12 · License headers 0 missing
13 · API breaking 0 incompatible
14 · Binary size chatcli 96.7MB · operator 54.3MB 100MB each
15 · Provider parity 15 providers · 0 violations

Config: .github/quality-gate.yml. Workflow: .github/workflows/quality-gate.yml.

@diillson
diillson force-pushed the feat/keychain-windows branch 3 times, most recently from 627aa70 to 615a640 Compare September 5, 2026 20:06
…tform including Windows

CHATCLI_KEYCHAIN_BACKEND was read into a store that nothing ever called.
The credential-encryption key was a file no matter what the setting said,
on every platform, and the config screen reported a preference the runtime
never acted on. Windows had a second problem underneath that one: even the
unused store had no Windows branch, so its native case fell through to
false and would have gone to the file anyway.

The key now lives where the setting says. The file backend stays the file,
always. The keychain backend uses the keychain, migrating a key that is
already on disk exactly once and removing the file only after the keychain
has handed that key back — a write that appears to succeed and a read that
returns nothing would otherwise leave credentials that no future process
can decrypt. The default keeps using an existing file key without moving
it: relocating a working installation's key without being asked is not a
default's business. Only a key being created for the first time goes to the
keychain, and only where one is available.

Every failure keeps the file. A keychain that cannot be reached, refuses
the write, loses it, or returns something that is not a 32-byte key leaves
the on-disk key exactly where it was, with one warning per process rather
than one per credential read.

Windows reaches the Credential Manager through the advapi32 credential API,
because there is no CLI route: cmdkey can create and list credentials and
never reveals a secret. Entries are stored per machine rather than
roaming — a key that follows a profile onto other machines is a wider
secret than the one the user asked to store — and a machine where advapi32
cannot be loaded falls back to the file, the same shape as a Linux box with
no secret-tool.

The store now takes its platform behind an interface. Which backend a key
ends up in is the decision worth testing, and it is untestable on any
single machine while the platform is reached directly. The macOS path was
also exercised against the real keychain on a development machine; the
Windows path compiles and vets for its target and has not been run on one.
@diillson
diillson force-pushed the feat/keychain-windows branch from 615a640 to 0b78ab3 Compare September 5, 2026 20:09
@diillson
diillson merged commit 34a80df into main Sep 5, 2026
25 of 26 checks passed
@diillson
diillson deleted the feat/keychain-windows branch September 5, 2026 20:21
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