From 21e59f31471820bc07c933aecfe1ebbb0920510a Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Wed, 18 Mar 2026 19:05:34 +0900 Subject: [PATCH 1/7] refactor(matic): replace systemd service with PAM exec for keyring unlock Move GNOME Keyring TPM2 unlock from a systemd system service with retry loop to a pam_exec rule that runs immediately after pam_gnome_keyring in the greetd PAM session stack. Eliminates timing/retry issues entirely: - pam_gnome_keyring (order 12600): starts daemon - pam_exec (order 12610): decrypts TPM credential, speaks control socket protocol directly as the user via runuser Co-Authored-By: Claude Opus 4.6 (1M context) --- named-hosts/matic/README.md | 21 ++++----- named-hosts/matic/default.nix | 81 ++++++++++++++++------------------- 2 files changed, 45 insertions(+), 57 deletions(-) diff --git a/named-hosts/matic/README.md b/named-hosts/matic/README.md index 9b4723f1e..ee79bef36 100644 --- a/named-hosts/matic/README.md +++ b/named-hosts/matic/README.md @@ -19,22 +19,19 @@ sudo bash -c 'mkdir -p /etc/credstore.encrypted && \ - /etc/credstore.encrypted/gnome-keyring.cred' ``` -Then restart the service: - -```bash -sudo systemctl restart gnome-keyring-unlock.service -``` +The keyring password must be your **system login password** (the one PAM uses when you log in with password). ### How it works -1. `services.gnome.gnome-keyring.enable` starts the keyring daemon at login via PAM. -2. `security.pam.services.greetd.enableGnomeKeyring` auto-unlocks for password logins. -3. `systemd.services.gnome-keyring-unlock` runs as a **system service** with `User=skakinoki` so the system manager handles TPM decryption. It then speaks the gnome-keyring **control socket protocol** directly to unlock the running daemon — covering fingerprint logins where PAM has no password to forward. -4. The service skips silently if the credential file does not exist yet. +1. `pam_gnome_keyring.so` starts the keyring daemon during PAM session open (order 12600). +2. For **password login**: PAM forwards the password and the keyring auto-unlocks. +3. For **fingerprint login**: PAM has no password, so the keyring stays locked. Immediately after, `pam_exec.so` (order 12610) runs a script that: + - Decrypts the TPM2 credential via `systemd-creds decrypt` (runs as root, has TPM access) + - Uses `runuser` to switch to the target user + - Speaks the gnome-keyring **control socket protocol** directly to unlock the daemon +4. The script exits silently if the credential file does not exist. -> **Note:** `gnome-keyring-daemon --unlock` (v48+) ignores `GNOME_KEYRING_CONTROL` and always starts a fresh instance. The service works around this by writing directly to `$XDG_RUNTIME_DIR/keyring/control` using the binary protocol: credentials byte + big-endian `[oplen][op=1][pwlen][password]`, reads `[8][result]`. -> -> **Note:** The credential must be at `/etc/credstore.encrypted/gnome-keyring.cred` (not `~/.config`). User-level systemd services cannot access TPM/host keys — only the system manager can. +> **Note:** `gnome-keyring-daemon --unlock` (v48+) ignores `GNOME_KEYRING_CONTROL` and always starts a fresh instance. The PAM script works around this by writing directly to `$XDG_RUNTIME_DIR/keyring/control` using the binary protocol: credentials byte + big-endian `[oplen][op=1][pwlen][password]`, reads `[8][result]`. ### Re-encrypting after keyring password change diff --git a/named-hosts/matic/default.nix b/named-hosts/matic/default.nix index c326ef810..a4df85f5f 100644 --- a/named-hosts/matic/default.nix +++ b/named-hosts/matic/default.nix @@ -121,24 +121,27 @@ inputs.nixpkgs.lib.nixosSystem { # GNOME Keyring - auto-unlocks GPG key on login via PAM services.gnome.gnome-keyring.enable = true; - # Unlock GNOME Keyring via TPM2 credential at login. - # System service so the system manager (not user manager) handles TPM decryption. + # Unlock GNOME Keyring via TPM2 credential at login (PAM exec). + # Runs in the PAM session stack right after pam_gnome_keyring starts the daemon, + # so there are no timing/retry issues. Runs as root (can access TPM), then uses + # runuser to speak the control socket protocol as the target user (SO_PEERCRED). + # # Credential stored at /etc/credstore.encrypted/gnome-keyring.cred — create once with: # sudo bash -c 'mkdir -p /etc/credstore.encrypted && \ # systemd-ask-password "Keyring password:" | \ # systemd-creds encrypt --name=gnome-keyring --with-key=tpm2+host \ # - /etc/credstore.encrypted/gnome-keyring.cred' - systemd.services.gnome-keyring-unlock = { - description = "Unlock GNOME Keyring via TPM2 credential"; - after = [ "user@${toString 1000}.service" ]; - wantedBy = [ "user@${toString 1000}.service" ]; - unitConfig.ConditionPathExists = "/etc/credstore.encrypted/gnome-keyring.cred"; - serviceConfig = { - Type = "oneshot"; - User = username; - TimeoutStartSec = 60; - LoadCredentialEncrypted = "gnome-keyring:/etc/credstore.encrypted/gnome-keyring.cred"; - ExecStart = + + # Fingerprint authentication + services.fprintd.enable = true; + security.pam.services.greetd = { + fprintAuth = true; + enableGnomeKeyring = true; + rules.session = { + # Run after pam_gnome_keyring (which starts the daemon but can't unlock + # on fingerprint login). Decrypts the TPM2 credential and sends the + # password to the running daemon via the control socket protocol. + gnome_keyring_tpm_unlock = let # Speaks the gnome-keyring control socket protocol directly. # gnome-keyring-daemon --unlock (v48) ignores GNOME_KEYRING_CONTROL @@ -174,44 +177,32 @@ inputs.nixpkgs.lib.nixosSystem { _, result = struct.unpack(">II", resp) return result - import time - pw = sys.stdin.read().rstrip("\n") + result = unlock(pw) codes = {0: "OK", 1: "DENIED", 2: "FAILED", 3: "NO_DAEMON"} - uid = os.getuid() - sock_path = f"/run/user/{uid}/keyring/control" - - for attempt in range(10): - # Wait for the control socket to appear (keyring daemon to start) - if not os.path.exists(sock_path): - print(f"attempt {attempt+1}: waiting for control socket...", flush=True) - time.sleep(3) - continue - result = unlock(pw) - print(f"attempt {attempt+1}: gnome-keyring unlock: {codes.get(result, result)}", flush=True) - if result == 0: - sys.exit(0) - # DENIED might mean daemon not fully ready yet, retry - time.sleep(3) - - print("gnome-keyring unlock: gave up after 10 attempts", flush=True) - sys.exit(1) + print(f"gnome-keyring unlock: {codes.get(result, result)}", flush=True) + sys.exit(0 if result == 0 else 1) ''; - in - pkgs.writeShellScript "unlock-keyring" '' - export XDG_RUNTIME_DIR="/run/user/$(id -u)" - cat "$CREDENTIALS_DIRECTORY/gnome-keyring" | ${unlockPy} + + # PAM exec script: runs as root, decrypts TPM credential, then + # uses runuser to run the Python unlock as the target user. + pamScript = pkgs.writeShellScript "pam-gnome-keyring-tpm-unlock" '' + CRED="/etc/credstore.encrypted/gnome-keyring.cred" + [ -f "$CRED" ] || exit 0 + ${pkgs.systemd}/bin/systemd-creds decrypt --name=gnome-keyring "$CRED" - | \ + ${pkgs.util-linux}/bin/runuser -u "$PAM_USER" -- \ + ${pkgs.coreutils}/bin/env XDG_RUNTIME_DIR="/run/user/$(id -u "$PAM_USER")" \ + ${unlockPy} ''; - RemainAfterExit = "yes"; + in + { + order = config.security.pam.services.greetd.rules.session.gnome_keyring.order + 10; + control = "optional"; + modulePath = "${pkgs.pam}/lib/security/pam_exec.so"; + args = [ "${pamScript}" ]; + }; }; }; - - # Fingerprint authentication - services.fprintd.enable = true; - security.pam.services.greetd = { - fprintAuth = true; - enableGnomeKeyring = true; - }; security.pam.services.hyprlock = { fprintAuth = true; }; From 4287627039589939afb9af61c38928319639d8bb Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Wed, 18 Mar 2026 19:12:15 +0900 Subject: [PATCH 2/7] fix(matic): wait for keyring control socket before unlock attempt The PAM exec script was failing (exit code 1) because the gnome-keyring-daemon control socket isn't always ready by the time pam_exec runs. Add a poll loop (up to 5s) matching the original systemd service's retry logic. Co-Authored-By: Claude Sonnet 4.6 --- named-hosts/matic/default.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/named-hosts/matic/default.nix b/named-hosts/matic/default.nix index a4df85f5f..52500be20 100644 --- a/named-hosts/matic/default.nix +++ b/named-hosts/matic/default.nix @@ -189,6 +189,12 @@ inputs.nixpkgs.lib.nixosSystem { pamScript = pkgs.writeShellScript "pam-gnome-keyring-tpm-unlock" '' CRED="/etc/credstore.encrypted/gnome-keyring.cred" [ -f "$CRED" ] || exit 0 + SOCK="/run/user/$(id -u "$PAM_USER")/keyring/control" + for i in 1 2 3 4 5; do + [ -S "$SOCK" ] && break + sleep 1 + done + [ -S "$SOCK" ] || exit 1 ${pkgs.systemd}/bin/systemd-creds decrypt --name=gnome-keyring "$CRED" - | \ ${pkgs.util-linux}/bin/runuser -u "$PAM_USER" -- \ ${pkgs.coreutils}/bin/env XDG_RUNTIME_DIR="/run/user/$(id -u "$PAM_USER")" \ From 3cf60b1284227fd23af5dc1c2c34c9b1c4b6c5c6 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Wed, 18 Mar 2026 19:28:57 +0900 Subject: [PATCH 3/7] debug(matic): add logger output to keyring TPM unlock script Co-Authored-By: Claude Sonnet 4.6 --- named-hosts/matic/default.nix | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/named-hosts/matic/default.nix b/named-hosts/matic/default.nix index 52500be20..f0260523a 100644 --- a/named-hosts/matic/default.nix +++ b/named-hosts/matic/default.nix @@ -187,18 +187,28 @@ inputs.nixpkgs.lib.nixosSystem { # PAM exec script: runs as root, decrypts TPM credential, then # uses runuser to run the Python unlock as the target user. pamScript = pkgs.writeShellScript "pam-gnome-keyring-tpm-unlock" '' + log() { echo "gnome-keyring-tpm: $*" | ${pkgs.util-linux}/bin/logger -t gnome-keyring-tpm; } CRED="/etc/credstore.encrypted/gnome-keyring.cred" [ -f "$CRED" ] || exit 0 SOCK="/run/user/$(id -u "$PAM_USER")/keyring/control" + log "PAM_USER=$PAM_USER sock=$SOCK" for i in 1 2 3 4 5; do [ -S "$SOCK" ] && break + log "waiting for socket (attempt $i)..." sleep 1 done - [ -S "$SOCK" ] || exit 1 - ${pkgs.systemd}/bin/systemd-creds decrypt --name=gnome-keyring "$CRED" - | \ + if [ ! -S "$SOCK" ]; then + log "socket not found after 5s, giving up" + exit 1 + fi + log "socket found, decrypting credential and unlocking" + OUT=$(${pkgs.systemd}/bin/systemd-creds decrypt --name=gnome-keyring "$CRED" - | \ ${pkgs.util-linux}/bin/runuser -u "$PAM_USER" -- \ ${pkgs.coreutils}/bin/env XDG_RUNTIME_DIR="/run/user/$(id -u "$PAM_USER")" \ - ${unlockPy} + ${unlockPy} 2>&1) + STATUS=$? + log "result: $OUT (exit $STATUS)" + exit $STATUS ''; in { From 60fb5b4d104aef475b91c8ea484f70893869c31f Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Wed, 18 Mar 2026 19:38:15 +0900 Subject: [PATCH 4/7] fix(matic): retry keyring unlock in background after user session starts The gnome-keyring-daemon p11-kit backend fails to initialize at PAM session-open time (GCK_IS_SESSION assertions), causing the control socket to return DENIED immediately even with the correct password. The daemon becomes fully operational a few seconds after the user session starts (evidenced by discover_other_daemon and gcr-prompter). Decrypt the TPM credential synchronously (needs root), then fork a background retry loop (every 3s, up to 8 attempts) so PAM is never blocked and the unlock hits the fully-initialized daemon. Co-Authored-By: Claude Sonnet 4.6 --- named-hosts/matic/default.nix | 44 +++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/named-hosts/matic/default.nix b/named-hosts/matic/default.nix index f0260523a..0189ddf32 100644 --- a/named-hosts/matic/default.nix +++ b/named-hosts/matic/default.nix @@ -190,25 +190,35 @@ inputs.nixpkgs.lib.nixosSystem { log() { echo "gnome-keyring-tpm: $*" | ${pkgs.util-linux}/bin/logger -t gnome-keyring-tpm; } CRED="/etc/credstore.encrypted/gnome-keyring.cred" [ -f "$CRED" ] || exit 0 - SOCK="/run/user/$(id -u "$PAM_USER")/keyring/control" - log "PAM_USER=$PAM_USER sock=$SOCK" - for i in 1 2 3 4 5; do - [ -S "$SOCK" ] && break - log "waiting for socket (attempt $i)..." - sleep 1 - done - if [ ! -S "$SOCK" ]; then - log "socket not found after 5s, giving up" + + # Decrypt synchronously — requires root/TPM access (not available after fork). + PW=$(${pkgs.systemd}/bin/systemd-creds decrypt --name=gnome-keyring "$CRED" -) + if [ -z "$PW" ]; then + log "credential decrypt failed" exit 1 fi - log "socket found, decrypting credential and unlocking" - OUT=$(${pkgs.systemd}/bin/systemd-creds decrypt --name=gnome-keyring "$CRED" - | \ - ${pkgs.util-linux}/bin/runuser -u "$PAM_USER" -- \ - ${pkgs.coreutils}/bin/env XDG_RUNTIME_DIR="/run/user/$(id -u "$PAM_USER")" \ - ${unlockPy} 2>&1) - STATUS=$? - log "result: $OUT (exit $STATUS)" - exit $STATUS + + # The gnome-keyring-daemon p11-kit backend is not fully initialized at + # PAM session-open time — unlock attempts at this point return DENIED. + # Fork a background retry loop so login is never blocked; the daemon + # is ready within a few seconds of the user session starting. + USER_UID=$(id -u "$PAM_USER") + SOCK="/run/user/$USER_UID/keyring/control" + ( + for attempt in 1 2 3 4 5 6 7 8; do + sleep 3 + [ -S "$SOCK" ] || { log "attempt $attempt: socket not found"; continue; } + OUT=$(printf '%s' "$PW" | \ + ${pkgs.util-linux}/bin/runuser -u "$PAM_USER" -- \ + ${pkgs.coreutils}/bin/env XDG_RUNTIME_DIR="/run/user/$USER_UID" \ + ${unlockPy} 2>&1) + STATUS=$? + log "attempt $attempt: $OUT (exit $STATUS)" + [ "$STATUS" -eq 0 ] && break + done + ) & + + exit 0 ''; in { From f60b76666824974027e95b54084267030ca5f293 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Wed, 18 Mar 2026 19:41:42 +0900 Subject: [PATCH 5/7] fix(matic): skip keyring unlock for system users (uid < 1000) The background retry was spawning for the greeter user (uid=990) and producing noisy "socket not found" log entries after its session ended. Skip early for non-login users. Co-Authored-By: Claude Sonnet 4.6 --- named-hosts/matic/default.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/named-hosts/matic/default.nix b/named-hosts/matic/default.nix index 0189ddf32..46d85dcd2 100644 --- a/named-hosts/matic/default.nix +++ b/named-hosts/matic/default.nix @@ -203,6 +203,8 @@ inputs.nixpkgs.lib.nixosSystem { # Fork a background retry loop so login is never blocked; the daemon # is ready within a few seconds of the user session starting. USER_UID=$(id -u "$PAM_USER") + # Skip system/greeter users (uid < 1000) + [ "$USER_UID" -lt 1000 ] && exit 0 SOCK="/run/user/$USER_UID/keyring/control" ( for attempt in 1 2 3 4 5 6 7 8; do From 47a25d59b38bc32e9f225483e9090c0babbf9a2a Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Wed, 18 Mar 2026 20:09:24 +0900 Subject: [PATCH 6/7] fix(matic): harden keyring TPM unlock script error handling - Fix infinite recv loop: check for empty bytes (daemon closed connection) - Validate PAM_USER is non-empty before use - Check systemd-creds exit code explicitly; redirect stderr to /dev/null - Check id -u exit code; log specific error if UID resolution fails - Log clear message when all retry attempts are exhausted Co-Authored-By: Claude Sonnet 4.6 --- named-hosts/matic/default.nix | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/named-hosts/matic/default.nix b/named-hosts/matic/default.nix index 46d85dcd2..cbbed23a9 100644 --- a/named-hosts/matic/default.nix +++ b/named-hosts/matic/default.nix @@ -173,7 +173,10 @@ inputs.nixpkgs.lib.nixosSystem { s.sendall(pkt) resp = b"" while len(resp) < 8: - resp += s.recv(8 - len(resp)) + chunk = s.recv(8 - len(resp)) + if not chunk: + raise RuntimeError(f"daemon closed connection after {len(resp)} bytes") + resp += chunk _, result = struct.unpack(">II", resp) return result @@ -191,22 +194,33 @@ inputs.nixpkgs.lib.nixosSystem { CRED="/etc/credstore.encrypted/gnome-keyring.cred" [ -f "$CRED" ] || exit 0 + if [ -z "$PAM_USER" ]; then + log "PAM_USER is not set" + exit 1 + fi + # Decrypt synchronously — requires root/TPM access (not available after fork). - PW=$(${pkgs.systemd}/bin/systemd-creds decrypt --name=gnome-keyring "$CRED" -) - if [ -z "$PW" ]; then + PW=$(${pkgs.systemd}/bin/systemd-creds decrypt --name=gnome-keyring "$CRED" - 2>/dev/null) + if [ $? -ne 0 ] || [ -z "$PW" ]; then log "credential decrypt failed" exit 1 fi + USER_UID=$(id -u "$PAM_USER" 2>&1) + if [ $? -ne 0 ]; then + log "failed to resolve UID for PAM_USER='$PAM_USER': $USER_UID" + exit 1 + fi + # Skip system/greeter users (uid < 1000) + [ "$USER_UID" -lt 1000 ] && exit 0 + # The gnome-keyring-daemon p11-kit backend is not fully initialized at # PAM session-open time — unlock attempts at this point return DENIED. # Fork a background retry loop so login is never blocked; the daemon # is ready within a few seconds of the user session starting. - USER_UID=$(id -u "$PAM_USER") - # Skip system/greeter users (uid < 1000) - [ "$USER_UID" -lt 1000 ] && exit 0 SOCK="/run/user/$USER_UID/keyring/control" ( + UNLOCKED=0 for attempt in 1 2 3 4 5 6 7 8; do sleep 3 [ -S "$SOCK" ] || { log "attempt $attempt: socket not found"; continue; } @@ -216,8 +230,12 @@ inputs.nixpkgs.lib.nixosSystem { ${unlockPy} 2>&1) STATUS=$? log "attempt $attempt: $OUT (exit $STATUS)" - [ "$STATUS" -eq 0 ] && break + if [ "$STATUS" -eq 0 ]; then + UNLOCKED=1 + break + fi done + [ "$UNLOCKED" -eq 0 ] && log "all attempts exhausted — keyring was NOT unlocked for $PAM_USER" ) & exit 0 From 47bd108092337b1b956832030401e31005951e76 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Wed, 18 Mar 2026 20:13:41 +0900 Subject: [PATCH 7/7] fix(matic): harden PAM keyring unlock hook --- named-hosts/matic/README.md | 3 ++- named-hosts/matic/default.nix | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/named-hosts/matic/README.md b/named-hosts/matic/README.md index ee79bef36..8e9bba769 100644 --- a/named-hosts/matic/README.md +++ b/named-hosts/matic/README.md @@ -25,9 +25,10 @@ The keyring password must be your **system login password** (the one PAM uses wh 1. `pam_gnome_keyring.so` starts the keyring daemon during PAM session open (order 12600). 2. For **password login**: PAM forwards the password and the keyring auto-unlocks. -3. For **fingerprint login**: PAM has no password, so the keyring stays locked. Immediately after, `pam_exec.so` (order 12610) runs a script that: +3. For **fingerprint login**: PAM has no password, so the keyring stays locked. Immediately after, `pam_exec.so type=open_session` (order 12610) runs a script that: - Decrypts the TPM2 credential via `systemd-creds decrypt` (runs as root, has TPM access) - Uses `runuser` to switch to the target user + - Retries in the background until the keyring control socket is ready - Speaks the gnome-keyring **control socket protocol** directly to unlock the daemon 4. The script exits silently if the credential file does not exist. diff --git a/named-hosts/matic/default.nix b/named-hosts/matic/default.nix index cbbed23a9..c16c267f6 100644 --- a/named-hosts/matic/default.nix +++ b/named-hosts/matic/default.nix @@ -206,7 +206,7 @@ inputs.nixpkgs.lib.nixosSystem { exit 1 fi - USER_UID=$(id -u "$PAM_USER" 2>&1) + USER_UID=$(${pkgs.coreutils}/bin/id -u "$PAM_USER" 2>&1) if [ $? -ne 0 ]; then log "failed to resolve UID for PAM_USER='$PAM_USER': $USER_UID" exit 1 @@ -222,7 +222,7 @@ inputs.nixpkgs.lib.nixosSystem { ( UNLOCKED=0 for attempt in 1 2 3 4 5 6 7 8; do - sleep 3 + ${pkgs.coreutils}/bin/sleep 3 [ -S "$SOCK" ] || { log "attempt $attempt: socket not found"; continue; } OUT=$(printf '%s' "$PW" | \ ${pkgs.util-linux}/bin/runuser -u "$PAM_USER" -- \ @@ -245,7 +245,10 @@ inputs.nixpkgs.lib.nixosSystem { order = config.security.pam.services.greetd.rules.session.gnome_keyring.order + 10; control = "optional"; modulePath = "${pkgs.pam}/lib/security/pam_exec.so"; - args = [ "${pamScript}" ]; + args = [ + "type=open_session" + "${pamScript}" + ]; }; }; };