Framework 13: Arch, Secure Boot, & Nix
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/bootloader
1.
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:
- Bootloader.
- Kernel images.
- initramfs.
- 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:
- Attacker creates a malicious USB/external drive.
- Copies your
/boot
partition exactly. - Creates a fake root partition with the same UUID as your real root.
- 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.
/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."
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.↩︎
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."
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
On Arch,
/tmp
is atmpfs
by default. On NixOS, you may need to configure atmpfs
.↩︎The FAQ notes that this command simply modifies
/etc/nix/nix.conf
for you.↩︎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