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
6 changed files with 191 additions and 3 deletions
Showing only changes of commit 10f713103c - Show all commits
+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.lock # pinned input revisions (Renovate keeps this fresh)
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")
hosts/<Name>/ # per-machine config: configuration.nix + hardware-configuration.nix
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
(an ssh host shortcut, gammastep coordinates); imported on Lyra's hosts.
- [`users/emmathorpe/work.nix`](./users/emmathorpe/work.nix) — the work
toolchain (kubectl/helm/az/etc.), work-only LSP servers, and the corporate ssh
handling; imports
toolchain (kubectl/helm/az/etc.), work-only LSP servers, the corporate ssh
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),
the daily headless Renovate-PR review timer (EDaaS only).
+1
View File
@@ -435,6 +435,7 @@
git = ./home/git.nix;
editor = ./home/editor.nix;
claude = ./home/claude.nix;
secret-service = ./home/secret-service.nix;
desktop = ./home/desktop.nix;
sway = ./home/sway.nix;
};
+3
View File
@@ -8,6 +8,9 @@
./git.nix
./editor.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
+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
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
`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)
];
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 = {
docker = "/run/current-system/sw/bin/docker";
};