One CLI, three keychains
Every desktop OS ships a credential store, and every one of them speaks a different language. macOS has a command-line tool. Linux has a D-Bus API with a small CLI on top. Windows has a Win32 API and a CLI that can write passwords but not read them back. A tool that promises to keep secrets "in the OS keychain" is three adapters behind one interface.
This post walks through those three adapters in envsec: how a key maps to an item on each OS, which commands run, how values get there without being mangled, and how three different sets of failure modes collapse into two error types. The code excerpts are from packages/core/src, trimmed.
The contract: three operations
The whole OS-specific surface is an Effect service with three methods. Values go in and out as strings; the two error types are the only things a caller has to handle.
export class KeychainAccess extends Context.Service<
KeychainAccess,
{
readonly set: (
service: string,
account: string,
password: string
) => Effect.Effect<void, KeychainError>;
readonly get: (
service: string,
account: string
) => Effect.Effect<string, SecretNotFoundError | KeychainError>;
readonly remove: (
service: string,
account: string
) => Effect.Effect<void, KeychainError>;
}
>()("envsec/KeychainAccess") {}There is no list and no search. Exact lookup by name is the one thing all three stores do well and in the same way, so that is all the interface asks for. Listing, expiry and search live in a SQLite database instead; I explain that split in Why secret values live in the keychain and metadata in SQLite.
The implementation is picked once, when the module loads:
export const PlatformKeychainAccessLive = (() => {
switch (platform()) {
case "darwin":
return MacOsKeychainAccessLive;
case "linux":
return LinuxSecretServiceAccessLive;
case "win32":
return WindowsCredentialManagerAccessLive;
default:
return Layer.effect(
KeychainAccess,
Effect.fail(new UnsupportedPlatformError({ /* … */ }))
);
}
})();On top of it sits SecretStore, which combines the keychain with the metadata store. The CLI, the TUI and the SDK all go through SecretStore, so none of them knows which OS it runs on.
From a key to a keychain item
An envsec secret is addressed by a context (myapp.dev) and a dotted key (api.token). Every store wants a pair of names, so the last key segment becomes the account and everything else becomes the service, prefixed with envsec.:
const segmentPattern = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/u;
// "api.token" in context "myapp.dev"
const account = parts.at(-1); // "token"
const serviceParts = parts.slice(0, -1); // ["api"]
const service =
serviceParts.length > 0
? `envsec.${env}.${serviceParts.join(".")}` // "envsec.myapp.dev.api"
: `envsec.${env}`;Each key segment must match that pattern, and context names are limited to letters, digits, dots, hyphens and underscores. That validation is the first line of defence for the Windows adapter below: no name that reaches a PowerShell script can contain a quote.
| macOS | Linux | Windows | |
|---|---|---|---|
| Tool | security | secret-tool | powershell.exe + advapi32 |
| Item identity | service + account | attributes service, account | target name |
| myapp.dev / api.token | envsec.myapp.dev.api / token | envsec.myapp.dev.api / token | envsec:envsec.myapp.dev.api/token |
| Value handed over via | argv (-w) | stdin | -Command script |
| Not found means | exit code 44 | empty stdout | Win32 error 1168, exit 2 |
The mapping has a sharp edge I should be upfront about. Both contexts and keys can contain dots, so context myapp with key dev.api.token and context myapp.dev with key api.token land on the same item. It takes an unusual naming scheme to hit this, and up to 1.1.2 the second write silently replaced the first. Since 1.1.3 envsec looks for such an alias in the metadata before writing and refuses the second key with an error, and deleting one of two secrets that already collide keeps the shared item. The naming itself is unchanged, so existing secrets keep working.
macOS: the security CLI
macOS ships /usr/bin/security, which covers what envsec needs with three subcommands: add-generic-password, find-generic-password and delete-generic-password, each with -s for the service and -a for the account. -U updates an existing item instead of failing, and -w passes the password on write and prints only the password on read.
run([
"add-generic-password",
"-U",
"-s", service,
"-a", account,
"-w", password,
]);Every adapter starts its helper with execFile, never through a shell, so arguments are never re-parsed. The adapter passes the AbortSignal from Effect.callback to execFile: if you press Ctrl-C, the security process is killed instead of left behind. Debug logs record the subcommand and the exit code, never the arguments.
There is a cost to -w: the value is on the command line of the security process for as long as it runs, so it shows up in a process listing during that time. It is base64, not plaintext, but base64 is an encoding, not protection. I cover what that means in What envsec does not protect you from.
Errors on macOS are unambiguous. A missing item makes security exit with code 44, which the adapter turns into SecretNotFoundError. Any other non-zero exit becomes a KeychainError carrying stderr.
Linux: secret-tool and the Secret Service
On Linux, envsec talks to the freedesktop.org Secret Service API through secret-tool from libsecret (the libsecret-tools package on Debian and Ubuntu). Items are identified by attributes rather than fixed fields, and envsec uses two: service and account. The label is what you see in a keyring GUI.
run(
[
"store",
"--label",
`envsec:${service}/${account}`,
"service", service,
"account", account,
],
password // written to secret-tool's stdin
);Of the three adapters this is the only one where the value never appears on a command line: secret-tool store reads the password from stdin. Reading uses secret-tool lookup service … account … and removing uses secret-tool clear with the same attributes.
The "not found" trap
secret-tool lookup does not have a dedicated exit code for a missing item. It prints nothing, and exits 0 or 1 depending on the libsecret version. Until October 2026 the adapter treated any failure as "not found", which meant a locked keyring or a missing D-Bus session looked exactly like a secret that did not exist, and the SDK quietly returned null. The current rule:
// A missing item gives empty output and no diagnostics (exit 0 or 1,
// depending on the libsecret version). Anything on stderr with a
// non-zero exit (locked keyring, D-Bus unavailable, …) is a real error
// and must not be reported as "not found".
if (result.exitCode !== 0 && result.stderr.trim() !== "") {
return yield* new KeychainError({ command: "lookup", /* … */ });
}
if (result.stdout === "") {
return yield* new SecretNotFoundError({ /* … */ });
}Headless machines
secret-tool is a client. It needs a D-Bus session bus and a running Secret Service provider (GNOME Keyring, KWallet or another implementation) with an unlocked collection. A typical GNOME or KDE desktop session gives you all of that. An SSH session into a server or a CI runner usually gives you none of it, and envsec then fails with a KeychainError that carries secret-tool's stderr. On the Ubuntu CI runners, the end-to-end job starts a bus and an unlocked keyring before the tests:
eval "$(dbus-launch --sh-syntax)"
echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" >> "$GITHUB_ENV"
echo "test" | gnome-keyring-daemon --unlock --components=secretsThe password piped into gnome-keyring-daemon is a throwaway for that runner. The details of that setup are in Testing a keychain CLI on three OSes in CI.
Windows: from cmdkey to CredWrite
Windows Credential Manager is where the adapter changed most. If you have read "cmdkey + PowerShell" in envsec's docs, that describes an older version: the current adapter does not run cmdkey at all. It got there in three steps:
- March 2026: writes and deletes went through
cmdkey /generic:… /user:… /pass:…inside PowerShell.cmdkeycannot print a stored password, so reads already used a P/Invoke call toCredRead. - Five days later: to handle spaces and special characters, the call was wrapped in
cmd /cwith double quotes and^-escaping forcmd.exemetacharacters. That is three layers of quoting (JavaScript, PowerShell,cmd.exe). One of the escaping rules, doubling%, was removed again 35 minutes later. - April 2026: all three operations moved to P/Invoke calls into
advapi32.dll(CredWriteW,CredReadW,CredDeleteW), compiled withAdd-Typein a PowerShell script. One quoting layer is left.
The read side looks like this. Credentials are generic (type 1), the target name is envsec:<service>/<account>, and the blob is UTF-16, hence the division by two:
public static string Read(string target) {
IntPtr ptr;
if (!CredRead(target, 1, 0, out ptr)) {
var err = Marshal.GetLastWin32Error();
if (err == 1168) return null; // ERROR_NOT_FOUND
throw new System.ComponentModel.Win32Exception(err);
}
var c = (CREDENTIAL)Marshal.PtrToStructure(ptr, typeof(CREDENTIAL));
var pw = Marshal.PtrToStringUni(c.CredentialBlob, c.CredentialBlobSize / 2);
CredFree(ptr);
return pw;
}The PowerShell wrapper exits with code 2 when Read returns null, and the adapter maps exit 2 to SecretNotFoundError. Until October, any CredRead failure exited 1 and counted as "not found", the same trap as on Linux. Now only ERROR_NOT_FOUND (1168) does; every other Win32 error throws and becomes a KeychainError.
The one quoting layer left
Dynamic values are spliced into the script as PowerShell single-quoted strings. Inside those, ASCII ' is doubled and NUL bytes are stripped:
const escapePS = (s: string): string =>
s.replaceAll("\0", "").replaceAll("'", "''");
// …later, in the generated script:
`$ok = [CredWriter]::Write('${target}', '${user}', '${password}')`I do not rely on that function alone. The target and user name come from validated keys and contexts, and the password is base64 (next section), so every string that reaches the script is plain ASCII with no quote characters in it. The escaping is a second layer, not the only one.
Two limits worth knowing
- Output buffer. One commit (
2cd38e9b3) setmaxBufferto 1 MB on theexecFilecall. That option caps how much stdout and stderr the child may produce, not the size of the script, and 1 MB is also Node's documented default, so on Node it makes the limit explicit rather than raising it. - Value size. The CREDENTIAL structure caps the blob at
CRED_MAX_CREDENTIAL_BLOB_SIZE(5 × 512 bytes). envsec stores the base64 string as UTF-16, so by my arithmetic the largest value it can store on Windows is about 950 bytes of UTF-8. I have not tested that boundary on a real machine.
Every operation also starts a PowerShell process and compiles a small C# class. I have not measured it, but it is the heaviest of the three paths.
Why every value is base64
Until late March 2026, envsec stored values as given. That breaks on macOS as soon as a value contains non-ASCII characters: security find-generic-password -w prints it as hex. Here it is on my machine, with a throwaway item:
$$ security add-generic-password -U -s envsec.blogdemo.raw -a demo -w "café ⭐"$$ security find-generic-password -s envsec.blogdemo.raw -a demo -w$636166c3a920e2ad90Rather than detect and decode hex on one OS and fight quoting on another, SecretStore now encodes every value before it reaches any adapter:
const B64_PREFIX = "envsec:b64:";
const encodeValue = (value: string): string =>
`${B64_PREFIX}${Buffer.from(value, "utf-8").toString("base64")}`;
const decodeValue = (raw: string): string => {
if (raw.startsWith(B64_PREFIX)) {
return Buffer.from(raw.slice(B64_PREFIX.length), "base64").toString(
"utf-8"
);
}
// Legacy: return plaintext values as-is for backward compatibility
return raw;
};The same secret written through envsec:
$$ envsec -c blogdemo.dev add api.token -v "café ⭐ token"$✔ Secret "api.token" stored in context "blogdemo.dev"$$ security find-generic-password -s envsec.blogdemo.dev.api -a token -w$envsec:b64:Y2Fmw6kg4q2QIHRva2VuThat one change does several jobs:
- Every adapter only ever sees ASCII, so Unicode values and emoji survive the round trip on all three OSes. The end-to-end suite stores and reads back
café résumé naïveand an emoji string on each of them. - The adapters strip whitespace from the output they read back. With base64 that is harmless; with raw values it would eat spaces at the ends of a value.
- On Windows, it is the reason the password can never contain a quote character.
The prefix makes it backward compatible: an item written before the change has no envsec:b64: prefix and is returned unchanged.
Normalising errors
Each adapter reduces its OS to the same three outcomes:
- Success, with the stored string.
SecretNotFoundErroronly for an unambiguous "no such item": exit 44 on macOS, empty output on Linux, Win32 error 1168 on Windows.KeychainErrorfor everything else, with the subcommand (or Win32 function) and stderr attached. If the helper itself is missing, the message says what to install:libsecret-toolson Linux, PowerShell on Windows.
The distinction matters more than it looks. "Not found" is something callers act on: SecretStore.get turns it into a message telling you to clean up stale metadata, and the SDK returns null. A locked keyring must never take that path. Both bugs I described above, on Linux and on Windows, were the same bug: a real failure reported as a missing secret.
What envsec doctor checks on each OS
envsec doctor runs the same database checks everywhere (directory, permissions, schema, expired secrets, and metadata rows whose keychain item can no longer be read). The credential store checks differ:
| Credential store | Keychain read/write | |
|---|---|---|
| macOS | security list-keychains exits 0 | adds, reads back and deletes envsec.doctor.test / probe |
| Linux | secret-tool --version can be started | only checks that secret-tool can be started |
| Windows | PowerShell finds cmdkey | skipped |
Only macOS gets a real round trip. On Linux, doctor confirms that secret-tool is installed, not that a keyring daemon is running and unlocked, which is the failure you are most likely to hit. On Windows it still looks for cmdkey, a leftover from the old adapter. The end-to-end suite also skips doctor on Linux CI, with a note that it hangs there.
In short
- One Effect service with
set,getandremove; one implementation per OS, picked at load time. - A key maps to
envsec.<context>.<prefix>plus the last segment as account. Validation keeps quotes and shell metacharacters out of every name. - macOS uses
security, Linux usessecret-toolover D-Bus, Windows uses P/Invoke intoadvapi32from PowerShell. Only Linux keeps the value off the command line. - Values are base64 with an
envsec:b64:prefix, which fixed Unicode on macOS and removed a whole class of quoting problems on Windows. - "Not found" is reported only when the OS says so unambiguously; everything else is a
KeychainError.