keystore

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Jul 14, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

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

func Available(name string, log *slog.Logger) error

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 FilePath

func FilePath(home string) string

FilePath is the file-backed keystore vault path under home (keystore.json).

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) Get

func (k *FileKeystore) Get(name string) (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) Get

func (k *KeyringKeystore) Get(name string) (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

func ByName(name, home string, log *slog.Logger) (Keystore, error)

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
	// SourceUnavailable signals that this key's source backend can't be reached
	// 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:

  1. Copy the key into the target and read it back to VERIFY it (a copy that doesn't round-trip aborts).
  2. 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.
  3. 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).

Jump to

Keyboard shortcuts

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