From e941a2db3a3650a21cb00b40ebc70f2125501f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Sch=C3=A4fer?= Date: Mon, 9 Mar 2026 10:59:52 +0100 Subject: [PATCH] Add support for verity baked live ISO images Allow to build verity checked live ISO images by setting verity_blocks in combination with optional rd.kiwi.verity_options kernel boot options --- .../tumbleweed/test-image-live/appliance.kiwi | 4 +- dracut/modules.d/55kiwi-live/kiwi-live-lib.sh | 93 +++++++- dracut/modules.d/55kiwi-live/module-setup.sh | 3 +- kiwi/builder/live.py | 203 ++++++++++++------ kiwi/schema/kiwi.rnc | 2 +- kiwi/schema/kiwi.rng | 2 +- test/unit/builder/live_test.py | 97 ++++++++- 7 files changed, 333 insertions(+), 71 deletions(-) diff --git a/build-tests/x86/tumbleweed/test-image-live/appliance.kiwi b/build-tests/x86/tumbleweed/test-image-live/appliance.kiwi index f19821fb9e8..a4c84eabcc7 100644 --- a/build-tests/x86/tumbleweed/test-image-live/appliance.kiwi +++ b/build-tests/x86/tumbleweed/test-image-live/appliance.kiwi @@ -35,7 +35,7 @@ - + @@ -75,7 +75,7 @@ - + diff --git a/dracut/modules.d/55kiwi-live/kiwi-live-lib.sh b/dracut/modules.d/55kiwi-live/kiwi-live-lib.sh index 43d0b8c8386..0bf70d4ff49 100755 --- a/dracut/modules.d/55kiwi-live/kiwi-live-lib.sh +++ b/dracut/modules.d/55kiwi-live/kiwi-live-lib.sh @@ -129,6 +129,19 @@ function udev_pending { udevadm settle --timeout="${limit}" } +function set_device_lock { + # """ + # Set device lock, preferrable via udevadm or via + # flock as fallback if systemd/udev does not provide + # the command + # """ + if udevadm lock --help &>/dev/null;then + udevadm lock --device "$@" + else + flock -x "$@" + fi +} + function initGlobalDevices { if [ -z "$1" ]; then die "No root device for operation given" @@ -188,16 +201,92 @@ function mountCompressedContainerFromIso { squashfs_container="${iso_mount_point}/${live_dir}/${squash_image}" mkdir -m 0755 -p "${container_mount_point}" + if activate_verity "${squashfs_container}";then + squashfs_container=/dev/mapper/verityroot + fi + if activate_luks "${squashfs_container}" luks; then squashfs_container=/dev/mapper/luks fi if ! mount -n "${squashfs_container}" "${container_mount_point}";then - die "Failed to mount live ISO squashfs container" + die "Failed to mount live ISO compressed read-only container" fi echo "${container_mount_point}" } +function activate_verity { + # The method loopback mounts the given compressed root image + # and calls veritysetup to activate the verity device. The + # activatrion of the verity device can also be done with help + # from systemd-veritysetup which would automatically be called + # after losetup if the dracut module systemd-veritysetup + # would be included to the initrd. Unfortunately no proper + # veritytab file can be provided if the root image is based on + # squashfs because there is no support for UUIDs in squashfs + # which means the veritytab would have to be modified with the + # plain loopback device after the losetup call. Such a modification + # then requires to call "systemctl daemon-reload" followed by + # "systemctl restart systemd-veritysetup@verityroot" to make + # systemd-veritysetup aware of the changes in the veritytab. + # To avoid this complexity the verity device is activated via + # veritysetup directly. + local compressed_root=$1 + local verity_loop + local verity_name + local data_device + local hash_device + local root_hash + local verity_options + local kiwi_verity_options + local veritysetup + local option + if [ -f /etc/veritytab ];then + read -r \ + verity_name data_device hash_device root_hash verity_options \ + < /etc/veritytab + verity_loop=$(losetup --show --find "${compressed_root}") + udevadm wait "${verity_loop}" + if [ "${data_device}" = "PLACEHOLDER" ];then + # squashfs does not support UUIDs, therefore the initial + # veritytab only contains a placeholder for the device name + sed -ie "s|PLACEHOLDER|${verity_loop}|g" /etc/veritytab + fi + read -r \ + verity_name data_device hash_device root_hash verity_options \ + < /etc/veritytab + if [ "$(echo "${data_device}" | cut -f1 -d=)" = "UUID" ];then + data_device=/dev/disk/by-uuid/$(echo "${data_device}" | cut -f2 -d=) + udevadm wait "${data_device}" + fi + if [ "$(echo "${hash_device}" | cut -f1 -d=)" = "UUID" ];then + hash_device=/dev/disk/by-uuid/$(echo "${hash_device}" | cut -f2 -d=) + udevadm wait "${hash_device}" + fi + # Read kernel command line verity options and merge the options + kiwi_verity_options=$(getarg rd.kiwi.verity_options=) + if [ -n "${kiwi_verity_options}" ]; then + if [ -n "${verity_options}" ]; then + verity_options="${verity_options},${kiwi_verity_options}" + else + verity_options="${kiwi_verity_options}" + fi + fi + veritysetup="veritysetup open " + veritysetup="${veritysetup} ${data_device} ${verity_name} " + veritysetup="${veritysetup} ${hash_device} ${root_hash}" + for option in $(echo "${verity_options}" | tr , " ");do + veritysetup="${veritysetup} --${option}" + done + eval "${veritysetup}" + set_device_lock "${verity_loop}" \ + udevadm wait "/dev/mapper/verityroot" + return 0 + else + return 1 + fi +} + function activate_luks { local rootfs_image=$1 local mapname=$2 @@ -220,7 +309,7 @@ function mountReadOnlyRootImageFromContainer { local container_mount_point=$1 local overlay_base overlay_base=$(getOverlayBaseDirectory) - local rootfs_image="${container_mount_point}/LiveOS/rootfs.img" + local rootfs_image="${container_mount_point}/${live_dir}/rootfs.img" local root_mount_point="${overlay_base}/rootfsbase" mkdir -m 0755 -p "${root_mount_point}" diff --git a/dracut/modules.d/55kiwi-live/module-setup.sh b/dracut/modules.d/55kiwi-live/module-setup.sh index 8f546d33448..70ad6cca2f6 100755 --- a/dracut/modules.d/55kiwi-live/module-setup.sh +++ b/dracut/modules.d/55kiwi-live/module-setup.sh @@ -29,7 +29,8 @@ install() { inst_multiple \ umount dmsetup partx blkid lsblk dd losetup \ grep cut partprobe find wc fdisk tail mkfs.ext4 mkfs.xfs \ - dialog cat mountpoint curl dolly dd cryptsetup + dialog cat mountpoint curl dolly dd cryptsetup veritysetup \ + flock udevadm sed dmsquashdir=$(find "${dracutbasedir}/modules.d" -name "*dmsquash-live") if [ -n "${dmsquashdir}" ] && \ diff --git a/kiwi/builder/live.py b/kiwi/builder/live.py index e8a81c36fb8..31a00ed7f36 100644 --- a/kiwi/builder/live.py +++ b/kiwi/builder/live.py @@ -18,12 +18,20 @@ from contextlib import ExitStack import os import logging -from typing import Dict +from typing import ( + Dict, List, Union +) import shutil # project +import kiwi.defaults as defaults + +from kiwi.utils.veritysetup import VeritySetup +from kiwi.utils.block import BlockID from kiwi.utils.temporary import Temporary from kiwi.bootloader.config import create_boot_loader_config +from kiwi.bootloader.config.grub2 import BootLoaderConfigGrub2 +from kiwi.bootloader.config.systemd_boot import BootLoaderSystemdBoot from kiwi.bootloader.config.base import BootLoaderConfigBase from kiwi.filesystem import FileSystem from kiwi.filesystem.isofs import FileSystemIsoFs @@ -67,6 +75,8 @@ def __init__( self.bootloader = xml_state.get_build_type_bootloader_name() if self.bootloader != 'systemd_boot': self.bootloader = 'grub2' + self.root_filesystem_verity_blocks = \ + xml_state.build_type.get_verity_blocks() self.arch = Defaults.get_platform_name() self.root_dir = root_dir self.target_dir = target_dir @@ -76,7 +86,8 @@ def __init__( Defaults.get_volume_id() self.mbrid = SystemIdentifier() self.mbrid.calculate_id() - self.application_id = self.xml_state.build_type.get_application_id() or \ + self.application_id = \ + self.xml_state.build_type.get_application_id() or \ self.mbrid.get_id() self.publisher = xml_state.build_type.get_publisher() or \ Defaults.get_publisher() @@ -92,7 +103,8 @@ def __init__( self.boot_image = BootImageDracut( xml_state, - f'{root_dir}/boot' if self.bootloader == 'systemd_boot' else target_dir, + f'{root_dir}/boot' + if self.bootloader == 'systemd_boot' else target_dir, self.root_dir ) self.firmware = FirmWare( @@ -113,6 +125,19 @@ def __init__( ) self.result = Result(xml_state) self.runtime_config = RuntimeConfig() + self.custom_iso_args = { + 'meta_data': { + 'publisher': self.publisher, + 'preparer': Defaults.get_preparer(), + 'volume_id': self.volume_id, + 'mbr_id': self.mbrid.get_id(), + 'application_id': self.application_id, + 'efi_mode': self.firmware.efi_mode(), + 'efi_partition_table': self.firmware.get_partition_table_type(), + 'gpt_hybrid_mbr': self.firmware.gpt_hybrid_mbr, + 'legacy_bios_mode': self.firmware.legacy_bios_mode() + } + } def create(self) -> Result: """ @@ -143,20 +168,6 @@ def create(self) -> Result: log.info('--> Application id: {0}'.format(self.application_id)) log.info('--> Publisher: {0}'.format(self.publisher)) log.info('--> Volume id: {0}'.format(self.volume_id)) - custom_iso_args = { - 'meta_data': { - 'publisher': self.publisher, - 'preparer': Defaults.get_preparer(), - 'volume_id': self.volume_id, - 'mbr_id': self.mbrid.get_id(), - 'application_id': self.application_id, - 'efi_mode': self.firmware.efi_mode(), - 'efi_partition_table': self.firmware.get_partition_table_type(), - 'gpt_hybrid_mbr': self.firmware.gpt_hybrid_mbr, - 'legacy_bios_mode': self.firmware.legacy_bios_mode() - } - } - log.info( 'Setting up live image bootloader configuration' ) @@ -187,47 +198,10 @@ def create(self) -> Result: self.boot_image.prepare() # create dracut initrd for live image - log.info('Creating live ISO boot image') - live_dracut_modules = Defaults.get_live_dracut_modules_from_flag( - self.live_type - ) - live_dracut_modules.append('pollcdrom') - for dracut_module in live_dracut_modules: - self.boot_image.include_module(dracut_module) - self.boot_image.omit_module('multipath') - self.boot_image.write_system_config_file( - config={ - 'modules': live_dracut_modules, - 'omit_modules': ['multipath'] - }, - config_file=self.root_dir + '/etc/dracut.conf.d/02-livecd.conf' + log.info('Creating live ISO boot image(s)') + self.create_live_iso_boot_images( + bootloader_config, modules=['pollcdrom'] ) - self.boot_image.create_initrd(self.mbrid) - # Clean up leftover dracut config file (which can break installs) - os.unlink(self.root_dir + '/etc/dracut.conf.d/02-livecd.conf') - if self.bootloader == 'systemd_boot': - # make sure the initrd name follows the dracut - # naming conventions - boot_names = self.boot_image.get_boot_names() - if self.boot_image.initrd_filename: - Command.run( - [ - 'mv', self.boot_image.initrd_filename, - self.root_dir + ''.join( - ['/boot/', boot_names.initrd_name] - ) - ] - ) - - # create EFI FAT image - if self.firmware.efi_mode(): - efi_loader = Temporary( - prefix='efi-loader.', path=self.target_dir - ).new_file() - bootloader_config._create_embedded_fat_efi_image( - efi_loader.name - ) - custom_iso_args['meta_data']['efi_loader'] = efi_loader.name # setup kernel file(s) and initrd in ISO boot layout if self.bootloader != 'systemd_boot': @@ -239,7 +213,7 @@ def create(self) -> Result: # calculate size and decide if we need UDF if rootsize.accumulate_mbyte_file_sizes() > 4096: log.info('ISO exceeds 4G size, using UDF filesystem') - custom_iso_args['meta_data']['udf'] = True + self.custom_iso_args['meta_data']['udf'] = True # pack system into live boot structure as expected by dracut log.info( @@ -287,7 +261,8 @@ def create(self) -> Result: root_dir=self.root_dir ) - device_provider = luks_provider.get_device() or DeviceProvider() \ + device_provider = \ + luks_provider.get_device() or DeviceProvider() \ if luks_provider else loop_provider live_filesystem = FileSystem.new( name=root_filesystem, @@ -312,7 +287,8 @@ def create(self) -> Result: ).new_dir() Path.create(self.live_container_dir.name + '/LiveOS') shutil.copy( - root_image.name, self.live_container_dir.name + '/LiveOS/rootfs.img' + root_image.name, + self.live_container_dir.name + '/LiveOS/rootfs.img' ) with FileSystem.new( name='squashfs', @@ -327,6 +303,14 @@ def create(self) -> Result: live_container_image.create_on_file( container_image.name ) + if self.root_filesystem_verity_blocks: + live_container_image.create_verity_layer( + self.root_filesystem_verity_blocks if + self.root_filesystem_verity_blocks != 'all' else None + ) + self._write_veritytab_to_boot_image( + container_image.name, live_container_image.veritysetup + ) Path.create(self.media_dir.name + '/LiveOS') os.chmod(container_image.name, 0o644) shutil.copy( @@ -359,6 +343,16 @@ def create(self) -> Result: get_exclude_list_for_root_data_sync() + Defaults. get_exclude_list_from_custom_exclude_files(self.root_dir) ) + + if self.root_filesystem_verity_blocks: + live_container_image.create_verity_layer( + self.root_filesystem_verity_blocks if + self.root_filesystem_verity_blocks != 'all' else None + ) + self._write_veritytab_to_boot_image( + container_image.name, live_container_image.veritysetup + ) + luks_image = None if self.luks is not None: # dump root filesystem blob on top of a LUKS blob @@ -406,11 +400,20 @@ def create(self) -> Result: self.media_dir.name + '/LiveOS/squashfs.img' ) + if self.root_filesystem_verity_blocks: + log.info('Rebuild live ISO boot image(s) to include veritysetup') + with self._create_bootloader_instance() as bootloader_config: + self.create_live_iso_boot_images( + bootloader_config, modules=['pollcdrom'] + ) + if self.bootloader != 'systemd_boot': + self._setup_live_iso_kernel_and_initrd() + # create iso filesystem from media_dir log.info('Creating live ISO image') with FileSystemIsoFs( device_provider=DeviceProvider(), root_dir=self.media_dir.name, - custom_args=custom_iso_args + custom_args=self.custom_iso_args ) as iso_image: iso_image.create_on_file(self.isoname) @@ -460,6 +463,84 @@ def create(self) -> Result: ) return self.result + def create_live_iso_boot_images( + self, + bootloader_config: Union[BootLoaderConfigGrub2, BootLoaderSystemdBoot], + modules: List[str] = [] + ) -> None: + live_dracut_modules = Defaults.get_live_dracut_modules_from_flag( + self.live_type + ) + modules + for dracut_module in live_dracut_modules: + self.boot_image.include_module(dracut_module) + self.boot_image.omit_module('multipath') + self.boot_image.write_system_config_file( + config={ + 'modules': live_dracut_modules, + 'omit_modules': ['multipath'] + }, + config_file=self.root_dir + '/etc/dracut.conf.d/02-livecd.conf' + ) + self.boot_image.create_initrd(self.mbrid) + # Clean up leftover dracut config file (which can break installs) + os.unlink(self.root_dir + '/etc/dracut.conf.d/02-livecd.conf') + if self.bootloader == 'systemd_boot': + # make sure the initrd name follows the dracut + # naming conventions + boot_names = self.boot_image.get_boot_names() + if self.boot_image.initrd_filename: + Command.run( + [ + 'mv', self.boot_image.initrd_filename, + self.root_dir + ''.join( + ['/boot/', boot_names.initrd_name] + ) + ] + ) + + # create EFI FAT image + if self.firmware.efi_mode(): + self.efi_loader = Temporary( + prefix='efi-loader.', path=self.target_dir + ).new_file() + bootloader_config._create_embedded_fat_efi_image( + self.efi_loader.name + ) + self.custom_iso_args['meta_data']['efi_loader'] = \ + self.efi_loader.name + + def _write_veritytab_to_boot_image( + self, container_image_name: str, veritysetup: VeritySetup + ) -> None: + log.info('Creating generic boot image etc/veritytab') + veritytab_filename = ''.join([self.root_dir, '/etc/veritytab']) + block_operation = BlockID(container_image_name) + filesystem = block_operation.get_filesystem() + if filesystem == 'squashfs': + # squashfs does not provide a label or uuid + # The real device name is not known at this point, and + # will be replaced in the initrd code with a device name + # based on the loopsetup result of the compressed image file + device_id = 'PLACEHOLDER' + else: + device_id = 'UUID={}'.format( + block_operation.get_blkid('UUID') + ) + with open(veritytab_filename, 'w') as veritytab: + veritytab.write( + 'verityroot {0} {0} {1} {2},{3}{4}'.format( + device_id, veritysetup.verity_dict.get('Roothash'), + f'hash-offset={veritysetup.verity_hash_offset}', + f'hash-block-size={defaults.VERITY_HASH_BLOCKSIZE}', + os.linesep + ) + ) + self.boot_image.include_file( + filename=os.sep + os.sep.join( + ['etc', os.path.basename(veritytab_filename)] + ), delete_after_include=True + ) + def _create_bootloader_instance(self) -> BootLoaderConfigBase: return create_boot_loader_config( name=self.bootloader, xml_state=self.xml_state, diff --git a/kiwi/schema/kiwi.rnc b/kiwi/schema/kiwi.rnc index 9a65b598b7a..6119779e57e 100644 --- a/kiwi/schema/kiwi.rnc +++ b/kiwi/schema/kiwi.rnc @@ -1800,7 +1800,7 @@ div { attribute verity_blocks { blocks-type } >> sch:pattern [ id = "verity_blocks" is-a = "image_type" sch:param [ name = "attr" value = "verity_blocks" ] - sch:param [ name = "types" value = "oem" ] + sch:param [ name = "types" value = "oem iso" ] ] k.type.embed_verity_metadata.attribute = ## In combination with the verity_blocks attribute, embed diff --git a/kiwi/schema/kiwi.rng b/kiwi/schema/kiwi.rng index 35f5360bdc8..ebbf86544e3 100644 --- a/kiwi/schema/kiwi.rng +++ b/kiwi/schema/kiwi.rng @@ -2675,7 +2675,7 @@ file the value 'all' can be specified. - + diff --git a/test/unit/builder/live_test.py b/test/unit/builder/live_test.py index d9995c43076..42e8b289e00 100644 --- a/test/unit/builder/live_test.py +++ b/test/unit/builder/live_test.py @@ -18,6 +18,9 @@ def setup(self): Defaults.set_platform_name('x86_64') self.firmware = Mock() + self.firmware.bios_mode.return_value = False + self.firmware.get_partition_table_type.return_value = 'gpt' + self.firmware.gpt_hybrid_mbr = False self.firmware.legacy_bios_mode = Mock( return_value=True ) @@ -74,6 +77,9 @@ def setup(self): ) self.xml_state = Mock() + self.xml_state.build_type.get_verity_blocks = Mock( + return_value=None + ) self.xml_state.get_fs_mount_option_list = Mock( return_value=['async'] ) @@ -181,6 +187,94 @@ def test_create_overlay_structure_boot_on_systemd_boot( ['mv', 'kiwi_used_initrd_name', 'root_dir/boot/dracut_initrd_name'] ) + @mark.parametrize('xml_filesystem', [None, 'squashfs', 'erofs']) + @patch('kiwi.builder.live.create_boot_loader_config') + @patch('kiwi.builder.live.LoopDevice') + @patch('kiwi.builder.live.DeviceProvider') + @patch('kiwi.builder.live.IsoToolsBase.setup_media_loader_directory') + @patch('kiwi.builder.live.Temporary') + @patch('kiwi.builder.live.shutil') + @patch('kiwi.builder.live.Iso.set_media_tag') + @patch('kiwi.builder.live.Iso') + @patch('kiwi.builder.live.FileSystemIsoFs') + @patch('kiwi.builder.live.FileSystem.new') + @patch('kiwi.builder.live.SystemSize') + @patch('kiwi.builder.live.Defaults.get_grub_boot_directory_name') + @patch('os.unlink') + @patch('os.path.exists') + @patch('os.chmod') + @patch('kiwi.builder.live.BlockID') + def test_create_overlay_structure_boot_verity_baked( + self, + mock_BlockID, + mock_chmod, + mock_exists, + mock_unlink, + mock_grub_dir, + mock_size, + mock_filesystem, + mock_isofs, + mock_Iso, + mock_tag, + mock_shutil, + mock_Temporary, + mock_setup_media_loader_directory, + mock_DeviceProvider, + mock_LoopDevice, + mock_create_boot_loader_config, + xml_filesystem + ): + if not xml_filesystem or xml_filesystem == 'squashfs': + mock_BlockID.return_value.get_filesystem.return_value = 'squashfs' + if xml_filesystem and xml_filesystem == 'erofs': + mock_BlockID.return_value.get_filesystem.return_value = 'erofs' + bootloader_config = Mock() + mock_create_boot_loader_config.return_value.__enter__.return_value = \ + bootloader_config + loop_provider = Mock() + mock_LoopDevice.return_value.__enter__.return_value = loop_provider + mock_exists.return_value = True + mock_unlink.return_value = True + mock_grub_dir.return_value = 'grub2' + temp_squashfs = Mock() + temp_squashfs.name = 'temp-squashfs' + temp_media_dir = Mock() + temp_media_dir.name = 'temp_media_dir' + tmpdir_name = [temp_squashfs, temp_media_dir] + + def side_effect(): + return tmpdir_name.pop() + + mock_Temporary.return_value.new_dir.side_effect = side_effect + mock_Temporary.return_value.new_file.return_value.name = 'kiwi-tmpfile' + self.live_image.live_type = 'overlay' + self.live_image.root_filesystem_verity_blocks = 'all' + self.xml_state.build_type.get_filesystem = Mock( + return_value=xml_filesystem + ) + iso_image = Mock() + iso_image.create_on_file.return_value = 'offset' + mock_isofs.return_value.__enter__.return_value = iso_image + rootsize = Mock() + rootsize.accumulate_mbyte_file_sizes = Mock( + return_value=8192 + ) + mock_size.return_value = rootsize + self.setup.export_package_changes.return_value = '.changes' + self.setup.export_package_verification.return_value = '.verified' + self.setup.export_package_list.return_value = '.packages' + + with patch('builtins.open', create=True): + self.live_image.create() + + if xml_filesystem is None: + mock_filesystem.return_value.__enter__.return_value.\ + create_verity_layer.assert_called_once_with(None) + + if xml_filesystem == 'squashfs': + mock_filesystem.return_value.\ + create_verity_layer.assert_called_once_with(None) + @mark.parametrize('xml_filesystem', ['xfs']) @patch('kiwi.builder.live.create_boot_loader_config') @patch('kiwi.builder.live.LoopDevice') @@ -395,9 +489,6 @@ def side_effect(): self.setup.export_package_verification.return_value = '.verified' self.setup.export_package_list.return_value = '.packages' - self.firmware.bios_mode.return_value = False - self.firmware.get_partition_table_type.return_value = 'gpt' - self.firmware.gpt_hybrid_mbr = False self.live_image.create() self.setup.import_cdroot_files.assert_called_once_with('temp_media_dir')