Skip to the content
nullroute
Browse the docs

Building a device

Everything above checks a device you were handed or built. This part builds one whose image a stranger can check.

Hardware

The design asks for capabilities, not a model: arm64, enough memory for Argon2id at the shipped cost plus a Chromium kiosk with no swap, an 800x480 touchscreen and a camera. Today's image targets the board below; others follow as each boots it. research/pi-boards.md lists what each needs first. Target build is under $120.

Part Choice Notes
Board Raspberry Pi 4 (4GB) The only board this image supports.
Board, in bring-up Raspberry Pi 5 The card carries its boot files, on Raspberry Pi's own kernel, the only one that drives the 7 inch panel on a Pi 5. Nothing has booted on one yet: bring-up, not support.
Display Official Raspberry Pi 7 inch touchscreen The DSI panel, provisioning/build/overlays/nullroute-7inch-dsi.dts. Pi 4: config.txt names the overlay in the Pi 4's section (INV-PROV-25) and it is on the card as a device tree blob (INV-PROV-26); the build refuses to finish unless merging it into bcm2711-rpi-4-b.dtb leaves dsi@7e700000 enabled with the panel and touch controller present. Pi 5: the firmware detects the panel and loads Raspberry Pi's own overlay; both overlays it can choose are on the card (INV-PROV-26), and the build refuses to finish unless every label they target exists in all three Pi 5 trees. Touch Display 2 (720x1280 portrait) is driven but not supported. Only a boot with the panel attached shows whether it lights up. Every screen is measured at exactly 800x480.
Camera A USB (UVC) webcam CSI camera modules come later: the pinned Debian kernel (linux-image-6.12.94+deb13-arm64) builds no IMX708 driver (not in mainline) or IMX219 driver and leaves the Pi 4's CSI receiver (VIDEO_BCM2835_UNICAM) unset, so on that kernel only a USB (UVC) camera has a driver. Raspberry Pi's kernel, which the card also carries for the Pi 5, has not been checked for CSI drivers. INV-PROV-29 checks every kernel version under the card's lib/modules: in each, uvcvideo and every module that version's modules.dep says it needs are present as files (not shown to load). The card also carries systemd-udevd and the two rules that load the driver on hotplug and give the node the video group (INV-PROV-30). The kiosk's device cgroup adds only char-video4linux (INV-PROV-27); the browser's managed policy grants the camera to http://127.0.0.1:5180 alone, audio off (INV-PROV-28). No camera has streamed on a Pi yet. Without one the device shows codes and receives nothing; an SD card transport is planned, not built.
Storage 16GB+ SD card, A2 class The image is small; the class rating matters for Argon2id-adjacent I/O, not capacity.
Case Any Prefer one that makes tampering visible over one that looks nice.
Power Official PSU An underpowered supply causes undervoltage throttling, which shows up as inexplicable slowness.

Radios. The Pi 4's wifi and Bluetooth chip stays on the board; the image removes the software that drives it:

  • the firmware-brcm80211, bluez, wpasupplicant and wireless-tools packages are absent
  • the wireless and Bluetooth kernel modules (drivers, 802.11 stack, Bluetooth subsystem) and the brcm firmware files are absent

INV-PROV-13 asserts both against the built image. The boot test prints a count of radio modules in the mounted root filesystem, but nothing checks that count. There is no dtoverlay=disable-wifi or dtoverlay=disable-bt in config.txt and no modprobe blacklist. The radio hardware is still attached to the SoC, and anyone who can rewrite the card can put the drivers and firmware back. For physical certainty, use a board with no radio hardware.


What the hardening actually does

The image removes the network stack rather than disabling it, mounts what it can read-only, drops capabilities and syscalls the daemon does not need, and runs the browser as a separate unit, unconfined on purpose. provisioning/HARDENING.md covers which controls, why, and what three are worth on this hardware (less than they look), next to the profiles that assert it.


The three tiers

Provisioning is staged because the strongest option is irreversible and the useful one is not. Never describe a lower tier in a higher tier's language.

Tier What it establishes What it costs Lands
0 The image you flashed is byte-identical to one built from the source. Today that means an image you built: make image writes checksums and make image-repro shows two builds agree. Checking a published, signed image is planned: nothing is published or signed. Nothing irreversible. Works on any supported board. Phase 2, signing and publishing planned
1 The system partition has not been modified since the build, and the device says so at boot. A more involved build. Still reversible. Phase 3
2 The boot chain itself is verified by the silicon, so the root hash the device shows cannot be chosen by an attacker. Irreversible. Burns one-time fuses. Losing the signing key bricks every device provisioned with it. Phase 7

Tier 0 answers "let me spin up another one". No fuses, no key custody, no special hardware; it is the default.

Tier 1's limit is the important one. dm-verity means the system partition cannot be altered without changing its root hash. But the boot partition is not covered, and the initramfs reads the root hash from it (/boot/system.roothash). An attacker who rewrites the boot partition supplies their own system.roothash and initramfs, and the device displays the number they chose. Tier 1 raises the bar and gives an attentive user a chance to notice. It does not close the gap.

Tier 2 closes it, at a real price. The root hash travels inside an image the BootROM verifies against a key whose hash is fused into the chip, so the chain runs from silicon to every block of the root filesystem. Its costs are listed below.


Verifying an image

You check our tool with someone else's tools. The commands below use coreutils (sha256sum, head, stat) and veritysetup, none of them ours. make image prints a dd command for the flash itself.

What a build produces

make image writes three files to out/release/:

File What it is
nullroute-<version>.img The card image
system.roothash The dm-verity root hash of the system partition
SHA256SUMS Plain sha256sum output covering the other two

Like MANIFEST.lock, SHA256SUMS needs only sha256sum -c to check.

Planned, not built: a published release carrying these files, a detached minisign signature over SHA256SUMS, lock files recording the pinned build values, the package list and every contributing .deb, an SBOM, and a REPRODUCE.md. No signing key exists, so there is no public key to compare and nothing is signed.

Before flashing

$ cd out/release && sha256sum -c SHA256SUMS

This compares the image with checksums from the same build. It catches a file damaged between build and flash, nothing more. It is not a signature check.

After flashing

Read the card back to confirm the bytes that landed are the bytes you wrote; this catches a failing SD card as readily as a tampered one. Read exactly the image's length: it is not a whole number of 4 MB blocks, and reading past its end changes the hash.

$ sudo head -c "$(stat -c %s <image>)" /dev/<card> | sha256sum

At tier 1 and above, also confirm the hash tree over the system partition:

$ sudo veritysetup verify /dev/<card>p2 /dev/<card>p3 $(cat system.roothash)

At boot

The lock screen shows two hashes:

  • sha256sum MANIFEST.lock, attesting the application. Compare it with the value you computed from the source at the same commit.
  • The dm-verity root hash of the system partition, where there is a mapping. nullroute-attest.service reads it from the live device-mapper table (what the kernel actually checks), not from /boot/system.roothash (what an attacker who rewrote the boot partition would choose). With no mapping (make dev on a workstation) no second number is drawn, rather than a blank or a zero.

Tier 1 has landed, and INV-BOOT-1 declares this behaviour.

Both numbers are reported by the software you are looking at. They catch an accident, a failed write, and an unsophisticated substitution. Until tier 2 they do not catch an attacker who replaced the code that draws them, so do not treat them as proof on their own.

Reproducing the build

The strongest check, and the only one that does not rely on us at all, is building the image yourself and comparing hashes. With no release there is no REPRODUCE.md yet: check out the commit, run make image, and compare system.roothash with the number the device shows.

Measured today: two builds on ONE machine, in two separate containers; make image-repro runs them, and the root hash and the whole card image agree. Not measured: a second machine, path or architecture. The build is deterministic here and unproven elsewhere. A gate running both builds in one container once missed mmdebstrap writing each container's hostname into /etc/hostname, which gave four builds of one commit four root hashes.


What provisioning does not solve

  • The root of trust is closed source. On a Pi the earliest boot code lives in silicon nobody outside Raspberry Pi Ltd can audit. Signed boot moves the question from "do you trust this SD card" to "do you trust this BootROM": better, but a move, not an elimination.
  • Signed boot is irreversible. It burns one-time-programmable fuses; a mistake is permanent and can brick the board. It will never be the default, and a device with it enabled cannot be returned to one without.
  • On its own, dm-verity moves the gap rather than closing it. This is the most important limitation on the page, described under tier 1. Only OTP-fused secure boot, with the root hash inside the signed image, closes it.
  • An integrity hash tree detects modification; it does not prevent it. A replacement cannot produce the same root hash, so the device notices, which helps only if someone reads the number.
  • dm-verity provides integrity, not confidentiality. It says nothing about who can read the system partition, and nothing at all about the boot partition.
  • Nothing here defends the hardware. We cannot check that your Pi is a real Pi, that its BootROM is the published one, or that nothing was added to the board between the factory and you.
  • Reproducibility depends on an upstream archive. The build host, system partition, kernel and firmware install from snapshot.debian.org at one timestamp (NULLROUTE_SNAPSHOT in provisioning/build/Dockerfile), so a later build of the same commit sees the same archive. That moves the dependency rather than removing it: the build relies on snapshot.debian.org staying up and serving what it served. The Pi 5's kernel is the exception: Raspberry Pi's archive keeps no snapshots, so when it drops 6.18.50 from its pool the build stops until someone moves the pin. Kernel and firmware packages are checked against pinned digests; the rest by apt against Debian's signed indices for that moment, with no hashes recorded in the repository yet. Builds on different days have not yet been compared.

Status of this document

The hardware, the hardening controls and the constraints above are settled and will not change materially.

The image builds and boots under QEMU: make image-boot-test opens the dm-verity mapping, reads every block, checks that systemd reports starting the signing daemon, and requires a copy with one byte changed to fail. The first run on a Raspberry Pi, which exercises the firmware path from power-on to the kernel, is the next milestone.

The contract that build has to satisfy is written and runs. A backend is supported when the unchanged verifiers pass against its output. Every provisioning assertion carries an executing verifier, run against the profiles, an assembled root filesystem, an image file, or a booted device's console log. make profiles prints the current counts and checks the ones provisioning/README.md states.

Two image verifiers catch defects that would make the root hash impossible to compare: a random dm-verity salt changes it every build, even for a byte-identical filesystem, and generated partition and filesystem identifiers make two images differ invisibly to a file-level diff. So every identifier is derived from the release version, the version in package.json, by provisioning/checks/identifiers.mjs. A third party recomputes the verity salt with coreutils; the digest printed is the salt:

printf 'nullroute/verity-salt/0.1.0' | sha256sum

make fixture-image shows those verifiers failing on purpose. It writes two synthetic, non-bootable images with the superblocks and partition table at the offsets the published formats use and checks that they compare equal, then writes a third with a drifting salt, which the verifier has to catch. The real image goes through the same verifiers via make verify-image and make image-repro.


Rendered from docs/VERIFICATION.md, the same bytes that ship with the code. SHA-256 15ae2ba12417b4691f63d9a4777eba2c328ff694961efea68f5e7dec4ae2e0bd