Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions home-manager/packages/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -125,19 +125,26 @@ with pkgs;
]
++ lib.optionals (stdenv.isLinux && isDesktop) [
_1password-gui
baobab
brightnessctl
celluloid
cheese
chromium
clickup
cliphist
code-cursor
discord
eog
evince
ffmpeg
file-roller
gedit
ghostty
github-desktop
gnome-disk-utility
google-chrome
grim
gthumb
hypridle
hyprlock
hyprpicker
Expand All @@ -146,11 +153,14 @@ with pkgs;
hyprsunset
libnotify
linux-wallpaperengine
loupe

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

This change introduces loupe, and another part of the pull request adds eog (on line 137). Both are image viewers, with loupe being the modern successor to eog in GNOME. To avoid redundancy in installed applications, you might want to consider including only one of them. For a more modern setup, keeping loupe and removing eog would be a reasonable choice.

nautilus
pavucontrol
playerctl
rofi
rofimoji
seahorse
shotwell
signal-desktop
slack
slurp
Expand Down
41 changes: 41 additions & 0 deletions named-hosts/matic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# matic

Setup and operational notes for the `matic` NixOS host.

---

## GNOME Keyring Auto-Unlock via TPM2

The system unlocks the GNOME Keyring automatically at login — including when using fingerprint auth — by storing the keyring password as a `systemd-creds` credential encrypted with the machine's TPM2 + host key. The credential can only be decrypted on this machine.

### One-time setup

After first running `make switch`, create the credential (requires sudo for TPM access):

```bash
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'
```

Then restart the service:

```bash
sudo systemctl restart gnome-keyring-unlock.service
```

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

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

### Re-encrypting after keyring password change

If you change your keyring password (via seahorse), re-run the setup command above to update the credential.
95 changes: 92 additions & 3 deletions named-hosts/matic/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,99 @@ inputs.nixpkgs.lib.nixosSystem {
# Thunderbolt support
services.hardware.bolt.enable = true;

# 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.
# 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" ];
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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 =
let
# Speaks the gnome-keyring control socket protocol directly.
# gnome-keyring-daemon --unlock (v48) ignores GNOME_KEYRING_CONTROL
# and always starts a new instance, so we bypass it entirely.
#
# Protocol (all big-endian):
# 1. connect to $XDG_RUNTIME_DIR/keyring/control (UNIX stream)
# 2. send \x00 — daemon reads our UID via SO_PEERCRED
# 3. send [oplen:4][op=1:4][pwlen:4][password bytes]
# where oplen = 8 + 4 + len(password)
# 4. read [8:4][result:4] — result 0 = OK
unlockPy = pkgs.writeScript "unlock-gnome-keyring.py" ''
#!${pkgs.python3}/bin/python3
import os, socket, struct, stat, sys

def unlock(password):
uid = os.getuid()
xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{uid}")
sock_path = os.path.join(xdg, "keyring", "control")
st = os.lstat(sock_path)
if not stat.S_ISSOCK(st.st_mode) or st.st_uid != uid:
raise RuntimeError(f"bad socket: {sock_path}")
pw = password.encode()
oplen = 8 + 4 + len(pw)
pkt = struct.pack(">II", oplen, 1) + struct.pack(">I", len(pw)) + pw
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.connect(sock_path)
s.sendall(b"\x00")
s.sendall(pkt)
resp = b""
while len(resp) < 8:
resp += s.recv(8 - len(resp))
Comment on lines +172 to +173

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

P1: Infinite loop if the daemon closes the connection before sending a full 8-byte response. socket.recv() returns b"" on a closed connection, so resp never grows and the while loop spins forever. Add a check for an empty recv.

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 171:

<comment>Infinite loop if the daemon closes the connection before sending a full 8-byte response. `socket.recv()` returns `b""` on a closed connection, so `resp` never grows and the `while` loop spins forever. Add a check for an empty recv.</comment>

<file context>
@@ -137,12 +137,53 @@ inputs.nixpkgs.lib.nixosSystem {
+                          s.sendall(b"\x00")
+                          s.sendall(pkt)
+                          resp = b""
+                          while len(resp) < 8:
+                              resp += s.recv(8 - len(resp))
+                      _, result = struct.unpack(">II", resp)
</file context>
Suggested change
while len(resp) < 8:
resp += s.recv(8 - len(resp))
while len(resp) < 8:
chunk = s.recv(8 - len(resp))
if not chunk:
raise RuntimeError(f"connection closed after {len(resp)} bytes")
resp += chunk
Fix with Cubic

_, result = struct.unpack(">II", resp)
return result

import time

pw = sys.stdin.read().rstrip("\n")
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)

@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: Handle unlock(pw) exceptions inside the retry loop; checking path existence alone does not prevent startup races from aborting the service.

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 190:

<comment>Handle `unlock(pw)` exceptions inside the retry loop; checking path existence alone does not prevent startup races from aborting the service.</comment>

<file context>
@@ -173,11 +174,28 @@ inputs.nixpkgs.lib.nixosSystem {
+                          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:
</file context>
Suggested change
result = unlock(pw)
try:
result = unlock(pw)
except (OSError, RuntimeError) as exc:
print(f"attempt {attempt+1}: unlock failed before daemon was ready: {exc}", flush=True)
time.sleep(3)
continue
Fix with Cubic

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)
'';
in
pkgs.writeShellScript "unlock-keyring" ''
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
cat "$CREDENTIALS_DIRECTORY/gnome-keyring" | ${unlockPy}
'';
RemainAfterExit = "yes";
};
};

# Fingerprint authentication
services.fprintd.enable = true;
security.pam.services.greetd = {
fprintAuth = true;
enableGnomeKeyring = true;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
};
security.pam.services.hyprlock = {
fprintAuth = true;
Expand Down Expand Up @@ -438,9 +527,9 @@ inputs.nixpkgs.lib.nixosSystem {
services.gpg-agent = {
enable = true;
enableSshSupport = false;
pinentry.package = pkgs.pinentry-tty;
defaultCacheTtl = 94608000; # 3 years
maxCacheTtl = 94608000; # 3 years
pinentry.package = pkgs.pinentry-gnome3;
defaultCacheTtl = 2147483647; # max (effectively forever)
maxCacheTtl = 2147483647; # max (effectively forever)
Comment on lines +531 to +532
Comment on lines +530 to +532

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For GnuPG gpg-agent (current stable docs), what are recommended default-cache-ttlandmax-cache-ttl values for desktop usage, and what are the security implications of setting them to 2147483647?

💡 Result:

For desktop usage, the official GnuPG documentation recommends the default values for gpg-agent: default-cache-ttl of 600 seconds (10 minutes) and max-cache-ttl of 7200 seconds (2 hours). These are explicitly stated as the defaults in the gpg-agent man page and agent options documentation across multiple GnuPG versions, including the current stable series (2.5.x as of 2026). No specific "recommended" values different from defaults are given for desktop usage in official docs; the defaults are considered suitable for general interactive use, balancing convenience and security. Setting default-cache-ttl and max-cache-ttl to 2147483647 (2^31-1 seconds, approximately 68 years) effectively disables passphrase expiration. Security implications include: - Passphrases remain in memory indefinitely (until agent restart or manual eviction), increasing risk if the machine is compromised, stolen, or accessed by an unauthorized local user. - Heightens exposure to memory-dumping attacks (e.g., via tools like gpg_reaper demonstrated in a GnuPG bug report). - Conflicts with security best practices for desktop environments, where session-based caching (e.g., until logout) is preferable. - Even with long TTLs, newer GnuPG versions use timers for eviction (not just on access), but extremely long values still pose risks. To apply: Edit ~/.gnupg/gpg-agent.conf with the options (without leading --), then gpgconf --kill gpg-agent; gpgconf --launch gpg-agent.

Citations:


Avoid effectively-forever GPG cache TTLs.

Lines 473–474 set passphrase cache retention to ~68 years (2147483647 seconds). Official GnuPG documentation recommends default-cache-ttl of 600 seconds (10 minutes) and max-cache-ttl of 7200 seconds (2 hours). Extended TTLs significantly weaken workstation security if the user session is compromised, as passphrases remain in memory indefinitely and increase exposure to memory-dumping attacks. Keep the GNOME unlock flow but bound gpg-agent TTLs to practical limits.

Suggested change
           services.gpg-agent = {
             enable = true;
             enableSshSupport = false;
             pinentry.package = pkgs.pinentry-gnome3;
-            defaultCacheTtl = 2147483647; # max (effectively forever)
-            maxCacheTtl = 2147483647; # max (effectively forever)
+            defaultCacheTtl = 28800; # 8h
+            maxCacheTtl = 86400; # 24h cap
           };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pinentry.package = pkgs.pinentry-gnome3;
defaultCacheTtl = 2147483647; # max (effectively forever)
maxCacheTtl = 2147483647; # max (effectively forever)
pinentry.package = pkgs.pinentry-gnome3;
defaultCacheTtl = 28800; # 8h
maxCacheTtl = 86400; # 24h cap
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@named-hosts/matic/default.nix` around lines 472 - 474, The GPG agent TTLs are
set to effectively forever via the attributes defaultCacheTtl and maxCacheTtl
(with pinentry.package set to pkgs.pinentry-gnome3); change these to bounded,
practical values (e.g., defaultCacheTtl = 600 and maxCacheTtl = 7200) to follow
GnuPG recommendations while preserving the GNOME pinentry flow; update the named
attributes defaultCacheTtl and maxCacheTtl accordingly so passphrases are not
retained indefinitely.

};

# GPG_TTY is set in fish shell init instead of sessionVariables
Expand Down
Loading