Documentation
¶
Overview ¶
Package keystore stores private key material, addressed by key name.
Role in the key-management design ¶
Key management is split across two layers with one rule: keys.json (the registry, owned by package keys) is the single source of truth for WHICH keys exist and WHERE each key's secret lives; a keystore backend is a dumb vault that answers "what is the secret stored under this name". A backend holds no metadata, no default selection, and no routing information — it cannot, because not every backend is a file this CLI controls (the OS keyring has no enumeration API at all). Anything that needs to know what keys exist must ask the registry, never a vault.
Backends ¶
Two backends, constructed by name via ByName:
- "file" — keystore.json under the CLI home, each secret sealed with AES-256-GCM. The encryption key is embedded in the binary BY DESIGN and honestly documented as casual-disclosure protection only (see file.go). It is the default for new keys because it needs no UI and works on headless hosts.
- "keychain" — the OS-native keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service). Opt-in, OS-enforced at-rest protection, may prompt interactively. macOS is served by a direct Security.framework integration (keychain_darwin.go, via purego — no cgo, so cross-compiles are unaffected) so the stored item binds to this signed binary's code signature rather than a helper tool's; Windows and Linux use zalando/go-keyring (keychain_other.go), which calls the Credential Manager and the Secret Service natively in pure Go. The active provider is the package var keychain, swapped to an in-memory fake by MockKeychain in tests.
The backend choice is PER KEY: each registry record names the backend that holds its secret, so e.g. an order-placing key can sit in the OS keychain while read-only keys stay in the file vault. config.json's "keystore" value is only the default applied to newly created keys — changing it never moves or re-routes an existing key.
A backend stores opaque secret strings — an ED25519 private-key PEM or an HMAC shared secret. The vault does not know or care which; the registry's per-key "type" field owns what the secret means. That keeps Migrate type-agnostic.
Contracts to preserve ¶
- Get returns "" for a plain miss; a non-nil error always means corruption or a backend failure. Callers rely on this to tell "no such key" from "the vault is broken".
- Concurrency contract (file backend): Set and Delete hold an exclusive cross-process advisory lock (keystore.json.lock, via internal/fslock) across their whole load → modify → atomic-rename cycle. Because every Set rewrites the ENTIRE vault, two concurrent unlocked Sets would each load the same snapshot and the second rename would silently drop the first's key — losing freshly-added PRIVATE material while its registry record survives. Get is LOCK-FREE: the atomic rename guarantees a reader sees a complete, consistent file. The keychain backend needs no lock — the OS keyring is its own serialization domain.
- Lock ordering — registry → vault, NEVER the reverse. The vault lock is acquired AFTER the registry lock when a keys.Manager mutation (Add/Remove/Rename) drives Set/Delete while holding keys.json.lock. The two are DIFFERENT lock files, which is the only reason that nesting is deadlock-free; this layer must NEVER acquire the registry lock.
- Migrate is NOT whole-run atomic. It is a sequence of individually-locked steps (each dst.Set takes the vault lock, each commit takes the registry lock, both briefly). Per-key consistency — the copy → verify → commit → delete ordering below — is what is preserved, not atomicity across the whole run.
- Available (and the keychain ProbeKeyring) is a READ-ONLY reachability probe: it must never write, and must never trigger an interactive OS prompt — `keystore status` and doctor call it freely.
- Migrate is the only code that moves secrets between backends. Its per-key ordering is the safety contract: copy into the target → verify the read-back → commit (the caller stamps the key's registry record) → only then delete from the source. A failure rolls the in-flight key's target entry back and aborts, so every key is always in exactly one consistent state; keys committed before the failure stay migrated (re-running continues where it stopped).
- Secrets never appear in errors, logs, or reports — Migrate's report carries key names only.
To add a backend, add ONE entry to the internal registry in keystore.go (a constructor and a read-only availability probe — a nil probe means always-available, as for "file") and the matching name to config.Backends. ByName and Available are plain lookups into that registry, so there are no twin switches to keep in sync, and keystore_test.go's drift guard fails loudly if the registry and config.Backends disagree — a backend added to one but not the other never ships. Two assumptions here bound that: Migrate assumes Set works on every backend, and the registry hands callers a PEM. A backend that can't accept imported material (a non-exportable hardware credential) or that signs on-device rather than divulging a secret breaks one of those — changing it means changing Migrate or the registry's signer handoff.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Available ¶
Available reports whether the named backend can be used on this machine. The file backend is always available; the keychain backend depends on the OS keyring being reachable (e.g. a headless Linux box with no Secret Service has none), so it is checked with a read-only reachability probe. A nil return means usable; a ConfigError explains why not.
The two ConfigError cases are deliberately distinguishable: an unknown name wraps errUnknownBackend (errors.Is), while a recognized-but-unreachable backend returns the probe's own ConfigError. A CI box with no keyring legitimately hits the latter — the drift guard must not confuse it with the former.
func MockKeychain ¶
func MockKeychain()
MockKeychain replaces the OS keychain with a fresh in-memory store for the duration of a test. It is the analogue of the real backend being present and empty. Call it before exercising the "keychain" backend so the test never reads or writes the developer's real keychain.
func MockKeychainUnavailable ¶
func MockKeychainUnavailable(err error)
MockKeychainUnavailable replaces the OS keychain with an in-memory store whose reachability probe fails with err, simulating a host where the keychain backend cannot be used (e.g. a headless Linux box with no Secret Service).
func ProbeKeyring ¶
func ProbeKeyring() error
ProbeKeyring checks that the OS keychain is reachable, without prompting and without writing anything: it issues a read for a sentinel account. A "not found" result means the keychain is present and working (the entry simply doesn't exist); any other error means the backend itself is unavailable (no Secret Service / DBus on a headless Linux box, a locked store, etc.). Actually storing a key still happens (and is re-verified) during migration, where a prompt or write failure surfaces loudly — so this probe never gratuitously pops a keychain dialog.
"Available" therefore means *reachable for read*, not *guaranteed writable*: a keychain that reads fine but rejects a write still passes the probe, and the write failure is caught and rolled back by migration instead. That keeps `keystore status`/`doctor` prompt-free.
Types ¶
type FileKeystore ¶
type FileKeystore struct {
// Log is an optional operational logger (nil = silent). It records the
// lock/read/atomic-write steps and decrypt failures — never the secret or
// ciphertext. Set by keystore.ByName at construction; tests may set it
// directly.
Log *slog.Logger
// contains filtered or unexported fields
}
FileKeystore encrypts each secret with AES-256-GCM and stores it in keystore.json (mode 0600).
func NewFile ¶
func NewFile(home string) *FileKeystore
NewFile returns a file-backed keystore rooted at home.
func (*FileKeystore) Backend ¶
func (k *FileKeystore) Backend() string
func (*FileKeystore) Delete ¶
func (k *FileKeystore) Delete(name string) error
func (*FileKeystore) Set ¶
func (k *FileKeystore) Set(name, secret string) error
type KeyringKeystore ¶
type KeyringKeystore struct {
// Log is an optional operational logger (nil = silent). It records each
// Set/Get/Delete against the OS keychain — the service + account IDENTIFIER
// and, on failure, the provider's error (which carries the macOS OSStatus,
// e.g. errSecItemNotFound / a consent denial) — but NEVER the returned secret
// bytes. Set by keystore.ByName at construction; tests may set it directly.
Log *slog.Logger
}
KeyringKeystore stores private keys in the OS keychain (macOS Keychain, Linux Secret Service, Windows Credential Manager), giving OS-enforced at-rest protection. On macOS the item is created through Security.framework by this signed binary, so its access-control list binds to this tool's code signature: this CLI (and same-Developer-ID builds) reads it without a prompt, while any other program triggers the system's keychain-consent dialog.
func NewKeyring ¶
func NewKeyring() *KeyringKeystore
NewKeyring returns an OS-keychain-backed keystore.
func (*KeyringKeystore) Backend ¶
func (k *KeyringKeystore) Backend() string
func (*KeyringKeystore) Delete ¶
func (k *KeyringKeystore) Delete(name string) error
func (*KeyringKeystore) Set ¶
func (k *KeyringKeystore) Set(name, secret string) error
type Keystore ¶
type Keystore interface {
// Backend names the backend ("file" | "keychain"), surfaced in diagnostics.
Backend() string
Set(name, secret string) error
Get(name string) (string, error)
Delete(name string) error
}
Keystore stores secret strings (private key material) under a key name. Get returns "" when no entry exists; a non-nil error signals corruption or a backend failure, never a plain miss.
func ByName ¶
ByName builds the keystore for an explicit backend name ("file" | "keychain"). Key records select their backend by name, so this is the constructor the registry resolves through; migration also uses it to address the source and target backends at once. An unknown name is a ConfigError.
log (nil = silent) is attached to the constructed backend so its per-key Set/Get/Delete and the file vault's lock/read/write steps are traceable under --debug. It is threaded here, at the single construction point, so every backend a Manager builds is instrumented automatically.
type MigrateItem ¶
type MigrateItem struct {
Name string
// Source is the backend currently recorded for this key.
Source Keystore
// at all here — e.g. its record points at the OS keyring on a headless host
// that has none. In that mode Migrate never reads from or deletes in the
// source for this key; it can only salvage material that already exists in
// the target (the recovery path that `doctor` points at when a key's backend
// is unavailable). A key present only in the unreachable source is reported
// Missing. This is distinct from a source that is *reachable but errors*
// (e.g. a corrupt keystore.json), which must still fail loudly — the caller
// sets this flag only from a real backend-availability probe, never from a
// per-key read error.
SourceUnavailable bool
}
MigrateItem is one key to migrate: its name and the backend its registry record currently points at. Keys may come from different source backends in a single run (each key carries its own).
type MigrateOptions ¶
type MigrateOptions struct {
// KeepSource leaves the migrated keys in their source backends instead of
// deleting them once they are safely in the target.
KeepSource bool
}
MigrateOptions tunes a Migrate call.
type MigrateReport ¶
type MigrateReport struct {
Target string `json:"target"`
KeptSource bool `json:"keptSource"`
// Moved: present in the source, copied into the target (which had no prior
// entry) and removed from the source unless KeepSource.
Moved []string `json:"moved"`
// Identical: the target already held byte-identical material; re-copied and
// removed from the source unless KeepSource.
Identical []string `json:"identical"`
// Replaced: the target held a *different* (stale) entry under this name; it
// was overwritten with the authoritative source material.
Replaced []string `json:"replaced"`
// Recovered: absent from the source but already present in the target — left
// in place, record re-pointed. This is the orphan-recovery path (the record
// pointed at an empty or unreachable backend while the material sat in the
// target one).
Recovered []string `json:"recovered"`
// Missing: absent from both backends — the key's private material is gone;
// re-import it. A warning, not a failure: the record is still re-pointed at
// the target so a later re-import lands there.
Missing []string `json:"missing"`
// AlreadyInTarget: the key's record already points at the target backend —
// nothing to do. Makes re-running a partially-failed migration a clean
// continue instead of an error.
AlreadyInTarget []string `json:"alreadyInTarget"`
// DeleteWarnings records source-deletion failures that happened *after* a
// key's migration already committed (so they don't undo a valid migration).
DeleteWarnings []string `json:"deleteWarnings,omitempty"`
}
MigrateReport summarizes what a migration did, per key. The buckets are mutually exclusive; a name lands in exactly one. It carries no secret material — only key names.
func Migrate ¶
func Migrate(items []MigrateItem, dst Keystore, commit func(name string) error, opts MigrateOptions, log *slog.Logger) (MigrateReport, error)
Migrate moves each item's key material into dst and re-points its registry record there via commit(name). The per-key ordering is the safety contract:
- Copy the key into the target and read it back to VERIFY it (a copy that doesn't round-trip aborts).
- Only after the copy verifies, call commit(name) — the caller stamps the key's record in keys.json with the target backend. This is that key's point of no return.
- Only after commit succeeds, delete the original from the source.
Any failure rolls the in-flight key's target entry back to its exact prior state and aborts the run — that key is untouched, while keys committed before it stay migrated (each key is individually consistent; re-running continues, since committed keys then report AlreadyInTarget). A source-delete failure after commit is non-fatal (the key is already safe in the target) and is reported in DeleteWarnings.
Items whose source backend already equals the target are bucketed AlreadyInTarget and skipped.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package keystoretest provides an in-memory keystore.Keystore for tests that need to exercise migration and error paths without touching a platform keyring (which isn't available or deterministic in unit tests).
|
Package keystoretest provides an in-memory keystore.Keystore for tests that need to exercise migration and error paths without touching a platform keyring (which isn't available or deterministic in unit tests). |