This article assumes an Arch Linux system using the default layout and tooling unless stated otherwise.

You should keep configuration under strict control, ideally in dedicated configuration files or a managed repository. Boot-related changes are unforgiving, and small mistakes tend to compound quickly.

The Unified Kernel Image is expected at /efi/EFI/BOOT/BOOTx64.efi, inside ESP mount point /efi. You MUST let systemd handle automatic mounting of ESP, or pay close attention to paths mentioned below.

This solution needs tpm2-tools tpm2-tss ukify, sbctl and dracut.

You should configure command line arguments in /etc/kernel/cmdline.

Status Quo

The flow

On a default Arch installation, mkinitcpio generates the relevant initramfs, and the user at install time picks a bootloader, whose job is to load the kernel, initramfs, and user-defined kernel command line arguments. Secure boot requires signing the entirety of ESP, of which may contain dozens of files. Disk encryption either have manual password prompts, or use TPM but only PCR slot 7 which is not very secure.

This is ancient and fragile:

  • OS Updates may brick secure boot if even a single file on the ESP is missed or unsigned
  • PCR values are hard to pre-calculate when Grub / Shim is involved
  • PCR slot 11 is un-measured, and lots of people enroll Microsoft keys to prevent bricking devices with OptionROM. Which means someone may swap the initrd/UKI and perform key extraction.

Let’s address these problems one by one.

What now?

For now, we’ll still use sbctl to manage secure boot keys. Create them if not exists:

1
sbctl create-keys

OS Updates brick secure boot

Now that we have Unified Kernel Images, operating system updates typically affect only a single file on the ESP: the UKI itself.

The Unified Kernel Image contains OS release information, kernel command line arguments, the initrd and much more in separate PE sections. It also contains the public key in PEM format that matches the signatures of the .pcrsig PE section, and a JSON file encoding expected PCR 11 hash values.

Essentially, it is the sole executable required to boot the system, which is also executable by the firmware. The “stub” component handles the transition from UEFI to the Linux kernel, as illustrated below:

stub

In moeOS, Unified Kernel Image generation use kernel-install. So let’s configure it via /etc/kernel/install.conf.d/moeOS.conf:

1
2
3
layout=uki
uki_generator=ukify
initrd_generator=dracut

The file first tells kernel-install to use UKI layout. You may wonder how is ukify and dracut is involved here, it’s because kernel-install alone can’t generate UKIs or initrds, and ukify can’t do initrds either.

You may remember that we are still using sbctl to manage secure boot keys, and it has several hooks like: kernel-install, mkinitcpio, pacman. We need to mask them, to prevent sbctl from messing with kernel images:

1
2
3
4
5
6
7
8
9
10
ln -sf \
/dev/null \
/etc/kernel/install.d/91-sbctl.install
ln -sf \
/dev/null \
/etc/kernel/postinst.d/91-sbctl.install
mkdir -p \
/etc/pacman.d/hooks/ \
ln -sf /dev/null \
/etc/pacman.d/hooks/zz-sbctl.hook \

Dracut’s pacman hook should also be disabled:

1
2
ln -sf /dev/null \
/etc/pacman.d/hooks/90-dracut-install.hook

With the default hooks removed, we need pacman hooks that trigger kernel-install when relevant files change:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# /usr/share/libalpm/hooks/40-moe-kernel-install.hook

[Trigger]
Type = Path
Operation = Upgrade
Operation = Remove
Target = usr/lib/modules/*/vmlinuz

[Trigger]
Type = Path
Operation = Install
Operation = Upgrade
Operation = Remove
Target = usr/lib/initcpio/*
Target = usr/lib/initcpio/*/*
Target = usr/lib/firmware/*
Target = usr/lib/modules/*/extramodules/*
Target = usr/src/*/dkms.conf
Target = usr/lib/dracut/*
Target = usr/lib/dracut/*/*
Target = usr/lib/dracut/*/*/*
Target = usr/lib/kernel/*
Target = usr/lib/kernel/*/*
Target = boot/*-ucode.img

[Action]
Description = Removing kernel and initrd using kernel-install...
When = PostTransaction
Exec = /usr/share/libalpm/scripts/moe-kernel-install remove
NeedsTargets
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# /usr/share/libalpm/hooks/90-kernel-install-add.hook

[Trigger]
Type = Path
Operation = Install
Operation = Upgrade
Target = usr/lib/modules/*/vmlinuz

[Trigger]
Type = Path
Operation = Install
Operation = Upgrade
Operation = Remove
Target = usr/lib/initcpio/*
Target = usr/lib/initcpio/*/*
Target = usr/lib/firmware/*
Target = usr/lib/modules/*/extramodules/*
Target = usr/src/*/dkms.conf
Target = usr/lib/booster/*
Target = usr/lib/dracut/*
Target = usr/lib/dracut/*/*
Target = usr/lib/dracut/*/*/*
Target = usr/lib/kernel/*
Target = usr/lib/kernel/*/*
Target = boot/*-ucode.img

[Action]
Description = Installing kernel and initrd using kernel-install...
When = PostTransaction
Exec = /usr/share/libalpm/scripts/moe-kernel-install add
NeedsTargets

and the relevant alpm script: /usr/share/libalpm/scripts/moe-kernel-install

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#!/bin/bash

set -euo pipefail
shopt -s inherit_errexit nullglob

cd /

all_kernels=0
declare -A versions

add_file() {
local kver="$1"
kver="${kver##usr/lib/modules/}"
kver="${kver%%/*}"
versions["$kver"]=""
}

while read -r path; do
case "$path" in
usr/lib/modules/*/vmlinuz | usr/lib/modules/*/extramodules/*)
add_file "$path"
;;
*)
all_kernels=1
;;
esac
done

((all_kernels)) && for file in usr/lib/modules/*/vmlinuz; do
pacman -Qqo "$file" 1>/dev/null 2>/dev/null &&
add_file "$file"
done

for kver in "${!versions[@]}"; do
kimage="/usr/lib/modules/$kver/vmlinuz"
kernel-install "$@" "$kver" "$kimage" || true
done

Those should ensure kernel-install is triggered on-demand.

Next, configure Ukify via /etc/kernel/uki.conf:

1
2
3
4
5
6
7
8
9
10
[UKI]
SecureBootSigningTool=systemd-sbsign
SecureBootPrivateKey=/var/lib/sbctl/keys/db/db.key
SecureBootCertificate=/var/lib/sbctl/keys/db/db.pem
SignKernel=yes
Cmdline="@/etc/kernel/cmdline"

[PCRSignature:system]
PCRPrivateKey=/var/lib/moeOS/TPM-Keys/Private.pem
PCRPublicKey=/var/lib/moeOS/TPM-Keys/Public.pem

Looks self-explanatory, isn’t it? systemd-sbsign is used as a signing tool, while keys generated by sbctl is used to sign the Linux binary itself before it is embedded into the combined image. You may be wondering what the heck is PCR signature? TPM measurements change on every update, but signatures should remain unchanged. Hence it is more useful where software update is possible without losing access to previously-enrolled LUKS2 volumes. We’ll create it with ukify:

1
2
3
ukify genkey \
--pcr-public-key=/var/lib/moeOS/TPM-Keys/Public.pem \
--pcr-private-key=/var/lib/moeOS/TPM-Keys/Private.pem

The dracut configuration also needs update, to load the relevant TPM and pcrphase modules:

1
2
3
4
5
# /etc/dracut.conf.d/moeOS.conf
add_dracutmodules+=" systemd-pcrphase systemd-creds systemd-cryptsetup tpm2-tss crypt "
hostonly="no"
ro_mnt="no"
uefi="no"

By default, kernel-install installs UKIs into versioned paths. To avoid maintaining UEFI boot entries, we can create an override at /etc/kernel/install.d/90-uki-copy.install to always install it to the standard location, ensuring firmware can always boot the system without relying on NVRAM entries:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#!/bin/bash

COMMAND="${1:?}"
KERNEL_VERSION="${2:?}"
# shellcheck disable=SC2034
ENTRY_DIR_ABS="$3"
KERNEL_IMAGE="$4"

ENTRY_TOKEN="$KERNEL_INSTALL_ENTRY_TOKEN"
BOOT_ROOT="$KERNEL_INSTALL_BOOT_ROOT"

UKI_DIR="$BOOT_ROOT/EFI/BOOT"

case "$COMMAND" in
add)
;;
*)
exit 0
;;
esac

[ "$KERNEL_INSTALL_LAYOUT" = "uki" ] || exit 0

if ! [ -d "$UKI_DIR" ]; then
[ "$KERNEL_INSTALL_VERBOSE" -gt 0 ] && echo "creating $UKI_DIR"
mkdir -p "$UKI_DIR"
fi

UKI_FILE="$UKI_DIR/BOOTx64.efi"

# If there is a UKI named uki.efi on the staging area use that, if not use what
# was passed in as $KERNEL_IMAGE but insist it has a .efi extension
if [ -f "$KERNEL_INSTALL_STAGING_AREA/uki.efi" ]; then
[ "$KERNEL_INSTALL_VERBOSE" -gt 0 ] && echo "Installing $KERNEL_INSTALL_STAGING_AREA/uki.efi as $UKI_FILE"
install -m 0644 "$KERNEL_INSTALL_STAGING_AREA/uki.efi" "$UKI_FILE" || {
echo "Error: could not copy '$KERNEL_INSTALL_STAGING_AREA/uki.efi' to '$UKI_FILE'." >&2
exit 1
}
elif [ -n "$KERNEL_IMAGE" ]; then
[ -f "$KERNEL_IMAGE" ] || {
echo "Error: UKI '$KERNEL_IMAGE' not a file." >&2
exit 1
}
[ "$KERNEL_IMAGE" != "${KERNEL_IMAGE%*.efi}.efi" ] && {
echo "Error: $KERNEL_IMAGE is missing .efi suffix." >&2
exit 1
}
[ "$KERNEL_INSTALL_VERBOSE" -gt 0 ] && echo "Installing $KERNEL_IMAGE as $UKI_FILE"
install -m 0644 "$KERNEL_IMAGE" "$UKI_FILE" || {
echo "Error: could not copy '$KERNEL_IMAGE' to '$UKI_FILE'." >&2
exit 1
}
else
[ "$KERNEL_INSTALL_VERBOSE" -gt 0 ] && echo "No UKI available. Nothing to do."
exit 0
fi

chown root:root "$UKI_FILE" || :

exit 0

Automatic discovery of Root / Swap / ESP

systemd-gpt-auto-generator is a unit generator that automatically discovers the root partition, /home/, /srv/, /var/, /var/tmp/, the EFI System Partition (ESP), the Extended Boot Loader Partition (XBOOTLDR), and swap partitions and creates mount and swap units for them, based on the partition type GUIDs of GUID partition tables (GPT). See UEFI Specification, chapter 5 for more details. It implements the UAPI.2 Discoverable Partitions Specification.

For the auto generator to work, you first need to set the right partition types. See the UAPI.2 specification for detail.

If you use btrfs subvolumes, you may still need a rootflags entry in your kernel cmdline, and an fstab entry for /home. The rest of which (albeit root=, rd.luks.name= cmdline, other fstab entries) can now be removed.

Measuring PCR slot 11

With all components in place, TPM measurement can be extended to PCR 11.

Note: the following assumes your root LUKS partition is in /dev/nvme0n1p2, adapt accordingly!

Enroll TPM PCR 7 and PCR 11 w/ public key:

1
systemd-cryptenroll /dev/nvme0n1p2 --tpm2-device=auto --tpm2-pcrs=7 --tpm2-public-key=/var/lib/moeOS/TPM-Keys/Public.pem --tpm2-public-key-pcrs=11

Re-generate your UKI with kernel-install add-all and reboot.


At this point, the system boots using a single signed UKI, automatically discovers partitions, and unlocks disk encryption using measured boot with PCR 11 enforced.