Files
nixfiles/home/secret-service.nix
T
Emma ThorpeandClaude Opus 5 10f713103c
CI / flake (push) Skipped
CI / flake (pull_request) Successful in 3m57s
feat(work): headless Secret Service for gcx keychain tokens
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>
2026-08-11 15:03:18 +01:00

143 lines
5.9 KiB
Nix

# 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" ];
};
};
}