One might be inclined to google “luks nixos” to see what options are available for FDE, upon which they would come across this page on the NixOS wiki. A more mischievous person might notice how this page doesn’t describe a setup with a detached header – but fret not for the first entry in the further-reading list links to Shen Zhou Hong’s tutorial.

Now, if you were like me and thought “Hey, I have an sd card reader on my laptop and that would be a cool spot for the header and the boot partition! Lemme do that real quick!” but had, say, a ThinkPad, you were in for a disappointment: Your laptop doesn’t support booting from the SD-card slot. You might have also noticed that this encryption scheme is, in fact, not deniable: There’s a partition on your root drive.

This is supposed to be a tutorial for my (quite particular) wants and needs:

  1. I want the root drive to be indistinguishable from a random-filled drive
  2. While /boot doesn’t necessarily need to be removable, the LUKS header must not be in internal storage.
  3. The only additional work I have to do must be to enter a password.

Lets ignore for now the fact that I can’t boot from the SD card. 1 and 2 can be achieved at the same time with /boot not having its own partition: The SD card could unlock the main drive (let’s say its LUKS-encrypted LVM with ext4 for the root), use the vmlinuz/bzImage and the initrd files and the appropriate options, and continue on the boot from there. Small issue: I would have to enter my password once in the bootloader and once for the kernel. This is in obvious violation of requirement 3. It follows from this fact that i must have a portable /boot. (Keyfiles are implicitly ruled out)

And so had I determined the objective: Install NixOS on the main drive with /boot pointing to /dev/mmcblk0p1 with the LUKS header on /dev/mmcblk0p2, which was a fairly trivial task. Oh how disappointing it was to see that the laptop wouldn’t boot from the SD card.

If you aren’t stubborn like me, you might get a slim USB drive and use that as the boot device but no, I was dead set on using my SD card as the boot drive. So how could I accomplish this? By adding yet another device to the boot process of course! So the plan was now:

  1. Boot into a USB drive: It reads the /boot stuff located in /dev/mmcblk0p1 (Linux has the relevant drivers to read the SD card obviously – unlike my BIOS)
  2. kexec into NixOS based off the newest entry in /boot/loader/entries/* (I am using systemd-boot).
  3. Unplug the USB at my leisure after the last step.
  4. Enter my password, carry on with the boot with an unlocked main drive.

Astute readers might guess that steps 1 and 2 are the only involved ones. I’m not really good with words so I’ll just be outlining the steps by which to achieve these goals, and the issues I came across in the process of figuring this out. I’m not good at these things so it took a bit of effort to set this up and I wish to spare the next fool the headache.

I’ll still be assuming some technical knowhow. You probably shouldn’t be worrying about deniable FDE otherwise.

The Steps#

Preparation#

First of all, get yourself an installation ISO from here. dd it to a USB drive or whatever.

Boot into the USB, do a sudo su.

Let’s say that the drive you want to denaibly encrypt is /dev/nvme0n1. In order to be able to claim that this drive is not used, you should probably first get rid of any data on it irrevocably:

dd if=/dev/zero of=/dev/nvme0n1 oflag=direct bs=512k status=progress
dd if=/dev/urandom of=/dev/nvme0n1 oflag=direct bs=512k status=progress

This step, as noted in the original article, is also useful to obfuscate where the encrypted data is, in case the drive was previously filled with unencrypted data.

Partitioning#

Let’s assume that the boot drive will be /dev/mmcblk0, and that it will contain the following partitions (no LVM):

  • /dev/mmcblk0p1: FAT32, flags: boot, esp
  • /dev/mmcblk0p2: LUKS

The rest can be optionally allocated to be ext4 or whatever for data storage

Let’s say that the afformentioned root drive will be partitioned s.t.:

  • /dev/nvme0n1: No partition table, LUKS (let’s say this is at /dev/mapper/crypt)
  • /dev/mapper/crypt: the only LVM PV for the LVM VG vg
  • /dev/vg/root: ext4, mounted at /
  • /dev/vg/swap: swap

The boot drive#

after doing parted /dev/mmcblk0, enter in the following into the parted cli:

  • mklabel gpt to create the partition table
  • mkpart ESP fat32 0% 1024MiB to create the partition to be mounted at /boot. 1GiB is quite a generous amount, you may decrease this.
  • set 1 boot on to set the flags esp and boot on the 1st partition that was just created
  • mkpart primary 1024MiB 1152MiB for the LUKS header. This is around 8 times the required size at 128MiB but from what I can tell, the LUKS specification doesn’t actually limit the keyslots to 32 for the on-disk representation ( which is the cause of the ~16MiB size for the header).
  • optional: mkpart primary 1152MiB 100% for the previously mentioned data partition.

Create the boot filesystem: mkfs -F32 -n BOOT /dev/mmcblk0p1

We’ll leave /dev/mmcblk0p2 alone for now.

The main drive#

We won’t be creating any partitions on this drive.

Prepare LUKS:

cryptsetup luksFormat /dev/nvme0n1 --type luks2 --header /dev/mmcblk0p2
cryptsetup luksOpen /dev/nvme0n1 hostname_crypt --header /dev/mmcblk0p2

These two commands should create /dev/mapper/hostname_crypt. You should rename hostname_crypt to whatever you want, it’s just the scheme i decided on using.

Create the LVM PV and the VG:

pvcreate /dev/mapper/hostname_crypt
vgcreate vg_hostname /dev/mapper/hostname_crypt

vg_hostname, much like hostname_crypt is just my naming convention.

Create the LVM LVs:

lvcreate -L 48G -n swap vg_hostname
lvcreate -L '100%FREE' -n root vg_hostname

Replace 48G with a value slightly above the amount of installed RAM of your system (or double if you think you’ll be swapping often).

These commands will create:

  • /dev/mapper/vg_hostname-swap
  • /dev/mapper/vg_hostname-root
  • /dev/vg_hostname/swap
  • /dev/vg_hostname/root

And the relevant /dev/disk/ entries.

Format the new paritions:

mkswap -L swap /dev/vg_hostname/swap
mkfs.ext4 -L hostname /dev/vg_hostname/root

Mount & Prepare Things for hardware_configuration.nix#

Briefly:

mount /dev/vg_hostname/root /mnt

mkdir /mnt/boot
mount /dev/mmcblk0p1 /mnt/boot

swapon /dev/vg_hostname/swap

NixOS Setup#

Running the following will create /etc/nixos/configuration.nix:

nixos-generate-config --root /mnt

You can now modify your configuration to your heart’s content but you must add the following to your configuration somewhere:

boot.initrd.luks.devices.hostname_crypt = {
  device = "/dev/disk/by-id/READ_BELOW";
  header = "/dev/mmcblk0p2";
  preLVM = true;
  # consider allowDiscards
};

Since we have no partition table, the main drive has no UUIDs attched to it. I had initially put /dev/nvme0n1 into the device section above but the boot would sometimes fail with /dev/vg_hostname/root being missing. I have come to realise that this is because I have two nvme drives and I can’t always depend on the encrypted drive being located at /dev/nvme0n1. The solution to this is using the seldom-used directory of /dev/disk/by-id.

I am assuming that your device has a single SD-card reader so using /dev/mmcblk* for the LUKS header is fine.

After all these, run the following to create a NixOS installation on /mnt:

nixos-install --root /mnt --cores 0

The Actual Boot Drive#

Recall that we can’t just boot off the SD-card. The next step is to create a USB drive capable of reading the card’s contents and boot NixOS accordingly. Basically: A bootloader in Linux.

This is easy enough with a NixOS ISO flake:

{
  description = "quasi bootloader";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
  outputs = { self, nixpkgs }: {
    nixosConfigurations = {
      iso = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ({ lib, pkgs, modulesPath, ... }: let
            mkForce = lib.mkForce;
          in {
            imports = [ (modulesPath + "/installer/cd-dvd/installation-cd-minimal.nix") ];

            environment.systemPackages = with pkgs; mkForce [
              busybox bash
              kexec-tools
            ];

            services.getty.autologinUser = mkForce "root";
            users.users.nixos.shell = pkgs.bash;
            programs.bash.shellInit = ''
              mkdir asd
              mount /dev/mmcblk0p1 asd

              cat "asd/loader/entries/$(ls asd/loader/entries | sort -V | tail -1)" |
                head -n 6 |
                tail -n 3 |
                sed "s/linux \\(.*\\)$/-l asd\\1 \\\\/" |
                sed "s/initrd \\(.*\\)$/--initrd=asd\\1 \\\\/" |
                sed "s/options \\(.*\\)$/--command-line=\\\\\"\\1\\\\\"/" |
                (echo "echo kexec \\" && cat) |
                bash |
                bash

              kexec -e
            '';

            boot.loader.timeout = mkForce 0;
          })
        ];
      };
    };
  };
}

Save this in a fresh folder under the name flake.nix and run the following:

git init
git add flake.nix
nix build .#nixosConfigurations.iso.config.system.build.isoImage

This, however, produces a fairly large ISO that might cause issues related to graphics drivers and whatnot, resulting in a black screen after the kexec -e invocation. For a smaller and less error-prone ISO flake, see here

After you generate an ISO located in result/iso, dd it to a flash drive. Set your BIOS up s.t. it boots from the USB drive by default and you’re good to go. You can unplug the usb after booting up BTW.