Test the keychain on a headless host¶
The keychain backend needs a reachable OS credential store — macOS Keychain, Windows
Credential Manager, or a freedesktop Secret Service provider on Linux. On a
headless Linux host (server, SSH-only box, CI runner) the session bus is often present
but no Secret Service is registered, so Probe
correctly returns false and any flow using ModeChoices
hides the keychain option.
This guide shows how to (1) check reachability, (2) stand up a real Secret Service to exercise the genuine go-keyring round-trip, (3) fake it when you only need code coverage, (4) verify a stored secret at the platform level, and (5) prove a regulated build stripped the keychain code entirely.
1. Check keychain reachability¶
Before anything else, find out what your host actually offers.
| Platform | Backend | Ready out of the box? |
|---|---|---|
| macOS | Keychain | Yes — login keychain, unlocked after login. |
| Linux (desktop) | Secret Service (GNOME Keyring, KWallet) | If a desktop session is running. |
| Linux (headless / SSH / container) | — | No — Probe returns false; keychain hidden. Use option 2 below to unblock. |
| Windows | Credential Manager | Yes on a logged-in user session. |
On Linux, ping the Secret Service directly:
dbus-send --session --print-reply \
--dest=org.freedesktop.secrets /org/freedesktop/secrets \
org.freedesktop.DBus.Peer.Ping
Interpret the result:
- Empty
DBUS_SESSION_BUS_ADDRESS→ no session bus at all. ServiceUnknownerror → the bus is up, but no Secret Service is registered.- No error → bus and Secret Service are both live;
Probeshould succeed.
Or ask the library directly with a tiny diagnostic program (note Probe takes a
context):
package main
import (
"context"
"fmt"
_ "gitlab.com/phpboyscout/go/credentials/keychain" // registers the backend
"gitlab.com/phpboyscout/go/credentials"
)
func main() {
fmt.Println("Available:", credentials.KeychainAvailable())
fmt.Println("Probe: ", credentials.Probe(context.Background()))
}
Available=false→ thecredentials/keychainsubpackage is not linked (missing blank import, or a stub build).Available=true/Probe=false→ the backend is compiled in but the live round-trip failed: on Linux almost always a missing or locked Secret Service; on macOS a locked login keychain; on Windows a disabled Credential Manager.
2. Stand up a real Secret Service (the genuine round-trip)¶
Option A — GNOME Keyring with dbus-run-session¶
Spawns a transient session bus, runs an unlocked gnome-keyring-daemon inside it, and
tears everything down when the shell exits — no persistent state.
sudo apt-get install -y gnome-keyring libsecret-tools dbus-user-session
# Fedora: sudo dnf install -y gnome-keyring libsecret dbus-daemon
# Arch: sudo pacman -S gnome-keyring libsecret dbus
Run a single scripted command against a live keyring:
dbus-run-session -- bash -c '
# --login creates the keyring (if missing) AND unlocks it in one step,
# reading the password from stdin — avoids the gcr-prompter dialog that
# --unlock alone would trigger on a fresh, display-less environment.
eval "$(printf "test-pass\n" | \
gnome-keyring-daemon --daemonize --login --components=secrets)"
# Sanity check with a canary entry.
printf "canary-value" | \
secret-tool store --label="canary" service test account canary
secret-tool lookup service test account canary # → canary-value
# Now run your tool (or the diagnostic program from section 1).
go run ./cmd/mytool ...
'
Why --login and not --unlock?
On modern gnome-keyring (42+), --unlock requires an existing keyring file — a
fresh dbus-run-session has none — and triggers the graphical gcr-prompter,
which fails on a headless host with cannot open display. --login bypasses the
prompter by reading the password from stdin.
For an interactive session, keep the subshell alive with dbus-run-session -- bash -l
and run the eval line inside it.
Caveats: dbus-run-session must run from a real login session (SSH, tmux,
screen); under sudo -u or a daemon context it often fails with "Failed to open
connection to bus". Entries written this way live in a transient keyring, invisible
to your normal login session — that isolation is the point.
Option B — Containerised Secret Service¶
A throwaway container with gnome-keyring, for testing a specific distro/library
version or when host policy blocks dbus-run-session:
docker run --rm -it -v "$PWD":/src -w /src ubuntu:24.04 bash -c '
apt-get update -qq && apt-get install -y --no-install-recommends \
golang-go gnome-keyring libsecret-tools dbus ca-certificates git >/dev/null
dbus-run-session -- bash -c "
eval \"\$(printf \"test-pass\n\" | \
gnome-keyring-daemon --daemonize --login --components=secrets)\"
go run ./cmd/mytool ...
"
'
3. Fake it with the in-memory backend (code coverage, no real keychain)¶
When you only need to exercise your own code paths — the keychain-mode branch of a
setup flow, a resolver, config writes — and not go-keyring itself, register the
in-process credtest.MemoryBackend. It reports
Available() == true and satisfies Probe, so your flow runs the keychain branch
unchanged, with nothing touching a session bus.
For unit tests use credtest.Install(t) — see Test credential flows.
To make a built binary use it on a headless host, temporarily swap your keychain
opt-in file:
// cmd/mytool/keychain.go — TEMPORARY dev swap; restore before committing.
package main
import (
"gitlab.com/phpboyscout/go/credentials"
"gitlab.com/phpboyscout/go/credentials/credtest"
)
//nolint:gochecknoinits // side-effect registration for headless test runs
func init() {
credentials.RegisterBackend(&credtest.MemoryBackend{})
}
This covers the wizard UI, the Probe round-trip, config writes, and your resolver
cascade — but not go-keyring's real behaviour against a platform store, which only
options A/B (or a desktop) exercise. Restore the real import before committing:
4. Verify a stored secret at the platform level¶
After a keychain store under service/account (e.g. mytool/github.auth):
Clean up test entries afterwards:
5. Test the graceful-degradation path¶
To confirm your flow hides keychain (rather than erroring) when the store is unreachable, force the probe to fail on a host that would otherwise pass:
The keychain option must simply not appear; the flow falls back to env-var and literal modes.
6. Prove a regulated build stripped the keychain¶
For a build that omits the keychain blank import (see Enable OS-keychain storage), confirm the linker dropped go-keyring and its IPC chain:
go build -o mytool-regulated ./cmd/mytool
go tool nm mytool-regulated | grep -cE "zalando|godbus" # → 0
A binary-level SBOM is the definitive check — see The keychain opt-out.
Troubleshooting¶
- Keychain option missing when you expect it. Run the diagnostic program in
section 1.
Available=false→ the subpackage is not linked;Probe=false→ the live round-trip failed (locked/absent Secret Service, locked login keychain). secret-tool: command not found. Installlibsecret-tools(Debian/Ubuntu) orlibsecret(Fedora/Arch).dbus-run-sessionfails to open a bus. Run it from a real login session, not a daemon orsudo -ucontext.
Related¶
- Enable OS-keychain storage
- Test credential flows — unit testing with
credtest - The keychain opt-out