Skip to content

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.
  • ServiceUnknown error → the bus is up, but no Secret Service is registered.
  • No error → bus and Secret Service are both live; Probe should 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 → the credentials/keychain subpackage 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:

git checkout -- cmd/mytool/keychain.go

4. Verify a stored secret at the platform level

After a keychain store under service/account (e.g. mytool/github.auth):

secret-tool lookup service mytool account github.auth
security find-generic-password -a github.auth -s mytool -w
cmdkey /list:mytool*

Clean up test entries afterwards:

secret-tool clear service mytool account github.auth
security delete-generic-password -a github.auth -s mytool
cmdkey /delete:mytool:github.auth

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:

DBUS_SESSION_BUS_ADDRESS=unix:path=/dev/null go run ./cmd/mytool ...

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. Install libsecret-tools (Debian/Ubuntu) or libsecret (Fedora/Arch).
  • dbus-run-session fails to open a bus. Run it from a real login session, not a daemon or sudo -u context.