feat(work): headless Secret Service for gcx keychain tokens
CI / flake (push) Skipped
CI / flake (pull_request) Successful in 3m57s

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>
This commit is contained in:
Emma Thorpe
2026-08-11 15:03:18 +01:00
co-authored by Claude Opus 5
parent cc6cb24c78
commit 10f713103c
6 changed files with 191 additions and 3 deletions
+4 -3
View File
@@ -30,7 +30,7 @@ profiles. The full module catalogue is below.
flake.nix # inputs, mkHost/mkDarwinHost, the host tables, dev shell + checks flake.nix # inputs, mkHost/mkDarwinHost, the host tables, dev shell + checks
flake.lock # pinned input revisions (Renovate keeps this fresh) flake.lock # pinned input revisions (Renovate keeps this fresh)
modules/ # reusable NixOS system modules (see "Module catalogue") modules/ # reusable NixOS system modules (see "Module catalogue")
home/ # home-manager profile: shell, git, editor, claude, desktop, sway home/ # home-manager profile: shell, git, editor, claude, secret-service, desktop, sway
users/ # identity registry + per-user home extras (see "Users") users/ # identity registry + per-user home extras (see "Users")
hosts/<Name>/ # per-machine config: configuration.nix + hardware-configuration.nix hosts/<Name>/ # per-machine config: configuration.nix + hardware-configuration.nix
lib/ # small pure helpers (currently the Catppuccin Mocha palette) lib/ # small pure helpers (currently the Catppuccin Mocha palette)
@@ -94,8 +94,9 @@ Per-user home extras live under `users/<name>/`:
- [`users/lyrathorpe/home.nix`](./users/lyrathorpe/home.nix) — personal extras - [`users/lyrathorpe/home.nix`](./users/lyrathorpe/home.nix) — personal extras
(an ssh host shortcut, gammastep coordinates); imported on Lyra's hosts. (an ssh host shortcut, gammastep coordinates); imported on Lyra's hosts.
- [`users/emmathorpe/work.nix`](./users/emmathorpe/work.nix) — the work - [`users/emmathorpe/work.nix`](./users/emmathorpe/work.nix) — the work
toolchain (kubectl/helm/az/etc.), work-only LSP servers, and the corporate ssh toolchain (kubectl/helm/az/etc.), work-only LSP servers, the corporate ssh
handling; imports handling, and the headless Secret Service that gcx needs for its keychain
tokens (see [`home/secret-service.nix`](./home/secret-service.nix)); imports
[`users/emmathorpe/renovate-review.nix`](./users/emmathorpe/renovate-review.nix), [`users/emmathorpe/renovate-review.nix`](./users/emmathorpe/renovate-review.nix),
the daily headless Renovate-PR review timer (EDaaS only). the daily headless Renovate-PR review timer (EDaaS only).
+1
View File
@@ -435,6 +435,7 @@
git = ./home/git.nix; git = ./home/git.nix;
editor = ./home/editor.nix; editor = ./home/editor.nix;
claude = ./home/claude.nix; claude = ./home/claude.nix;
secret-service = ./home/secret-service.nix;
desktop = ./home/desktop.nix; desktop = ./home/desktop.nix;
sway = ./home/sway.nix; sway = ./home/sway.nix;
}; };
+3
View File
@@ -8,6 +8,9 @@
./git.nix ./git.nix
./editor.nix ./editor.nix
./claude.nix ./claude.nix
# Declares services.headlessSecretService; opt-in, off by default. Graphical
# hosts should prefer home-manager's own services.gnome-keyring.
./secret-service.nix
]; ];
# Manage the XDG base-directory layout and ~/.config files. Tools above # Manage the XDG base-directory layout and ~/.config files. Tools above
+142
View File
@@ -0,0 +1,142 @@
# Headless Secret Service (org.freedesktop.secrets) on the user session bus,
# for CLI tools that keep credentials in the system keychain rather than in a
# config file of their own.
#
# Current consumer: gcx, the Grafana Cloud CLI (users/emmathorpe/work.nix). gcx
# stores its OAuth access and refresh tokens in the keychain unconditionally --
# its config file holds only opaque `keychain:gcx:v2:...` handles -- and offers
# no plaintext fallback (there is no environment variable or config key to
# select a file-backed store). With nothing owning org.freedesktop.secrets,
# `gcx login` authenticates against Grafana successfully and then dies writing
# its config: "The name is not activatable".
#
# home-manager already ships services.gnome-keyring, but it does not fit a
# headless host on two counts:
#
# * it is WantedBy graphical-session-pre.target, which never activates
# without a desktop session, so the service would simply never start; and
# * it cannot unlock the login keyring (it passes no --unlock). An unlocked
# collection is mandatory: writing to a locked one blocks on a GUI prompter
# (gcr) that does not exist here, so the caller hangs rather than fails.
#
# Security posture, stated plainly: the login 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. It is the same
# posture as the existing ~/.jenkinsenv and ~/.splunkenv token files, and it is
# the price of unattended operation -- systemd --user timers start with no
# human present to type a passphrase.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.headlessSecretService;
# Where the generated unlock password lives when no external passwordFile is
# supplied. Under $XDG_DATA_HOME rather than the nix store, which is
# world-readable.
defaultPasswordFile = "${config.xdg.dataHome}/gnome-keyring/login-password";
passwordFile = if cfg.passwordFile != null then cfg.passwordFile else defaultPasswordFile;
keyringDaemon = pkgs.writeShellApplication {
name = "headless-secret-service";
runtimeInputs = [
pkgs.gnome-keyring
pkgs.coreutils
];
text = ''
pwfile=${lib.escapeShellArg passwordFile}
if [ ! -s "$pwfile" ]; then
echo "headless-secret-service: no keyring password at $pwfile" >&2
exit 1
fi
# The daemon takes the whole of stdin as the password, so a trailing
# newline would silently become part of it. Strip it, so a hand-written or
# agenix-managed file unlocks the same keyring the generated one created.
#
# --components=secrets ONLY. The ssh component must stay off: it would
# claim SSH_AUTH_SOCK and displace services.ssh-agent, breaking SSH auth
# and signed commits. pkcs11 is not needed by anything here.
tr -d '\n' <"$pwfile" |
exec gnome-keyring-daemon --foreground --components=secrets --unlock
'';
};
in
{
options.services.headlessSecretService = {
enable = lib.mkEnableOption ''
a headless gnome-keyring serving org.freedesktop.secrets on the user
session bus, with the login keyring unlocked at service start'';
passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/run/agenix/gnome-keyring-login";
description = ''
Path to a file holding the login keyring password. It is read at service
start, not at build time, so it need not exist when the system is built
-- this is the seam for an agenix-managed secret.
When null, a random 32-byte password is generated on first activation at
${defaultPasswordFile} (mode 0600) and reused from then on.
Pointing this at a different file after the login keyring already exists
does NOT re-key the keyring: the daemon will fail to unlock it. To
change the password, delete ~/.local/share/keyrings and re-authenticate
every tool that stored a secret there.
'';
};
};
config = lib.mkIf cfg.enable {
# secret-tool, for inspecting or repairing the keyring by hand when a stored
# credential misbehaves (`secret-tool search --all service gcx`).
home.packages = [ pkgs.libsecret ];
# Generate the unlock password on first activation. Guarded on us owning it:
# an externally supplied passwordFile is never created or written here.
home.activation = lib.mkIf (cfg.passwordFile == null) {
headlessSecretServicePassword = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
pwfile=${lib.escapeShellArg defaultPasswordFile}
if [ ! -s "$pwfile" ]; then
run mkdir -p "$(dirname "$pwfile")"
# Create the file empty at 0600 first, then fill it: the redirect
# keeps the existing mode, so the password is never briefly readable.
run install -m 600 /dev/null "$pwfile"
run ${pkgs.bash}/bin/sh -c \
'head -c 32 /dev/urandom | base64 -w0 > "$1"' sh "$pwfile"
fi
'';
};
systemd.user.services.headless-secret-service = {
Unit = {
Description = "GNOME Keyring (Secret Service, headless)";
Documentation = "man:gnome-keyring-daemon(1)";
# The daemon claims its name on the user session bus.
Requires = [ "dbus.socket" ];
After = [ "dbus.socket" ];
};
Service = {
Type = "simple";
ExecStart = lib.getExe keyringDaemon;
Restart = "on-failure";
RestartSec = 2;
};
# default.target, not graphical-session-pre.target: there is no graphical
# session on this host. With `linger` enabled (see the host table in
# flake.nix) default.target is reached at boot, so the keyring is also up
# for unattended systemd --user timers, not just interactive logins.
Install.WantedBy = [ "default.target" ];
};
};
}
+34
View File
@@ -38,6 +38,40 @@ the daily headless **Renovate PR review** timer firing — defined in
(imported only from `work.nix`, so it exists on this machine alone). See that (imported only from `work.nix`, so it exists on this machine alone). See that
file's header for the auth (Vertex AI ADC), triage policy, and caveats. file's header for the auth (Vertex AI ADC), triage policy, and caveats.
## Secret Service (keychain)
`work.nix` sets `services.headlessSecretService.enable = true`, which runs
`gnome-keyring` as a `systemd --user` service owning `org.freedesktop.secrets`
on the session bus, with the login keyring unlocked at start.
This exists for **gcx**, the Grafana Cloud CLI. gcx stores its OAuth access and
refresh tokens in the keychain unconditionally (its config keeps only opaque
`keychain:gcx:v2:...` handles) and has no plaintext fallback, so without a
Secret Service `gcx login` authenticates and then fails to persist with "The
name is not activatable".
Home-manager's own `services.gnome-keyring` does not work here: it is
`WantedBy=graphical-session-pre.target`, which never activates on this headless
box, and it cannot unlock the keyring. See
[`../../home/secret-service.nix`](../../home/secret-service.nix) for the full
rationale and the security trade-off of an auto-unlocked keyring.
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.
Checking it:
```sh
systemctl --user status headless-secret-service
busctl --user list | grep secrets # expect org.freedesktop.secrets
secret-tool search --all service gcx # inspect what gcx stored
gcx config check # end-to-end
```
If the keyring password is ever lost or changed, the login keyring cannot be
unlocked: delete `~/.local/share/keyrings` and re-run `gcx login`.
## stateVersion ## stateVersion
`system.stateVersion = "24.11"` — the release this box was first installed on. `system.stateVersion = "24.11"` — the release this box was first installed on.
+7
View File
@@ -49,6 +49,13 @@
pkgs.gcx # Grafana Cloud CLI (dashboards, SLOs, synthetics, alerts) pkgs.gcx # Grafana Cloud CLI (dashboards, SLOs, synthetics, alerts)
]; ];
services.ssh-agent.enable = true; services.ssh-agent.enable = true;
# gcx (above) keeps its OAuth tokens in the system keychain and has no
# plaintext fallback, so this WSL box needs something owning
# org.freedesktop.secrets. See home/secret-service.nix for why
# home-manager's services.gnome-keyring cannot be used on a headless host,
# and for the security trade-off of an auto-unlocked keyring.
services.headlessSecretService.enable = true;
home.shellAliases = { home.shellAliases = {
docker = "/run/current-system/sw/bin/docker"; docker = "/run/current-system/sw/bin/docker";
}; };