feat(work): headless Secret Service for gcx keychain tokens #89

Merged
lyrathorpe merged 1 commits from feat/gcx-secret-service into main 2026-08-11 15:09:26 +01:00
Owner

Why

gcx stores its OAuth access and refresh tokens in the system keychain
unconditionally — its config file keeps only opaque keychain:gcx:v2:...
handles — and exposes no plaintext fallback (no env var or config key selects a
file-backed store). With nothing owning org.freedesktop.secrets on the
headless EDaaS box, gcx login authenticates against Grafana successfully and
then dies writing its config:

error: Writing config — inspect keychain entry for "stack:citrix" field
       "oauth-token": The name is not activatable

What

Adds services.headlessSecretService (home/secret-service.nix), enabled on
work.nix: gnome-keyring as a systemd --user service, unlocking the login
keyring at start.

home-manager's own services.gnome-keyring does not fit a headless host:

  • it is WantedBy=graphical-session-pre.target, which never activates without a
    desktop session, so the service would never start; and
  • it passes no --unlock, and writing to a locked collection blocks on a GUI
    prompter (gcr) that does not exist here — the caller hangs rather than fails.

Only the secrets component is started. The ssh component is deliberately
off: it would claim SSH_AUTH_SOCK and displace services.ssh-agent, breaking
SSH auth and signed commits.

Unlock password, and the agenix seam

The password defaults to a random 32-byte value generated on first activation at
$XDG_DATA_HOME/gnome-keyring/login-password (mode 0600, outside the
world-readable nix store). The passwordFile option supplies it from elsewhere
instead — once #55 lands, that is one line:

services.headlessSecretService.passwordFile =
  config.age.secrets.gnome-keyring-login.path;

Note agenix cannot replace the keyring: gcx needs a D-Bus name, not a file, and
these tokens rotate (gcx writes refreshed tokens back), so the store must be
writable at runtime. agenix is the right home for the one static value, the
unlock password.

Security trade-off

Stated plainly in the module header: the keyring is encrypted at rest, but the
password unlocking it is readable by the same user on the same machine. That
protects the tokens from something reading the keyring file directly; it
protects them from nothing already running as this user. Same posture as the
existing ~/.jenkinsenv / ~/.splunkenv files, and the price of unattended
operation — systemd --user timers start with no human present to type a
passphrase.

Testing

  • nix flake check — all checks pass.
  • nix eval of the emmathorpe-edaas toplevel — evaluates clean.
  • Ran the nix-built ExecStart script directly (not a hand-rolled
    equivalent): claims org.freedesktop.secrets, ssh-agent and both loaded
    keys unaffected.
  • Killed the daemon and restarted it from that script; gcx config check
    reports the citrix context valid and online (Grafana 13.2.0) and
    gcx datasources list returns live results — so the tokens survive a restart,
    i.e. a reboot.
  • secret-tool store/lookup/clear round-trip, headless, no prompt.

Not applied to the running system yet — nixos-rebuild switch is left to the
reviewer.

## Why gcx stores its OAuth access and refresh tokens in the system keychain unconditionally — its config file keeps only opaque `keychain:gcx:v2:...` handles — and exposes no plaintext fallback (no env var or config key selects a file-backed store). With nothing owning `org.freedesktop.secrets` on the headless EDaaS box, `gcx login` authenticates against Grafana successfully and then dies writing its config: ``` error: Writing config — inspect keychain entry for "stack:citrix" field "oauth-token": The name is not activatable ``` ## What Adds `services.headlessSecretService` (`home/secret-service.nix`), enabled on `work.nix`: gnome-keyring as a `systemd --user` service, unlocking the login keyring at start. home-manager's own `services.gnome-keyring` does not fit a headless host: - it is `WantedBy=graphical-session-pre.target`, which never activates without a desktop session, so the service would never start; and - it passes no `--unlock`, and writing to a locked collection blocks on a GUI prompter (gcr) that does not exist here — the caller hangs rather than fails. Only the `secrets` component is started. The `ssh` component is deliberately off: it would claim `SSH_AUTH_SOCK` and displace `services.ssh-agent`, breaking SSH auth and signed commits. ## Unlock password, and the agenix seam The password defaults to a random 32-byte value generated on first activation at `$XDG_DATA_HOME/gnome-keyring/login-password` (mode 0600, outside the world-readable nix store). The `passwordFile` option supplies it from elsewhere instead — once #55 lands, that is one line: ```nix services.headlessSecretService.passwordFile = config.age.secrets.gnome-keyring-login.path; ``` Note agenix cannot replace the keyring: gcx needs a D-Bus name, not a file, and these tokens rotate (gcx writes refreshed tokens back), so the store must be writable at runtime. agenix is the right home for the one static value, the unlock password. ## Security trade-off Stated plainly in the module header: the keyring is encrypted at rest, but the password unlocking it is readable by the same user on the same machine. That protects the tokens from something reading the keyring file directly; it protects them from nothing already running as this user. Same posture as the existing `~/.jenkinsenv` / `~/.splunkenv` files, and the price of unattended operation — `systemd --user` timers start with no human present to type a passphrase. ## Testing - `nix flake check` — all checks pass. - `nix eval` of the `emmathorpe-edaas` toplevel — evaluates clean. - Ran the **nix-built** `ExecStart` script directly (not a hand-rolled equivalent): claims `org.freedesktop.secrets`, `ssh-agent` and both loaded keys unaffected. - Killed the daemon and restarted it from that script; `gcx config check` reports the `citrix` context valid and online (Grafana 13.2.0) and `gcx datasources list` returns live results — so the tokens survive a restart, i.e. a reboot. - `secret-tool` store/lookup/clear round-trip, headless, no prompt. Not applied to the running system yet — `nixos-rebuild switch` is left to the reviewer.
lyrathorpe added 1 commit 2026-08-11 15:05:16 +01:00
feat(work): headless Secret Service for gcx keychain tokens
CI / flake (push) Skipped
CI / flake (pull_request) Successful in 3m57s
10f713103c
gcx stores its OAuth access and refresh tokens in the system keychain
unconditionally -- its config file keeps only opaque `keychain:gcx:v2:...`
handles -- and exposes no plaintext fallback. With nothing owning
org.freedesktop.secrets on this headless WSL box, `gcx login` authenticates
against Grafana and then dies writing its config: "The name is not activatable".

Add services.headlessSecretService: gnome-keyring as a systemd --user service,
unlocking the login keyring at start. home-manager's own services.gnome-keyring
does not fit here on two counts -- it is WantedBy graphical-session-pre.target,
which never activates without a desktop session, and it passes no --unlock, so
writes would block on a GUI prompter that does not exist.

Only the secrets component is started. The ssh component is deliberately off: it
would claim SSH_AUTH_SOCK and displace services.ssh-agent, breaking SSH auth and
signed commits.

The unlock password defaults to a random one generated on first activation under
$XDG_DATA_HOME. The passwordFile option is the seam for supplying it from an
agenix secret instead, once that lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lyrathorpe scheduled this pull request to auto merge when all checks succeed 2026-08-11 15:06:02 +01:00
lyrathorpe merged commit f471d226e0 into main 2026-08-11 15:09:26 +01:00
lyrathorpe deleted branch feat/gcx-secret-service 2026-08-11 15:09:27 +01:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: lyrathorpe/nixfiles#89