Signing BL2 and the FIP tamper test: one bit flipped, EAUTH, clean halt
TF-A verifying the next stage against a certificate chain inside the FIP — software, on blank fuses, refusing for real. Flipping one bit produced EAUTH on BL33 and a clean halt. Plus the honest limits of what that proves.
I flipped one bit. The board refused to boot. That is the whole post, but the details are where it gets interesting.
Where I'd got to
Last time I explained what secure boot actually is: four separate checks, not one, and only the first of them needs anything burned into the chip permanently. Since then I've done the unglamorous work — generating keys, signing the bootloader, and writing a verifier to check my own signatures before any hardware sees them.
Then I built the second link: TF-A's trusted board boot. That's the stage where the first bootloader (BL2) verifies the next stage — the container holding U-Boot and the secure monitor — against a chain of X.509 certificates.
The reason this one matters to me is that it needs no fuses at all. It's software I build. It reads the certificates and stops if they don't add up, because that's what the code does. No permanent commitment to anything.
Getting it to build was not the interesting part
But it's worth recording, because three separate things wasted my afternoon and they'll waste yours.
Buildroot doesn't rebuild when you change a build flag. It stamps each package as built and skips it. Changing TRUSTED_BOARD_BOOT=1 in the config did precisely nothing — the build "succeeded" and produced a byte-identical FIP. You have to force it: make arm-trusted-firmware-dirclean arm-trusted-firmware-rebuild.
And the config file you edit isn't the config that's used. Buildroot reads a generated .config. Editing the Nerves defconfig does nothing until Nerves regenerates it, which happens during mix firmware. So I forced a TF-A rebuild that faithfully used my old settings, and spent a while staring at a FIP with no certificates in it wondering why.
Buildroot puts its own Python first on your PATH. It builds an interpreter from scratch with no third-party modules, then shadows the system one. My signing script died with ModuleNotFoundError: No module named 'cryptography'. Inside a Buildroot hook you are not in your normal shell — use absolute paths or check.
Then a fourth one, which is the worst kind because nothing fails:
mix firmware doesn't always run Buildroot. On an incremental build it skips it entirely and just repacks the firmware from whatever's already sitting in the images directory. My signing step is hooked into a Buildroot phase — so it never fired, and the firmware I was about to flash contained a completely unsigned bootloader. No error. No warning.
I only caught it because I unzipped the finished firmware and checked the bootloader inside it, rather than trusting the build's own output. That's now a rule I've written down: verify the artifact, not the inputs. Every intermediate step can be correct and the thing you ship still wrong.
The keys, and the byte that makes the hash wrong
Plain OpenSSL is enough; ST's STM32_KeyGen_CLI produces identical key material.
openssl ecparam -name prime256v1 -genkey -noout -out keys/dev/dev-root-key.pem
chmod 600 keys/dev/dev-root-key.pem
openssl ec -in keys/dev/dev-root-key.pem -pubout -out keys/dev/dev-root-key-pub.pem
The public half is computed from the private half — you never generate them separately, which is why losing the public one costs nothing and losing the private one is fatal.
Then the 32 bytes that would go into the fuses:
key : keys/dev/dev-root-key-pub.pem
raw point : keys/dev/dev-root-key-pub-raw.bin (64 bytes)
X : 764887fbefd71371a079091f5dfc89971e4ece3a6cfb546f1e49c04ef7c0daa0
Y : 34ea022265a101eb671bdfbe4d3c8983241b58b1b9b671ec7da69a71326fc888
PKH (SHA-256): 537d02df50be782112a12b7e211fbafbfcdc0ef5321bcb8d79c98e75f169adcc
Why a script rather than one openssl command? Because of a trap that gives you a
confidently wrong answer rather than an error.
An EC public key is a point with two 256-bit coordinates, X and Y — 64 bytes. It is
conventionally stored as 65: a leading 0x04 meaning "uncompressed format", then X, then
Y. That 0x04 is a format marker, not key material. Hash all 65 bytes and you get a
perfectly valid SHA-256 of the wrong thing — a number that looks exactly like a PKH, that
you would have burned into silicon, and that would match nothing.
Strip the leading byte, hash 64, and check the result twice by different routes before it goes anywhere near a fuse. (It is not going anywhere near a fuse in this series — but the habit is the point.)
The test
Two firmware images. One bit of difference.
I took the good one, flipped a single bit inside the U-Boot payload in the FIP, and rebuilt so the firmware archive stayed internally consistent. That last part matters — if I'd just edited the zip, the flashing tool would have rejected it on its own checksum, and I'd have been testing the flashing tool instead of the boot chain.
Both images carry an identically signed bootloader. The FIP is the only variable.
Flashed the tampered one. Powered on.
NOTICE: TRUSTED_BOARD_BOOT support enabled
NOTICE: ROTPK is not deployed on platform. Skipping ROTPK verification.
NOTICE: ROTPK is not deployed on platform. Skipping ROTPK verification.
ERROR: BL2: Failed to load image id 5 (-80)
And then nothing. On a good boot the next line is BL2: Booting BL32. It never came. The board just stopped.
Reading the error properly
I could have left it at "it failed, good". But "failed" isn't evidence — why it failed is. So I went into TF-A's source:
image id 5 is BL33_IMAGE_ID. That's U-Boot — exactly the payload I corrupted. It didn't fail vaguely; it failed on the right thing.
-80 is EAUTH, "Authentication error". Not a read error, not a size mismatch. Specifically cryptographic.
The halt is load_auth_image() failing, then plat_error_handler(), which never returns.
And the detail I liked most: the secure monitor, image id 4, loaded fine. I hadn't touched it. So BL2 authenticates each image separately and rejected precisely the one bit I changed, rather than throwing out the whole container.
One bit. Correct image named. Correct cause. Clean halt.
The part I have to be honest about
Look at that log again:
ROTPK is not deployed on platform. Skipping ROTPK verification.
The chain's anchor is supposed to be a key hash burned into the chip. Mine is blank, because I'm never fusing this board. So TF-A checks that the certificate chain is internally consistent — but not that it's mine.
Which means: corrupt an image, caught. Corrupt a certificate, caught. But replace the entire chain with a complete, self-consistent one signed by somebody else's key, and it would sail through. That's exactly what the anchor check would catch, and it's the check that's skipped.
I could close that gap by burning the fuses. I'm not going to, and I want to be clear that this is a real limitation and not something I'm hand-waving past.
There's a second honest caveat in the same log. Bootrom authentication failed appears now and didn't before I started signing. That's actually good news — it means the chip's own ROM is now attempting to authenticate my bootloader, where previously it didn't bother. But it fails because the key hash in the fuses is blank, and it would fail that way whether or not my signature is correct. It proves the ROM is reading my header. It doesn't prove my signature is right. For that I rely on my own verifier, which checks the maths directly.
Why I'm still not burning the fuses
I wrote in Part 1 that I'd made this rule permanent, and doing this test has made me more comfortable with it rather than less.
The reason isn't only that fuses are one-way, though they are. It's that burning them would permanently marry this board to the throwaway keys on my build server — keys that sit there unencrypted, that I regenerate whenever I feel like it. A development board's whole value is that I can reflash it with anything. Fusing removes exactly that, in exchange for a check I can already reason about precisely.
And this test is the argument. I demonstrated enforced, cryptographic refusal — a real halt, for a real reason, on real hardware — with zero irreversible operations. If I want to prove the anchoring too, that needs a board bought specifically to be sacrificed, with production keys in a hardware security module, as its own deliberate exercise. Not an afternoon's curiosity on the unit I develop on.
Where that leaves the chain
Two of four links done: the bootloader is signed and I can verify it offline, and the stage after it genuinely refuses tampered input.
The next demo is the uncomfortable one. With all this in place, I'm going to modify the root filesystem and boot it — and it will boot, happily, because the signed chain currently stops at U-Boot. I want that on the record, because the difference between a security story and a feeling is whether you can say exactly where your chain ends.
Next: signing the kernel, and then dm-verity over the filesystem.