diff options
| author | Tom Rini <[email protected]> | 2026-08-10 12:32:48 -0600 |
|---|---|---|
| committer | Tom Rini <[email protected]> | 2026-08-10 12:37:16 -0600 |
| commit | 6ea67890d034c8f285dcea438dcee2e06af3921c (patch) | |
| tree | e28d966945f8a2f359044c661785e9b887f55366 /test | |
| parent | 28515d7ff5285e0b942f15577e3198f15c667a25 (diff) | |
| parent | fe9877c7d9dea740985edd11f7ff583e311568be (diff) | |
Merge patch series "boot: fit: authenticate the dm-verity roothash"
Daniel Golle <[email protected]> says:
A signed FIT configuration can delegate the integrity of a (potentially
large) root filesystem image to the kernel's dm-verity instead of having
U-Boot hash the whole payload at boot: the FIT carries a "dm-verity"
subnode with the roothash, salt and block parameters, U-Boot passes the
roothash to Linux through the dm-mod.create bootargs, and dm-verity then
validates the filesystem block by block against it.
For that to be safe the roothash has to be trusted, and in a signed
configuration the only thing that establishes trust is the configuration
signature. The roothash was not covered by it. fit_config_add_hash()
collected the image node, its hash subnodes and its cipher subnode into
the signed region, but not the dm-verity subnode, so the roothash, the
sole integrity anchor for the filesystem, was left unsigned.
The result is a verified-boot bypass for the root filesystem: an
attacker who can rewrite the boot medium can replace the filesystem,
recompute a matching dm-verity tree, write the new roothash into the
unsigned dm-verity subnode, and the configuration signature still
verifies. dm-verity then faithfully validates the malicious filesystem
against the attacker's roothash.
This series closes the gap.
Link: https://lore.kernel.org/r/[email protected]
Diffstat (limited to 'test')
| -rw-r--r-- | test/boot/fit_verity.c | 200 | ||||
| -rw-r--r-- | test/py/tests/test_fit_verity_sign.py | 203 |
2 files changed, 403 insertions, 0 deletions
diff --git a/test/boot/fit_verity.c b/test/boot/fit_verity.c index 7459a9d6f81..4b5db839085 100644 --- a/test/boot/fit_verity.c +++ b/test/boot/fit_verity.c @@ -6,6 +6,11 @@ */ #include <image.h> +#include <fdt_region.h> +#include <malloc.h> +#include <linux/kernel.h> +#include <linux/libfdt.h> +#include <u-boot/hash-checksum.h> #include <test/test.h> #include <test/ut.h> @@ -304,3 +309,198 @@ static int fit_verity_test_bad_blocksize(struct unit_test_state *uts) return 0; } FIT_VERITY_TEST(fit_verity_test_bad_blocksize, 0); + +#if CONFIG_IS_ENABLED(FIT_SIGNATURE) +/** + * build_signed_verity_fit() - build a FIT with a signable verity config + * @buf: output buffer (at least FIT_BUF_SIZE bytes) + * + * Like build_verity_fit(), but the filesystem image also carries a hash + * subnode (required for a configuration to be signable) so the config's + * signed-region node list can be built with fit_config_get_signed_nodes(). + * + * Return: configuration node offset, or -ve on error + */ +static int build_signed_verity_fit(void *buf) +{ + int images_node, confs_node, conf_node, img_node, hash_node, verity_node; + fdt32_t val; + int ret; + + ret = fdt_create_empty_tree(buf, FIT_BUF_SIZE); + if (ret) + return ret; + + images_node = fdt_add_subnode(buf, 0, "images"); + if (images_node < 0) + return images_node; + + img_node = fdt_add_subnode(buf, images_node, "rootfs"); + if (img_node < 0) + return img_node; + ret = fdt_setprop_string(buf, img_node, FIT_TYPE_PROP, "filesystem"); + if (ret) + return ret; + + hash_node = fdt_add_subnode(buf, img_node, "hash-1"); + if (hash_node < 0) + return hash_node; + ret = fdt_setprop_string(buf, hash_node, FIT_ALGO_PROP, "sha256"); + if (ret) + return ret; + ret = fdt_setprop(buf, hash_node, FIT_VALUE_PROP, test_digest, + sizeof(test_digest)); + if (ret) + return ret; + + verity_node = fdt_add_subnode(buf, img_node, FIT_VERITY_NODENAME); + if (verity_node < 0) + return verity_node; + ret = fdt_setprop_string(buf, verity_node, FIT_VERITY_ALGO_PROP, + "sha256"); + if (ret) + return ret; + val = cpu_to_fdt32(4096); + ret = fdt_setprop(buf, verity_node, FIT_VERITY_DBS_PROP, &val, + sizeof(val)); + if (ret) + return ret; + ret = fdt_setprop(buf, verity_node, FIT_VERITY_HBS_PROP, &val, + sizeof(val)); + if (ret) + return ret; + val = cpu_to_fdt32(100); + ret = fdt_setprop(buf, verity_node, FIT_VERITY_NBLK_PROP, &val, + sizeof(val)); + if (ret) + return ret; + ret = fdt_setprop(buf, verity_node, FIT_VERITY_HBLK_PROP, &val, + sizeof(val)); + if (ret) + return ret; + ret = fdt_setprop(buf, verity_node, FIT_VERITY_DIGEST_PROP, test_digest, + sizeof(test_digest)); + if (ret) + return ret; + ret = fdt_setprop(buf, verity_node, FIT_VERITY_SALT_PROP, test_salt, + sizeof(test_salt)); + if (ret) + return ret; + + confs_node = fdt_add_subnode(buf, 0, "configurations"); + if (confs_node < 0) + return confs_node; + conf_node = fdt_add_subnode(buf, confs_node, "conf-1"); + if (conf_node < 0) + return conf_node; + ret = fdt_setprop_string(buf, conf_node, FIT_LOADABLE_PROP, "rootfs"); + if (ret) + return ret; + + return conf_node; +} + +/* + * Test: the dm-verity roothash and salt are inside the region covered by the + * configuration signature. + * + * A dm-verity filesystem image is not hashed by U-Boot; its integrity is + * delegated to the kernel, which trusts the roothash from the FIT dm-verity + * subnode. That roothash must therefore be part of the signed region, so that + * an attacker cannot swap both the filesystem and the roothash while keeping + * the configuration signature valid. + * + * This checks the property without a private key, so it also runs on real + * devices: it builds the exact node list the signature is computed over + * (fit_config_get_signed_nodes), turns it into hashed regions, and verifies both + * that the roothash bytes fall inside a region and that tampering them changes + * the hash. It uses the same hash path a device would (crypto accelerated where + * available). + */ +static int fit_verity_test_roothash_signed(struct unit_test_state *uts) +{ + char buf[FIT_BUF_SIZE]; + char *node_inc[32]; + char path_buf[256]; + char region_path[256]; + struct fdt_region fdt_regions[64]; + struct image_region *region = NULL; + int conf_node, verity_node; + int count, i, digest_len; + const void *digest; + ulong digest_off, region_off; + bool covered = false; + u8 hash_clean[32], hash_tampered[32], hash_control[32]; + + conf_node = build_signed_verity_fit(buf); + ut_assert(conf_node >= 0); + + verity_node = fdt_path_offset(buf, "/images/rootfs/dm-verity"); + ut_assert(verity_node >= 0); + + /* Build the node list the configuration signature is computed over. */ + count = fit_config_get_signed_nodes(buf, conf_node, node_inc, + ARRAY_SIZE(node_inc), path_buf, + sizeof(path_buf)); + ut_assert(count > 0); + + /* + * Turn the node list into hashed regions. No exclude list is needed: + * the excluded properties (data, data-size, data-offset, + * data-position) never include the dm-verity digest or salt, so the + * coverage answer is the same with or without it. + */ + count = fdt_find_regions(buf, node_inc, count, NULL, 0, fdt_regions, + ARRAY_SIZE(fdt_regions) - 1, region_path, + sizeof(region_path), 0); + ut_assert(count > 0); + /* Region array exhausted: mirror the bound fit_config_check_sig() enforces. */ + ut_assert(count < ARRAY_SIZE(fdt_regions) - 1); + + region = fit_region_make_list(buf, fdt_regions, count, NULL); + ut_assertnonnull(region); + + digest = fdt_getprop(buf, verity_node, FIT_VERITY_DIGEST_PROP, + &digest_len); + ut_assertnonnull(digest); + ut_assert(digest_len > 0); + digest_off = (ulong)((const char *)digest - (const char *)buf); + + /* + * Control: the hash covers a non-empty region and reacts to a change + * inside it. Flip a byte of the (signed) image hash value and confirm + * the computed hash differs, proving the region set and hash work. + */ + ut_assertok(hash_calculate("sha256", region, count, hash_clean)); + for (i = 0; i < count; i++) { + region_off = (ulong)((const char *)region[i].data - + (const char *)buf); + if (digest_off >= region_off && + digest_off + digest_len <= region_off + region[i].size) { + covered = true; + break; + } + } + + /* The roothash must be covered by the configuration signature. */ + ut_assert(covered); + + /* + * Tampering the roothash must change the signed hash. Only the digest + * is flipped here; salt sits in the same dm-verity node, so coverage + * of one implies coverage of the other. + */ + buf[digest_off] ^= 0xff; + ut_assertok(hash_calculate("sha256", region, count, hash_tampered)); + buf[digest_off] ^= 0xff; + ut_assert(memcmp(hash_clean, hash_tampered, sizeof(hash_clean)) != 0); + + /* Sanity: with the byte restored the hash matches the clean value. */ + ut_assertok(hash_calculate("sha256", region, count, hash_control)); + ut_asserteq_mem(hash_clean, hash_control, sizeof(hash_clean)); + + free(region); + return 0; +} +FIT_VERITY_TEST(fit_verity_test_roothash_signed, 0); +#endif /* FIT_SIGNATURE */ diff --git a/test/py/tests/test_fit_verity_sign.py b/test/py/tests/test_fit_verity_sign.py new file mode 100644 index 00000000000..3c75ef8558c --- /dev/null +++ b/test/py/tests/test_fit_verity_sign.py @@ -0,0 +1,203 @@ +# SPDX-License-Identifier: GPL-2.0 +# Copyright 2026 Daniel Golle <[email protected]> + +"""Verify that the dm-verity roothash is covered by the FIT configuration +signature. + +A dm-verity protected filesystem image is not hashed by U-Boot; its integrity +is delegated to the kernel, which trusts the roothash taken from the FIT +``dm-verity`` subnode. That roothash must therefore be part of the signed +region of the configuration, otherwise an attacker can replace both the +filesystem and the roothash while keeping the configuration signature valid. + +This test signs a configuration referencing a filesystem image that carries a +``dm-verity`` subnode, then flips one byte of the roothash and of the salt and +checks that verification rejects the image. A control tampering a byte that is +known to be signed confirms that the check is able to detect a broken region. + +The FIT pairs a signed configuration with a filesystem image carrying a +``dm-verity`` subnode: + +.. code-block:: devicetree + + images { + rootfs-1 { + data = /incbin/("rootfs.bin"); + type = "filesystem"; + compression = "none"; + hash-1 { + algo = "sha256"; + }; + dm-verity { + algo = "sha256"; + data-block-size = <4096>; + hash-block-size = <4096>; + num-data-blocks = <16>; + hash-start-block = <16>; + }; + }; + }; + + configurations { + conf-1 { + kernel = "kernel-1"; + loadables = "rootfs-1"; + signature-1 { + algo = "sha256,rsa2048"; + key-name-hint = "dev"; + sign-images = "kernel", "loadables"; + }; + }; + }; + +mkimage builds the dm-verity hash tree when assembling the image and records +the resulting roothash and salt in the ``dm-verity`` subnode; fit_check_sign +must reject an image where either was modified after signing. +""" + +import os +import pytest +import utils + +# 16 blocks of 4096 bytes, matching num-data-blocks/data-block-size below. +ROOTFS_SIZE = 16 * 4096 + +ITS = ''' +/dts-v1/; +/ { + description = "verity roothash signing coverage test"; + #address-cells = <1>; + + images { + kernel-1 { + description = "kernel"; + data = /incbin/("kernel.bin"); + type = "kernel"; + arch = "arm64"; + os = "linux"; + compression = "none"; + load = <0x40000000>; + entry = <0x40000000>; + hash-1 { algo = "sha256"; }; + }; + rootfs-1 { + description = "rootfs"; + data = /incbin/("rootfs.bin"); + type = "filesystem"; + arch = "arm64"; + compression = "none"; + hash-1 { algo = "sha256"; }; + dm-verity { + algo = "sha256"; + data-block-size = <4096>; + hash-block-size = <4096>; + num-data-blocks = <16>; + hash-start-block = <16>; + }; + }; + }; + + configurations { + default = "conf-1"; + conf-1 { + description = "signed config"; + kernel = "kernel-1"; + loadables = "rootfs-1"; + signature-1 { + algo = "sha256,rsa2048"; + key-name-hint = "dev"; + sign-images = "kernel", "loadables"; + }; + }; + }; +}; +''' + +VERITY_NODE = '/images/rootfs-1/dm-verity' +ROOTFS_HASH_NODE = '/images/rootfs-1/hash-1' + + +def flip_prop_byte(ubman, fit, node, prop): + """Flip the first byte of a byte-array property in a FIT, in place. + + The property is rewritten with the same length so that no node is + relaid out and the signed regions keep their offsets. + """ + val = utils.run_and_log(ubman, 'fdtget -t bx %s %s %s' % (fit, node, prop)) + bytelist = val.split() + bytelist[0] = '%x' % (int(bytelist[0], 16) ^ 0xff) + utils.run_and_log(ubman, 'fdtput -t bx %s %s %s %s' % + (fit, node, prop, ' '.join(bytelist))) + + [email protected]('sandbox') [email protected]('fit_signature') [email protected]('dtc') [email protected]('fdtget') [email protected]('fdtput') [email protected]('openssl') [email protected]('veritysetup') +def test_fit_verity_roothash_signed(ubman): + """The dm-verity roothash must be inside the signed configuration region.""" + tmpdir = os.path.join(ubman.config.result_dir, 'verity-sign') + '/' + if not os.path.exists(tmpdir): + os.makedirs(tmpdir) + mkimage = ubman.config.build_dir + '/tools/mkimage' + fit_check_sign = ubman.config.build_dir + '/tools/fit_check_sign' + dtc_args = '-I dts -O dtb -i %s' % tmpdir + its = tmpdir + 'verity.its' + fit = tmpdir + 'verity.itb' + dtb = tmpdir + 'control.dtb' + + # Signing key and empty control dtb to receive the public key. + utils.run_and_log(ubman, 'openssl genpkey -algorithm RSA -out %sdev.key ' + '-pkeyopt rsa_keygen_bits:2048 ' + '-pkeyopt rsa_keygen_pubexp:65537' % tmpdir) + utils.run_and_log(ubman, 'openssl req -batch -new -x509 -key %sdev.key ' + '-out %sdev.crt' % (tmpdir, tmpdir)) + with open(tmpdir + 'control.dts', 'w') as f: + f.write('/dts-v1/; / { model = "verity-test"; };\n') + utils.run_and_log(ubman, 'dtc -O dtb -o %s %scontrol.dts' % (dtb, tmpdir)) + + # Payloads. The rootfs must be a whole number of data blocks so mkimage can + # build the dm-verity hash tree and compute the roothash. + with open(tmpdir + 'rootfs.bin', 'wb') as f: + f.write(b'R' * ROOTFS_SIZE) + with open(tmpdir + 'kernel.bin', 'wb') as f: + f.write(b'KERNEL') + + with open(its, 'w') as f: + f.write(ITS) + + # Build and sign. -E keeps the (large) rootfs external, as on a real device. + utils.run_and_log(ubman, [mkimage, '-D', dtc_args, '-E', '-f', its, + '-k', tmpdir, '-K', dtb, '-r', fit]) + + # Baseline: the freshly signed image must verify. + utils.run_and_log(ubman, [fit_check_sign, '-f', fit, '-k', dtb]) + + # Control: tampering a byte that is signed (the filesystem image hash value) + # must be detected. This proves the check can fail. + control = tmpdir + 'control.itb' + utils.run_and_log(ubman, 'cp %s %s' % (fit, control)) + flip_prop_byte(ubman, control, ROOTFS_HASH_NODE, 'value') + utils.run_and_log_expect_exception( + ubman, [fit_check_sign, '-f', control, '-k', dtb], + 1, 'Failed to verify required signature') + + # Roothash: tampering the dm-verity digest must be rejected. If the digest + # is outside the signed region this check passes and boot is compromised. + tampered = tmpdir + 'tamper-digest.itb' + utils.run_and_log(ubman, 'cp %s %s' % (fit, tampered)) + flip_prop_byte(ubman, tampered, VERITY_NODE, 'digest') + utils.run_and_log_expect_exception( + ubman, [fit_check_sign, '-f', tampered, '-k', dtb], + 1, 'Failed to verify required signature') + + # Salt: likewise, the salt feeds the dm-verity target and must be signed. + tampered = tmpdir + 'tamper-salt.itb' + utils.run_and_log(ubman, 'cp %s %s' % (fit, tampered)) + flip_prop_byte(ubman, tampered, VERITY_NODE, 'salt') + utils.run_and_log_expect_exception( + ubman, [fit_check_sign, '-f', tampered, '-k', dtb], + 1, 'Failed to verify required signature') |
