Skip to content

Rewrite persistence to use Windows / macOS / Linux native keyrings#59

Open
matt-edmondson wants to merge 3 commits into
mainfrom
claude/audit-cross-platform-library-neaR5
Open

Rewrite persistence to use Windows / macOS / Linux native keyrings#59
matt-edmondson wants to merge 3 commits into
mainfrom
claude/audit-cross-platform-library-neaR5

Conversation

@matt-edmondson
Copy link
Copy Markdown
Contributor

Audit findings

This branch is the result of an audit-and-finish pass on the library. The headline issues found on main:

  1. README lied about security. It claimed the cache "encrypts sensitive credentials using platform-specific security features" — the actual implementation serialised credentials as plaintext JSON to %APPDATA%/ktsu/CredentialCache via ktsu.PersistenceProvider. There was no encryption anywhere.
  2. Couldn't restore. CredentialCache.csproj inherited TargetFrameworks=net5..10;netstandard2.0;netstandard2.1 from ktsu.Sdk but every upstream ktsu.*Provider dep only ships net9.0/net10.0. dotnet restore failed on every non-net9/10 TFM.
  3. Upstream namespaces had drifted. ktsu.UniversalSerializer.Json.JsonSerializer had moved to ktsu.UniversalSerializer.Services.Json.JsonSerializer; FileSystemProvider's ctor now requires options. The library no longer even compiled against the pinned packages.
  4. README documented an API that doesn't exist. UsernamePasswordCredential, ApiKeyCredential, OAuthCredential, CertificateCredential, CredentialCacheOptions, Store/Get/TryGet/Contains/Remove/Clear, expiration — none of it was real. The real surface is a CredentialCache.Instance singleton over PersonaGUID keys.
  5. Tests polluted user state. With no isolation hook, tests ran against the singleton's default disk provider and accumulated credentials in the real user AppData on every run.
  6. CI was Windows-only. Despite shipping as a cross-platform library, runs-on: windows-latest was the only matrix entry.

What's in this PR

New persistence layer (Storage/)

Platform Implementation API
Windows WindowsCredentialStore advapi32.dllCredReadW / CredWriteW / CredDeleteW / CredEnumerateW
macOS MacOsCredentialStore Security.frameworkSecKeychainAddGenericPassword / Find / ModifyAttributesAndData / Delete
Linux LinuxSecretServiceCredentialStore libsecret-1.so.0secret_password_store_sync / lookup_sync / clear_sync
opt-out InMemoryCredentialStore for tests and headless CI agents

Each PersonaGUID becomes its own entry in the OS keyring (service-scoped). CredentialStoreFactory.CreateDefault() picks the right one for the current RuntimeInformation.IsOSPlatform(...) and throws PlatformNotSupportedException otherwise. CredentialStoreException wraps native failures with the underlying Win32Exception/status code.

CredentialCache refactor

  • New public constructor CredentialCache(ICredentialStore) for DI and tests.
  • Kept the Instance singleton (lazy + thread-safe) for back-compat, but it now resolves its store via CredentialStoreFactory.CreateDefault() instead of hard-wiring the broken AppDataPersistenceProvider.
  • Added Remove(PersonaGUID), Dispose() that doesn't double-flush, and ResetSingletonForTesting() so tests stop polluting user AppData.
  • Renamed ConfigurePersistenceProviderConfigureStore to match the new abstraction.
  • TryGet now falls through to the backing store on a miss, populating the in-memory cache.

Serialization

  • Dropped ktsu.AppDataStorage / ktsu.FileSystemProvider / ktsu.PersistenceProvider / ktsu.UniversalSerializer (broken upstream surface).
  • New public CredentialSerialization helper using System.Text.Json + [JsonPolymorphic] directly.
  • Wired in ktsu.RoundTripStringJsonConverterFactory so SemanticString<T> values (CredentialToken, CredentialUsername, CredentialPassword, PersonaGUID) round-trip as JSON strings — without it, STJ treats them as IEnumerable<char> and explodes on deserialize.

Project & CI

  • Constrained TargetFrameworks to net9.0;net10.0 (the only TFMs the remaining deps actually publish for).
  • New .github/workflows/cross-platform.yml: build + test matrix on ubuntu-latest / windows-latest / macos-latest with libsecret-1-0 installed on Linux. The existing publish-oriented dotnet.yml is untouched.
  • Test project sets UseAppHost=false so distro-specific Microsoft.NETCore.App.Host.{rid} packages aren't required at restore time.

Tests

  • Every test now constructs its own CredentialCache over a fresh InMemoryCredentialStore — no shared singleton, no disk writes.
  • Migrated Assert.ThrowsException<>Assert.Throws<> for MSTest 4.x.
  • Added: backing-store round-trip, Remove across both layers, Dispose semantics, null-store guard, singleton store-injection behaviour, and a polymorphic JSON round-trip for every Credential subclass.
  • All 17 tests pass locally on Linux.

Platform caveats (also documented in README)

  • Windows Credential Manager caps the credential blob at 2560 bytes; oversized credentials throw CredentialStoreException.
  • Linux needs libsecret-1 installed and a running Secret Service (gnome-keyring / KWallet bridge / KeePassXC / …). Headless CI agents typically have neither — use InMemoryCredentialStore.
  • macOS uses the default login keychain and will prompt the user the first time the app accesses it.
  • EnumerateKeys() is fully implemented on Windows. macOS/Linux currently return an empty sequence (the modern SecItemCopyMatching / secret_search_sync paths need substantial CoreFoundation / GLib marshalling for a use case most consumers can serve by tracking persona GUIDs themselves).

Test plan

  • dotnet build CredentialCache.sln — clean on net9.0 + net10.0 (3 SourceLink warnings only).
  • dotnet test on Linux — 17/17 pass.
  • CI matrix run against Linux / macOS / Windows runners.
  • Manual smoke test that WindowsCredentialStore writes into the real Credential Manager (cannot exercise from CI sandbox).
  • Manual smoke test of MacOsCredentialStore against the login keychain.
  • Manual smoke test of LinuxSecretServiceCredentialStore against gnome-keyring or equivalent.

https://claude.ai/code/session_017B9mN9F7C3pWGZRyoKBd6R


Generated by Claude Code

claude added 3 commits May 11, 2026 09:45
The library previously claimed in its README to "encrypt sensitive credentials
using platform-specific security features" but actually serialized credentials
as plaintext JSON to AppData via ktsu.PersistenceProvider. The upstream
ktsu.UniversalSerializer / ktsu.FileSystemProvider / ktsu.PersistenceProvider
namespaces have also shifted, so the project no longer even restored against
the netstandard2.0/2.1 targets the SDK was producing.

Highlights:
- Introduce ICredentialStore with one implementation per supported OS, each
  storing one entry per PersonaGUID in the host's native keyring:
    Windows -> Credential Manager via advapi32 (CredRead/CredWrite/CredDelete)
    macOS   -> Keychain Services via Security.framework (SecKeychain*)
    Linux   -> libsecret (Secret Service) via libsecret-1.so.0
  Plus an InMemoryCredentialStore for tests and explicit opt-out.
- Drop the broken AppData / UniversalSerializer / FileSystemProvider deps and
  serialize Credential subclasses directly via System.Text.Json, wiring
  ktsu.RoundTripStringJsonConverter so SemanticString values round-trip
  correctly (previously broke as IEnumerable<char>).
- Make CredentialCache constructible with an explicit ICredentialStore in
  addition to the singleton, expose Remove / Dispose semantics, and add
  ResetSingletonForTesting so test runs no longer pollute user AppData.
- Constrain TargetFrameworks to net9.0;net10.0 (matches what the remaining
  ktsu deps actually publish) and add a cross-platform CI workflow that
  builds and tests on ubuntu/macos/windows.
- Rewrite README so it documents the real API (PersonaGUID-keyed
  AddOrReplace/TryGet/Remove) and the actual at-rest behaviour per platform.

https://claude.ai/code/session_017B9mN9F7C3pWGZRyoKBd6R
The cross-platform CI run on PR #59 fails on Linux and macOS with IDE0055
(formatting) because .editorconfig declares `end_of_line = crlf` for the
whole tree, but actions/checkout@v4 leaves files at their git-normalised
LF on non-Windows runners. The original CI was Windows-only, where the
hosted runner's git defaults to autocrlf=true, masking the discrepancy.

Mark .cs/.csproj/.props/.targets/.editorconfig as `text eol=crlf` so every
checkout (including Linux and macOS) renders these files with CRLF to match
the editorconfig declaration. The committed blob in git stays normalised;
this only affects the working tree.

https://claude.ai/code/session_017B9mN9F7C3pWGZRyoKBd6R
`global.json` selects `Microsoft.Testing.Platform` as the test runner, which
does not accept the legacy VSTest `--logger "console;verbosity=normal"` form.
The argument was being misinterpreted as a project path and the runner
reported `Zero tests ran` with exit code 1 on every platform.

Removing the unused logger flag is enough -- the platform's default console
output is sufficient and all 17 tests pass locally with the same command.

https://claude.ai/code/session_017B9mN9F7C3pWGZRyoKBd6R
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.

2 participants