Framework 13: Arch, Secure Boot, & Nix

Contents

In early 2025, I finally found my dream Linux laptop: the AMD Framework 13. Combining the modular, repair-first ethos of the original ThinkPads with a gorgeous 3:2 display.

Big Blue

A 14” ThinkPad T420—repeatedly upgraded for more RAM, storage, and eventually even a faster CPU—carried me through college. I fell in love with its pragmatic design, hacker friendliness, and broad plug-and-play Linux kernel support (most kernel developers—at the time, anyway—were ThinkPad users).

But something happened at Lenovo: in 2015–2016, a string of generously-named security incidents shattered consumer confidence, and each year felt like a step backwards in ThinkPad quality: Lenovo dialed back repairability in favor of sleek design, and capped screen resolutions for flagship models at 1080/1440p. In 2020, when the soldered-in-place RAM module on my brand new X1 Carbon failed after mere months of use, I elected to replace Lenovo instead of the machine.

The AMD Framework 13 laptop is everything I loved about ThinkPad: repairable, upgradeable, with high-quality displays and mainline kernel support (Tainted kernels aren’t my thing). It is a wonderful, tiny machine with a 3:2 display is just large enough for comfortable daily use and a chassis just light enough for carry as a second laptop when I’m on-call at work.

Hardware Overview

I built the hardware myself, meaning my Framework 13 required last-mile assembly by the end-user. This gives a better sense of control & repairability and lets you customize the machine, and it only takes a few minutes to assemble. I read complaints online about build quality but haven’t personally noticed anything bothersome besides wishing the bezel were aluminum (a very minor thing).

The AMD Ryzen AI 7 350 with Radeon 860M provides excellent performance and Linux compatibility. The integrated graphics work well with ollama for local AI inference:

$ cat /proc/cpuinfo | grep '^model name' | head -n1
model name      : AMD Ryzen AI 7 350 w/ Radeon 860M

The trackpad impressed me—it allows full tactile buttonpress over most of the surface (everything except the top half-centimeter). The keyboard is good quality; I chose blank keycaps for style and to encourage memorizing my layout. The power button doubles as a fingerprint reader that works reliably with out-of-box drivers.

Battery

I get excellent battery life for a Linux machine. With the power-saver power governeror (from powerprofilesctl), I get between 6 and 10 hours of battery life depending on usage.

Suspend

Suspend didn’t work flawlessly out of the box: sometimes the system would cold boot instead of resume.

I noticed something odd in the smartctl output:

# # smartctl -a /dev/nvme0n1  | grep -i shutdown
Unsafe Shutdowns:                   44,053

This is particular to the WD_BLACK SN7100 2TB drive that I ordered. I’ll describe the fixes later in this article.

Security Hardware

This AMD processor includes the Pod Security Processor, AMD’s equivalent to Intel Management Engine. The downside is that these secondary processors are often vulnerable and have privileged access to system hardware. The upside is this provides functionality such as Secure Boot.

As far as I know, it is not currently possible to use CoreBoot on the latest AMD Framework 13, but experimental support is in progress for the previous gen of AMD CPUs, leaving me hopeful that we will see it here eventually. However, the default firmware supports Secure Boot with custom keys, which is good enough for me at the moment.

Installation

NixOS is my distro of choice, but didn’t work for me: it failed to detect the Framework’s internal keyboard, using both the minimal and graphical NixOS ISOs. I tested external keyboards—which worked. As far as NixOS was concerned, the internal Framework keyboard device did not exist. I was able to successfully plug in external USB keyboards, however, and fell back to what I’m comfortable running: Arch.

To capture some of the benefits of Nix, I took a hybrid approach: Arch Linux as the base system with Nix (pacman -S nix) and home-manager for userspace configuration. This lets me reuse and improve configurations from my NixOS desktop while maintaining hardware compatibility. The dividing line I established: home-manager manages nearly all of my homedir, while pacman handles system packages and interactive configuration.

I plan to eventually migrate the full system NixOS. The nice thing about this is migrations from running systems are popular enough to be well-supported (at least by the community), and NixOS’s declarative nature lends itself particularly well to in-place upgrades.

Disk Layout and Encryption

Taking advantage of the built-in TPM, I configured automatic dm-crypt key storage. The layout uses separate /boot and /boot/efi partitions, with the main /dev/nvme0n1p2 partition housing LUKS. Within LUKS is LVM: one volume group for encrypted swap, and another divided into /home, /var/, and / btrfs subvolumes.

$ lsblk
NAME          MAJ:MIN RM  SIZE RO TYPE  MOUNTPOINTS
nvme0n1       259:0    0  1.8T  0 disk
├─nvme0n1p1   259:1    0    2G  0 part  /boot
├─nvme0n1p2   259:2    0  1.8T  0 part
│ └─cryptroot 253:0    0  1.8T  0 crypt
│   ├─vg-swap 253:1    0   32G  0 lvm   [SWAP]
│   └─vg-main 253:2    0  1.8T  0 lvm   /home
│                                       /var
│                                       /
└─nvme0n1p3   259:3    0    2G  0 part  /boot/efi

Security Configuration

With the base system and disk layout in place, next up is hardening the system security posture. The Framework 13 supports modern security functionality like secure boot, TPM-based disk encryption, and biometric authentication.

Secure Boot Setup

I use systemd-ukify, sbsign, and pacman hooks for a fully automated Secure Boot configuration—meaning I don’t have to manually re-sign binaries when I build a new initramfs or pull down a kernel update.

# sbctl status
Installed:      ✓ sbctl is installed
Owner GUID:     9df5ea07-e33e-4361-9c03-51d46b0dd9ed
Setup Mode:     ✓ Disabled
Secure Boot:    ✓ Enabled
Vendor Keys:    none

To get here you need to generate keys:

# sbctl create-keys

This creates keys in /var/lib/sbctl/keys/:

# ls /var/lib/sbctl/keys
db  KEK  PK

Then you reboot, put the secure-boot in setup mode (which allows writing keys), and enroll your keys with sbctl enroll-keys.

sbctl and signing

When you enable secure boot, your bootloader and kernel images will require signing. I ensure this with a pacman hook:

# cat /etc/pacman.d/hooks/90-secureboot-systemd-update.hook
[Trigger]
Operation = Upgrade
Type = Package
Target = systemd # systemd-boot is part of the systemd package

[Action]
Description = Updating and signing systemd-boot on ESP for Secure Boot...
When = PostTransaction
Exec = /usr/local/bin/update-signed-bootloader
NeedsTargets

Which calls a script /update-signed/bootloader1.

A database at /var/lib/sbctl/files.json tracks which files need to be signed. To sign a file and add it to the database, simply sign it with -s, --save:

$ sbctl sign --save /boot/efi/EFI/Linux/arch-troubleshoot.efi

Now this file is tracked for future updates:

# sbctl list-files
/boot/efi/EFI/systemd/systemd-bootx64.efi
Signed:         ✓ Signed

...

/boot/efi/EFI/Manual/arch.efi
Signed:         ✓ Signed

/boot/efi/EFI/Manual/arch-troubleshoot.efi
Signed:         ✓ Signed

Before rebooting, be sure to check the status is healthy:

# sbctl status
Installed:      ✓ sbctl is installed
Owner GUID:     9df5ea07-e33e-4361-9c03-51d46b0dd9ed
Setup Mode:     ✓ Disabled
Secure Boot:    ✓ Enabled
Vendor Keys:    none

ukify

The traditional Linux boot chain is multi-step and offers multiple opportunities for customization. In turn, these are multiple opportunities for compromise:

  1. Bootloader.
  2. Kernel images.
  3. initramfs.
  4. Command line options.

Instead, we can combine each of these steps after the initial bootloader into one image for signing and execution. I’m using ukify to generate unified kernel images with an embedded initramfs and kernel image. This is done via a file /etc/pacman.d/hooks/95-sbctl-ukify:

[Trigger]
Operation = Upgrade
Type = Package
Target = linux
Target = amd-ucode # Add other relevant packages like 'systemd' if its updates affect initramfs build

[Action]
Description = Rebuilding UKIs and signing with sbctl...
When = PostTransaction
Exec = /usr/local/bin/rebuild-ukis-for-sbctl.sh
NeedsTargets

This calls a script /usr/local/bin/rebuild-ukis-for-sbctl.sh, that handles image signing and configuring root disk unlock:

#!/bin/bash
set -euo pipefail

# Configuration
LUKS_UUID="$(blkid -s UUID -o value /dev/nvme0n1p2)"
SWAP_UUID="$(blkid -s UUID -o value /dev/mapper/vg-swap)"
UKI_PATH="/boot/efi/EFI/Manual"

# Basic kernel command line
CMDLINE="root=/dev/mapper/vg-main rw rootflags=subvol=@ rd.luks.name=${LUKS_UUID}=cryptroot rd.luks.options=${LUKS_UUID}=tpm2-device=auto,tries=3 resume=UUID=${SWAP_UUID} nvme_core.default_ps_max_latency_us=25000"

# Signing configuration
SECUREBOOT_KEY="/var/lib/sbctl/keys/db/db.key"
SECUREBOOT_CERT="/var/lib/sbctl/keys/db/db.pem"

build_uki() {
    local kernel="$1"
    local initrd="$2"
    local output="$3"
    local extra_cmdline="${4:-}"

    echo "Building $(basename "$output")..."

    ukify build \
        --linux="$kernel" \
        --initrd="$initrd" \
        --microcode='/boot/amd-ucode.img' \
        --os-release='@/etc/os-release' \
        --cmdline="${CMDLINE} ${extra_cmdline}" \
        --signtool='sbsign' \
        --output="$output"
    sbctl sign "${output}"
}

# Build main kernel
build_uki \
    "/boot/vmlinuz-linux" \
    "/boot/initramfs-linux.img" \
    "${UKI_PATH}/arch.efi"

echo "UKIs rebuilt and signed successfully."

TPM Disk Encryption

With the standard dm-crypt/LUKS configuration in place, it’s time to setup TPM enrollment. The goal of our TPM encryption is to support automatic disk decryption only when the entire Secure Boot chain is untampered. If the measured boot state changes, it falls back to password entry. Mistakes here mean an adversary with physical access can convince the TPM to unlock your OS without even a password! So we need to validate the firmware at boot time, and the kerel image that we’re booting, and the filesystem layout which we are booting against, to ensure an untampered boot chain all the way up to a running system.

Your TPM uses hashes of system state, or measurements stored in Platform Configuration Registers (PCRs), to determine the state of the machine before releasing key material. To see the available PCRs for your system:

# systemd-analyze pcrs
NR NAME                SHA256
 0 platform-code       [6aebf...                    sha256                    ...dea32]
 1 platform-config     [...                         sha256                         ...]
 2 external-code       [...                         sha256                         ...]
 3 external-config     [...                (same as external-code)                 ...]
 4 boot-loader-code    [...                         sha256                         ...]
 5 boot-loader-config  [...                         sha256                         ...]
 6 host-platform       [...                         sha256                         ...]
 7 secure-boot-policy  [...                         sha256                         ...]
 8 -                   0000000000000000000000000000000000000000000000000000000000000000
 9 kernel-initrd       [...                         sha256                         ...]
10 ima                 0000000000000000000000000000000000000000000000000000000000000000
11 kernel-boot         [...                         sha256                         ...]
12 kernel-config       0000000000000000000000000000000000000000000000000000000000000000
13 sysexts             0000000000000000000000000000000000000000000000000000000000000000
14 shim-policy         0000000000000000000000000000000000000000000000000000000000000000
15 system-identity     [...                         sha256                         ...]
16 debug               0000000000000000000000000000000000000000000000000000000000000000
17 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
18 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
19 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
20 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
21 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
22 -                   ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
23 application-support 0000000000000000000000000000000000000000000000000000000000000000

You need to pick which PCRs must match before TPM auto-unlock proceeds. Here are the most popular:

PCR Name Description
0 platform-code Core system firmware and boot code; changes on firmware updates
4 boot-loader-code Boot loader and additional drivers, PE binaries invoked by the boot loader; changes on boot loader updates. sd-stub(7) measures system extension images read from the ESP here too (see systemd-sysext(8)).
6 host-platform Host platform configuration and data
7 secure-boot-policy Secure Boot state; changes when UEFI SecureBoot mode is enabled/disabled, or firmware certificates (PK, KEK, db, dbx, …) changes.
15 system-identity systemd-cryptsetup(8) optionally measures the volume key of activated LUKS volumes into this PCR. systemd-pcrmachine.service(8) measures the machine- id(5) into this PCR. systemd-pcrfs@.service(8) measures mount points, file system UUIDs, labels, partition UUIDs of the root and /var/ filesystems into this PCR.

I enrolled the encryption key with the TPM:

# systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs="0+6+7" /dev/nvme0n1p2

To confirm that worked:

# cryptsetup luksDump /dev/nvme0n1p2 | grep "hash-pcrs"
        tpm2-hash-pcrs:   0+6+7

This enrolls PCRs 0, 6 and 7. If those values have changed, then the TPM will refuse to unlock the disk.

Warning: Even with this TPM-based disk unlock technique, attackers can decrypt your disk! Nearly every disk encryption guide for Linux misses this attack, as it is difficult to defend against.

As mentioned in the Arch Wiki:

*Only binding to PCRs measured pre-boot (PCRs 0-7) opens a vulnerability from rogue operating systems. A rogue partition with metadata copied from the real root filesystem (such as partition UUID) can mimic the original partition. Then, initramfs will attempt to mount the rogue partition as the root filesystem (decryption failure will fall back to password entry), leaving pre-boot PCRs unchanged. The rogue root filesystem with files controlled by an attacker is still able to receive the decryption key for the real root partition. See Brave New Trusted Boot World and BitLocker documentation for additional information.

To resolve this issue, we will configure a post-boot systemd unit to measure PCR 15. For now, let’s just enroll encryption with a PIN.

Enroll the TPM with systemd-cryptenroll. Later, I’ll implement full-chain root filesystem verification for improved convenience.

# systemd-cryptenroll /dev/nvme0n1p2 \
    --tpm2-device=auto \
    --tpm2-pcrs=0+6+7 \
    --tpm2-with-pin=yes
🔐 Please enter current passphrase for disk /dev/nvme0n1p2: •••••••••••••••••••••••••••••
🔐 Please enter TPM2 PIN: ••••••••••
🔐 Please enter TPM2 PIN (repeat): ••••••••••

Now let’s update our kernel cmdline to unlock the root device, with 3 tries for the PIN:

rd.luks.options=${LUKS_UUID}=tpm2-device=auto,tries=3

Trigger a ukify rebuild and reboot. The TPM PIN prompting should work as expected.

PCR Signing

The configuration technique provided earlier has a design weakness. The TPM unlocks the LUKS disk if and only if the PCRs supplied in --tpm2-pcrs have specific, exact hash values. These values either match when we set them, or boot fails. So a more restrictive PCR configuration is going to require more frequent re-enrollment. If we wanted to bind the kernel image to the TPM unlock, then we would have to re-enroll on every kernel image update.

Instead, we can configure the TPM to unlock when the PCRs are signed by a given signing key. This will sign PCR11, which changes on every kernel update, initramfs rebuild, and so on. Luckily ukify can handle this for us.

UKIFY(1)                             ukify                             UKIFY(1)

NAME
       ukify - Combine components into a signed Unified Kernel Image for UEFI
       systems

       [...]

       If PCR signing keys are provided via the
       PCRPrivateKey=/--pcr-private-key= and PCRPublicKey=/--pcr-public-key=
       options, PCR values that will be seen after booting with the given
       kernel, initrd, and other sections, will be calculated, signed, and
       embedded in the UKI.  systemd-measure(1) is used to perform this
       calculation and signing.

First, generate a keypair:

# ukify genkey \
    --pcr-private-key=/var/lib/pcr-keys/pcr-initrd.key.pem \
    --pcr-public-key=/var/lib/pcr-keys/pcr-initrd.pub.pem

Now we update our ukify build pipeline to use the keys:

# ukify build \
    --linux='/boot/vmlinuz-linux' \
    --initrd='/boot/initramfs-linux' \
    --microcode='/boot/amd-ucode.img' \
    --os-release='@/etc/os-release' \
    --cmdline="${CMDLINE}" \
    --pcrs=0,6,7 \
    --pcr-private-key='/var/lib/pcr-keys/pcr-initrd.key.pem' \
    --pcr-public-key='/var/lib/pcr-keys/pcr-initrd.pub.pem' \
    --signtool='sbsign' \
    --secureboot-private-key='/var/lib/sbctl/keys/db/db.key' \
    --secureboot-certificate='/var/lib/sbctl/keys/db/db.pem' \
    --output='/boot/efi/EFI/Manual/arch.efi'

Now ukify is handling both secureboot and PCR signing.

Wipe and re-enroll the TPM:

# systemd-cryptenroll --wipe-slot=tpm2 /dev/nvme0n1p2
# systemd-cryptenroll --tpm2-device=auto \
  --tpm2-pcrs=0+6+7 \
  --tpm2-public-key=/var/lib/pcr-keys/pcr-initrd.pub.pem \
  /dev/nvme0n1p2

Now you see a hybrid PCR approach:

# cryptsetup luksDump /dev/nvme0n1p2
    ...
0: systemd-tpm2
        tpm2-hash-pcrs:   0+6+7
        tpm2-pcr-bank:    sha256
        tpm2-pubkey:
        tpm2-pubkey-pcrs: 11
        tpm2-primary-alg: ecc
        tpm2-pin:         false
        tpm2-pcrlock:     false
        tpm2-salt:        false
        tpm2-srk:         true
        tpm2-pcrlock-nv:  false
        Keyslot:    1

Defense against boot attacks

As mentioned earlier, if you only depend on pre-boot PCR measurements, attackers can decrypt your disk by constructing a system with the same measurements, letting it auto-unlock, then stealing your keys.

The attack:

  1. Attacker creates a malicious USB/external drive.
  2. Copies your /boot partition exactly.
  3. Creates a fake root partition with the same UUID as your real root.
  4. Boots the system from this malicious setup.

Now they can configure the system to mount your real root.

Let’s defend against this.

The problem: PCR 15 measures the filesystems using a content-derived identity hash. It gets invalidated as the system boots, runs pivot_root, and mounts the main filesystems. As our defense, we are going to validate that PCR 15 matches its expected value, before pivot_root but after TPM unlock occurs.2

We’ll confirm the kernel cmdline and the PCR 15 values.

/usr/local/bin/check-pcr15.sh:

#!/bin/bash

LOG_DIR=/run/pcr15
mkdir -p "${LOG_DIR}"

log() {
    local message="${@}"
    echo "$(/bin/date '+%Y-%m-%d %H:%M:%S') - $@" |
        tee -a "${LOG_DIR}/validation.log" >&2
}

parse_cmdline() {
    local cmdline
    cmdline="$(cat /proc/cmdline)"
    read -r cmdline < /proc/cmdline
    if [[ "${cmdline}" =~ expected_pcr15=([0-9a-fA-F]+) ]]; then
        echo "${BASH_REMATCH[1]}"
    else
        echo ""
    fi
}

get_pcr15() {
    local pcr_output
    pcr_output="$(systemd-analyze pcrs 15 --json=short 2>/dev/null)"

    # Extract sha256 field using bash string manipulation
    if [[ "${pcr_output}" =~ \"sha256\":\"([0-9a-fA-F]+)\" ]]; then
        echo "${BASH_REMATCH[1]}"
    else
        echo ""
    fi
}

expected_pcr15="$(parse_cmdline)"
actual_pcr15="$(get_pcr15)"

if [ -n "${expected_pcr15}" ]; then
    log "Checking PCR 15 value"
    if [ "${actual_pcr15}" != "${expected_pcr15}" ]; then
        log "PCR 15 check failed. Expected '${expected_pcr15}', got '${actual_pcr15}'"
        log "WARNING: PCR 15 check failed - values don't match"
        log "This could indicate filesystem tampering or normal system changes"
        log "pcr15 mismatch // expected: '${expected_pcr15}' // actual: '${actual_pcr15}'"
        systemctl start emergency.target
        exit 1
    else
        log "PCR 15 check succeeded with value ${actual_pcr15}"
    fi
else
    log "No expected PCR 15 value provided - capture this for future validation!"
    log "Add 'expected_pcr15=${actual_pcr15}' to your kernel command line."
fi

Add a systemd unit:

[Unit]
Description=Measure filesystem integrity into PCR 15
DefaultDependencies=no
After=cryptsetup.target
After=systemd-pcrphase-initrd.service
Before=initrd-switch-root.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/check-pcr15.sh
StandardOutput=journal+console
StandardError=journal+console

[Install]
WantedBy=initrd.target
RequiredBy=initrd-switch-root.target

Add a stub hook /etc/initcpio/hooks/check-pcr15:

#!/usr/bin/bash

run_hook() {
    true
}

Have the hook install your dependencies (/etc/initcpio/install/check-pcr15). This is important because it does automatic dependency resolution, and lets us hardcode extra paths if we think they’re being skippe:

#!/bin/bash

build() {
    # Include the TPM2 tools
    add_binary /usr/bin/tpm2_pcrread
    add_binary /usr/bin/systemd-analyze

    # Dependencies for tpm2_pcrread if needed
    add_binary /usr/lib/libtss2-tcti-device.so
    add_binary /usr/lib/libtss2-tcti-cmd.so
    add_binary /usr/lib/libtss2-mu.so
    add_binary /usr/lib/libtss2-esys.so
    add_binary /usr/lib/libtss2-tctildr.so
}

help() {
    cat <<HELPEOF
This hook includes TPM2 tools needed for PCR validation.
HELPEOF
}

Add your hook to /etc/mkinitcpio.conf:

HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole sd-encrypt block lvm2 resume check-pcr15 filesystems fsck)

Ensure the files are present:

# BINARIES
# This setting includes any additional binaries a given user may
# wish into the CPIO image.  This is run last, so it may be used to
# override the actual binaries included by a given hook
# BINARIES are dependency parsed, so you may safely ignore libraries
BINARIES=(/bin/bash /usr/bin/systemd-analyze /usr/bin/tpm2_pcrread /bin/date)

# FILES
# This setting is similar to BINARIES above, however, files are added
# as-is and are not parsed in any way.  This is useful for config files.
FILES=(/etc/systemd/system/check-pcr15.service /usr/local/bin/check-pcr15.sh /etc/systemd/system/initrd.target.wants/check-pcr15.service)

Now you must find the measured PCR 15 value. Since we used systemd, we can fetch it from the journal:

# systemctl status check-pcr15.service
○ check-pcr15.service - Measure filesystem integrity into PCR 15
     Loaded: loaded (/etc/systemd/system/check-pcr15.service; enabled; preset: disabled)
     Active: inactive (dead) since Mon 2025-07-14 04:08:52 EDT; 17h ago
   Duration: 72ms
 Invocation: 4e768bbf20c147d18521aecd5fd30778
   Main PID: 349 (code=exited, status=0/SUCCESS)

Jul 14 04:08:51 archlinux systemd[1]: Starting Measure filesystem integrity into PCR 15...
Jul 14 04:08:51 archlinux check-pcr15.sh[367]: 2025-07-14 08:08:51 - Checking PCR 15 value
Jul 14 04:08:52 archlinux check-pcr15.sh[370]: 2025-07-14 08:08:51 - PCR 15 check succeeded with value [...]
Jul 14 04:08:52 archlinux systemd[1]: Finished Measure filesystem integrity into PCR 15.
Jul 14 04:08:52 archlinux systemd[1]: check-pcr15.service: Deactivated successfully.
Jul 14 04:08:52 archlinux systemd[1]: Stopped Measure filesystem integrity into PCR 15.

Now boot the system once without expected_pcr15, read /run/pcr15/validation.log for the measured PCR15 value. This corresponds to your filesystem layout. Add it into your UKI rebuilder script3 and you’re good to go.

Fingerprint Unlock

First install fprintd with pacman. It then needs to be configured with PAM. Modify /etc/pam.d/system-auth:

# diff -c10 /etc/pam.d/system-auth.bk /etc/pam.d/system-auth
*** /etc/pam.d/system-auth.bk   2025-05-01 04:34:54.821598714 -0400
--- /etc/pam.d/system-auth      2025-04-29 23:22:27.302944835 -0400
***************
*** 1,16 ****
--- 1,17 ----
  #%PAM-1.0

  auth       required                    pam_faillock.so      preauth
  # Optionally use requisite above if you do not want to prompt for the password
  # on locked accounts.
  -auth      [success=2 default=ignore]  pam_systemd_home.so
+ auth       sufficient                  pam_fprintd.so
  auth       [success=1 default=bad]     pam_unix.so          try_first_pass nullok
  auth       [default=die]               pam_faillock.so      authfail
  auth       optional                    pam_permit.so
  auth       required                    pam_env.so
  auth       required                    pam_faillock.so      authsucc
  # If you drop the above call to pam_faillock.so the lock will be done also
  # on non-consecutive authentication failures.

  -account   [success=1 default=ignore]  pam_systemd_home.so
  account    required                    pam_unix.so

To enroll your fingers, use fprintd-enroll (optionally with -f right-middle-finger and so forth). The readings are stored in /var/lib/fprint.

You can now login and sudo with your fingerprint.

NVMe Powersaving settings

Returning to the drive powersaving issues described earlier, we need to fix suspend by adjusting the NVMe powersaving settings. Suspend issues present as intermittent cold boots from lid-closed suspend state.

This is the same issue currently being hacked on in the Framework forums and similar to some described in the Ubuntu StackExchange.

In my case, I had a really high count of Unsafe shutdowns on my NVMe drive:

# nvme id-ctrl /dev/nvme0 | grep -E "(fr|mn) "
mn        : WD_BLACK SN7100 2TB
fr        : 7612M0WD
# smartctl -a/dev/nvme0n1  | grep -i shutdown
Unsafe Shutdowns:                   44,055

It turns out broken suspend was a result of the NVMe drive not successfully entering deeper suspend states. The drive has a parameter controlling how aggressively the SSD can enter low-power states. Higher latency values allow deep sleep states that save more power, but take longer to awake. In our case, the firmware has issues with power transitions, so we need to tell the device to be less aggressive with powersaving.

To check its powersaving status:

# nvme id-ctrl /dev/nvme0n1 | grep -A6 '^ps '
ps      0 : mp:4.60W operational enlat:0 exlat:0 rrt:0 rrl:0
            rwt:0 rwl:0 idle_power:0.2200W active_power:4.60W
            active_power_workload:80K 128KiB SW
            emergency power fail recovery time: -
            forced quiescence vault time: 10 (unit: 1 second)
            emergency power fail vault time: -
ps      1 : mp:3.00W operational enlat:0 exlat:0 rrt:0 rrl:0
            rwt:0 rwl:0 idle_power:0.2200W active_power:3.00W
            active_power_workload:80K 128KiB SW
            emergency power fail recovery time: -
            forced quiescence vault time: 10 (unit: 1 second)
            emergency power fail vault time: -
ps      2 : mp:2.50W operational enlat:0 exlat:0 rrt:0 rrl:0
            rwt:0 rwl:0 idle_power:0.2200W active_power:2.50W
            active_power_workload:80K 128KiB SW
            emergency power fail recovery time: -
            forced quiescence vault time: 10 (unit: 1 second)
            emergency power fail vault time: -
ps      3 : mp:0.0200W non-operational enlat:2000 exlat:3000 rrt:3 rrl:3
            rwt:3 rwl:3 idle_power:0.0200W active_power:-
            active_power_workload:-
            emergency power fail recovery time: -
            forced quiescence vault time: 10 (unit: 1 second)
            emergency power fail vault time: -
ps      4 : mp:0.0050W non-operational enlat:4000 exlat:12000 rrt:4 rrl:4
            rwt:4 rwl:4 idle_power:0.0050W active_power:-
            active_power_workload:-
            emergency power fail recovery time: -
            forced quiescence vault time: 10 (unit: 1 second)
            emergency power fail vault time: -
ps      5 : mp:0.0030W non-operational enlat:176000 exlat:25000 rrt:5 rrl:5
            rwt:5 rwl:5 idle_power:0.0030W active_power:-
            active_power_workload:-
            emergency power fail recovery time: -
            forced quiescence vault time: 10 (unit: 1 second)

This issue appeared far less often after upgrading to the 6.15.7 kernel and disabling ps 5, the deepest powersave state:

# echo 25000 | sudo tee /sys/module/nvme_core/parameters/default_ps_max_latency_us
# cat /sys/module/nvme_core/parameters/default_ps_max_latency_us
25000

To permanently disable NVMe ps 5 (deepest sleep), add nvme_core.default_ps_max_latency_us=25000 to your kernel cmdline.

Userspace management

I now use nix with home-manager to manage my homedir, similar to my NixOS machine.

Install Nix on Arch:

# pacman -S nix

The goal is, over time, more of the system will be eaten by Nix configuration. I view this as a good thing.

Nix settings4

You may want to consider enabling Nix store auto-optimization. This is disabled by default.

# echo "auto-optimise-store = true" >> /etc/nix/nix.conf

I also suggest enabling the build sandbox, if it is disabled, and configuring build-dir to point to a tmpfs:5

$ grep -E '(build|sandbox)' /etc/nix/nix.conf
sandbox = true
# Unix group containing the Nix build user accounts
build-users-group = nixbld
build-dir = /tmp/nix-build

Cachix

I also configure Cachix community caching. This is useful for emacs-overlay. Note that community binary caches are not without their downsides: caches are trusted. If Cachix is compromised, you will be as well.

This can be done by modifying /etc/nix/nix.conf:

substituters = https://cache.nixos.org https://nix-community.cachix.org
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=

Alternatively, use the Cachix installer.6

Home-Manager Flake

People say Nix is all about reproducible builds, but Nix derivations aren’t very reproducible to bat: nothing tracks which dependency versions were used when building a derivation! To address this issue, I formerly used Niv to pin dependencies for Nix projects. Flakes aim to provide a standard way to pin dependencies for Nix projects, which is why despite 85% community adoption, Flakes have yet to be standardized.7 That means it’s time use home-manager Flakes!

First, we need to enable Flakes:8

# echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf

Flakes track Nix dependencies using lockfiles and formalize the input/output interfaces of Nix modules. Let’s look at a home-manager configuration flake:

{
  description = "My full system config (Arch + Home Manager)";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, home-manager, ... }: let
    system = "x86_64-linux";
    username = builtins.getEnv "USER";
    homeDirectory = builtins.getEnv "HOME";

    secrets = {
      # ...
    };
  in {
    homeConfigurations.${username} = home-manager.lib.homeManagerConfiguration {
      pkgs = import nixpkgs {
        inherit system;
      };
      modules = [
        ../home-manager/arch.nix
        {
          programs.home-manager.enable = true;
          home.stateVersion = "24.11";
        }
      ];
      extraSpecialArgs = {
        inherit system;
        secrets = secrets;
        username = username;
        homeDirectory = homeDirectory;
      };
    };
  };
}

Building our Flake

Uh oh! I’m reading environment variables. That’s because there are certain values I’d rather keep out of my Git repo, like my user’s Git committer email. In vanilla Nix, we could import files from our .gitignore for secrets and the like, but with Flakes only committed files can be imported (internally, Flakes copy inputs to the Nix store prior to build). My workaround for this is to rely upon the environment, but that’s at odds with reproducibility: in general, don’t assume the same environment variables will be set across systems.

But this is a dotfiles configuration. We want a unique committer email across systems. Let’s use the --impure flag to make our environment available to the builder:

$ . ./secrets.sh
$ nix run github:nix-community/home-manager -- switch --impure --flake .

This isn’t the best long-term strategy for secrets. It’s fine if my email adress is discovered, but I wouldn’t want to expose SSH private keys this way they are available to all subprocesses, regardless of whether or not they require the secret. For example, let’s’ say I have a high-value secret at SECRET_KEY:

$ export SECRET_KEY='thxbud'
$ bash
(subshell) $ echo $$
13830

Now any user with this PID can read its environment:

$ TARGET_PID=13830
$ bash -c 'cat "/proc/${TARGET_PID}/environ" | tr ":\0" "\n" | grep -ai secret'
SECRET_KEY=thxbud

Luckily, my home configuration does not require extensive secrets management. If I had to do more, I wuld probaly wire something up with age and/or my Yubikeys.

Local AI with ollama

With the lates LLM craze, I figure it’s worth showing how to run some LLMs on this machine. I’m currently using nix-shell to run ollama. I had a hell of a time getting this to work with the Radeon 860M, so here are my steps.

First, you need to reboot and dedicate a portion of your RAM to use as VRAM. Cool that this works! But you can’t adapt it dynamically, so go tweak your UEFI settings.

Next, use rocminfo to get some magic variables:

$ nix-shell -p "rocmPackages.rocminfo" --run "rocminfo" | grep "gfx"
  Name:                    gfx1152
        Name:                    amdgcn-amd-amdhsa--gfx1152

Now set the env vars with the magic make rocm work values, the first of which is taken from rocminfo:

#!/usr/bin/env bash

OLLAMA_PACKAGE=ollama-rocm
ENV_VARS='HCC_AMDGPU_DEVICES=gfx1152 HSA_OVERRIDE_GFX_VERSION="11.0.0" ROCR_VISIBLE_DEVICES=0'
COMMAND="$ENV_VARS ollama serve"
echo "running '${COMMAND}'"
echo nix-shell -p "${OLLAMA_PACKAGE}" --command "${COMMAND}"

If it works, you will see output lines like this, indicating rocm is working:

time=2025-05-06T23:21:04.985-04:00 level=INFO source=types.go:130 msg="inference compute" id=0 library=rocm variant="" compute=gfx1152 driver=0.0 name=1002:1114 total="8.0 GiB" available="7.9 GiB"

If you get an error, like msg=unsupported Radeon iGPU detected skipping" id=0 total="512.0 MiB" then this means you need to reboot to your bootloader and change the iGPU settings to allocate static vRAM to your GPU. Sorry. I gave 8 GiB to mine, which is stolen permanently from RAM.


  1. /usr/local/bin/update-signed-bootloader:

    #!/usr/bin/env bash
    
    set -e
    
    ESP_PATH="/boot/efi"
    SYSTEMD_BOOT_SRC="/usr/lib/systemd/boot/efi/systemd-bootx64.efi"
    SYSTEMD_BOOT_DEST_MAIN="${ESP_PATH}/EFI/systemd/systemd-bootx64.efi"
    SYSTEMD_BOOT_DEST_FALLBACK="${ESP_PATH}/EFI/BOOT/BOOTX64.EFI"
    
    echo "Updating systemd-boot on ESP..."
    # Ensure destination directories exist
    mkdir -p "${ESP_PATH}/EFI/systemd"
    mkdir -p "${ESP_PATH}/EFI/BOOT"
    
    # Copy the new bootloader files to the ESP
    cp "${SYSTEMD_BOOT_SRC}" "${SYSTEMD_BOOT_DEST_MAIN}"
    cp "${SYSTEMD_BOOT_SRC}" "${SYSTEMD_BOOT_DEST_FALLBACK}"
    echo "Successfully copied systemd-boot to ESP."
    
    echo "Running sbctl sign-all to update signatures for bootloader..."
    # sbctl sign-all will detect that the files on the ESP have changed
    # (because their checksums are now different after the copy)
    # and re-sign them if they are tracked in its database.
    sbctl sign-all -g
    
    # Alternatively, for more explicit signing of just these files:
    # sbctl sign "${SYSTEMD_BOOT_DEST_MAIN}"
    # sbctl sign "${SYSTEMD_BOOT_DEST_FALLBACK}"
    
    echo "Bootloader update and signing process complete."
    ↩︎
  2. Unfortunately, measuring these values after disk unlock is imperfect: it leaves open a timing window where the disk is unlocked, but the post-boot verification has not yet run. We’re already brittle enough as-is, however, and this is essentially unpaved ground in the Linux Secure Boot world, so we’ll leave our solution at PCR15 mid-boot measurement and let others carry the torch.↩︎

  3. The full, final version of my /usr/local/bin/rebuild-ukis-for-sbctl.sh script is below:

    #!/bin/bash
    set -euo pipefail
    
    LUKS_UUID="$(blkid -s UUID -o value /dev/nvme0n1p2)"
    SWAP_UUID="$(blkid -s UUID -o value /dev/mapper/vg-swap)"
    UKI_PATH_ROOT="/boot/efi/EFI/Manual"
    DEFAULT_UKI_PATH="${UKI_PATH_ROOT}/arch.efi"
    TROUBLESHOOT_UKI_PATH="${UKI_PATH_ROOT}/arch-troubleshoot.efi"
    LTS_UKI_PATH="${UKI_PATH_ROOT}/arch-lts.efi"
    TROUBLESHOOT_LTS_UKI_PATH="${UKI_PATH_ROOT}/arch-lts-troubleshoot.efi"
    HARDENED_UKI_PATH="${UKI_PATH_ROOT}/arch-hardened.efi"
    TROUBLESHOOT_HARDENED_UKI_PATH="${UKI_PATH_ROOT}/arch-hardened-troubleshoot.efi"
    
    # usbcore quirks - fix Goodix Fingerprint USB Device
    # nvme_core.default_ps_max_latency_us=
    COMMON_CMDLINE="root=/dev/mapper/vg-main rw rootflags=subvol=@ usbcore.quirks=27c6:609c:0x40 rd.luks.name=${LUKS_UUID}=cryptroot rd.luks.options=${LUKS_UUID}=tpm2-device=auto,tpm2-measure-pcr=yes,discard,tries=3 lsm=capability,landlock,lockdown,yama,apparmor,bpf mitigations=full nvme_core.default_ps_max_latency_us=25000"
    SECURE_CMDLINE="audit=1 iommu=pt"
    SUSPEND_CMDLINE="rtc_cmos.use_acpi_alarm=1"
    RESUME_CMDLINE="resume=UUID=${SWAP_UUID}"
    TROUBLE_CMDLINE="log_level=7 rd.debug"
    
    SIGN_TOOL=sbsign
    SECUREBOOT_PRIVATE_KEY="/var/lib/sbctl/keys/db/db.key"
    SECUREBOOT_CERTIFICATE="/var/lib/sbctl/keys/db/db.pem"
    
    PCR_PRIVATE_KEY='/var/lib/pcr-keys/pcr-initrd.key.pem'
    PCR_PUBLIC_KEY='/var/lib/pcr-keys/pcr-initrd.pub.pem'
    
    EXPECTED_PCR15='01ba4719...daca546b'
    
    echo "cmdline:"
    echo "  ${COMMON_CMDLINE}"
    
    echo "hardened cmdline:"
    echo "  ${COMMON_CMDLINE} ${SECURE_CMDLINE}"
    
    build_uki() {
        local kernel_path="$1"
        local initrd_path="$2"
        local cmdline="$3"
        local output_path="$4"
        local pcr15_value="${5:-}"
    
        set -x
        # Add expected PCR 15 to kernel command line
        if [[ -n "${pcr15_value}" ]]; then
            echo "got pcr15 value: ${pcr15_value}"
            cmdline="${cmdline} expected_pcr15=${pcr15_value}"
        fi
    
        local cmdline_file="$(mktemp)"
        echo "$cmdline" > "${cmdline_file}"
        trap "rm -f '${cmdline_file}'; echo >&2 'Cleaned up temp file: ${cmdline_file}'" EXIT
            # --pcrs=0,6,7 \
    
        echo "Building $(basename "$output_path")..."
        ukify build \
            --linux="${kernel_path}" \
            --initrd="${initrd_path}" \
            --microcode='/boot/amd-ucode.img' \
            --os-release="@/etc/os-release" \
            --cmdline="@${cmdline_file}" \
            --pcr-private-key="${PCR_PRIVATE_KEY}" \
            --pcr-public-key="${PCR_PUBLIC_KEY}" \
            --signtool="${SIGN_TOOL}" \
            --secureboot-private-key="${SECUREBOOT_PRIVATE_KEY}" \
            --secureboot-certificate="${SECUREBOOT_CERTIFICATE}" \
            --output="${output_path}"
        set +x
    }
    
    build_uki \
        "/boot/vmlinuz-linux" \
        "/boot/initramfs-linux.img" \
        "${COMMON_CMDLINE} ${SUSPEND_CMDLINE} ${RESUME_CMDLINE}" \
        "${DEFAULT_UKI_PATH}" \
        "${EXPECTED_PCR15}"
    
    build_uki \
        "/boot/vmlinuz-linux" \
        "/boot/initramfs-linux-fallback.img" \
        "${COMMON_CMDLINE} ${SUSPEND_CMDLINE} ${TROUBLE_CMDLINE}" \
        "${TROUBLESHOOT_UKI_PATH}" \
        "${EXPECTED_PCR15}"
    
    build_uki \
        "/boot/vmlinuz-linux-lts" \
        "/boot/initramfs-linux-lts.img" \
        "${COMMON_CMDLINE} ${SUSPEND_CMDLINE} ${RESUME_CMDLINE}" \
        "${LTS_UKI_PATH}" \
        "${EXPECTED_PCR15}"
    
    build_uki \
        "/boot/vmlinuz-linux-lts" \
        "/boot/initramfs-linux-lts-fallback.img" \
        "${COMMON_CMDLINE} ${SUSPEND_CMDLINE} ${TROUBLE_CMDLINE}" \
        "${TROUBLESHOOT_LTS_UKI_PATH}" \
        "${EXPECTED_PCR15}"
    
    build_uki \
        "/boot/vmlinuz-linux-hardened" \
        "/boot/initramfs-linux-hardened.img" \
        "${COMMON_CMDLINE} ${SUSPEND_CMDLINE} ${SECURE_CMDLINE}" \
        "${HARDENED_UKI_PATH}" \
        "${EXPECTED_PCR15}"
    
    build_uki \
        "/boot/vmlinuz-linux-hardened" \
        "/boot/initramfs-linux-hardened-fallback.img" \
        "${COMMON_CMDLINE} ${SUSPEND_CMDLINE} ${TROUBLE_CMDLINE}" \
        "${TROUBLESHOOT_HARDENED_UKI_PATH}" \
        "${EXPECTED_PCR15}"
    
    echo "UKIs rebuilt and signed."
    ↩︎
  4. You will have to restart the Nix daemon for your changes to take effect:

    # systemctl daemon-reload &&
        systemctl restart nix-daemon &&
        systemctl status nix-daemon
    ↩︎
  5. On Arch, /tmp is a tmpfs by default. On NixOS, you may need to configure a tmpfs.↩︎

  6. The FAQ notes that this command simply modifies /etc/nix/nix.conf for you.↩︎

  7. Confused? See Nix.dev.↩︎

  8. You will have to restart the Nix daemon for your changes to take effect:

    # systemctl daemon-reload &&
        systemctl restart nix-daemon &&
        systemctl status nix-daemon
    ↩︎