feat(work): headless Secret Service for gcx keychain tokens #89
@@ -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).
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user