credstore

package
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: May 27, 2026 License: MIT Imports: 14 Imported by: 0

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

View Source
const BackendFlagName = "backend"

BackendFlagName is the canonical long-flag name CLIs should register.

View Source
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".

View Source
const MigrationFieldName = "_migration"

MigrationFieldName is the top-level JSON key under which the migration signal is emitted (§1.8).

View Source
const MigrationSchemaVersion = 1

MigrationSchemaVersion is the _migration object's schema version (§1.8). Bumped only on a breaking change to the JSON shape.

Variables

View Source
var (
	ErrRefEmpty        = &RefError{Kind: RefErrorEmpty}
	ErrRefSegmentCount = &RefError{Kind: RefErrorSegmentCount}
	ErrRefInvalidChar  = &RefError{Kind: RefErrorInvalidChar}
)

Sentinels for errors.Is. They carry only a Kind.

View Source
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.

View Source
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.

View Source
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.

View Source
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

func BackendEnvVar(service string) string

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

func BindBackendFlag(opts *Options, flagValue string, flagSet bool, configValue string) error

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

func DefaultRef(service string) (string, error)

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

func EscapeRefSegment(raw string) string

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

func FormatRef(service, profile string) (string, error)

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

func MigrationConflictError(cli, field, legacyLocation, ref string) error

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

func NoLeakAssertion(output []byte, secrets ...string) error

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

func ParseRef(ref string) (service, profile string, err error)

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

func ParseBackend(name string) (Backend, error)

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

type KeyError struct {
	Key     string
	Allowed []string
}

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.

func (*KeyError) Error

func (e *KeyError) Error() string

func (*KeyError) Is

func (e *KeyError) Is(target error) bool

Is matches any *KeyError so callers can write errors.Is(err, credstore.ErrKeyNotAllowed).

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

func NewRedactor(secrets ...string) *Redactor

NewRedactor returns a Redactor pre-loaded with secrets. Empty strings are ignored (see Add); duplicates are de-duplicated.

func (*Redactor) Add

func (r *Redactor) Add(secret string)

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

func (r *Redactor) Redact(s string) string

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

func (r *Redactor) RedactHeaders(h http.Header)

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

func (r *Redactor) RedactWriter(w io.Writer) io.Writer

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.

func (*RefError) Error

func (e *RefError) Error() string

func (*RefError) Is

func (e *RefError) Is(target error) bool

Is reports whether target is a *RefError of the same Kind, so callers can write errors.Is(err, credstore.ErrRefEmpty).

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 Source

type Source string

Source describes how the backend was selected.

const (
	SourceAuto     Source = "auto"     // OS default — wired later unit
	SourceEnv      Source = "env"      // <SERVICE>_KEYRING_BACKEND — later
	SourceConfig   Source = "config"   // keyring.backend in config — later
	SourceExplicit Source = "explicit" // Options.Backend set by caller
)

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

func Open(service string, opts *Options) (*Store, error)

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

func (s *Store) Backend() (Backend, Source)

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

func (s *Store) Close() error

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

func (s *Store) Delete(profile, key string) error

Delete removes the entry at (profile, key). Enforces the allowlist (§1.5.2). Missing entry → ErrNotFound.

func (*Store) DeleteBundle

func (s *Store) DeleteBundle(profile string) ([]string, error)

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

func (s *Store) Exists(profile, key string) (bool, error)

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

func (s *Store) Get(profile, key string) (string, error)

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

func (s *Store) ListBundle(profile string) ([]string, error)

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

func (s *Store) Set(profile, key, value string, opts ...SetOpt) error

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

func (s *Store) SetBundle(profile string, kv map[string]string, opts ...SetOpt) (Result, error)

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

Jump to

Keyboard shortcuts

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