Documentation
¶
Overview ¶
Package credstore is the shared credential-store library for Open CLI Collective CLIs. It implements the Open CLI Collective Secret-Handling Standard (working-with-secrets.md; epic INT-310, ticket INT-429).
This file implements the credential-ref grammar (standard §1.3) and the default-ref codification (§2.1). A credential ref is exactly "<service>/<profile>": two segments joined by a single '/'. Each segment is drawn from [A-Za-z0-9_-] and is non-empty. '/' is structural and is therefore forbidden inside a segment.
The OS-keyring backends, the Store/Open lifecycle, bundle operations, redaction, and legacy migration helpers are intentionally not in this file; they are separate units of work under epic INT-310.
Index ¶
- Constants
- Variables
- func BackendEnvVar(service string) string
- func BackendFlagUsage() string
- func BindBackendFlag(opts *Options, flagValue string, flagSet bool, configValue string) error
- func DefaultRef(service string) (string, error)
- func EmitMigrationStderr(field, ref string)
- func EscapeRefSegment(raw string) string
- func FormatRef(service, profile string) (string, error)
- func MigrationConflictError(cli, field, legacyLocation, ref string) error
- func NoLeakAssertion(output []byte, secrets ...string) error
- func ParseRef(ref string) (service, profile string, err error)
- func ValidBackendNames() []string
- type Backend
- type KeyError
- type MigrationBlock
- type MigrationChange
- type MigrationObject
- type Options
- type Redactor
- type RefError
- type RefErrorKind
- type Result
- type SetOpt
- type Source
- type Store
- func (s *Store) Backend() (Backend, Source)
- func (s *Store) Close() error
- func (s *Store) Delete(profile, key string) error
- func (s *Store) DeleteBundle(profile string) ([]string, error)
- func (s *Store) Exists(profile, key string) (bool, error)
- func (s *Store) Get(profile, key string) (string, error)
- func (s *Store) ListBundle(profile string) ([]string, error)
- func (s *Store) Set(profile, key, value string, opts ...SetOpt) error
- func (s *Store) SetBundle(profile string, kv map[string]string, opts ...SetOpt) (Result, error)
Constants ¶
const BackendFlagName = "backend"
BackendFlagName is the canonical long-flag name CLIs should register.
const DefaultProfile = "default"
DefaultProfile is the profile segment used when a CLI does not specify one. The standard (§2.1) codifies the default credential ref as "<service>/default".
const MigrationFieldName = "_migration"
MigrationFieldName is the top-level JSON key under which the migration signal is emitted (§1.8).
const MigrationSchemaVersion = 1
MigrationSchemaVersion is the _migration object's schema version (§1.8). Bumped only on a breaking change to the JSON shape.
Variables ¶
var ( ErrRefEmpty = &RefError{Kind: RefErrorEmpty} ErrRefSegmentCount = &RefError{Kind: RefErrorSegmentCount} ErrRefInvalidChar = &RefError{Kind: RefErrorInvalidChar} )
Sentinels for errors.Is. They carry only a Kind.
var ( ErrNotFound = errors.New("credstore: credential not found") ErrExists = errors.New("credstore: credential already exists (use WithOverwrite to replace)") ErrStoreClosed = errors.New("credstore: store is closed") ErrBackendNotImplemented = errors.New("credstore: backend not implemented") // ErrFilePassphraseRequired is returned by Open when the file // backend is selected but no passphrase source is available // (neither <SERVICE>_KEYRING_PASSPHRASE nor Options.FilePassphrase). // Fail-closed, §1.4: the error names the env var and never contains // secret material. ErrFilePassphraseRequired = errors.New("credstore: file backend passphrase required") // ErrSecretServiceFailClosed is returned by Open on the Linux auto // path when Secret Service is present but unusable (locked / denied // / auth failure) or its state is ambiguous. Per §1.4 the wrapper // fails closed rather than silently downgrading to the file backend; // the wrapped error is actionable (names the backend, the probe // operation, and remediation) and contains no secret material. ErrSecretServiceFailClosed = errors.New("credstore: secret-service unavailable, failing closed") )
Sentinels and typed errors. All are matchable via errors.Is.
var ErrKeyNotAllowed = &KeyError{}
ErrKeyNotAllowed is the sentinel for errors.Is against *KeyError. Its fields must not be mutated by callers — it is a type sentinel, not a value carrier; the actual key/allowed set live on the returned error.
var ErrMigrationConflict = errors.New("credstore: legacy plaintext value conflicts with existing keyring value")
ErrMigrationConflict is the stable identity of the error MigrationConflictError returns. errors.Is(err, ErrMigrationConflict) holds regardless of the message text, mirroring the PR6 ErrSecretLeaked/leakError pattern.
var ErrSecretLeaked = errors.New("credstore: secret material leaked into output")
ErrSecretLeaked is the stable identity of the error NoLeakAssertion returns on a leak. errors.Is(err, ErrSecretLeaked) holds regardless of the (fail-closed, possibly empty) message text, so callers keep a programmatic signal even when the human-readable detail is suppressed. This constant is a fixed phrase, not runtime secret material.
Functions ¶
func BackendEnvVar ¶ added in v0.2.0
BackendEnvVar returns the per-service env-var name that controls the backend (e.g. service "atlassian-cli" -> "ATLASSIAN_CLI_KEYRING_BACKEND"). Exposed so CLI help text can show the actual var, not a placeholder.
Precondition: service must already be a valid credstore service segment (the same value passed to credstore.Open). Service-name validation is credstore.Open's responsibility, not this helper's — callers pass a constant in practice.
func BackendFlagUsage ¶ added in v0.2.0
func BackendFlagUsage() string
BackendFlagUsage returns help text listing valid values and naming the per-service env var mechanism. Built fresh from allBackends each call so it stays in lock-step with the recognized set — a function rather than an exported var so external packages cannot accidentally overwrite it and corrupt every consumer's help text.
func BindBackendFlag ¶ added in v0.2.0
BindBackendFlag applies the user-supplied --backend flag value and the config-file value to opts, validating the flag value. configValue is passed through to opts.ConfigBackend unchanged — credstore.Open will validate it during selection (so a stale config value surfaces as a clean error at Open time, not silent acceptance). Pass "" for configValue when no config-file value is set.
flagSet must reflect whether the user actually supplied --backend on the command line (typically cmd.Flags().Changed("backend") in cobra). When flagSet is false, flagValue is ignored and opts.Backend is not touched. When flagSet is true, flagValue must be a recognized backend name; an explicit empty --backend= is rejected as fail-closed, not silently treated as "no flag." This prevents an explicit empty flag from silently falling through to lower-precedence env/config/auto selection.
On invalid flag input, opts is not mutated and the returned error wraps ErrBackendNotImplemented.
opts must be non-nil; passing nil returns an error rather than panicking. CLIs must NOT read <SERVICE>_KEYRING_BACKEND themselves and pass it here as flagValue — credstore reads that env var directly in selectBackend, and remapping it would corrupt SourceEnv attribution.
func DefaultRef ¶
DefaultRef returns "<service>/default", codifying the standard's §2.1 default credential ref. It is exactly FormatRef(service, DefaultProfile).
func EmitMigrationStderr ¶
func EmitMigrationStderr(field, ref string)
EmitMigrationStderr prints the one-time migration signal to stderr (§1.8). A CLI calls this on the run where it moved a legacy plaintext field into the keyring. field is the legacy config field name; ref is the credential ref it now lives under. Never include a secret value in either argument — by contract these are descriptive identifiers only.
func EscapeRefSegment ¶
EscapeRefSegment deterministically encodes an arbitrary string into the segment charset [A-Za-z0-9_-], for CLIs that derive a profile from a richer identifier such as an email address (standard §1.3).
Scheme: bytes in [A-Za-z0-9-] pass through; '_' is the escape byte, so a literal '_' becomes "__"; every other byte becomes "_x" followed by two lowercase hex digits (per UTF-8 byte). For example "rian@monitapp.io" encodes to "rian_x40monitapp_x2eio". The encoding is reversible by construction; an Unescape is intentionally not provided until a consumer needs it.
EscapeRefSegment("") == "". An empty string is not a valid ref segment; that only becomes an error at FormatRef/ParseRef validation time, not here.
func FormatRef ¶
FormatRef is the inverse of ParseRef: it validates both segments and joins them with '/'. The error's Ref field carries the offending segment value (non-secret per §1.2), or "" for an empty segment.
Validation order is deliberate and pinned by tests: service before profile, and within each segment, empty before charset. It differs from ParseRef's order (which resolves all structural concerns — slash count, segment emptiness — before any charset check) because FormatRef receives the two segments already separated, so there is no structural phase. Keep these orders stable; reordering will fail the pinned test cases.
func MigrationConflictError ¶
MigrationConflictError builds the §1.8 (line 254) conflict error: the legacy plaintext value differs from the value already in the keyring, so the CLI must not silently pick a winner. The message names both locations, states that the values differ, and offers the three remediation options. It is leak-proof by construction: it takes no value argument, so it cannot print either value, masked or unmasked (§1.8 line 254 / §1.12).
cli is the tool name (for the `<cli> config clear` remediation); field is the conflicting legacy field; legacyLocation is a human description of where the plaintext lives (e.g. the config file path); ref is the keyring ref holding the existing value.
func NoLeakAssertion ¶
NoLeakAssertion is the test helper backing each CLI's mandatory "no-leak" test (§1.12). It returns a non-nil error (matching ErrSecretLeaked via errors.Is) when any non-empty secret appears in output, naming each leaked secret by its argument ordinal and length only — never the value, not even a masked prefix (§1.8/§1.12 treat masked secret material as still secret). Empty secrets are skipped. Returns nil when output is clean.
The error string is itself fail-closed: the detailed "secret #N (len=K)" wording can contain a short or placeholder-shaped secret (a secret of "len", "1", or "secret" is a substring of the message), so the message is degraded to a guaranteed secret-free form — finally the empty string — before being returned. The leak still surfaces as a non-nil error matching ErrSecretLeaked; it just never echoes the value in the failure path.
func ParseRef ¶
ParseRef splits a credential ref into its service and profile segments. Validation order (standard §1.3): empty input, then exactly-one-'/', then non-empty segments, then charset.
func ValidBackendNames ¶ added in v0.2.0
func ValidBackendNames() []string
ValidBackendNames returns the recognized backend name list in stable order. Derived from allBackends; use for completion, error messages, and help text generation. The returned slice is a fresh copy and is safe for callers to mutate.
Types ¶
type Backend ¶
type Backend string
Backend identifies which credential backend a Store is using.
const ( BackendKeychain Backend = "keychain" // macOS — later unit BackendWinCred Backend = "wincred" // Windows — later unit BackendSecretService Backend = "secret-service" // Linux — later unit BackendFile Backend = "file" // encrypted file — later unit BackendPass Backend = "pass" // pass(1) CLI shell-out; !windows; requires `pass` binary + initialized password-store BackendMemory Backend = "memory" // tests/CI, no disk )
func ParseBackend ¶ added in v0.2.0
ParseBackend validates a user-supplied backend name and returns the typed Backend. On failure the error lists every valid value and wraps ErrBackendNotImplemented so callers can classify with errors.Is — matching selectBackend's existing failure class.
type KeyError ¶
KeyError is returned by Set/Delete when a key is not in the Store's allowed-key set (§1.5.2). The key name is not secret. Allowed is the sorted allowed set so messages are deterministic.
type MigrationBlock ¶
type MigrationBlock struct {
Version int `json:"version"`
Changes []MigrationChange `json:"changes"`
}
MigrationBlock is the value of the _migration field: a schema version plus the list of changes that occurred on this run. A CLI that embeds the signal as a field of its own response type tags that field `json:"_migration"` with this as the value.
func NewMigrationBlock ¶
func NewMigrationBlock(changes ...MigrationChange) MigrationBlock
NewMigrationBlock builds the _migration value with the current schema version and a non-nil Changes slice (so it marshals "changes":[], never null, on the degenerate empty call).
type MigrationChange ¶
type MigrationChange struct {
Field string `json:"field"`
From string `json:"from"`
To string `json:"to"`
}
MigrationChange is one entry in the _migration signal: which legacy field moved, and the descriptive opaque from/to locations. from and to are location descriptors (e.g. "config:legacy_plaintext", "keyring:atlassian-cli/default/api_token") — never the value (§1.8).
func MigrationJSONEntry ¶
func MigrationJSONEntry(field, from, to string) MigrationChange
MigrationJSONEntry constructs one MigrationChange (the §2.1-named helper). field is the legacy config field; from/to are opaque location descriptors — never the secret value.
type MigrationObject ¶
type MigrationObject struct {
Migration MigrationBlock `json:"_migration"`
}
MigrationObject is the standalone §1.8 object — it marshals to exactly {"_migration":{"version":1,"changes":[...]}}. For CLIs that merge objects into their JSON response rather than embedding a struct field.
func NewMigrationObject ¶
func NewMigrationObject(changes ...MigrationChange) MigrationObject
NewMigrationObject builds the standalone {"_migration":{...}} object.
type Options ¶
type Options struct {
// AllowedKeys is the CLI's allowed-key allowlist (§2.1/§1.5.2). When
// non-empty, Set and Delete reject any key not in this set. When empty,
// only key syntax is validated (useful for tooling/tests).
AllowedKeys []string
// Backend, when non-empty, forces a specific backend (highest
// precedence, §1.4) and is reported as SourceExplicit. It is still
// validated: an unrecognized value fails closed with
// ErrBackendNotImplemented.
Backend Backend
// ConfigBackend is the backend the CLI read from its config file
// (keyring.backend). credstore does not parse config; the CLI passes
// the value so credstore can apply §1.4 precedence and report
// SourceConfig distinctly. Lower precedence than Backend and the
// <SERVICE>_KEYRING_BACKEND env var.
ConfigBackend Backend
// FilePassphrase optionally supplies the encrypted-file backend's
// passphrase when the <SERVICE>_KEYRING_PASSPHRASE env var is unset
// (e.g. an interactive prompt provided by the CLI). Consulted only
// for BackendFile. Nil is fine for headless/CI/tests that set the
// env var instead.
FilePassphrase func() (string, error)
}
Options configures Open.
type Redactor ¶
type Redactor struct {
// contains filtered or unexported fields
}
Redactor scrubs known secret values out of strings, HTTP headers, and writer streams. The zero value is usable (no secrets loaded → input is returned unchanged); NewRedactor is the normal constructor. A Redactor is safe for concurrent use: CLIs typically Add a refreshed token from one goroutine while another logs through a RedactWriter.
func NewRedactor ¶
NewRedactor returns a Redactor pre-loaded with secrets. Empty strings are ignored (see Add); duplicates are de-duplicated.
func (*Redactor) Add ¶
Add loads an additional secret discovered after construction (e.g. a refreshed token). An empty secret is ignored rather than rejected: there is no error return, and an empty string would match everywhere (strings.Contains(x, "") is always true), corrupting all output. A secret already loaded is a no-op (linear-scan dedup).
The secret set is expected to be small and bounded — the handful of values a CLI loaded from the keyring plus the occasional refresh — so the O(n) dedup scan is intentionally not replaced with a map and the list is intentionally uncapped. A caller that adds unbounded distinct secrets in a loop is misusing the type.
func (*Redactor) Redact ¶
Redact replaces every occurrence of every loaded secret in s with "<redacted, len=N>". It scans the original input for all secret occurrences as half-open byte intervals, unions intervals that strictly overlap (adjacent-but-distinct secrets stay separate placeholders, each carrying its own length), and emits one placeholder per merged interval where N is the number of original bytes redacted. In the common non-overlapping case N == len(secret), matching the standard's example exactly; in the overlap corner N is the redacted span length — still only a length, never secret material.
Matching against the original input (not iterative replacement) means the result is order-independent. Two layers keep it fail-closed: a placeholder that would itself contain a loaded secret drops that span to "" (safeReplacement), and a final whole-output guard suppresses the entire result (returns "") if any loaded secret still appears — which can happen when a dropped span lets gap text join across the seam. The empty string can contain no non-empty secret, so the result is guaranteed secret-free; normal token secrets never hit either layer.
func (*Redactor) RedactHeaders ¶
RedactHeaders scrubs h in place for HTTP wire logs (§1.12). Every value of Authorization, Cookie, and Set-Cookie is replaced wholesale with a length-only placeholder; every other header value is run through Redact so any header whose value contains a loaded secret ("any custom auth headers") is scrubbed by substring. A nil map is a no-op.
Whole-value redaction of the always-redact set is deliberately more conservative than §1.12's illustrative scheme-preserving "Bearer <redacted, len=84>": preserving the scheme word is a CLI-side nicety, out of scope for the shared helper, and never preserving it cannot leak.
func (*Redactor) RedactWriter ¶
RedactWriter returns a writer that runs every buffer through Redact before forwarding to w, so a CLI's debug-log writer auto-scrubs without each call site remembering to.
Limitation: redaction is per-Write. A secret split across two Write calls is not caught — there is intentionally no internal buffering (buffering a debug stream would itself retain secret bytes and needs a flush contract). Debug / wire loggers write whole records per call, which is the supported case.
Fail-closed drop: when Redact suppresses the whole buffer (a degenerate secret/placeholder collision), the wrapper forwards zero bytes and still reports Write success (len(p), nil) — the record is silently dropped rather than emitted unredacted. This is intended security behavior; it only happens for pathological non-token secrets.
type RefError ¶
type RefError struct {
Kind RefErrorKind
// Segment is "service" or "profile" when the failure is specific to
// one segment; otherwise "".
Segment string
// Ref is the offending input (a full ref for ParseRef, or the
// offending segment value for FormatRef). Non-secret per §1.2.
Ref string
}
RefError is the typed error returned by all ref operations so callers can produce actionable messages and branch on errors.Is against the package sentinels. Credential refs are non-secret configuration (standard §1.2), so RefError safely echoes the offending value — no leak concern.
type RefErrorKind ¶
type RefErrorKind int
RefErrorKind classifies why a credential ref failed validation.
const ( // RefErrorEmpty means the ref input was empty, or one of its segments // was empty (e.g. "/profile" or "service/"). RefErrorEmpty RefErrorKind = iota // RefErrorSegmentCount means the input was not of the form // "<service>/<profile>" — it did not contain exactly one '/'. RefErrorSegmentCount // RefErrorInvalidChar means a segment contained a byte outside the // allowed set [A-Za-z0-9_-]. RefErrorInvalidChar )
type Result ¶
type Result struct {
Written []string
Restored []string
Absent []string
Untouched []string
RollbackFailed []string
}
Result reports the disposition of a SetBundle call.
Written – writes retained on return (bare keys, sorted)
Restored – pre-existing keys put back to their prior value by rollback
Absent – new keys the rollback guarantees are not present; includes
a key whose own forward write failed and so was never
stored (named "Absent", not "Deleted", because no physical
deletion necessarily occurred for such a key)
Untouched – target keys not changed by this call: a failed
no-overwrite ErrExists key (left as the racer wrote it)
plus keys never attempted after the failure point
RollbackFailed – keys whose rollback step itself failed, so the
backend's value for them is undefined; the returned error
also names these, but the slice lets callers remediate
(e.g. prompt to delete) without parsing error text. A key
here appears in no other slice.
On success only Written is populated; on rollback Written is nil. A non-empty RollbackFailed always accompanies a non-nil error.
type SetOpt ¶
type SetOpt func(*setOptions)
SetOpt configures Set.
func WithOverwrite ¶
func WithOverwrite() SetOpt
WithOverwrite allows Set to replace an existing entry (§1.5). Without it, Set on an existing entry returns ErrExists. The exists check is exact only for the in-memory backend; on OS/file backends it is best-effort against a concurrent cross-process writer (no CAS in the underlying library, §1.5.1).
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is a service-scoped credential store. It is safe for concurrent use.
func Open ¶
Open returns a service-scoped Store. The service segment must satisfy the §1.3 ref grammar. Backend selection (§1.4) and the Linux auto-fallback classification fail closed. It delegates to openWithDeps with the real environment, GOOS, and Secret Service probe; the seam exists only for deterministic testing.
func (*Store) Backend ¶
Backend reports the selected backend and how it was selected. It is metadata only — no error, and it remains valid after Close.
func (*Store) Close ¶
Close releases the backend and best-effort clears stored values. It is idempotent and safe to call repeatedly. Note: Go string secrets cannot be guaranteed zeroized in memory; this drops references and clears the backing store, which is the best a Go library can do.
func (*Store) Delete ¶
Delete removes the entry at (profile, key). Enforces the allowlist (§1.5.2). Missing entry → ErrNotFound.
func (*Store) DeleteBundle ¶
DeleteBundle removes every key under profile (config clear, §1.7). It is idempotent (a valid profile with no keys → (nil, nil)) and not allowlist-gated. It does not fail-fast: every key is attempted; if any deletes fail it still returns the keys actually deleted plus an error naming all failed keys (fail-fast would strand later secrets).
func (*Store) Exists ¶
Exists reports whether (profile, key) is present. Missing → (false, nil). Syntax and closed-state errors are still returned. Not allowlist-gated (read path, §1.5.2).
func (*Store) Get ¶
Get returns the value at (profile, key). Missing entry → ErrNotFound. Read paths are syntax-validated but intentionally not allowlist-gated (§1.5.2 gates Set/Delete only): a key written before an allowlist change must stay readable.
func (*Store) ListBundle ¶
ListBundle returns the sorted keys stored under profile. A valid profile with no keys returns (nil, nil). Not allowlist-gated — it reports stored reality (§1.5.2 gates writes/deletes, not reads).
func (*Store) Set ¶
Set writes value at (profile, key). Enforces the allowlist (§1.5.2). Without WithOverwrite, an existing entry → ErrExists (best-effort on OS/file backends; see WithOverwrite).
func (*Store) SetBundle ¶
SetBundle writes kv under profile, implementing the §1.5.1 atomicity contract: validate everything first; without WithOverwrite any pre-existing target fails the whole call; with WithOverwrite, prior values are snapshotted before any write so a mid-bundle failure can be rolled back. The call-scoped snapshot is best-effort cleared before return (Go strings cannot be guaranteed zeroized).
This contract is exact only for the in-memory backend. On the OS/file backends the underlying library has no compare-and-swap, so the conflict check and rollback are best-effort against a concurrent cross-process writer — practical, not transactional, atomicity (§1.5.1).