pr1v8.ca

Encrypting a running CachyOS install next to Windows: your own Secure Boot keys, a YubiKey and a locked Limine

· 35 min read · linuxcachyosencryptionsecure-bootyubikey

My desktop dual-boots CachyOS and Windows 11 from one NVMe disk. Windows had BitLocker available, while Linux sat on a plain btrfs partition that anyone with a screwdriver could read. This post covers how I moved the running Linux system onto an encrypted partition without reinstalling it, put Secure Boot under my own keys, and locked the boot menu. It also covers how Windows and BitLocker ended up living next to all of that.

It is for someone with a similar setup: CachyOS (or Arch) with Limine as the boot loader, mkinitcpio, a btrfs root, and Windows on the same disk. Most of it carries over to other setups, and I say where it doesn’t.

At the end:

What you need:

The order matters. Every step leaves something that boots, and the old unencrypted system stays bootable until the encrypted one has proven itself:

  1. Look before changing anything
  2. A backup you have read back
  3. Secure Boot with your own keys
  4. Two new partitions
  5. LUKS2 and the ways in
  6. Test the unlock first
  7. Moving a live root with btrfs replace
  8. Locking down /boot
  9. Windows and BitLocker
  10. Erase, delete, grow
  11. The boot entry that vanished
  12. What is still open

The disk, to scale (3.6 TiB) Before Windows C: free 1670 GiB btrfs root 1024 GiB old ESP 4 GiB 1020 GiB, unencrypted After Windows C: LUKS2 → btrfs root, 2698 GiB new ESP 4 GiB Windows ESP and reserved partitions (left edge) never change.

1. Look before changing anything#

Updated: 2026-10-14

Your values#

Every command in this post uses placeholders in angle brackets, such as <DISK>, because device names and partition numbers differ from PC to PC. Replace the whole placeholder, brackets included, with your own value. The shell rejects most commands with a placeholder left in before they run, but not when the placeholder sits inside quotes, so read each command before pressing Enter. These commands find each value and change nothing:

findmnt -nvo SOURCE /
findmnt -nvo SOURCE /boot
echo /dev/$(lsblk -no PKNAME "$(findmnt -nvo SOURCE /)")
lsblk -npo NAME,PARTN,START,SIZE,PARTTYPENAME <DISK>

The third prints the disk your root partition is on (it works in fish and bash). The last lists every partition on that disk with its GPT entry number (PARTN) and start sector.

Placeholder What it is Where to find it On my PC
<DISK> the disk holding both Windows and Linux the echo /dev/… command /dev/nvme0n1
<OLD_ROOT> the current, unencrypted Linux root partition findmnt -nvo SOURCE / /dev/nvme0n1p5
<OLD_ESP> the current Linux boot partition findmnt -nvo SOURCE /boot /dev/nvme0n1p4
<OLD_ROOT_NUM>, <OLD_ESP_NUM> their GPT entry numbers the PARTN column 5, 4
<ESP_NUM>, <LUKS_NUM> GPT entry numbers for the two new partitions the two lowest numbers not in the PARTN column 7, 8
<NEW_ESP>, <LUKS> the two new partitions’ device names <DISK> plus the number; NVMe and eMMC disks put a p in between, SATA and USB disks don’t /dev/nvme0n1p7, /dev/nvme0n1p8
<BACKUP> where your backup disk or share is mounted findmnt after mounting it a NAS share
<BOOTNUM> a firmware boot entry’s number the four digits after Boot in efibootmgr 0002

The rest come later, where they are worked out: sector numbers in section 4, the LUKS partition’s UUID in section 6, and disk positions in section 11. The partition names follow the GPT entry numbers, so after the renumbering in section 11 <NEW_ESP> and <LUKS> get new names; section 11 says how.

What to check#

Before planning, read the machine. These change nothing:

lsblk -o NAME,SIZE,TYPE,FSTYPE,PARTTYPENAME,PARTLABEL,MOUNTPOINTS
sudo parted -s <DISK> unit s print free
sudo sgdisk -p <DISK>
findmnt /boot
sudo efibootmgr -v
grep -E '^(HOOKS|MODULES)' /etc/mkinitcpio.conf
sudo sbctl status

What to look for:

Also run a scrub and don’t continue unless it reports no errors:

sudo btrfs scrub start -B /

Two habits made this go smoothly. Every command that writes to the disk or the firmware got its own go-ahead after I’d read what it would do. And the plan lived on a page I could open on my phone, with the recovery steps filled in with my real device names, because when the PC doesn’t boot, the PC can’t show you the instructions.

A note for fish users, which is CachyOS’s default shell: bash’s VAR=$(...) doesn’t work there. The commands below avoid shell variables. Read a value, then paste it into the next command.

2. A backup you have read back#

The migration copies a live system. A backup is the only thing that undoes a mistake, and a backup that hasn’t been read back may not restore. For btrfs, a send stream per subvolume is compact and can be checked completely.

Mount the top of the filesystem and take read-only snapshots of the subvolumes worth keeping (CachyOS uses @, @home, @root and @srv; @cache, @log and @tmp can be recreated empty):

sudo mount --mkdir -o subvolid=5 <OLD_ROOT> /mnt/btrfs-top
sudo btrfs subvolume snapshot -r /mnt/btrfs-top/@ /mnt/btrfs-top/bk-@

Send each one, compressed, to the backup location:

sudo sh -c 'btrfs send --proto 2 --compressed-data /mnt/btrfs-top/bk-@ | zstd -T0 > <BACKUP>/@.btrfs.zst'

Then read every stream back in full:

sudo sh -c 'zstdcat <BACKUP>/@.btrfs.zst | btrfs receive --dump > /dev/null && echo STREAM OK'

--dump parses the whole stream without writing anything, so a truncated or corrupt file fails here instead of on the day you need it. --proto 2 --compressed-data sends already-compressed extents as they are, which kept a 500 GB home folder fast to send.

Also save the boot partition and the partition table:

sudo tar -C /boot -cf <BACKUP>/boot-esp.tar .
sudo sgdisk --backup=<BACKUP>/gpt.bin <DISK>

This backup is unencrypted and holds everything, SSH keys and the Secure Boot signing keys included. Delete it, or move it into encrypted storage, once the new setup has run for a week.

For the live USB, write the CachyOS ISO to a stick and boot it once now, before anything depends on it. The ISO isn’t signed for Secure Boot, so turn Secure Boot off in the firmware first. That doesn’t erase any keys. Check the network while you’re there: my Wi-Fi card had no driver on the live system, so recovery would have needed a cable.

3. Secure Boot with your own keys#

sbctl makes this short. Put the firmware in Setup Mode (on my Gigabyte board: Secure Boot Mode Custom, Factory Key Provision Disabled, then Reset to Setup Mode; other boards call it “clear keys”), boot Linux, and check:

sudo sbctl status

Create keys if sbctl doesn’t have any yet:

sudo sbctl create-keys

Enroll them, keeping Microsoft’s and the board maker’s:

sudo sbctl enroll-keys --microsoft --firmware-builtin

Don’t leave out --microsoft on a desktop with a graphics card. The card’s UEFI option ROM is signed by Microsoft’s third-party CA; without that CA in the key database the firmware refuses to run it and the screen stays black. sbctl checks the TPM event log for option ROMs and warns. Never answer that warning with --yes-this-might-brick-my-machine. --firmware-builtin copies the board maker’s default keys from dbDefault and KEKDefault.

Resetting to Setup Mode also emptied dbx, the revocation list of known-bad boot loaders. The firmware keeps the factory copy as dbxDefault, so I tried to write it back, and the firmware refused, even in Setup Mode. efi-updatevar reported a file-permission error, which is how Linux passes on the firmware’s security violation. This AMI firmware wants dbx updates signed, even when no keys are enrolled. After enrolling, signing it with the new key-exchange key worked:

sudo sh -c 'tail -c +5 /sys/firmware/efi/efivars/dbxDefault-* > /root/dbx.esl'
sudo sign-efi-sig-list -k /var/lib/sbctl/keys/KEK/KEK.key -c /var/lib/sbctl/keys/KEK/KEK.pem dbx /root/dbx.esl /root/dbx.auth
sudo efi-updatevar -f /root/dbx.auth dbx

The tail -c +5 drops the 4 attribute bytes at the start of every efivarfs file.

Then make sure everything the boot needs is signed, and turn Secure Boot on:

sudo sbctl verify

Every line should show a ✓. Sign anything missing with sudo sbctl sign -s <file>; the -s adds it to the list sbctl’s pacman hook re-signs after updates. After a reboot with Secure Boot on:

bootctl status | grep 'Secure Boot'

You want enabled (user). Boot Windows once to check it still starts.

A BIOS update or a CMOS clear can put the factory keys back. Then Limine is rejected until you redo this section. The keys in /var/lib/sbctl/keys stay the same, so nothing needs re-signing.

4. Two new partitions#

Two partitions go into the free space: a new 4 GiB ESP for /boot, and the partition LUKS will live in. A separate ESP means the old Linux partitions can be erased completely later, and it keeps Linux kernels off the Windows ESP, which is usually 100 MB and belongs to Windows. 4 GiB sounds like a lot until you count the kernels and initramfs files Limine keeps for every snapper snapshot entry.

The LUKS partition must be at least the size of the old root partition plus 16 MiB for the LUKS2 header, because btrfs replace in section 7 needs a target at least as big as its source.

Work in sectors from parted unit s print free. With 512-byte sectors, round the start of the free block up to a multiple of 2048, add 8388608 sectors (4 GiB) for the ESP, and start the LUKS partition right after:

sudo sgdisk -n <ESP_NUM>:<ESP_START>:<ESP_END> -t <ESP_NUM>:ef00 -c <ESP_NUM>:"Limine ESP" <DISK>
sudo sgdisk -n <LUKS_NUM>:<LUKS_START>:<LUKS_END> -t <LUKS_NUM>:8309 -c <LUKS_NUM>:"cryptroot" <DISK>
sudo partprobe <DISK>

For <ESP_NUM> and <LUKS_NUM> I used 7 and 8, because 4, 5 and 6 were still taken by the old Linux partitions. That left the numbering out of step with the order on disk, which is fine for Linux and a problem for Windows. Section 11 explains it and how to fix it once the old partitions are gone.

5. LUKS2 and the ways in#

Save the passphrase in a password manager before typing it.

sudo cryptsetup luksFormat --type luks2 <LUKS>

With a YubiKey, enroll it:

sudo systemd-cryptenroll --fido2-device=auto <LUKS>

It asks for the passphrase, the key’s FIDO2 PIN, and a touch. By default the enrolled slot needs both PIN and touch at every boot. The FIDO2 PIN is shared with everything else that uses the key’s FIDO2 function, SSH keys included. If a PIN ever gets locked, don’t reset the FIDO2 application to clear it: that deletes every credential on the key.

Then a recovery key, which every setup needs:

sudo systemd-cryptenroll --recovery-key <LUKS>

It prints a long key that works at the ordinary passphrase prompt. Store it, and compare the stored copy character by character.

sudo systemd-cryptenroll <LUKS>

That lists the slots: password, fido2, recovery.

Without a YubiKey, the passphrase alone is a complete daily unlock. The option I didn’t use, and so haven’t tested, is TPM + PIN:

sudo systemd-cryptenroll --tpm2-device=auto --tpm2-with-pin=yes --tpm2-pcrs=7 <LUKS>

Bind only PCR 7, which records the Secure Boot state and keys. With Secure Boot on and a config hash enrolled, Limine measures the kernel command line and paths into PCR 8 and the files themselves into PCR 9, and those change with every kernel update. Keep the PIN: TPM unlock without one opens the disk for anyone who can switch the PC on. Expect the passphrase prompt after anything that changes PCR 7, including Secure Boot key changes, BIOS updates, and the dbx updates Windows Update installs. Re-seal with --wipe-slot=tpm2 and the same command afterwards.

Finally, back up the LUKS header, the part that holds the key slots:

sudo cryptsetup luksHeaderBackup <LUKS> --header-backup-file /root/luks-header.img

Copy it off the machine. It keeps every key slot that existed when it was made, so after changing a passphrase, make a new header backup and delete the old one.

6. Test the unlock first#

At this point the encrypted partition is empty and the system still runs from the old root. If the next boot fails, nothing is lost. That’s why the unlock gets tested now.

Add sd-encrypt to the initramfs hooks, after sd-vconsole and the Plymouth hooks and before filesystems. On a CachyOS hook line ending in filesystems):

sudo sed -i 's/ filesystems)/ sd-encrypt filesystems)/' /etc/mkinitcpio.conf
grep '^HOOKS' /etc/mkinitcpio.conf

Then tell the kernel which partition to unlock. On CachyOS the Limine entries are generated from /etc/default/limine by limine-update, so the command line goes there. Read the partition’s UUID:

sudo blkid -s UUID -o value <LUKS>

and append, with that UUID pasted in twice:

echo 'KERNEL_CMDLINE[default]+=" rd.luks.name=<LUKS_UUID>=cryptroot rd.luks.options=<LUKS_UUID>=fido2-device=auto,discard"' | sudo tee -a /etc/default/limine

rd.luks.name opens the partition as /dev/mapper/cryptroot. The root=UUID= already on the command line stays as it is, because section 7 keeps the filesystem’s UUID. For TPM + PIN use tpm2-device=auto; for a passphrase only, leave the device option out. discard passes TRIM through to the SSD, at the cost of showing which blocks are in use.

While editing that file, stop Limine scanning for other boot loaders. The scan adds a Windows entry that chainloads Windows from inside Limine, and an “EFI fallback” entry. Section 8 replaces the first and removes the second:

echo 'FIND_BOOTLOADERS=no' | sudo tee -a /etc/default/limine

Rebuild with limine-update, never mkinitcpio -P. On this setup the initramfs that boots is the one limine-update writes and hashes; /boot/initramfs-*.img isn’t used.

sudo limine-update
sudo sbctl verify

Then reboot twice. First with the YubiKey plugged in: the boot splash asks for the security token PIN, the key blinks, a touch, and the desktop appears. Then with the key unplugged: after about 30 seconds the splash gives up on the key and asks for the passphrase.

The PIN prompt has three traps, and the first test boot hit all of them:

To find that out I added rd.systemd.log_level=debug at the Limine menu (press E on the entry), fell back to the passphrase, and read the unlock log:

journalctl -b _COMM=systemd-cryptse

A FIDO2 status of 0x31 is a wrong PIN; 0x27 is a refused or timed-out touch. Do any debugging like this now: once the menu is locked in section 8, the editor is gone.

One harmless message shows at every boot: libcryptsetup-token-systemd-recovery.so: cannot open. No such plugin exists; the recovery “token” is only a label on a passphrase slot.

7. Moving a live root with btrfs replace#

btrfs replace is meant for swapping a failing disk under a mounted filesystem. Here it moves the whole filesystem from the old partition onto /dev/mapper/cryptroot while the system keeps running, and keeps the filesystem UUID, so fstab and root=UUID= don’t change.

sudo btrfs replace start -B <OLD_ROOT> /dev/mapper/cryptroot /

It stays silent until it’s done; sudo btrfs replace status / in a second terminal shows progress. 646 GiB took just under 4 minutes, NVMe to NVMe through dm-crypt. No sleep during the copy, and nothing heavy running.

sudo btrfs filesystem show /

Exactly one device should be listed, /dev/mapper/cryptroot. Then let the filesystem use the whole partition:

sudo btrfs filesystem resize 1:max /

Reboot, unlock, and check:

findmnt /

/dev/mapper/cryptroot[/@] means the system now runs encrypted. I also tested the recovery key once at the passphrase prompt at this point.

If your root isn’t btrfs, there’s no live copy like this. The options are copying from the live USB with rsync -aHAXx and pointing fstab and root= at the new filesystem, encrypting in place with cryptsetup reencrypt --encrypt, or reinstalling with the installer’s encryption and restoring /home. I haven’t done any of those here.

8. Locking down /boot#

/boot can’t be encrypted with Limine, so it has to be protected another way: nothing on it may run unless it is exactly what I put there.

What checks what at boot Firmware, Secure Boot on db: my key + Microsoft + board maker signature Limine, signed with my key limine.conf hash built into the binary config hash, then file hashes kernel#hash, initramfs#hash any mismatch stops the boot initramfs unlocks LUKS2 YubiKey PIN + touch, passphrase, recovery key btrfs root, all subvolumes Windows entry sets boot-next, restarts firmware starts Windows Limine not measured \EFI\BOOT\BOOTX64.EFI unenrolled copy: no config check at all. Deleted. Solid arrows: each stage checks the next before running it.

First, move /boot to the new ESP. Format it, copy, compare, and switch fstab to its UUID:

sudo mkfs.fat -F 32 -n LIMINE <NEW_ESP>
sudo mount --mkdir <NEW_ESP> /mnt/newesp
sudo rsync -rt /boot/ /mnt/newesp/
sudo diff -rq /boot /mnt/newesp

No output means identical. Unmount it, replace the old ESP’s UUID on the /boot line in /etc/fstab with the new one (sudo blkid -s UUID -o value <NEW_ESP>), then sudo umount /boot, sudo systemctl daemon-reload, sudo mount /boot, and check findmnt /boot shows the new partition.

Next, Windows should start through the firmware. Limine can start Windows two ways. With protocol: efi_chainload it loads Windows Boot Manager itself, which puts Limine into Windows’ measured boot chain, so every Limine update looks like tampering to BitLocker. With protocol: efi_boot_entry it sets the firmware’s boot-next variable and restarts; the firmware then starts Windows as if you’d picked it from the boot menu. The entry in /boot/limine.conf:

/Windows
    protocol: efi_boot_entry
    entry: Windows Boot Manager

entry must match the firmware entry’s name exactly (efibootmgr shows it). A hand-written section like this survived limine-update on my install.

Now lock the config. In /etc/default/limine, ENABLE_VERIFICATION=yes appends a BLAKE2b hash to every kernel and initramfs path. Then:

echo 'ENABLE_ENROLL_LIMINE_CONFIG=yes' | sudo tee -a /etc/default/limine

With that set, limine-update builds the config’s hash into the Limine binary. With Secure Boot on and a hash enrolled, Limine refuses a config that doesn’t match, refuses any file whose path has no hash or a wrong one, and disables its menu editor.

Enrolling changes the binary, so it must be signed after enrolling. CachyOS’s hook /etc/boot/hooks/post.d/90-limine-enroll-config does the enrolling; I added a small hook numbered after it, 95-sbctl-sign, to sign the result:

#!/bin/sh
command -v sbctl >/dev/null || exit 0
sbctl sign /boot/EFI/limine/limine_x64.efi
sudo limine-update
sudo sbctl verify

From here on every change to limine.conf goes through limine-update. A hand edit without it stops the next boot with a checksum error, and the fix is a live USB.

The fallback copy gets around the lock. limine-update can also install Limine at \EFI\BOOT\BOOTX64.EFI, the path the firmware tries when it has no boot entries. I expected that copy to be enrolled like the main one. It isn’t. The binary carries its config hash after a marker, and you can read it:

sudo grep -ao 'CONFIG_B2SUM_SIGNATURE++[0-9a-f]*' /boot/EFI/BOOT/BOOTX64.EFI

On the fallback copy that’s all zeros, even with the fallback option on. A signed Limine with no enrolled hash runs any config without checks, and anyone at the keyboard can pick it from the firmware’s boot menu. So it went:

sudo sed -i 's/^ENABLE_LIMINE_FALLBACK=yes$/ENABLE_LIMINE_FALLBACK=no/' /etc/default/limine
sudo sbctl remove-file /boot/EFI/BOOT/BOOTX64.EFI
sudo rm -r /boot/EFI/BOOT
sudo limine-entry-tool --remove-entry "EFI fallback"

(--remove-efi-path didn’t match the entry; removing it by name did.) The same grep on /boot/EFI/limine/limine_x64.efi shows a real hash. The downside is that if the firmware ever loses its boot entries, it can’t find Limine by itself, and the entry has to be recreated from the live USB with efibootmgr --create.

After locking, the boot menu came up plain white. Under the lock, Limine silently skips a wallpaper or font without a hash, and my theme’s background colour is transparent, so nothing was left. The fix is a hash on the wallpaper: line:

sudo b2sum /boot/limine-splash.png

The line becomes wallpaper: boot():/limine-splash.png#<hash>. A file that doesn’t match its hash stops the boot instead of being skipped, so any later change to the image needs a new hash and a limine-update.

While I was at it, I replaced Limine’s default font. Limine wants a raw 256-glyph font in code page 437 order, 8 pixels wide. Linux console fonts are PSF files with a different glyph order and a Unicode table, so I wrote a short converter that looks up each CP437 character in the PSF’s Unicode table:

import gzip, struct, sys
src, dst = sys.argv[1], sys.argv[2]
d = gzip.open(src).read() if src.endswith('.gz') else open(src, 'rb').read()
if d[:2] == b'\x36\x04':            # PSF1
    mode, h = d[2], d[3]; n = 512 if mode & 1 else 256; hdr = 4; bpg = h; w = 8
    glyphs = [d[hdr+i*bpg:hdr+(i+1)*bpg] for i in range(n)]
    tab = d[hdr+n*bpg:]; umap = {}; i = 0; g = 0
    while g < n and i + 1 < len(tab):
        u = struct.unpack_from('<H', tab, i)[0]; i += 2
        if u == 0xFFFF: g += 1; continue
        if u == 0xFFFE: continue
        umap.setdefault(u, g)
else:                                # PSF2
    magic, ver, hdr, flags, n, bpg, h, w = struct.unpack_from('<8I', d)
    glyphs = [d[hdr+i*bpg:hdr+(i+1)*bpg] for i in range(n)]
    tab = d[hdr+n*bpg:]; umap = {}; g = 0; i = 0
    while g < n and i < len(tab):
        if tab[i] == 0xFF: g += 1; i += 1; continue
        if tab[i] == 0xFE: i += 1; continue
        b = tab[i]; ln = 1 if b < 0x80 else 2 if b < 0xE0 else 3 if b < 0xF0 else 4
        umap.setdefault(ord(tab[i:i+ln].decode('utf-8')), g); i += ln
assert w == 8, f'width {w} not supported'
ctrl = '\u0000☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼'
chars = [ctrl[c] if c < 32 else ('⌂' if c == 127 else bytes([c]).decode('cp437')) for c in range(256)]
out = bytearray(); missing = []
for c, ch in enumerate(chars):
    gi = umap.get(ord(ch))
    if gi is None and c == 0: gi = umap.get(32)
    if gi is None: missing.append(ch); gi = umap.get(ord('?'))
    out += glyphs[gi][:h]
open(dst, 'wb').write(out)
print(f'{src}: {n} glyphs, 8x{h}, wrote {len(out)} bytes; missing CP437: {"".join(missing) or "none"}')

Terminus Bold 8×16 from the terminus-font package converts with nothing missing:

python3 -I psf2cp437.py /usr/share/kbd/consolefonts/ter-i16b.psf.gz limine-font.f16

Copy it to /boot, hash it with b2sum, and add two lines to limine.conf:

term_font: boot():/limine-font.f16#<hash>
term_font_size: 8x16

Last, create a firmware entry for the new ESP and test both systems:

sudo efibootmgr --create --disk <DISK> --part <ESP_NUM> --label "CachyOS Limine" --loader '\EFI\limine\limine_x64.efi'

Reboot into Linux, then reboot and pick Windows in Limine. The PC should restart once and land in Windows. Until section 10, the old ESP and its firmware entry are still a working way back.

9. Windows and BitLocker#

I finished every Secure Boot change before turning BitLocker on, because BitLocker notices changes to what it measures and asks for the recovery key.

Which measurements it uses depends on the PC. Normally BitLocker binds to PCR 7 (Secure Boot state) and PCR 11. On my desktop, Windows reports that PCR 7 binding isn’t possible (see “PCR7 Configuration” in msinfo32), because the graphics card’s option ROM is signed by Microsoft’s third-party CA. BitLocker then falls back to PCRs 0, 2, 4 and 11: firmware, option ROMs, the boot manager, and Windows’ own boot stages. Consequences:

Suspend-BitLocker -MountPoint C: -RebootCount 2

I use TPM + PIN for BitLocker. At its PIN screen, don’t press Escape. Escape goes to the recovery path. The first time it happened during setup, the hardware test failed and Windows removed the new protectors without encrypting anything; running the setup again worked.

Two more rules for living next to Windows: never let Disk Management “initialise”, format or give drive letters to the Linux partitions (it lists them as unknown), and after big Windows updates check that the Linux boot entry still exists and is still first. Section 11 is why.

10. Erase, delete, grow#

The old root partition still held an unencrypted copy of everything, including my SSH keys and the Secure Boot signing keys. Deleting a partition only removes its entry from the table; the data stays on the disk. So erase first, after checking that nothing still uses it:

sudo btrfs filesystem show /
findmnt /boot

Only /dev/mapper/cryptroot, and the new ESP. Then:

sudo blkdiscard -f <OLD_ROOT>
sudo blkdiscard -f <OLD_ESP>

On an SSD this tells the drive those blocks are free, and most drives then read back zeros. It doesn’t promise the old data has left every flash cell. Only a whole-drive secure erase does that, which would take Windows with it. Rotating the SSH keys that were on the old root covers the practical risk.

Remove the old firmware entries that point at the old ESP. In sudo efibootmgr -v they are the lines whose device path starts HD(<OLD_ESP_NUM>,; delete each with sudo efibootmgr -b <BOOTNUM> -B, never touching Windows Boot Manager or the new entry. Then delete the old partitions and grow the encrypted one into the space after it, one layer at a time:

sudo sgdisk -d <OLD_ESP_NUM> -d <OLD_ROOT_NUM> <DISK>

Add another -d <number> for any other partition you are giving to Linux. I also deleted a stale Windows recovery partition at the end of the disk, which Windows no longer used.

sudo parted <DISK> resizepart <LUKS_NUM> 100%
sudo cryptsetup resize cryptroot
sudo btrfs filesystem resize 1:max /

cryptsetup resize asks for a key, which can be the YubiKey. The root went from 1.63 TiB to 2.63 TiB without a reboot.

One thing about snapper: snapshot entries from before section 6 can’t boot any more, because their initramfs knows nothing about the encryption. Take a new baseline snapshot now, never roll back to an older one, and let the old ones age out.

11. The boot entry that vanished#

My disk now held GPT entries 1, 2, 3, 7 and 8. Linux and the firmware were happy: they address partitions by their GPT entry number. Then BitLocker went on in Windows, and on the next restart the PC went straight to Windows. The CachyOS Limine firmware entry was gone.

Recreating the entry from Windows with bcdedit didn’t help either: it existed, it was first in the boot order, and the firmware ignored it. Reading the firmware variable showed why. The device path said HD(4, GPT, …). Windows doesn’t number partitions by GPT entry; it counts them in disk order, and the Linux ESP is the 4th partition on the disk. Windows wrote its own number into the boot entry. There’s no GPT entry 4, so the firmware treated the entry as invalid and dropped it at startup.

Two ways to count partitions on disk Win ESP reserved C: Linux ESP LUKS2 GPT entry 12378 Windows 12345 Windows writes the Linux entry as HD(4, …) no GPT entry 4 exists, so the firmware drops the entry after renumber 12345 GPT entry = position, so HD(4) is the Linux ESP only the table’s numbering changes; no data moves

I saw the wrong number directly in the entry Windows created. That BitLocker setup rewrote the original entry the same way, and that this is why it disappeared, is the explanation that fits everything, but I haven’t proven it.

Writing the entry directly with the right number got Linux booting again. The lasting fix is to make the GPT entry numbers match the order on disk, so whatever Windows writes is correct. Nothing moves; only the entries in the table swap places.

Check whether your disk needs it. This lists the partitions in disk order:

lsblk -nrpo NAME,PARTN,START <DISK> | awk 'NF==3' | sort -n -k3 | cat -n

The first column is each partition’s position on the disk and the third is its GPT entry number. If the two columns match on every line, skip the rest of this section. Otherwise, note the positions of the new ESP and the LUKS partition as <ESP_POS> and <LUKS_POS>. Mine printed 4 /dev/nvme0n1p7 7 … and 5 /dev/nvme0n1p8 8 …, so <ESP_POS> was 4 and <LUKS_POS> was 5.

Next, check that nothing refers to partitions by device name. fstab, crypttab and the kernel command line should all use UUIDs:

sudo grep -rnsE '/dev/(nvme|sd|vd|mmcblk)' /etc/fstab /etc/crypttab /etc/default/limine

No output is what you want. Then renumber:

sudo sgdisk --transpose=<ESP_NUM>:<ESP_POS> --transpose=<LUKS_NUM>:<LUKS_POS> <DISK>

--transpose swaps two entries, so it changes only these two partitions when entry numbers <ESP_POS> and <LUKS_POS> are unused, which they usually are once the old partitions are gone. If another partition still holds one of them, use sudo sgdisk --sort <DISK> instead, which renumbers everything in disk order in one step. I haven’t run --sort myself.

sgdisk warns that the kernel still uses the old table. Don’t run partprobe: the kernel can’t renumber partitions that are in use, and the reboot does it. Before rebooting, create the firmware entry with the new number:

sudo efibootmgr --create --disk <DISK> --part <ESP_POS> --label "CachyOS Limine" --loader '\EFI\limine\limine_x64.efi'

Check that the new entry’s device path starts HD(<ESP_POS>,, then delete the old entry, the CachyOS Limine line that still says HD(<ESP_NUM>,:

sudo efibootmgr -b <BOOTNUM> -B

After the reboot the two partitions have names that match their positions: on my disk /boot moved from nvme0n1p7 to nvme0n1p4 and the LUKS partition from nvme0n1p8 to nvme0n1p5. Use the new names for <NEW_ESP> and <LUKS> from now on. Limine started from the new entry, and Secure Boot still said enabled (user). BitLocker didn’t notice: the partition table is measured into PCR 5, which neither BitLocker profile uses.

If I did this again, I’d plan the final numbers from the start and renumber before ever turning BitLocker on.

12. What is still open#