keyring

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 20, 2026 License: MIT Imports: 8 Imported by: 0

README

keyring

macOS keychain access for Go via the security CLI — no cgo, no third-party dependencies. Built for CLI tools and daemons that need an API key or token without config-file or env-var plumbing.

store, err := keyring.New("myapp")
if err != nil { ... }

// Keychain-first, env fallback (works headless, works on Linux).
key, err := store.GetOrEnv("anthropic", "MYAPP_ANTHROPIC_API_KEY")

Guarantees

  1. Secrets never touch argv. Writes pipe the value to security add-generic-password on stdin, so it can never appear in ps or a process-table snapshot.
  2. Writes are verified. A locked keychain can make security report success while storing nothing; Set reads the value back and compares.
  3. Absolute binary path. /usr/bin/security is invoked absolutely — a hijacked $PATH cannot substitute a malicious binary into the credential path.
  4. "Not found" is never conflated with "could not read". Only security's confirmed item-not-found (exit 44) maps to ErrNotFound. A locked keychain, a denied dialog, or a timeout is ErrUnreadable — callers using Has as an overwrite guard must block on it, because treating "couldn't read" as "absent" invites clobbering a value that is actually there.
  5. Bounded calls. Every security invocation carries a timeout (default 10s, WithTimeout to change), so a wedged unlock prompt becomes an error instead of a hang.

The not-found/unreadable split is a best-effort classification of the Darwin security CLI (exit status + stderr text). It is reliable on stock macOS, but it is a CLI heuristic, not an OS guarantee.

The CLI

go install github.com/dkoosis/keyring/cmd/keyring@latest — the same guarantees as the library (it IS the library underneath), no security(1) incantation anywhere. Every failure message ends with the next command to run.

First-time setup — store a key, prove it landed:

keyring set myapp anthropic     # hidden prompt; strips a pasted trailing newline
keyring get myapp anthropic     # masked receipt; --raw to reveal

"My app can't find its key":

keyring doctor myapp

Doctor names what's wrong — missing item, locked keychain, duplicate items, a stale env var shadowing the keychain, a value with a pasted newline — and prints the exact fix for each. With a keyring.json manifest in the repo it also diffs expected vs. stored accounts and tells you where to obtain each missing credential. --fix applies the safe repairs after a per-item confirm.

"I think my key is wrong":

keyring get myapp anthropic --raw   # compare with the source of truth
keyring set myapp anthropic --force # overwrite; read-back verified

Cleaning up legacy items (old myapp-anthropic-style names, duplicates, pasted newlines) — plan first, nothing applies before one confirmation:

keyring migrate myapp

Agent-driven setup — no TTY, everything from exit code + --json:

printf %s "$KEY" | keyring set myapp anthropic --stdin --json  # verified:true
keyring doctor myapp --json                                    # exit 8 → findings[]
keyring doctor myapp --fix --yes --json                        # heal, receipts

Exit codes are stable and map 1:1 to the library sentinels: 0 ok · 2 validation · 3 not found · 4 unreadable/locked · 5 verify failed · 6 already exists · 7 unsupported/disabled · 8 doctor/migrate found unresolved problems.

Destructive actions (rm, --fix, migrate) confirm on a terminal and fail closed without --yes when piped — an agent can never delete or rewrite an item by accident.

Set vs. SetIfAbsent

Set writes with -U (update-if-exists) — it always succeeds against an existing item, silently overwriting it. A concurrent writer landing between another caller's presence check (Has) and its own Set is clobbered with no error to either side; this package has no compare-and-swap, and Has is advisory only (see Guarantees #4). Callers that need cross-process integrity — no two writers racing to the same account — must either serialize writes per (service, account) themselves, or use SetIfAbsent.

SetIfAbsent is the write-once primitive: it skips -U, so add-generic-password fails with a confirmed duplicate-item error (errSecDuplicateItem, exit 45) against an existing item instead of overwriting it. That failure maps to ErrExists; on confirmed absence it stores and read-back verifies exactly like Set. Use it for bootstrap or token-refresh flows where two processes might race to initialize the same account and only one write should win — the loser gets ErrExists, never a clobber.

Non-darwin

On non-darwin builds Supported() returns false and every keychain operation returns ErrUnsupported. GetOrEnv falls through to the environment there, so one code path serves macOS and Linux. ErrUnreadable never falls through — a locked keychain surfaces as an error rather than silently downgrading to env.

Conventions

  • service = your app's name (myapp) — the keychain namespace.
  • account = the secret's purpose (anthropic, github).
  • env fallback = <APP>_<PROVIDER>_API_KEY (MYAPP_ANTHROPIC_API_KEY).

Store a secret from a terminal (value prompted hidden, off argv):

keyring set myapp anthropic

Single-item precondition

Set writes with add-generic-password -U -s <service> -a <account> and verifies with find-generic-password -s <service> -a <account>. -U updates the item matching that attribute set; find returns the first service+account match in keychain search order. Both target the same item only if exactly one exists.

This is a hard precondition, not a caveat. If a duplicate (service, account) item exists — planted by another tool with extra attributes, or living in a different keychain (system vs. login) — write and read-back can silently address different items:

  • Get can return the OTHER item's value: stale, or attacker-controlled if the higher-priority keychain isn't yours to trust.
  • Set's read-back can verify against the other item too, masking a write that landed on the wrong one — a correct write followed by a read of the other item produces a spurious ErrVerifyFailed, or (if the values happen to match) a masked no-op.

A caller MUST do one of the two:

  1. Pin a keychain with keyring.New(service, keyring.WithKeychain(path)) — every find/add call scopes to that one keychain file, closing the ambiguity outright. path must be absolute.
  2. Guarantee uniqueness — exactly one (service, account) item across the whole default keychain search path, for the life of the Store.

This library assumes it is the only writer for its service namespace. If ErrVerifyFailed fires on what looks like a correct write and you have not pinned a keychain, check for a duplicate item first (keyring doctor <service> flags duplicate groups across the search list) — the fix is keyring migrate <service> or --fix to dedupe, pinning a keychain, or picking a distinct service name, not retrying the write.

Test kill-switch

KEYRING_DISABLE=1 (any non-empty value) makes every operation return ErrUnsupported and Supported() report false — the Store behaves exactly like a build with no backend, and GetOrEnv falls through to the environment. It exists for test harnesses that exec a built consumer binary (blackbox/txtar suites), where WithSecurityBin cannot be injected: set it in the subprocess env and the developer's real keychain can never leak into an env-isolated test. Read at call time, so in-process tests can toggle it with t.Setenv(keyring.DisableEnv, "1").

Consuming this module (it is private)

github.com/dkoosis/keyring is a private repo. Builds that fetch it need:

export GOPRIVATE='github.com/dkoosis/*'
# plus a github.com git credential (gh auth login covers local dev)

CI (GitHub Actions) — the default GITHUB_TOKEN cannot read another private repo. Add a fine-grained PAT (read-only Contents on this repo) as a repo secret, e.g. DKOOSIS_MODULES_TOKEN, then before any Go step:

- run: git config --global url."https://x-access-token:${{ secrets.DKOOSIS_MODULES_TOKEN }}@github.com/".insteadOf "https://github.com/"
  env: {}
- run: echo 'GOPRIVATE=github.com/dkoosis/*' >> "$GITHUB_ENV"

Docker builds need the same via a build secret — never bake the token into a layer.

Testing

go test ./... runs stub-based contract tests (argv shape, stdin protocol, error classification, timeouts) on any OS. The live end-to-end test against the real keychain is opt-in — KEYRING_LIVE_E2E=1 go test -run TestLiveKeychain in an interactive terminal (security(1) prompts on /dev/tty, so it cannot run headless). CI runs contract tests only; a manual live check on a real Mac gates each release.

Documentation

Overview

Package keyring stores and retrieves secrets in the macOS keychain via the `security` CLI — no cgo, no third-party dependency. Secrets never appear on a process argv, writes are verified by read-back, and "not found" is kept strictly distinct from "could not read" so callers get an honest answer to "does this exist right now" — but that answer is a point-in-time read, not an atomic guard: `security add-generic-password -U` unconditionally overwrites on Set, so a concurrent writer between a caller's presence check and its Set is clobbered with no error to either side. Has is advisory, not a compare-and-swap; this package assumes one writer per account unless callers serialize writes per (service, account) themselves or use SetIfAbsent, which offers write-once semantics instead. See Has, SetIfAbsent.

PRECONDITION: exactly one (service, account) item across the keychain search list, or a pinned keychain. `find-generic-password` returns the FIRST match in keychain search order and `add-generic-password -U` updates "the" matching item — whichever one that search order picks. A duplicate item planted by another tool in a higher-priority keychain means Get can return that other item's value (stale or attacker-controlled) and Set's read-back can verify against it too, masking a write to the wrong item. Callers that cannot guarantee uniqueness across the search list MUST pin a keychain with WithKeychain. See WithKeychain and the README's "Single-item assumption" section.

Service names, account names, and values must be printable ASCII (0x20-0x7e): `security find-generic-password -w` hex-transcribes any stored value containing a byte >=0x80 on read, indistinguishable from a real value, so Set refuses non-ASCII input up front. Encode non-ASCII or multi-line material (e.g. base64) before storing.

On non-darwin builds every keychain operation returns ErrUnsupported; GetOrEnv falls through to the environment there, so cross-platform callers can use one code path.

Index

Constants

View Source
const DisableEnv = "KEYRING_DISABLE"

DisableEnv is the environment kill-switch: when set to any non-empty value, every keychain operation returns ErrUnsupported and Supported() reports false, exactly as on a platform with no backend — so GetOrEnv falls through to the environment. It exists for test harnesses that exec a BUILT consumer binary (blackbox/txtar suites), where WithSecurityBin cannot be injected: setting KEYRING_DISABLE=1 in the subprocess env guarantees the developer's real keychain can never leak into an env-isolated test. It is read at call time, not init, so in-process tests can toggle it with t.Setenv.

Variables

View Source
var (
	// ErrNotFound means the item is CONFIRMED absent from the keychain.
	ErrNotFound = errors.New("not found")
	// ErrUnreadable means the item could not be read for a reason other than
	// confirmed absence — locked keychain, denied access dialog, timeout. It
	// is NOT "absent": a caller using presence as an overwrite guard must
	// block on it, never treat it as "no value, safe to write".
	ErrUnreadable = errors.New("keychain item could not be read")
	// ErrVerifyFailed means the post-Set read-back failed or did not match
	// the stored value. The returned error chains the underlying cause where
	// one exists.
	//
	// INDETERMINATE, not "not written": write and read-back are two separate
	// `security` invocations with no lock or transaction spanning the gap, so
	// this error can fire while the value is durably stored — a concurrent
	// Set to the same account landing in the gap (read-back sees the other
	// writer's value; the keychain state is still consistent, last write
	// wins), or the keychain locking/denying access after the write lands but
	// before the read-back runs. A caller MUST NOT treat ErrVerifyFailed as
	// proof the value is absent, and must not route the secret to a fallback
	// store on this error — doing so risks two sources of truth for the same
	// account. Set deliberately does not auto-rollback (delete the item) on
	// this error either: a transient read failure is indistinguishable from
	// the cases above, and rolling back could destroy a good pre-existing
	// value that Set never touched. Retry Get (or Has) to resolve the
	// uncertainty instead.
	ErrVerifyFailed = errors.New("read-back verification failed")
	// ErrExists means SetIfAbsent found an item already present under this
	// account. The write was rejected outright, not attempted: the existing
	// value is untouched. Contrast ErrVerifyFailed, which fires AFTER a write
	// attempt and is indeterminate about whether that write landed.
	ErrExists = errors.New("item already exists")
	// ErrUnsupported means no keychain backend is compiled into this binary
	// (any non-darwin build).
	ErrUnsupported = errors.New("keychain not supported on this platform")
)

Sentinel errors, wrapped with %w — test with errors.Is.

The ErrNotFound / ErrUnreadable split is a best-effort classification of the Darwin `security` CLI's behavior (exit status 44, or a "could not be found" stderr message). It is reliable on stock macOS but is a CLI heuristic, not an OS guarantee: treat ErrNotFound as "confirmed absent on this backend", and never treat any OTHER failure as absence.

Functions

func Supported

func Supported() bool

Supported reports whether a real keychain backend is compiled into this binary — false on non-darwin builds, where every Store operation returns ErrUnsupported. Lets a doctor/status surface tell "backend absent" apart from "secret absent".

Types

type Account

type Account struct {
	Account     string `json:"account"`
	Env         string `json:"env"`
	Description string `json:"description"`
	ObtainURL   string `json:"obtain_url"`
	Required    bool   `json:"required"`
}

Account is one manifest-declared account under a Manifest's service.

type DuplicateGroup

type DuplicateGroup struct {
	Service string
	Account string
	Items   []Item
}

DuplicateGroup is every item found under one (service, account) pair when more than one exists across the search list — the ambiguity WithKeychain exists to close (see WithKeychain's doc comment). DumpDuplicates only ever returns groups with len(Items) > 1.

func DumpDuplicates

func DumpDuplicates(_ context.Context, service string, opts ...Option) ([]DuplicateGroup, error)

DumpDuplicates returns ErrUnsupported on every non-darwin build. It still validates service/opts via New so a caller sees the same argument errors on every platform, not just darwin.

type Item

type Item struct {
	Account  string
	Keychain string // absolute path to the keychain file this item lives in
}

Item is one keychain entry's public attributes: the account name and the keychain file it lives in. Never carries secret bytes — List and DumpDuplicates populate it from `security dump-keychain` output WITHOUT -w, so there is nothing else to attach.

type Manifest

type Manifest struct {
	Version  int       `json:"version"`
	Service  string    `json:"service"`
	Accounts []Account `json:"accounts"`
}

Manifest is the keyring.json schema v1: which accounts a service expects, with enough metadata for `doctor` to diagnose gaps (a required account with no keychain item) and orphans (an item with no manifest entry), and for `set` prompts to point a human at where to get the credential (ObtainURL). See LoadManifest.

The manifest lives in this portable file — no darwin build tag — so a consumer can load and validate it on any platform, even one with no keychain backend at all.

func LoadManifest

func LoadManifest(path string) (*Manifest, error)

LoadManifest reads and validates a keyring.json manifest at path.

Validation is strict on the fields doctor and the CLI depend on: version must be exactly 1 (no forward-compat guessing — a future v2 gets its own bead), service must be non-empty, and every string field (service, account, env, description, obtain_url) must be printable ASCII, the same contract Set enforces on values — a manifest is checked into a repo and rendered in terminal output, so it gets no more latitude than a keychain account name. Optional fields (env, description, obtain_url) may be empty; when present they are still ASCII-validated.

type Option

type Option func(*Store)

Option configures a Store.

func WithKeychain

func WithKeychain(path string) Option

WithKeychain pins every find-generic-password and add-generic-password call to one keychain file, instead of `security`'s default search list.

Precondition this closes: find-generic-password returns the FIRST service+account match in keychain search order, and add-generic-password -U updates "the" matching item — whichever one that search order picks. A duplicate (service, account) item planted by another tool in a higher-priority keychain (e.g. system ahead of login) means Get can return that OTHER item's value — attacker-controlled or merely stale — and Set's read-back can verify against it too, masking a write that landed on the wrong item entirely. See "Single-item assumption" in the package doc and README.

WithKeychain removes the ambiguity by scoping both the read and the write to one named keychain file: `security ... -s <service> -a <account> -w <path>`. Set it whenever more than one keychain on the search list could plausibly hold an item under this service+account — e.g. a shared machine, or a service name that isn't guaranteed unique. New rejects a non-absolute path outright, mirroring WithSecurityBin: a relative path is ambiguous about which keychain it resolves to.

func WithSecurityBin

func WithSecurityBin(path string) Option

WithSecurityBin overrides the path to the `security` binary. FOR TESTS ONLY — it exists so consumers can point a Store at a stub and assert the CLI contract. Production code must keep the default absolute path. New rejects a non-absolute path outright: a relative or $PATH-resolved override reopens the PATH-hijack hole the default exists to close, and whatever binary ends up at that path receives the secret on stdin during Set.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout overrides the per-invocation timeout (default 10s).

type ServiceItem added in v0.2.0

type ServiceItem struct {
	Service string
	Item
}

ServiceItem is one keychain entry with its service attached — the cross-service sibling of Item, returned by DumpItems for callers (the CLI's legacy-rename migration, a future `ls --all`) that must see items OUTSIDE one store's service. Attributes only, never secret bytes, same as Item.

func DumpItems added in v0.2.0

func DumpItems(_ context.Context, opts ...Option) ([]ServiceItem, error)

DumpItems returns ErrUnsupported on every non-darwin build, matching List.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store reads and writes secrets under one keychain service name.

func New

func New(service string, opts ...Option) (*Store, error)

New returns a Store scoped to the given keychain service name. The service name is the namespace every account lives under — convention: the consuming app's name (service "ferret", account "anthropic"). Empty or whitespace-only service names are rejected so two sloppy callers cannot silently collide in an unnamed namespace.

func (*Store) Delete added in v0.2.0

func (s *Store) Delete(account string) error

Delete removes the item stored under account. A confirmed item-not-found is reported as ErrNotFound so a caller can tell "already gone" from a deletion that could not run (ErrUnreadable: locked, denied, timed out).

Like every other operation here, Delete addresses items by (service, account) through the `security` CLI, which acts on the FIRST match in the search list — under a duplicate-item ambiguity (see WithKeychain) one Delete removes one item, not the whole group. Honors WithKeychain to pin which keychain is targeted.

func (*Store) Get

func (s *Store) Get(account string) (string, error)

Get reads the secret stored under account. It assumes the stored value is printable ASCII, the contract Set enforces on write — Get itself cannot verify this. If an item was written by another tool (or Keychain Access) with bytes >=0x80, `security find-generic-password -w` hex-transcribes those bytes into an ASCII string with exit 0 and no error: Get would return that hex string as if it were the real secret, silently wrong (see printableASCIIOnly). Items written through this package never hit that case; items written elsewhere might.

func (*Store) GetOrEnv

func (s *Store) GetOrEnv(account, envVar string) (string, error)

GetOrEnv reads keychain-first and falls back to os.Getenv(envVar) when the keychain CONFIRMS absence (ErrNotFound) or no backend exists (ErrUnsupported). ErrUnreadable does NOT fall through: a locked or denied keychain must surface as an error, never silently downgrade to the environment. If the fallback env var is empty or unset, the ORIGINAL keychain error is returned so the caller always learns why the keychain missed.

func (*Store) Has

func (s *Store) Has(account string) (bool, error)

Has reports whether a value is stored under account, returning an error the caller MUST block on. The three outcomes are distinct:

  • (true, nil) — the value was present at the moment of this read.
  • (false, nil) — CONFIRMED not-found at the moment of this read.
  • (false, err) — the slot could not be read (locked, denied, timed out); the caller must NOT treat this as absent, or a later overwrite could clobber a value that is actually there.

Has is ADVISORY-ONLY, not an atomic overwrite guard: the result is stale the instant it returns. Set writes with `security add-generic-password -U` (update-if-exists), so a concurrent writer that stores a value in the window between this call and a following Set is silently overwritten — no error to either caller. The `security` CLI has no compare-and-swap; this package cannot offer one. Treat (false, nil) as "no value seen just now", and rely on it only when the account has a single writer — or skip the Has-then-Set pair entirely and call SetIfAbsent, which closes exactly this gap by skipping -U and mapping the resulting duplicate-item error to ErrExists.

func (*Store) List

func (s *Store) List(_ context.Context) ([]Item, error)

List returns ErrUnsupported on every non-darwin build — no keychain backend is compiled in. LoadManifest, by contrast, has no build tag and works everywhere; only the enumeration calls that hit `security` are platform-gated.

func (*Store) Set

func (s *Store) Set(account, value string) error

Set stores value under account and reads it back to verify. A locked keychain can make `security` report success while storing nothing — the read-back turns that silent corruption into a hard error before the caller moves on. The value is piped to `security` on stdin, never placed on its argv, so it cannot appear in a process-table snapshot.

Empty or whitespace-only account names are rejected, mirroring the empty service-name check in New: every empty-account write in a service would otherwise land on one shared slot, with -U silently overwriting whatever was already there.

The write and the read-back are two separate `security` executions with no lock or transaction spanning the gap between them: an ErrVerifyFailed return is therefore INDETERMINATE, not confirmation the value was never stored — see ErrVerifyFailed. Callers must treat it as "unknown, go check" (Get/Has), never as "not written".

Set always writes with -U (update-if-exists): a concurrent writer landing between another caller's presence check and its Set is silently clobbered, no error to either side (see Has). Callers needing cross-process integrity — no two writers racing to the same account — must either serialize writes per (service, account) themselves, or use SetIfAbsent for write-once semantics.

func (*Store) SetIfAbsent

func (s *Store) SetIfAbsent(account, value string) error

SetIfAbsent stores value under account only if no item currently exists there — the write-once primitive for callers (bootstrap, token refresh) that need protection against a concurrent writer clobbering a value that's already in place, the gap Set's unconditional -U leaves open (see Set, Has). It skips -U: `security add-generic-password` then fails with a duplicate-item error when the item already exists, which SetIfAbsent maps to ErrExists instead of overwriting. On confirmed absence it stores and read-backs verify, exactly like Set — including ErrVerifyFailed's INDETERMINATE semantics (see Set, ErrVerifyFailed). Same validation as Set: non-empty account, printable ASCII account and value. Honors WithKeychain the same way Set's write path does.

SetIfAbsent is write-once, not a distributed lock: `security`'s duplicate-item check runs against whatever is already committed in the keychain at call time, so two callers racing SetIfAbsent for the same account cannot both win — exactly one gets nil, the other ErrExists — but it makes no ordering promise about WHICH one wins, only that the loser never clobbers the winner's value.

Directories

Path Synopsis
cmd
keyring command
Command keyring is the CLI skin over the keyring library: set/get/ls/rm for macOS keychain items, serving two audiences from one binary.
Command keyring is the CLI skin over the keyring library: set/get/ls/rm for macOS keychain items, serving two audiences from one binary.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL