Skip to content

feat: store API keys in the system keyring#136

Open
bendrucker wants to merge 15 commits intoschpet:mainfrom
bendrucker:feat/keyring-credentials
Open

feat: store API keys in the system keyring#136
bendrucker wants to merge 15 commits intoschpet:mainfrom
bendrucker:feat/keyring-credentials

Conversation

@bendrucker
Copy link
Contributor

@bendrucker bendrucker commented Feb 12, 2026

Move API key storage from plaintext TOML to OS-native keyrings (macOS Keychain, Linux libsecret, Windows Credential Manager). The credentials file retains only workspace metadata. Keys are loaded into an in-memory cache at startup so all downstream reads remain synchronous — no changes needed to any command files.

Changes

Keyring (src/keyring/)

  • Platform-detecting wrapper with getPassword, setPassword, deletePassword exports
  • macOS: /usr/bin/security (exit 44 = not found)
  • Linux: secret-tool via stdin for writes (exit 1 = not found)
  • Windows: Deno.dlopen("advapi32.dll") FFI calling CredReadW/CredWriteW/CredDeleteW directly
  • _setBackend() test seam for injecting an in-memory Map backend

Windows Credential Manager via FFI

The Windows backend calls advapi32.dll directly via Deno's FFI (Deno.dlopen) rather than shelling out to PowerShell. This matches the standard approach taken by every comparable credential tool:

The implementation packs the 80-byte CREDENTIALW struct manually via DataView, encodes strings as UTF-16LE for the W-suffix APIs, and uses GetLastError from kernel32.dll to distinguish "not found" (ERROR_NOT_FOUND = 1168) from real failures. DLLs are lazy-loaded so the module import doesn't fail on macOS/Linux.

Credentials (src/credentials.ts)

  • Credentials interface changed from index signature to { default?: string; workspaces: string[] }
  • apiKeyCache Map populated at startup, keeping getCredentialApiKey() sync
  • addCredential/removeCredential write to keyring first, only mutate local state on success
  • parseInlineCredentials / parseKeyringCredentials / populateKeyringCache extracted from loadCredentials
  • Parallel keyring lookups via Promise.all
  • Malformed TOML parse errors caught with recovery guidance
  • Warnings for: missing keyring entries, dangling default workspace, inline format detected

Backward Compatibility

  • Inline-format TOML files (keys stored as workspace = "lin_api_...") are detected by hasInlineKeys and served from the file directly
  • addCredential on an inline-format installation rewrites the file to keyring format

Auth List (src/commands/auth/auth-list.ts)

  • Replaces removed getAllCredentials() with getApiKeyForWorkspace()
  • Distinguishes auth errors (401/403) from network/other failures instead of labeling everything "invalid credentials"

CI

  • Added keyring-integration job on macos-latest and windows-latest for real credential round-trip testing

Testing

  • Subprocess isolation via deno eval for credential tests (required by top-level await loadCredentials())
  • Mock keyring backend injected via _setBackend — covers happy paths, error propagation, and cache consistency
  • Integration test (test/keyring.integration.test.ts) exercises the real macOS Keychain and Windows Credential Manager lifecycle
  • Edge cases covered: keyring write/delete failures leave state unchanged, null keyring returns warn but don't crash, dangling default dropped on load, inline→keyring format transition on addCredential

References

Closes #130

src/keyring.ts Outdated
case "darwin":
return "Could not find /usr/bin/security. Is this a macOS system?"
case "linux":
return "Could not find secret-tool. Install it with: apt install libsecret-tools\n" +
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this assumes debian/ubuntu - i don't want to disappoint arch users etc

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to mention both apt and pacman as examples:

Could not find secret-tool. Install libsecret (e.g. apt install libsecret-tools, pacman -S libsecret).

src/keyring.ts Outdated

function escapePowerShell(s: string): string {
return s.replace(/'/g, "''")
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a deno std thing that might help with this? this looks fragile

Copy link
Contributor Author

@bendrucker bendrucker Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No Deno std helper for this. The escaping is only needed for Windows because we pass a PowerShell script string via -Command and values get interpreted by PowerShell's parser. On macOS/Linux this isn't needed since Deno.Command passes args directly to the subprocess without shell interpretation.

Single-quote doubling (''') is PowerShell's escaping rule for single-quoted strings.

https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.5#including-quote-characters-in-a-string

@bendrucker bendrucker force-pushed the feat/keyring-credentials branch 5 times, most recently from 05e6f59 to 3a2ceda Compare February 13, 2026 05:14
@bendrucker bendrucker force-pushed the feat/keyring-credentials branch from 3a2ceda to c9ade3d Compare February 13, 2026 05:21
@bendrucker bendrucker force-pushed the feat/keyring-credentials branch from 62c5e3f to aa78f69 Compare February 13, 2026 06:05
@bendrucker bendrucker marked this pull request as ready for review February 13, 2026 06:15
Replace the Windows keyring backend's PowerShell subprocess + runtime
C# compilation approach with direct FFI calls to advapi32.dll via
Deno.dlopen. This is the standard approach taken by every comparable
CLI tool (gh, docker-credential-helpers, keytar, python-keyring, etc.)
and eliminates the unusual pattern of shelling out to powershell.exe
and compiling C# at runtime via Add-Type.

The new implementation:

- Lazy-loads advapi32.dll (CredReadW, CredWriteW, CredDeleteW, CredFree)
  and kernel32.dll (GetLastError) on first use so the module import
  doesn't fail on macOS/Linux
- Encodes/decodes strings as null-terminated UTF-16LE for Win32
  W-suffix functions
- Packs the 80-byte CREDENTIALW struct (64-bit layout) via DataView
  with pointer fields at correct offsets using Deno.UnsafePointer
- Uses GetLastError to distinguish ERROR_NOT_FOUND (1168) from real
  failures, matching the existing behavior of returning null for
  missing credentials and tolerating not-found on delete
- Works around a TS 5.9 Uint8Array generic variance issue by
  constructing buffers with explicit ArrayBuffer backing

What's removed: escapePowerShell(), the CRED_MANAGER_CS C# source
string, credScript(), all powershell subprocess invocations, and the
run() import.

The KeyringBackend interface is unchanged so all consumers, tests,
and the CI Windows integration test are unaffected.
Deno's FFI boundary clobbers the Win32 thread-local error code
before GetLastError can be called through a separate dlopen call.
Go, Python, and .NET capture it atomically inside their syscall
trampolines; Deno's dlopen does not.

Treat GetLastError() == 0 as not-found for CredReadW and
CredDeleteW — the credential doesn't exist and the real
ERROR_NOT_FOUND (1168) was cleared by the FFI layer.
The Windows FFI backend opens advapi32.dll and kernel32.dll as
process-lifetime singletons via Deno.dlopen. Deno's test runner
flags these as resource leaks. Disable the resource sanitizer
since these handles are intentionally never closed.
@bendrucker bendrucker force-pushed the feat/keyring-credentials branch from af97461 to d562098 Compare February 13, 2026 17:17
Each backend now owns its subprocess logic instead of importing a
shared run() from index.ts. This removes the circular-feeling
dependency and makes spawn error messages specific to the actual
binary (security, secret-tool) rather than a generic platformHint.

Also: document CREDENTIALW struct layout and GetLastError FFI
limitation in windows.ts, extract mock backend in keyring tests,
fix stale PowerShell reference in docs.
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.

Store API keys in the system keyring instead of plaintext

2 participants