Skip to content

Instantly share code, notes, and snippets.

@assarbad
Created July 24, 2026 09:31
Show Gist options
  • Select an option

  • Save assarbad/17f432dbfeedbbb64a1a6ede942813a5 to your computer and use it in GitHub Desktop.

Select an option

Save assarbad/17f432dbfeedbbb64a1a6ede942813a5 to your computer and use it in GitHub Desktop.
Full Disk Encryption on Thinkpad T14s with Kubuntu 26.04 LTS

Installing Kubuntu 26.04 with FDE on a Thinkpad T14s Gen2

Up front: Calamares (Kubuntu installer) didn't cut it. So I went the well-trodden path of manual installation combined with later package selection that would mimick what Calamares would have picked.

Preliminary assumption

These instructions make assumptions. The main assumption is, that you dear reader, are not a numpty. Therefore use your own judgment and adjust arguments (especially device paths) to your own needs. Don't blame me for doing stupid things on your end. Got enough stupid decisions of my own to deal with ...

Preparations

  1. you may want to enter the system setup (F1) and enable secure boot
  2. you may want to live-boot once, run fwupdmgr update and then reboot to have the latest UEFI version

Live media boot

  1. Boot the Kubuntu 26.04 live media
  2. Open the terminal and sudo su -; pretty much everything below requires superuser rights, so we won't bother with sudo in front of every invocation
  3. Update the live system, prepare for convenient use:
    apt update
    apt install -y mc unzip tmux git make gdisk debootstrap vim-nox openssh-server
    apt full-upgrade
    git clone https://github.com/assarbad/dotfiles .dotfiles
    make -C .dotfiles TGTDIR=/root install
    
  4. time to ssh into the system, use ip a or whatever else rocks your boat on the live-booted system to determine the correct IP address
    • ssh root@1.2.3.4
    • then I like to start Tmux with my own mux alias, but YMMV

Inside the SSH session

  1. Optionally: if you are working with a system using an NVMe, install: apt install sedutil nvme-cli
    • nvme id-ctrl /dev/nvme0n1
    • sedutil-cli --query /dev/nvme0n1
  2. Set up partitions using gdisk
    • UEFI ESP: partition 1, 512M, type EF00
    • /boot: partition 2, 2G, type 8309
    • remainder of space goes into a third unformatted partition: partition 3, accept start and end with Enter, type 8309
    • for good measure you may want to run partprobe /dev/nvme0n1
  3. Format the unencrypted ESP partition: mkfs.fat -n ESP -F 32 /dev/nvme0n1p1
  4. Set up LUKS2 volumes:
    • Set up LUKS1 volume for /boot: cryptsetup luksFormat --type luks1 --label=BOOT.LUKS /dev/nvme0n1p2 (I ran into compatibility issues with GRUB, so this was the cure: LUKS1 instead of LUKS2)
    • Set up LUKS2 volume for the remainder: cryptsetup luksFormat --type luks2 --cipher aes-xts-plain64 --key-size 512 --hash sha256 --pbkdf argon2id --pbkdf-memory $((1024*1024)) --pbkdf-parallel 4 --iter-time 2500 --label=REMAINDER /dev/nvme0n1p3
      (this assumes Bash with its arithmetic operations)
      • Another option is to use --pbkdf-parallel $(nproc) above ...
  5. Open the LUKS2 volumes:
    • cryptsetup open /dev/nvme0n1p2 cryptboot
    • cryptsetup open /dev/nvme0n1p3 cryptmain (if rerunning this step on a pre-partitioned device: partprobe /dev/mapper/cryptmain)
  6. Format the /boot partition: mkfs.ext4 -m 1 -L BOOT /dev/mapper/cryptboot
  7. Partition the /dev/mapper/cryptmain container, for me that's a root and a data partition, again use your judgment for your use case
    • gdisk /dev/mapper/cryptmain
    • partprobe /dev/mapper/cryptmain
    • Format the partitions:
      • mkfs.btrfs -fKL ROOT --checksum xxhash /dev/mapper/cryptmain1
      • mkfs.btrfs -fKL DATA --checksum xxhash /dev/mapper/cryptmain2

Example results

The result in my case looks like this:

# sgdisk -p /dev/nvme0n1
# ...
Number  Start (sector)    End (sector)  Size       Code  Name
   1            2048         1050623   512.0 MiB   EF00
   2         1050624         5244927   2.0 GiB     8309  Linux LUKS
   3         5244928      1953523711   929.0 GiB   8309  Linux LUKS

# sgdisk -p /dev/mapper/cryptmain
#...
Number  Start (sector)    End (sector)  Size       Code  Name
   1            2048       268437503   128.0 GiB   8300  Linux filesystem
   2       268437504      1948243967   801.0 GiB   8300  Linux filesystem

If the LUKS2 volumes are opened, it looks like this when viewed from the live-booted system:

# lsblk --exclude 7 -o NAME,MAJ:MIN,SIZE,RO,TYPE,FSTYPE,LABEL,MOUNTPOINT
NAME          MAJ:MIN   SIZE RO TYPE  FSTYPE      LABEL               MOUNTPOINT
sda             8:0       0B  0 disk
sr0            11:0     4.7G  0 rom   iso9660     Kubuntu 26.04 amd64 /cdrom
nvme0n1       259:0   931.5G  0 disk
├─nvme0n1p1   259:1     512M  0 part  vfat        ESP
├─nvme0n1p2   259:2       2G  0 part  crypto_LUKS BOOT.LUKS
│ └─cryptboot 252:0       2G  0 crypt ext4        BOOT
└─nvme0n1p3   259:3     929G  0 part  crypto_LUKS REMAINDER
  └─cryptmain 252:1     929G  0 crypt

... after partprobe /dev/mapper/cryptmain it looks like this then:

# lsblk --exclude 7 -o NAME,MAJ:MIN,SIZE,RO,TYPE,FSTYPE,LABEL,MOUNTPOINT
NAME             MAJ:MIN   SIZE RO TYPE  FSTYPE      LABEL               MOUNTPOINT
sda                8:0       0B  0 disk
sr0               11:0     4.7G  0 rom   iso9660     Kubuntu 26.04 amd64 /cdrom
nvme0n1          259:0   931.5G  0 disk
├─nvme0n1p1      259:1     512M  0 part  vfat        ESP
├─nvme0n1p2      259:2       2G  0 part  crypto_LUKS BOOT.LUKS
│ └─cryptboot    252:0       2G  0 crypt ext4        BOOT
└─nvme0n1p3      259:3     929G  0 part  crypto_LUKS REMAINDER
  └─cryptmain    252:1     929G  0 crypt
    ├─cryptmain1 252:2     128G  0 part  btrfs       ROOT
    └─cryptmain2 252:3     801G  0 part  btrfs       DATA

Preparing the root volume with subvolumes

Up front we want to prepare the btrfs volumes on the root partition:

  1. mount -m -o subvolid=5 /dev/mapper/cryptmain1 /target (mount root volume by ID)
  2. btrfs subvolume create /target/@ our future /
  3. btrfs subvolume create /target/@home our future /home
  4. umount /target

Preparing the chroot environment`

Proceed as follows to mount the volumes as needed:

  1. mount -m -o subvol=@ /dev/mapper/cryptmain1 /target
  2. mount -m -o subvol=@home /dev/mapper/cryptmain1 /target/home
  3. mount -m /dev/mapper/cryptboot /target/boot
  4. mount -m /dev/nvme0n1p1 /target/boot/efi (ESP)

Example result

# lsblk --exclude 7 -o NAME,MAJ:MIN,SIZE,RO,TYPE,FSTYPE,LABEL,MOUNTPOINT
NAME             MAJ:MIN   SIZE RO TYPE  FSTYPE      LABEL               MOUNTPOINT
sda                8:0       0B  0 disk
sr0               11:0     4.7G  0 rom   iso9660     Kubuntu 26.04 amd64 /cdrom
nvme0n1          259:0   931.5G  0 disk
├─nvme0n1p1      259:1     512M  0 part  vfat        ESP                 /target/boot/efi
├─nvme0n1p2      259:2       2G  0 part  crypto_LUKS BOOT.LUKS
│ └─cryptboot    252:0       2G  0 crypt ext4        BOOT                /target/boot
└─nvme0n1p3      259:3     929G  0 part  crypto_LUKS REMAINDER
  └─cryptmain    252:1     929G  0 crypt
    ├─cryptmain1 252:2     128G  0 part  btrfs       ROOT                /target/home
    └─cryptmain2 252:3     801G  0 part  btrfs       DATA

Bootstrapping the system

Very simply (adjust the URL to a local mirror):

debootstrap --arch=amd64 resolute /target http://de.archive.ubuntu.com/ubuntu/

Personally I do this in Tmux, so I sometimes have several windows ("tabs") open to do things in parallel.

Preparing the chroot target to be less frugal

To be able to do most things, including installations beyond bootstrapping, you should run the following to bind-mount some paths from the host (live-booted system) into the chroot target environment:

for d in proc sys dev run; do mount -m --rbind --make-rslave /$d /target/$d; done

The above for loop is crucial to be able to do things like installing the boot-loader while inside the chroot.

NB: do this only once. No need to do this prior to every chroot to hop in.

If you're unsure, check with mount|grep -E '/target/(proc|sys|dev|run)'|wc -l and it should show a number above 0 ;)

After this point you can hop into the target environment (and out again) at will with:

chroot /target /bin/bash

Fixing up /etc/apt/sources.list

The /etc/apt/sources.list file inside the chroot should be edited to read something like:

deb http://de.archive.ubuntu.com/ubuntu/ resolute main restricted universe multiverse
deb http://security.ubuntu.com/ubuntu/ resolute-security main restricted universe multiverse
deb http://de.archive.ubuntu.com/ubuntu/ resolute-updates main restricted universe multiverse

Easiest method (assuming your live-booted system is the same Kubuntu version you want to install): cp -fa /etc/apt/sources.list /target/etc/apt/sources.list ¯\(ツ)/¯ ... otherwise use your favorite editor.

Note that 26.04 == resolute and again you may want to pick a local mirror.

Inside the chroot target environment

If everything is in place, we can now switch into the target environment and from there:

  1. apt update
    • We'll ignore the message: Notice: Some sources can be modernized. Run 'apt modernize-sources' to do so. altogether
  2. Optionally: apt full-upgrade
  3. Installing Kubuntu: apt install --install-recommends kubuntu-desktop
  4. We'll want to boot a Linux kernel, too: apt install linux-image-generic dracut shim-signed cryptsetup btrfs-progs kpartx
    • enable cryptodisk: echo "GRUB_ENABLE_CRYPTODISK=y"|tee -a /etc/default/grub
      • Actually I did: printf "GRUB_ENABLE_CRYPTODISK=y\nGRUB_DISABLE_OS_PROBER=true\n"|tee -a /etc/default/grub
    • install GRUB: grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=Ubuntu
  5. Optional: I always want phased updates directly printf "Update-Manager::Always-Include-Phased-Updates;\nAPT::Get::Always-Include-Phased-Updates;\n"|tee /etc/apt/apt.conf.d/99-Phased-Updates
  6. Set a host name: echo "yourhostname"|tee /etc/hostname
    • Make it known in hosts:
      export NEW_HOSTNAME=$(cat /etc/hostname)
      sed '/^127\.0\.0\.1/s/$/ '"$NEW_HOSTNAME"'/g; /^::1\s/s/$/ '"$NEW_HOSTNAME"'/g' /etc/hosts
      
    • Optionally: hostnamectl hostname yourhostname
  7. Set timezone: ln -sf /usr/share/zoneinfo/Etc/UTC /etc/localtime (or dpkg-reconfigure tzdata)
  8. Configure locale: update-locale LANG=en_US.UTF-8 followed by locale-gen (or dpkg-reconfigure locales)
  9. Create unprivileged user:
    • adduser oliver
    • usermod -aG sudo,adm,cdrom,dip,plugdev,lpadmin,users oliver
  10. Populate /etc/crypttab:
    for luks in cryptboot:/dev/nvme0n1p2 cryptmain:/dev/nvme0n1p3; do printf "%s% 48s    -    luks,password-echo=masked,fido2-device=auto,nofail,discard\n" "${luks%%:*}" "UUID=$(cryptsetup luksUUID "${luks##*:}")"; done|tee -a /etc/crypttab
    
  11. Populate /etc/fstab with something along the lines of (use blkid to figure out UUID values):
    # <fs>       <mount point>                 <type>  <options>                                                                       <dump>  <pass>
    UUID=...     /                             btrfs   defaults,subvol=@,noatime,discard,compress-force=zstd:6                         0       1
    UUID=...     /home                         btrfs   defaults,subvol=@home,noatime,discard,compress-force=zstd:6                     0       1
    UUID=...     /boot                         ext4    defaults,noatime,discard                                                        0       2
    PARTUUID=... /boot/efi                     vfat    umask=0077,noatime,discard                                                      0       1
    
  12. Needed so that /etc/crypttab changes take effect: update-grub
  13. Optional: I like Samba/CIFS for file sharing:
    • apt install cifs-utils samba
    • usermod -aG sambashare oliver (or adduser oliver sambashare)
  14. Optional: I like using etckeeper with Git:
    • apt install etckeeper git
    • Optionally: disable signing for sudo scenarios:
      printf '#!/bin/sh\nset -e\ngit config --local commit.gpgsign false\n'|tee /etc/etckeeper/commit.d/01disable-signing && chmod +x /etc/etckeeper/commit.d/01disable-signing
      

Hacking up Dracut

Dracut has replaced the older method of creating an initramfs. But Dracut tries to be extra-smart, thereby making it harder to work with LUKS volumes that don't contain the extra LVM layer below. You may have noticed that I created my devices straight in the block device of the unlocked LUKS volume (cryptmain). Without rereading partition information the kernel won't recognize the contained partitions.

Now Dracut doesn't even care if a given LUKS volume is needed for the boot, it will simply put every LUKS volume into the cmdline and then the initrd will attempt to unlock that. For me that meant it would attempt to also unlock /boot again, which was already unlocked by GRUB for the boot. Alas, Dracut also behaves weirdly when it comes to prompting several times for the LUKS unlocking. With Plymouth it's hard to tell what I am unlocking, which may work nice when using the same passphrases and what not, but is a bad deal when you don't.

NB: I went through several iterations and it would be an understatement to say that this was a painful process. In the end I settled for the solution below. The underlying issue was that Dracut didn't issue a reread of the partition table in the unlocked LUKS block device, thereby preventing the real / from being mounted successfully. So I got dropped into the emergency shell numerous times.

module-setup.sh

This is the stansdard module-setup.sh for any Dracut module. I use 90rereadpart as the module name.

This script is the work horse. It uses cryptmain and cryptboot from /etc/crypttab to filter out the cryptboot (GRUB helps me unlock that) and making sure that the partition info gets reread in the unlocked LUKS block device.

#!/bin/bash
# vim: set autoindent smartindent ts=4 sw=4 sts=4 et filetype=sh:
command -v label_uuid_to_dev > /dev/null || label_uuid_to_dev() { # from modules.d/80base/dracut-lib.sh
    local _dev
    _dev="${1#block:}"
    case "$_dev" in
        LABEL=*)
            echo "/dev/disk/by-label/$(echo "${_dev#LABEL=}" | sed 's,/,\\x2f,g;s, ,\\x20,g')"
            ;;
        PARTLABEL=*)
            echo "/dev/disk/by-partlabel/$(echo "${_dev#PARTLABEL=}" | sed 's,/,\\x2f,g;s, ,\\x20,g')"
            ;;
        UUID=*)
            echo "/dev/disk/by-uuid/${_dev#UUID=}"
            ;;
        PARTUUID=*)
            echo "/dev/disk/by-partuuid/${_dev#PARTUUID=}"
            ;;
        *)
            echo "$_dev"
            ;;
    esac
}

check() { return 0; }
depends() { echo crypt dm; }

_remove_by_mapper_name() {
    # We need these dependencies to be gone from the initrd!
    (
        set -ex
        find "${initdir}" ! -type d -name "*$1.*" -print -exec rm -f -- {} +
        find "${initdir}" -type d -name "*$1.*" -print -exec rm -rf -- {} +
        sed -i "/$1/d" "${initdir}/usr/lib/dracut/hostonly-files"
    ) >&2
}

# called by dracut
install() {
    inst_multiple -o blockdev udevadm partx partprobe kpartx gdisk sgdisk
    inst_simple "$moddir/partprobe-cryptmain.service" "$systemdsystemunitdir/partprobe-cryptmain.service"
    systemctl --root="$initdir" add-wants initrd-root-device.target partprobe-cryptmain.service >&2
    local _mapper _dev _luksfile _luksoptions _uuid_dev _uuid
    local -a INITRD_LUKS_NEEDED=()
    local -a INITRD_LUKS_SUPPRESSED=()
    if ! cp -a "${initdir}"/etc/crypttab "${initdir}"/etc/crypttab.orig; then
        echo "Failed to backup crypttab" >&2
    else
        while read -r _mapper _dev _luksfile _luksoptions || [[ -n $_mapper ]]; do
            # ignore comments
            [[ "$_mapper" =~ ^[[:space:]]*# ]] && continue
            # ignore paths followed by a device specification
            if [[ $_luksfile == *":"* ]]; then
                continue
            fi
            _uuid_dev=$(label_uuid_to_dev "$_dev")
            _uuid=${_uuid_dev##*/}
            case "$_mapper" in
                cryptboot)
                    _remove_by_mapper_name "$_mapper"
                    INITRD_LUKS_SUPPRESSED+=("$_uuid")
                    ;;
                cryptmain)
                    # Write to filtered crypttab
                    printf "%s %s %s %s\n" "$_mapper" "$_dev" "$_luksfile" "$_luksoptions"
                    INITRD_LUKS_NEEDED+=("$_uuid")
                    ;;
            esac
        done < "${initdir}"/etc/crypttab.orig > "$initdir"/etc/crypttab
        # Suppress cryptboot
        for _uuid in "${INITRD_LUKS_SUPPRESSED[@]}"; do
            sed -i "/$_uuid/d" "${initdir}/etc/block_uuid.map"
        done
        for _uuid in "${INITRD_LUKS_NEEDED[@]}"; do
            printf " rd.luks.uuid=luks-%s\n" "$_uuid"
        done > "${initdir}/etc/cmdline.d/20-crypt.conf"
    fi
    # blkid -c /dev/null -t TYPE=crypto_LUKS
}

partprobe-cryptmain.service

Service unit for rereading the partition information. This is the glue between unlocking the cryptmain LUKS volume and finalizing the initrd phase before pivoting to the real root (initrd-root-device.target).

# /etc/systemd/system/partprobe-cryptmain.service (installed into initrd)
[Unit]
Description=Rescan partitions on cryptmain after LUKS unlock
After=systemd-cryptsetup@cryptmain.service
Requires=systemd-cryptsetup@cryptmain.service
Before=initrd-root-device.target
DefaultDependencies=no

[Service]
Type=oneshot
ExecStart=/sbin/partprobe /dev/mapper/cryptmain
ExecStartPost=/bin/udevadm settle
RemainAfterExit=yes

[Install]
WantedBy=initrd-root-device.target

The result

  • / mounts from inside cryptmain after rereading the partition information
  • initrd pivots to the real root
  • /boot does not get unlocked and mounted into the real root
    • I addressed this by creating an extra slot in the LUKS1 volume holding the /boot partition and using a key file (contained in the real root) to unlock it once again, postponing the mounts via systemd x-systemd.after= options (see below).
UUID=...     /boot      ext4    defaults,noatime,discard,x-systemd.after=systemd-cryptsetup@cryptboot.service   0       2
PARTUUID=... /boot/efi  vfat    umask=0077,noatime,discard,x-systemd.after=boot.mount                           0       1

Copypasta grab bag for recovery scenarios

apt install -y mc unzip tmux git make gdisk debootstrap vim-nox openssh-server ripgrep fd-find && git clone https://github.com/assarbad/dotfiles .dotfiles && make -C .dotfiles TGTDIR=/root install


# Separate commands for the "one-liner" above

apt install -y mc unzip tmux git make gdisk debootstrap vim-nox openssh-server ripgrep fd-find
git clone https://github.com/assarbad/dotfiles .dotfiles
make -C .dotfiles TGTDIR=/root install
apt full-upgrade -y

cryptsetup open /dev/nvme0n1p2 cryptboot; cryptsetup open /dev/nvme0n1p3 cryptmain; partprobe /dev/mapper/cryptmain

# Separate commands for the "one-liner" above

cryptsetup open /dev/nvme0n1p2 cryptboot
cryptsetup open /dev/nvme0n1p3 cryptmain
partprobe /dev/mapper/cryptmain

mount -m -o subvol=@ /dev/mapper/cryptmain1 /target && mount -m -o subvol=@home /dev/mapper/cryptmain1 /target/home && mount -m /dev/mapper/cryptboot /target/boot && mount -m /dev/nvme0n1p1 /target/boot/efi && for d in proc sys dev run; do mount -m --rbind --make-rslave /$d /target/$d; done && chroot /target /bin/bash

# Separate commands for the "one-liner" above

mount -m -o subvol=@ /dev/mapper/cryptmain1 /target
mount -m -o subvol=@home /dev/mapper/cryptmain1 /target/home
mount -m /dev/mapper/cryptboot /target/boot
mount -m /dev/nvme0n1p1 /target/boot/efi
for d in proc sys dev run; do mount -m --rbind --make-rslave /$d /target/$d; done

chroot /target /bin/bash

# inside

grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=Ubuntu
grub-mkconfig -o /boot/grub/grub.cfg
dracut -fv
update-grub
test -d /initrd || mkdir /initrd; rm -rf -- /initrd/*; cd /initrd && lsinitrd --unpack /boot/initrd.img

Prevent sleep while working remotely via SSH

CONUSER=kubuntu; printf "[AC][Display]\nDimDisplayIdleTimeoutSec=900\nTurnOffDisplayIdleTimeoutSec=3600\n\n[AC][SuspendAndShutdown]\nAutoSuspendAction=0\nLidAction=0\n" > /home/$CONUSER/.config/powerdevilrc; chown $CONUSER: /home/$CONUSER/.config/powerdevilrc; sudo -u $CONUSER bash -c 'env DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus systemctl --user restart plasma-powerdevil.service'

# Separate commands for the "one-liner" above

CONUSER=kubuntu
printf "[AC][Display]\nDimDisplayIdleTimeoutSec=900\nTurnOffDisplayIdleTimeoutSec=3600\n\n[AC][SuspendAndShutdown]\nAutoSuspendAction=0\nLidAction=0\n" > /home/$CONUSER/.config/powerdevilrc
chown $CONUSER: /home/$CONUSER/.config/powerdevilrc
sudo -u $CONUSER bash -c 'env DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus systemctl --user restart plasma-powerdevil.service'

The crux here is to run as the user interactively logged on, which is kubuntu from Kubuntu installation media.

initramfs debugging with dracut

  • udevadm info /dev/mapper/<name>
  • udevadm test /run/udev/rules/...
  • journalctl -b | grep -i udev
  • udevadm control --log-priority=debug
  • udevadm trigger --action=add /dev/mapper/<name> or --type=devices appropriately
  • journalctl -b | tail -100
  • udevadm trigger --action=change /dev/mapper/<name>
  • udevadm settle
  • cryptsetup luksDump /dev/<partition> | grep -A5 Tokens:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment