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 OnePasswordOptions
- 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 ( DefaultOnePasswordServiceTokenEnv = "OP_SERVICE_ACCOUNT_TOKEN" // #nosec G101 -- environment variable name, not a secret DefaultOnePasswordConnectTokenEnv = "OP_CONNECT_TOKEN" // #nosec G101 -- environment variable name, not a secret DefaultOnePasswordDesktopAccountEnv = "OP_DESKTOP_ACCOUNT_ID" // #nosec G101 -- environment variable name, not a secret DefaultOnePasswordTimeout = 30 * time.Second )
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 BackendOP Backend = "op" // 1Password service account backend BackendOPConnect Backend = "op-connect" // 1Password Connect backend BackendOPDesktop Backend = "op-desktop" // 1Password desktop integration backend 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 OnePasswordOptions ¶ added in v0.4.0
type OnePasswordOptions struct {
Timeout time.Duration
VaultID string
ItemTitlePrefix string
ItemTag string
ItemFieldTitle string
ConnectHost string
ConnectTokenEnv string
ServiceTokenEnv string
DesktopAccountID string
}
OnePasswordOptions configures the opt-in 1Password backend families. All fields are non-secret. Blank token env names and backend-specific connection selectors fall back to upstream ByteNess defaults during backend construction. Blank Timeout uses DefaultOnePasswordTimeout for backends that require one. Blank ItemTitlePrefix and ItemTag are scoped to the credstore service name so separate CLI consumers do not collide in the same 1Password vault.
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)
// OnePassword carries non-secret backend-specific wiring for the
// 1Password backends. Secrets themselves still come from the backend's
// runtime environment or native integration, never from config.
OnePassword *OnePasswordOptions
}
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).