Encrypting a running CachyOS install next to Windows: your own Secure Boot keys, a YubiKey and a locked Limine
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:
- Secure Boot is on, in user mode, with my own keys plus Microsoft’s and the board maker’s, and the factory revocation list put back.
- The whole Linux root (every btrfs subvolume) is inside LUKS2. A YubiKey unlocks it at boot with a PIN and a touch. A passphrase and a recovery key are the fallbacks.
/bootis a separate EFI system partition that isn’t encrypted but can’t be quietly changed: Limine is signed, the hash of its config is built into the signed binary, and every kernel and initramfs it loads must match a hash in that config.- Windows starts through the firmware’s own boot entry, so BitLocker never sees Limine.
- Nothing was reinstalled. The copy onto the encrypted partition ran while I used the PC.
What you need:
- free disk space at least as large as your current Linux root partition
- somewhere to put a full backup (an external disk or a NAS share)
- a USB stick for a live system, and an Ethernet cable or a phone that can tether, because the live system may not have a driver for your Wi-Fi card
- optionally a FIDO2 security key such as a YubiKey 5. Without one, a passphrase does the same job, and section 5 covers TPM + PIN as an option
sbctl,efitools,gptfdisk,libfido2andtpm2-tssinstalled
The order matters. Every step leaves something that boots, and the old unencrypted system stays bootable until the encrypted one has proven itself:
- Look before changing anything
- A backup you have read back
- Secure Boot with your own keys
- Two new partitions
- LUKS2 and the ways in
- Test the unlock first
- Moving a live root with btrfs replace
- Locking down /boot
- Windows and BitLocker
- Erase, delete, grow
- The boot entry that vanished
- What is still open
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:
- Where the free space is: later the encrypted partition grows into the space freed
by deleting the old Linux partitions, and a partition can only grow forwards. The new
partitions belong in the free space directly before the old Linux ones. In my case
Windows had been reinstalled smaller, which left 1670 GiB free right after
C:. - Whether
/bootis the ESP: on a default CachyOS + Limine install,/bootis a FAT partition holding Limine, the kernels and the initramfs. Limine can’t open LUKS, so this layout is what makes an unencrypted but integrity-checked/bootwork. - Whether the initramfs uses systemd:
HOOKSshould startbase systemdand containsd-vconsole. FIDO2 and TPM unlock come fromsd-encrypt, which needs the systemd hooks. - Whether the GPT entry numbers follow the order on disk: mine didn’t, and it cost me a boot entry (section 11).
- The Secure Boot state, and whether the firmware exposes its factory key databases
(
ls /sys/firmware/efi/efivars/ | grep Default). Section 3 uses them. - Whether Windows already uses BitLocker or “device encryption”: Windows 11 turns device encryption on by itself on many PCs. If it’s on, have the recovery key in hand and suspend it before changing anything in the firmware (section 9).
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:
- A character can already be sitting in the PIN box. The first attempt failed with the right PIN. Pressing Backspace a few times before typing fixed it every time since.
- A wrong PIN looks like nothing happened. Under Plymouth the box just comes back, with no “incorrect PIN” message.
- “Confirm presence” appears before the PIN has been checked, and stays on screen while the box waits for the PIN again. That looks like a touch prompt the key is ignoring. The key only blinks after a correct PIN. If it doesn’t blink, retype the PIN.
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.
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:
- Starting Windows through Limine’s
efi_boot_entrymatters here. A chainload would put Limine into PCR 4, and every Limine update would trigger BitLocker recovery. - Secure Boot key changes (PCR 7) no longer trip BitLocker. BIOS updates (PCR 0) and graphics card firmware flashes (PCR 2) do. Before either, in an administrator PowerShell:
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.
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#
- Whether Windows now leaves the boot entry alone: with matching numbers, an entry
Windows rewrites should still be valid. I haven’t repeated a BitLocker setup to
prove it, so after major Windows updates I still check
efibootmgr. - TPM + PIN for Linux is described in section 5 but untested here.
- A second YubiKey: enrolling a spare is one more
systemd-cryptenrollcall, and I should do it. - No fallback boot loader: copying the enrolled Limine binary to
\EFI\BOOT\BOOTX64.EFIfrom a hook would let the firmware find it again after losing its entries, without reopening the hole from section 8. I haven’t tried it. - The unencrypted backup gets deleted after a week of normal use.