Skip to main content

TECH VEDA

Linux kernel & Device drivers starts on 31st Oct 2026 enrollingCorporate on-site training - Submit proposal Pick your modulesSharpen your kernel skills: deep dives, drivers, Yocto, CVEs, careers — updated daily. Read the blog →
Tutorials

Building Linux for a RISC-V Board — Part 4: A Signed FIT Image U-Boot Will Verify

Build a signed FIT image holding the kernel, devicetree and boot script, and make U-Boot refuse a boot whose signature does not check out.

Building Linux for a RISC-V Board — Part 4: A Signed FIT Image U-Boot Will Verify

A signed FIT image puts the kernel, the devicetree and the boot script in one container and signs the configuration that names all three, so none of them can be swapped independently. Two things decide whether U-Boot actually enforces it: the public key must be in the control devicetree U-Boot is running, not the one you patched on the host, and the list of images the signature covers must match the list U-Boot rebuilds at verification time. Get either wrong and U-Boot prints Verifying Hash Integrity ... OK and boots an unsigned image.

Part 3 gave the board a partition table, an ext4 root filesystem and a boot script on a virtio block device, so nothing had to be typed at the U-Boot prompt. It also left a loose end: that script is a legacy U-Boot image, a legacy image cannot be signed, and so anything written to the first partition of that disk boots. This part closes it with a signed FIT image carrying the kernel, the devicetree and the script together, and makes U-Boot refuse to boot when the signature does not check out.

What you need before you start

Everything Part 3 produced, in the same ~/rv directory, plus openssl and the devicetree compiler package, which supplies fdtget. Host commands below ran under u-boot-tools 2025.10 and QEMU 8.2.2; quoted U-Boot source is v2026.10.

Why the Part 3 boot sequence trusts whatever is on the disk

Part 3 built its script with mkimage -A riscv -T script -C none -d boot.cmd, producing a legacy U-Boot image: a 64-byte header, a CRC32 over the payload, and the payload. The CRC detects a corrupted flash sector. It does not detect a replacement, because anyone who can write the payload can write a correct CRC over it, and that header has no field for a signature.

U-Boot treats this as deprecated and switches it off as soon as you turn signing on. boot/Kconfig declares CONFIG_LEGACY_IMAGE_FORMAT as default y if !FIT_SIGNATURE && !TI_SECURE_DEVICE, with help text saying it is disabled when FIT_SIGNATURE is set “so that unsigned images cannot be loaded”. Enabling signing therefore breaks Part 3’s boot.scr on purpose, and a signed FIT image is the replacement.

What a FIT holds, and why the signature goes on the configuration

A FIT is a devicetree blob used as a container. An /images node holds each payload with its metadata, and a /configurations node holds named combinations pointing at those images. A configuration is what you boot, and one can name a kernel, an fdt and a script at once, which is what lets all three travel together.

Signing each image separately is not enough, and U-Boot’s documentation gives two reasons. An attacker who cannot forge a signature can still add a configuration pairing a signed kernel from one build with a signed devicetree from another, the mix-and-match attack; or drop a signed image from an older FIT into a newer one, the roll-back attack. Signing the configuration closes both: the signature covers the configuration node, the images it references and the hash of each, so the plain per-image hashes become trustworthy.

Writing the image tree source

The .its file is what mkimage compiles: three images and one configuration naming all three.

/dts-v1/;

/ {
	description = "RISC-V kernel, devicetree and boot script";
	#address-cells = <1>;

	images {
		kernel {
			data = /incbin/("Image");
			type = "kernel";
			arch = "riscv";
			os = "linux";
			compression = "none";
			load = <0x84000000>;
			entry = <0x84000000>;
			hash-1 {
				algo = "sha256";
			};
		};

		fdt {
			data = /incbin/("qemu-virt.dtb");
			type = "flat_dt";
			arch = "riscv";
			compression = "none";
			load = <0x8c000000>;
			hash-1 {
				algo = "sha256";
			};
		};

		script {
			data = /incbin/("boot.cmd");
			type = "script";
			compression = "none";
			hash-1 {
				algo = "sha256";
			};
		};
	};

	configurations {
		default = "conf-1";

		conf-1 {
			kernel = "kernel";
			fdt = "fdt";
			script = "script";
			signature-1 {
				algo = "sha256,rsa2048";
				key-name-hint = "dev";
			};
		};
	};
};

The addresses are not arbitrary. 0x84000000 and 0x8c000000 are U-Boot’s own kernel_addr_r and fdt_addr_r for this board, set in include/configs/qemu-riscv.h, and 0x84000000 is 2 MiB aligned, which Documentation/arch/riscv/boot.rst requires: “The RISC-V kernel expects to be placed at a PMD boundary (2MB aligned for rv64 and 4MB aligned for rv32).”

Every image needs its hash-1 subnode, and the script is the one people skip because it feels like metadata. It is not optional: fit_config_add_hash() returns -ENOMSG after printing No hash nodes in image '%s', which fails the whole configuration, not just the script.

The boot.cmd this packages is shorter than Part 3’s, because the FIT now supplies the kernel and the devicetree:

setenv bootargs 'console=ttyS0 root=/dev/vda1 rootwait rw'
bootm ${scriptaddr}#conf-1

Making the key pair and signing

A 2048-bit RSA key pair and a self-signed certificate, in a keys directory:

raghu@techveda.org:~/rv$ mkdir -p keys
raghu@techveda.org:~/rv$ openssl genpkey -algorithm RSA -out keys/dev.key -pkeyopt rsa_keygen_bits:2048 -pkeyopt rsa_keygen_pubexp:65537
raghu@techveda.org:~/rv$ openssl req -batch -new -x509 -key keys/dev.key -out keys/dev.crt

The filename must match the hint. With -k pointing at a directory, mkimage derives the key path as <keydir>/<key-name-hint>.<ext>, so key-name-hint = "dev" means keys/dev.key. One command then produces the signed FIT image:

raghu@techveda.org:~/rv$ cp qemu-virt.dtb control.dtb
raghu@techveda.org:~/rv$ u-boot/tools/mkimage -f fit.its -k keys -K control.dtb -r fit.itb

The tail of its output is what to read:

 Default Configuration: 'conf-1'
 Configuration 0 (conf-1)
  Kernel:       kernel
  FDT:          fdt
  Sign algo:    sha256,rsa2048:dev
  Timestamp:    Sun Oct 18 13:45:45 2026
Signature written to 'fit.itb', node '/configurations/conf-1/signature-1'
Public key written to 'control.dtb', node '/signature/key-dev'

-k signs the configuration; -K writes the public key into a devicetree blob as a /signature/key-dev node holding the pre-processed RSA parameters U-Boot needs; -r adds required = "conf" to it. control.dtb grew from 4,806 to 6,214 bytes. Note what the listing does not say: it names the kernel and the fdt but never the script, although the configuration references one. It is no evidence of what the signature covers.

The signed node list is rebuilt, not read back

What the signature covers is recorded in the FIT’s hashed-nodes property, and it does include the script:

raghu@techveda.org:~/rv$ fdtget -t s fit.itb /configurations/conf-1/signature-1 hashed-nodes
/ /configurations/conf-1 /images/kernel /images/kernel/hash-1 /images/fdt /images/fdt/hash-1 /images/script /images/script/hash-1

U-Boot does not trust that property, and the reason explains the trap that follows. hashed-nodes sits inside the configuration node but is not itself covered by the signature it describes, so a list of what was signed that an attacker can edit is useless for deciding what to check. Instead fit_config_get_signed_nodes() rebuilds the list at verification time, walking every property of the configuration node except description, compatible and default, treating each remaining one as an image reference. The code comment gives the intent: “so any new image type is covered by default.”

That is why a script property is covered without U-Boot needing to know what a script is. It is also why mkimage’s optional sign-images property is a hazard: it names which images the signature should cover, and mkimage honours it. The same build with sign-images = "kernel", "fdt" gave this:

raghu@techveda.org:~/rv$ fdtget -t s fit-subset.itb /configurations/conf-1/signature-1 hashed-nodes
/ /configurations/conf-1 /images/kernel /images/kernel/hash-1 /images/fdt /images/fdt/hash-1

The script is gone from the signer’s list, but U-Boot v2026.10 rebuilds a list that includes it. The two sides then hash different byte sequences, so the signature cannot match and the configuration fails even though the right key signed it. Omitting the property gave the full list, script included. Leave sign-images out. To be exact: both hashed-nodes values above are real mkimage 2025.10 output, the rebuild behaviour is read from boot/image-fit-sig.c at v2026.10, and the resulting failure follows from those two facts rather than from a run on a board.

The control devicetree is the one U-Boot is running

This is the step that makes people conclude signing does not work. The public key lives in U-Boot’s control devicetree. On most boards that is u-boot.dtb, built alongside U-Boot, so mkimage -K u-boot.dtb is the whole story. On QEMU it is not.

board/emulation/qemu-riscv/Kconfig carries imply OF_HAS_PRIOR_STAGE. In dts/Kconfig, OF_BOARD is default y if SANDBOX || OF_HAS_PRIOR_STAGE, and OF_BOARD means the devicetree “is provided at runtime by a custom function”. QEMU generates a devicetree, OpenSBI passes it to U-Boot, and U-Boot uses that one. The u-boot.dtb you patched is never consulted, and nothing warns you.

So patch the key into the devicetree QEMU will hand over, which means getting a copy first. U-Boot documents this in doc/develop/devicetree/dt_qemu.rst: add dumpdtb= to the -machine argument and otherwise “use the same qemu-system-<arch> invocation that you would use to start U-Boot”. The generated tree describes the devices on the command line, so dumping it without -device virtio-blk-device leaves out the virtio node.

raghu@techveda.org:~/rv$ qemu-system-riscv64 -machine virt,dumpdtb=qemu-virt.dtb -cpu rv64 -smp 2 -m 512M -nographic -bios opensbi/build/platform/generic/firmware/fw_jump.bin -kernel u-boot/u-boot.bin -drive file=disk.img,format=raw,if=none,id=hd0 -device virtio-blk-device,drive=hd0
qemu-system-riscv64: info: dtb dumped to qemu-virt.dtb. Exiting.

QEMU dumps and exits without booting. That file is what the .its packages as its fdt image; the copy at control.dtb is what mkimage -K wrote the key into. Pass that copy back with -dtb and OpenSBI hands U-Boot a control devicetree containing the key. U-Boot’s documentation records a limitation on this approach: “there seems to be a bug in some versions of qemu where the output of dumpdtb does not quite match what is provided to U-Boot.”

Booting it

Put fit.itb on the first partition, where Part 3 kept boot.scr. Three commands then replace Part 3’s four:

=> virtio scan
=> load virtio 0:1 ${scriptaddr} /boot/fit.itb
=> source ${scriptaddr}#conf-1

The form of that last argument matters more than it looks. Given a configuration name, boot/image-board.c calls fit_config_verify() before it extracts the script, and fit_image_verify() on the script itself, so the signature is checked before a line of the script runs, and checked again when the script’s bootm loads the kernel from the same configuration. The subimage form source ${scriptaddr}:script takes a branch that never calls fit_config_verify() and runs the script against its hash alone. Use the #conf-1 form.

Why a failure to enforce looks like a success

Get the control devicetree wrong and U-Boot reaches fit_config_verify_required_keys(), which starts by looking for the key node:

key_node = fdt_subnode_offset(key_blob, 0, FIT_SIG_NODENAME);
if (key_node < 0) {
	debug("%s: No signature node found: %s\n", __func__,
	      fdt_strerror(key_node));
	return 0;
}

A return of 0 is success. No /signature node in the running control devicetree means no required keys, so nothing to enforce, so the configuration passes. The explanation goes to debug(), which an ordinary build does not compile in, so you see nothing before boot/image-fit.c prints its OK.

With the key present, fit_image_setup_verify() prints the algorithm and key name before the result, and a passing check adds + :

## Loading kernel (any) from FIT Image at 8c100000 ...
   Using 'conf-1' configuration
   Verifying Hash Integrity ... sha256,rsa2048:dev+ OK
   Trying 'kernel' kernel subimage

Without it, the same unsigned FIT on the same U-Boot gives:

   Verifying Hash Integrity ... OK

A bare OK with no algorithm and no key name means nothing was verified. That difference is the most useful thing here. A genuine rejection is noisier, and these strings come from image-fit-sig.c and image-fit.c:

   Verifying Hash Integrity ... sha256,rsa2048:dev-  error!
Verification failed for 'conf-1' config node
Failed to verify required signature 'key-dev'
Bad Data Hash

How to check a signed FIT image on your own board

Three checks, in this order. First, which devicetree is U-Boot using? bdinfo prints the source name from the table in lib/fdtdec.c: board means a prior stage supplied it, separate means U-Boot’s own u-boot.dtb.

=> bdinfo
...
fdt_blob    = 0x00000000bf7f0000
devicetree  = board

Second, is the key in it, and required? fdtcontroladdr holds the control devicetree address and CONFIG_CMD_FDT is default y:

=> fdt addr ${fdtcontroladdr}
=> fdt print /signature
signature {
	key-dev {
		required = "conf";
		algo = "sha256,rsa2048";
		...
	};
};

A missing node is the case above; a missing required means you forgot -r, and the required-key loop skips the key. Third, confirm the build options:

raghu@techveda.org:~/rv$ grep -E 'CONFIG_FIT=|CONFIG_FIT_SIGNATURE|CONFIG_RSA=|CONFIG_LEGACY_IMAGE_FORMAT' u-boot/.config
CONFIG_FIT=y
CONFIG_FIT_SIGNATURE=y
CONFIG_FIT_SIGNATURE_MAX_SIZE=0x10000000
CONFIG_RSA=y
# CONFIG_LEGACY_IMAGE_FORMAT is not set

FIT_SIGNATURE also selects FIT_FULL_CHECK, which brings one more trap: with signing on, node names may not contain @. The two messages are Signature checking prevents use of unit addresses (@) in nodes and Configuration node '%s' contains '@'. Name the nodes conf-1, not conf@1.

Limits, and what was not verified

The host side was run for real: the dumpdtb line, the openssl and mkimage commands, the mkimage listing, both hashed-nodes values and the control.dtb size change are actual output, trimmed, from packaged tools rather than a self-built tools/mkimage. No RISC-V boot was captured this week, so the U-Boot console excerpts follow the convention of Parts 1 to 3: field layout, host-varying values as placeholders, every string read from the source file that prints it at v2026.10. The ## Loading kernel (any) ... line carries a phase name older releases did not print, so compare the shape rather than the exact text on an older build.

One code path is named but not demonstrated: lib/fdtdec.c reads fdtcontroladdr from the early environment and, if set, replaces the control devicetree with whatever is at that address. This board uses CONFIG_ENV_IS_NOWHERE, so nothing persistent can set it, but on a product with a writable environment in flash that path deserves a look.

Key takeaways

  • A legacy U-Boot image cannot be signed, and CONFIG_FIT_SIGNATURE turns CONFIG_LEGACY_IMAGE_FORMAT off by default, breaking a legacy boot.scr on purpose.
  • Sign the configuration, not the individual images: that is what stops a valid kernel being paired with a devicetree from another build.
  • Give every referenced image a hash subnode, the script included, or the whole configuration fails verification.
  • Leave sign-images out. U-Boot rebuilds the covered-node list from the configuration’s properties and ignores the signer’s hashed-nodes.
  • On QEMU the control devicetree comes from the prior stage: dump it with dumpdtb=, run mkimage -K on that copy, boot with -dtb.
  • Run a signed script with source ${addr}#conf-1. The :script form skips the configuration signature check.
  • Verifying Hash Integrity ... OK with no algorithm or key name before it means nothing was checked.

This material is also covered as a structured programme in TECH VEDA’s training tracks.

What is next in this series

Part 5 moves the signed FIT image off the disk and onto the network, booting it over TFTP so the same verification runs on an image the board never stored, and looks at what changes when the bootloader itself becomes the thing you have to trust.

Was this worth your time?

Frequently asked questions

Why does U-Boot print OK when the FIT is not signed?
If the running control devicetree has no /signature node, fit_config_verify_required_keys() returns 0 and the caller treats that as success. The explanation goes to debug(), which an ordinary build does not compile in. The giveaway is an OK with no algorithm or key name before it.

Why is the public key not in the u-boot.dtb I patched?
The QEMU RISC-V board implies OF_HAS_PRIOR_STAGE, making OF_BOARD default to y, so U-Boot uses the devicetree QEMU generates rather than its own. Dump QEMU’s tree with dumpdtb=, run mkimage -K on that file and pass it back with -dtb.

Should I use the sign-images property in my .its file?
No. mkimage honours it and signs only the listed images, while U-Boot v2026.10 rebuilds the covered-node list from every image reference in the configuration. If the two lists differ, verification fails even though the right key signed the image.

Further reading

RB
Raghu Bharadwaj

Founder, TECH VEDA — 20+ years teaching the Linux kernel, device drivers and embedded systems.

Follow on LinkedIn

Get new posts by email

Kernel, embedded Linux and AI-era engineering — a few sharp reads a month. No spam.

We email occasionally and never share your address.