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
- Variables
- func Supported() bool
- type Account
- type DuplicateGroup
- type Item
- type Manifest
- type Option
- type ServiceItem
- type Store
- func (s *Store) Delete(account string) error
- func (s *Store) Get(account string) (string, error)
- func (s *Store) GetOrEnv(account, envVar string) (string, error)
- func (s *Store) Has(account string) (bool, error)
- func (s *Store) List(_ context.Context) ([]Item, error)
- func (s *Store) Set(account, value string) error
- func (s *Store) SetIfAbsent(account, value string) error
Constants ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
WithTimeout overrides the per-invocation timeout (default 10s).
type ServiceItem ¶ added in v0.2.0
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.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store reads and writes secrets under one keychain service name.
func New ¶
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
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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. |