Unlock a LUKS encrypted root filesystem over a FIPS mesh, from anywhere, removing the need for local console access (but not replacing it).
A Linux machine with an encrypted root stops during boot and waits for a
passphrase. Answering that prompt remotely is what dropbear-initramfs
is for, but it leaves you needing a route to the machine on the ordinary
IP network: a static address or a DHCP reservation, a port open through
whatever sits in front of it, and a way in that works before the machine
has finished booting.
This package puts a FIPS mesh node inside the initramfs and binds
dropbear to the fips0 interface it creates. The machine dials out to
its bootstrap peers, so it needs no inbound reachability at all, and it
comes up at the same mesh address on every boot, so you can write that
address down at install time and use it for the life of the machine.
Console passphrase entry is never disabled. Every failure in this path leaves the operator at the ordinary cryptsetup prompt, which is what makes it safe to install on a machine you can still reach.
The machine does not need FIPS installed, and most machines running
this will not have it. The package carries its own fips and fipsctl,
and the node exists only inside the initramfs, for the length of the
unlock. Nothing is left running once the real root is up.
A machine that does run FIPS is equally fine, and there the two identities are separate and should stay that way.
On the machine to be unlocked. Download the .deb from the
latest release
and install it:
curl -sSfLO https://github.com/jmcorgan/fips-initramfs/releases/download/v0.1.0/fips-initramfs_0.1.0_amd64.deb
sudo apt install ./fips-initramfs_0.1.0_amd64.debThe release also carries SHA256SUMS, and
Building from source covers building it yourself
instead.
The .deb already carries fips and fipsctl, taken from a published
FIPS release at build time and checked against the checksums that release
publishes, so the install downloads nothing else and needs no other
package.
The install asks two questions: the SSH public key allowed to unlock the machine, and a bootstrap peer. Leaving the peer blank keeps the FIPS public test mesh. What the peer prompt accepts, and what to edit afterwards for anything it cannot express, is in debian/README.Debian.
It then prints the name to connect to. Record it. It is the only thing you need afterwards, and it does not change:
fips-initramfs: unlock this node at npub1....fips
On the machine you unlock from, you need a running FIPS daemon connected to the public mesh, or to the mesh which contains the node you optionally configured to peer with. Then, once the machine under test has rebooted and is waiting:
ssh root@npub1....fipsThat is the whole unlock. The key is forced to run cryptroot-unlock
inside the image, so the session takes the passphrase and never opens a
shell.
It is recommended you create a host specific SSH client configuration
file that contains the FIPS npub.fips address, the identity you
configured, and the user specified as root:
# ~/.ssh/config.d/unlock-server1
Host unlock-server1
HostName npub1....fips
User root
IdentityFile ~/.ssh/id_ed25519_unlock
IdentitiesOnly yes
A file under ~/.ssh/config.d/ is read only if ~/.ssh/config begins
with Include config.d/*; without that line the file is ignored in
silence. Putting the block straight into ~/.ssh/config always works.
IdentitiesOnly is there because ssh otherwise offers every key your
agent holds before the one named here, and dropbear closes the
connection once it has refused enough of them.
The unlock is then ssh unlock-server1.
In any case, always connect to the <npub>.fips address, not to the
IPv6 address. This is required for the connecting FIPS node to properly
resolve and establish the mesh connection.
Before putting this on a machine that matters, read Security considerations.
The build produces one .deb, and that file is what you install. Its
version comes from debian/changelog, so a build of the trunk is a
development version ahead of the last release; a build of a release tag
carries that release's version. Two things it needs either way: a Debian or
Ubuntu system, and network access, because the build downloads fips
and fipsctl from a published FIPS release and verifies them against
the checksums that release publishes.
git clone https://github.com/jmcorgan/fips-initramfs
cd fips-initramfs
# Install exactly what debian/control declares, rather than a list
# copied out of it that can fall behind.
sudo apt install build-essential devscripts equivs
sudo mk-build-deps --install --remove \
--tool "apt-get -y --no-install-recommends" debian/control
dpkg-buildpackage -us -uc -bThe package lands one directory up. Install it with:
sudo apt install ../fips-initramfs_*_amd64.debSame file, in out/, with no build tools installed on the machine you
run it from:
mkdir -p out
docker run --rm -v "$PWD:/src:ro" -v "$PWD/out:/out" debian:13 sh -c '
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq build-essential devscripts equivs
cp -a /src /work && cd /work
mk-build-deps --install --remove \
--tool "apt-get -y -qq --no-install-recommends" debian/control
dpkg-buildpackage -us -uc -b
cp ../fips-initramfs_*.deb /out/'The source tree is mounted read only and copied inside the container,
because the build writes into it and leaves its artifacts one directory
up. The .deb is written to out/ owned by root.
The latest published one by default, and 0.5.1 is the floor: the build refuses anything older, because the daemon in 0.5.0 cannot start on Debian 12 or Ubuntu 22.04. Two variables change that:
FIPS_TAG=v0.5.1bundles that release rather than the latest.FIPS_DEB=/path/to/fips_x.y.z_amd64.debuses a local file and downloads nothing, for an offline build or to try a release candidate.
The built package records what it took in
/usr/share/fips-initramfs/fips-version, and a release also states it in
the BUILD-INFO.txt attached alongside the .deb.
A build from a clean checkout of a tag reproduces the file published
for that tag. Measured for v0.1.0: a fresh clone checked out at
v0.1.0 and built with the container recipe above produced the same
sha256 as the .deb attached to the release. So a package you built
yourself can be checked against the SHA256SUMS on the release page:
sha256sum fips-initramfs_0.1.0_amd64.deb # compare with SHA256SUMSBuild from a modified working tree and it will not match, which is the point of saying "clean checkout" rather than "the source".
sh tests/hook-test.sh # and functions-test.sh, premount-test.shThey build no image, need no root and need no dropbear, and each skips with status 77 rather than failing when a prerequisite is absent.
ci/run-local.sh runs a fuller check in a container per distribution:
it builds the package, installs it, generates an identity, builds an
image and reads what the hook put in it, then runs those three suites.
It needs docker and takes a few minutes per distribution. Name one to
run just that one, as ci/run-local.sh debian:13. It is a test runner
and not a way to obtain a package: each container is discarded when it
finishes, and the .deb it builds goes with it. Use one of the two
recipes above to get a file you can install.
Three pieces, run by initramfs-tools at three different moments:
- A build-time hook puts the daemon, its configuration and the
identity key into the generated image, and appends the node's address
to dropbear's options inside that image. It never fails the image
build: when something it needs is missing it warns and produces an
image with no FIPS node, because a hook that exits non-zero leaves
initramfs-toolshalf-configured and aborts the apt run it belonged to. - A premount script starts the node, waits for
fips0to get an address, and compares that address against the one written into the image. The comparison is the point. A FIPS daemon whose key is missing generates a new identity and runs perfectly at the wrong address, and a daemon whose TUN setup failed reports itself degraded and keeps going. Neither is caught by asking whether the daemon is alive. - An init-bottom script stops the node and removes the interface
before
switch_root, so nothing from the initramfs survives into the booted system.
On a machine that also installs the fips package, the hook takes that
package's binaries from /usr/bin in preference to the bundled ones, so
the node in the initramfs is the same build as the daemon the booted
system runs.
The identities stay separate either way. The install generates a
keypair under /etc/fips-initramfs/ and leaves the host's
/etc/fips/fips.key alone, so the machine answers to one npub while it
waits for a passphrase and a different one once it has booted. That is
deliberate: the initramfs key ships inside an unencrypted image on an
unencrypted /boot, which is the exposure the encrypted root exists to
avoid, so it should not also be the key that identifies the running
machine on the mesh.
debian/ Source package: control, rules, maintainer scripts, README.Debian
initramfs/ The three scripts: build-time hook, premount, init-bottom
conf/ Node configuration example and the package's own conffile
lib/ Shared shell functions, sourced by the hook and by postinst
tests/ Three suites: hook, shared functions, premount
debian/README.Debian is the operator manual, and it is longer than this file. It covers setting a machine up, the bootstrap peer prompt and the peer list, what the boot scripts check and what each warning means, script ordering, what the node adds to the image, the rebuild trigger, and removing against purging. It is where to look when a machine does not come back.
Built, installed and unlocked over the mesh on all five distributions, against the public FIPS test mesh.
This is a Debian source package and targets .deb-based
distributions only. It is built as a .deb, it installs as one, and
it hooks initramfs-tools, dropbear-initramfs and
cryptsetup-initramfs, none of which exist outside that family. Nothing
about the idea is Debian-specific, though, and a port to another
distribution that encrypts its root with LUKS would be welcome: what
would have to be rewritten is the packaging and the interface to the
initramfs generator, not the design. See
CONTRIBUTING.md.
Targets Debian 12 and 13 and Ubuntu 22.04, 24.04 and 26.04. Linux is not one target even within that family: each distribution assembles the initramfs with its own tooling and its own busybox, and a fault in this package is normally specific to one of them.
| Debian 12 | Debian 13 | Ubuntu 22.04 | Ubuntu 24.04 | Ubuntu 26.04 | |
|---|---|---|---|---|---|
dropbear-initramfs |
2022.83 | 2025.89 | 2020.81 | 2022.83 | 2025.89 |
| Commands the scripts need | ✅ | ✅ | ✅ | ✅ | ✅ |
| Builds, installs, images | ✅ | ✅ | ✅ | ✅ | ✅ |
| Installs, boots, unlocks | ✅ | ✅ | ✅ | ✅ | ✅ |
Installs, boots, unlocks means the package was installed on a machine with an encrypted root, the machine came up, was reached over the mesh, and unlocked with nothing typed on its console, with the address in the console output matching the one the install reported. All five were exercised that way on 2026-09-06.
All five run the bundled daemon, which is what FIPS 0.5.1 changed. Its binaries need glibc 2.34, and the oldest distribution here is Ubuntu 22.04 at 2.35. The 0.5.0 daemon needed 2.39 and so could not start on Debian 12 or Ubuntu 22.04; the package noticed at image-build time and left an image with no node, so console unlock still worked. That is why 0.5.1 is the floor.
The rows are covered differently, and only the first two are automated.
Commands the scripts need and builds, installs, images are
checked by ci/container-test.sh, which builds the package, installs it
both ways the debconf prompt allows and reads what the hook put in the
image. GitHub Actions runs it on every push, one distribution per job,
and ci/run-local.sh drives the same script across five containers on a
development machine. Installs, boots, unlocks needs a booted machine
with an encrypted root reaching a live mesh, which no hosted runner can
provide, so it is run outside CI and no check gates it.
Not yet exercised: a kernel upgrade regenerating the image, dpkg -r
against dpkg -P, and any architecture other than amd64.
FIPS 0.5.1 or later is required, and the build refuses anything
below it. Two things put the floor there: fipsctl address, which the
install needs and which first shipped in 0.5.0, and the glibc 2.34
requirement above, which arrived in 0.5.1. Nothing mechanically couples
the two projects, and the FIPS configuration schema is not yet stable,
so a much newer FIPS may meet a schema break at boot.
The initramfs image is not encrypted, and the node's private key is in
it. That is the whole point of the design: the node has to come up
before anything is decrypted. /boot is readable by anyone with the
disk, so treat the initramfs identity as compromised the moment the
machine is physically taken. It grants what any mesh identity grants,
which is why it must not be the same key that identifies the running
machine (see How it works).
This does not change what an unencrypted /boot already meant.
Anyone who can write to it could already replace the kernel or the
initramfs on a machine with no verified boot. This package adds a key to
what is exposed there; it does not create the exposure.
The unlock key is authorised to unlock and to do nothing else. The
hook prepends command="/usr/bin/cryptroot-unlock" to each key in the
image's authorized_keys, so a session takes the passphrase and never
reaches a shell. One exception: a key line that already carries
options is left exactly as written, because it expresses an intent of
your own that prepending to could defeat. If you write your own options,
you own the restriction.
The passphrase leaves your machine. Unlocking remotely means sending the LUKS passphrase over the network, which typing it at the console never does. Two layers carry it: the SSH session to dropbear, and FIPS's own encryption between your node and the target npub. Check dropbear's host key the first time you connect, so that something else answering at that name cannot collect the passphrase.
Anyone who knows the npub can reach the node. It sits on the mesh with dropbear listening, and the authorised key is what stands between that and an unlock. Use a key reserved for this, not a general-purpose one.
Console entry always remains. Physical access to the console unlocks the machine with the passphrase exactly as it did before, and no failure in this path can take that away.
MIT, see LICENSE.