diff --git a/README.md b/README.md index 50f6bdf..48a3146 100644 --- a/README.md +++ b/README.md @@ -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// # 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//`: - [`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). diff --git a/flake.nix b/flake.nix index dd82bf7..d982c83 100644 --- a/flake.nix +++ b/flake.nix @@ -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; }; diff --git a/home/default.nix b/home/default.nix index 66cd442..7b86638 100644 --- a/home/default.nix +++ b/home/default.nix @@ -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 diff --git a/home/secret-service.nix b/home/secret-service.nix new file mode 100644 index 0000000..cb52604 --- /dev/null +++ b/home/secret-service.nix @@ -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" ]; + }; + }; +} diff --git a/hosts/EDaaS/README.md b/hosts/EDaaS/README.md index 8d44c40..8338198 100644 --- a/hosts/EDaaS/README.md +++ b/hosts/EDaaS/README.md @@ -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. diff --git a/users/emmathorpe/work.nix b/users/emmathorpe/work.nix index 29d18c3..ef24022 100644 --- a/users/emmathorpe/work.nix +++ b/users/emmathorpe/work.nix @@ -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"; };