Skip to content
22 changes: 10 additions & 12 deletions named-hosts/matic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,20 @@ 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 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.

> **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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The previous version of the README included a note explaining why the credential file must be located at /etc/credstore.encrypted/gnome-keyring.cred (i.e., "User-level systemd services cannot access TPM/host keys — only the system manager can."). While the implementation has shifted from a systemd service to a PAM script, the credential location remains the same, and the rationale for this specific path is still valuable context for understanding the system's design. Consider re-adding this explanation to the README, perhaps in the "One-time setup" section or as a separate note, to prevent loss of this important detail for future maintainers.

> **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

Expand Down
132 changes: 86 additions & 46 deletions named-hosts/matic/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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).
#
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 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
Expand Down Expand Up @@ -170,48 +173,85 @@ 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

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)
Comment on lines 183 to +187
'';
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" ''
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

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" - 2>/dev/null)
if [ $? -ne 0 ] || [ -z "$PW" ]; then
log "credential decrypt failed"
exit 1
fi

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
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.
SOCK="/run/user/$USER_UID/keyring/control"
(
UNLOCKED=0
for attempt in 1 2 3 4 5 6 7 8; do
${pkgs.coreutils}/bin/sleep 3
[ -S "$SOCK" ] || { log "attempt $attempt: socket not found"; continue; }
OUT=$(printf '%s' "$PW" | \

@cubic-dev-ai cubic-dev-ai Bot Mar 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The runuser → Python unlock command has no execution timeout. If the gnome-keyring daemon accepts the socket connection but stalls (e.g., during initialization), the Python recv loop blocks forever, leaving an orphaned background process per login. Wrap the command in timeout to bound each attempt.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At named-hosts/matic/default.nix, line 211:

<comment>The `runuser` → Python unlock command has no execution timeout. If the gnome-keyring daemon accepts the socket connection but stalls (e.g., during initialization), the Python `recv` loop blocks forever, leaving an orphaned background process per login. Wrap the command in `timeout` to bound each attempt.</comment>

<file context>
@@ -190,25 +190,35 @@ inputs.nixpkgs.lib.nixosSystem {
+                    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" \
</file context>
Suggested change
OUT=$(printf '%s' "$PW" | \
OUT=$(printf '%s' "$PW" | \
${pkgs.coreutils}/bin/timeout 10 \
Fix with Cubic

${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)"
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
'';
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 = [
"type=open_session"
"${pamScript}"
];
};
};
};

# Fingerprint authentication
services.fprintd.enable = true;
security.pam.services.greetd = {
fprintAuth = true;
enableGnomeKeyring = true;
};
security.pam.services.hyprlock = {
fprintAuth = true;
};
Expand Down
Loading