feat: store API keys in the system keyring#136
Open
bendrucker wants to merge 15 commits intoschpet:mainfrom
Open
feat: store API keys in the system keyring#136bendrucker wants to merge 15 commits intoschpet:mainfrom
bendrucker wants to merge 15 commits intoschpet:mainfrom
Conversation
schpet
reviewed
Feb 12, 2026
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" + |
Owner
There was a problem hiding this comment.
this assumes debian/ubuntu - i don't want to disappoint arch users etc
Contributor
Author
There was a problem hiding this comment.
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).
schpet
reviewed
Feb 12, 2026
src/keyring.ts
Outdated
|
|
||
| function escapePowerShell(s: string): string { | ||
| return s.replace(/'/g, "''") | ||
| } |
Owner
There was a problem hiding this comment.
is there a deno std thing that might help with this? this looks fragile
Contributor
Author
There was a problem hiding this comment.
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.
05e6f59 to
3a2ceda
Compare
3a2ceda to
c9ade3d
Compare
62c5e3f to
aa78f69
Compare
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.
af97461 to
d562098
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/)getPassword,setPassword,deletePasswordexports/usr/bin/security(exit 44 = not found)secret-toolvia stdin for writes (exit 1 = not found)Deno.dlopen("advapi32.dll")FFI callingCredReadW/CredWriteW/CredDeleteWdirectly_setBackend()test seam for injecting an in-memoryMapbackendWindows Credential Manager via FFI
The Windows backend calls
advapi32.dlldirectly via Deno's FFI (Deno.dlopen) rather than shelling out to PowerShell. This matches the standard approach taken by every comparable credential tool:danieljoos/wincred— Go library callingadvapi32.dllviawindows.NewLazySystemDLL, used by:docker-credential-helpersghCLI (viazalando/go-keyring)aws-vault(via99designs/keyring)node-keytar— C++ N-API addon,#include <wincred.h>jaraco/keyring— Python,win32cred(pywin32-ctypes wrapping advapi32 via ctypes)The implementation packs the 80-byte
CREDENTIALWstruct manually viaDataView, encodes strings as UTF-16LE for theW-suffix APIs, and usesGetLastErrorfromkernel32.dllto 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)Credentialsinterface changed from index signature to{ default?: string; workspaces: string[] }apiKeyCacheMappopulated at startup, keepinggetCredentialApiKey()syncaddCredential/removeCredentialwrite to keyring first, only mutate local state on successparseInlineCredentials/parseKeyringCredentials/populateKeyringCacheextracted fromloadCredentialsPromise.allBackward Compatibility
workspace = "lin_api_...") are detected byhasInlineKeysand served from the file directlyaddCredentialon an inline-format installation rewrites the file to keyring formatAuth List (
src/commands/auth/auth-list.ts)getAllCredentials()withgetApiKeyForWorkspace()CI
keyring-integrationjob onmacos-latestandwindows-latestfor real credential round-trip testingTesting
deno evalfor credential tests (required by top-levelawait loadCredentials())_setBackend— covers happy paths, error propagation, and cache consistencytest/keyring.integration.test.ts) exercises the real macOS Keychain and Windows Credential Manager lifecycleaddCredentialReferences
Closes #130