Skip to content

The keychain opt-out

The OS-keychain backend lives in a separate subpackage, credentials/keychain, that you activate with a blank import. This page explains why it is structured that way, how the opt-out actually works at link time, and how to verify it.

The requirement: a provably keychain-free build

Some deployments — regulated environments, air-gapped hosts, compliance-audited tools — must ship a binary that performs no inter-process communication with the platform keychain: no D-Bus session-bus traffic, no Keychain Services calls, nothing to review. "We do not use the keychain" is not enough; they need "the keychain code is not in the binary", provable from the artefact.

That requirement drives the whole design.

How the opt-out works

The core package defines the Backend interface and installs a stub backend at init: every operation returns ErrCredentialUnsupported, and it imports nothing but the standard library and cockroachdb/errors. No keychain code is reachable.

The real backend lives in the keychain subpackage, which imports go-keyring (and, transitively, godbus on Linux and wincred on Windows). Its init() calls RegisterBackend, swapping the stub for the go-keyring implementation. A tool activates it with:

import _ "gitlab.com/phpboyscout/go/credentials/keychain"

The key property: Go's linker performs dead-code elimination on unimported packages. If your main never imports the keychain subpackage (directly or transitively), the linker never pulls go-keyring, godbus, or wincred into the binary. They exist in the module but not in the artefact. Keep the import in a dedicated file (cmd/mytool/keychain.go) and a build variant that deletes that file ships keychain-free.

This is dependency inversion: the core exposes a registration seam (RegisterBackend), and the optional heavy dependency plugs into it from the outside, so the core never depends on it.

Verify it from the artefact, not the source

A source-level SBOM generated from go.sum lists go-keyring regardless — the module graph contains it either way. That is expected and not a finding. What matters is the linked binary:

# Build a keychain-free variant (no keychain blank import), then inspect it.
go build -o mytool ./cmd/mytool
go version -m mytool        # module build info
# or, definitively, a binary-level SBOM:
cyclonedx-gomod bin -json mytool | grep -i keyring   # → no matches

A binary-level SBOM tool (syft, cyclonedx-gomod bin) reads what the linker actually included. When the blank import is absent, go-keyring does not appear. That is the audit evidence.

The module's own depfootprint_test.go encodes the same boundary as a test: the core and credtest packages must not depend on go-keyring, and the keychain subpackage must — a regression on either side fails CI.

Why a subpackage, not a separate module

You might expect each backend to be its own module (as the toolkit's chat providers are). It is a deliberate, recorded decision that credentials does not do this.

The go-tool-base decision log rejected a bundled "secrets-provider registry" with first-party vendor implementations (Vault, SSM, 1Password): secrets management is deployment-specific, and shipping one vendor's adapter invites a rabbit hole of them. The conclusion was that Backend is the extension point and tool authors supply their own vendor adapters — so there is no family of first-party backend modules to keep version-aligned.

That leaves exactly one first-party backend: the OS keychain. It is the light, canonical, cross-platform local store — the natural default, and go-keyring is a far lighter dependency than any cloud SDK. Splitting one small backend into its own module would add a version-compatibility matrix for no benefit. So it stays a subpackage, isolated by the linker rather than by a module boundary. Heavy, vendor-specific stores do not become subpackages either — they are your custom Backend.

Consequences

  • The default build is safe by default. A tool that does nothing links the stub; keychain support is an explicit act (the import), not something to remember to disable.
  • The opt-out is a one-line diff. Adding or removing the blank import is the whole operation — no build tags, no separate release artefacts.
  • The core stays framework-free. go-keyring never enters the core's dependency graph, so tools that only import credentials (not the subpackage) never carry it.