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