All posts
8 min readDavid Nussio

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.

ts
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:

ts
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.:

ts
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.

macOSLinuxWindows
Toolsecuritysecret-toolpowershell.exe + advapi32
Item identityservice + accountattributes service, accounttarget name
myapp.dev / api.tokenenvsec.myapp.dev.api / tokenenvsec.myapp.dev.api / tokenenvsec:envsec.myapp.dev.api/token
Value handed over viaargv (-w)stdin-Command script
Not found meansexit code 44empty stdoutWin32 error 1168, exit 2
How one secret is addressed on each OS.

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.

ts
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.

ts
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:

ts
// 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:

bash
eval "$(dbus-launch --sh-syntax)"
echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" >> "$GITHUB_ENV"
echo "test" | gnome-keyring-daemon --unlock --components=secrets

The 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:

  1. March 2026: writes and deletes went through cmdkey /generic:… /user:… /pass:… inside PowerShell. cmdkey cannot print a stored password, so reads already used a P/Invoke call to CredRead.
  2. Five days later: to handle spaces and special characters, the call was wrapped in cmd /c with double quotes and ^-escaping for cmd.exe metacharacters. That is three layers of quoting (JavaScript, PowerShell, cmd.exe). One of the escaping rules, doubling %, was removed again 35 minutes later.
  3. April 2026: all three operations moved to P/Invoke calls into advapi32.dll (CredWriteW, CredReadW, CredDeleteW), compiled with Add-Type in 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:

csharp
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:

ts
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

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:

Terminal
$$ security add-generic-password -U -s envsec.blogdemo.raw -a demo -w "café ⭐"
$$ security find-generic-password -s envsec.blogdemo.raw -a demo -w
$636166c3a920e2ad90

Rather than detect and decode hex on one OS and fight quoting on another, SecretStore now encodes every value before it reaches any adapter:

ts
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:

Terminal
$$ 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:Y2Fmw6kg4q2QIHRva2Vu

That one change does several jobs:

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:

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 storeKeychain read/write
macOSsecurity list-keychains exits 0adds, reads back and deletes envsec.doctor.test / probe
Linuxsecret-tool --version can be startedonly checks that secret-tool can be started
WindowsPowerShell finds cmdkeyskipped

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