diff --git a/acl/docs/architecture.md b/acl/docs/architecture.md index dce81d48c89..24559c50cc4 100644 --- a/acl/docs/architecture.md +++ b/acl/docs/architecture.md @@ -53,8 +53,9 @@ ACL's primary boot path uses **systemd-boot** with **Unified Kernel Images (UKI) The `/usr` partition (USR-A) is a read-only btrfs filesystem with zstd compression. **dm-verity** provides block-level integrity verification: -- The verity hash tree is appended to the USR partition data. -- At boot, `systemd-veritysetup` activates the verity device using kernel command-line parameters embedded in the UKI: `systemd.verity_usr_data`, `systemd.verity_usr_hash`, and `systemd.verity_usr_options=hash-offset=,panic-on-corruption`. +- The verity hash tree is stored in a dedicated hash partition (HASH-A, immediately following USR-A on disk). +- At boot, `systemd-veritysetup` activates the verity device using slot-specific parameters delivered via a per-slot systemd-stub addon: `systemd.verity_usr_data=PARTUUID=`, `systemd.verity_usr_hash=PARTUUID=`, and `systemd.verity_usr_options=panic-on-corruption`. +- The main UKI cmdline stays slot-independent (`mount.usr=/dev/mapper/usr`); Trident switches slots by swapping which addon is active in `.efi.extra.d/`, so the same signed UKI boots either A or B. (The secondary GRUB boot path instead builds the verity cmdline from filesystem/verity UUIDs, `UUID=` / `UUID=`.) - Any corruption of `/usr` causes an immediate kernel panic, preventing the system from running a tampered image. The A/B partition scheme (USR-A / USR-B) enables safe updates: the inactive slot is written, verified, and then atomically switched on reboot. diff --git a/build_library/build_image_util.sh b/build_library/build_image_util.sh index bf7f4dbba1b..3625ab9386a 100755 --- a/build_library/build_image_util.sh +++ b/build_library/build_image_util.sh @@ -886,6 +886,8 @@ EOF "${BUILD_LIBRARY_DIR}/disk_util" --disk_layout="${disk_layout}" --arch="${BOARD}" verity \ --root_hash="${BUILD_DIR}/${image_name%.bin}_verity.txt" \ + --verity_uuid="${BUILD_DIR}/${image_name%.bin}_verity_uuid.txt" \ + --fs_uuid="${BUILD_DIR}/${image_name%.bin}_fs_uuid.txt" \ "${BUILD_DIR}/${image_name}" # Magic alert! Root hash injection works by writing the hash value to a @@ -953,6 +955,15 @@ EOF if [[ -f "${verity_hash_file}" ]]; then bootloader_args+=(--verity_hash="${verity_hash_file}") fi + # Pass UUID files for UUID-based cmdline construction + local verity_uuid_file="${BUILD_DIR}/${image_name%.bin}_verity_uuid.txt" + if [[ -f "${verity_uuid_file}" ]]; then + bootloader_args+=(--verity_uuid="${verity_uuid_file}") + fi + local fs_uuid_file="${BUILD_DIR}/${image_name%.bin}_fs_uuid.txt" + if [[ -f "${fs_uuid_file}" ]]; then + bootloader_args+=(--fs_uuid="${fs_uuid_file}") + fi fi else bootloader_args+=(--noverity) diff --git a/build_library/disk_layout.json b/build_library/disk_layout.json index e9747044f72..6aa4b69b552 100644 --- a/build_library/disk_layout.json +++ b/build_library/disk_layout.json @@ -32,22 +32,36 @@ "fs_type":"btrfs", "fs_compression":"zstd", "mount":"/usr", + "verity_hash":"4", "features": ["prioritize", "verity"] }, "4":{ + "label":"HASH-A", + "uuid":"1fae528b-6baf-4efc-a226-2a23f9291ce6", + "type":"dps-usr-verity", + "blocks":"20480" + }, + "5":{ "label":"USR-B", "uuid":"e03dd35c-7c2d-4a47-b3fe-27f15780a57c", "type":"flatcar-rootfs", "blocks":"2097152", - "fs_blocks":"262144" + "fs_blocks":"262144", + "verity_hash":"6" }, - "5":{ + "6":{ + "label":"HASH-B", + "uuid":"accb60b1-1d72-4e05-9363-6fd51e2ce788", + "type":"dps-usr-verity", + "blocks":"20480" + }, + "7":{ "label":"ROOT-C", "uuid":"d82521b4-07ac-4f1c-8840-ddefedc332f3", "type":"blank", "blocks":"0" }, - "6":{ + "8":{ "label":"OEM", "fs_label":"OEM", "type":"data", @@ -56,17 +70,17 @@ "fs_compression":"zlib", "mount":"/oem" }, - "7":{ + "9":{ "label":"OEM-CONFIG", "type":"flatcar-reserved", "blocks":"131072" }, - "8":{ + "10":{ "type":"blank", "label":"flatcar-reserved", "blocks":"0" }, - "9":{ + "11":{ "label":"ROOT", "fs_label":"ROOT", "type":"dps-root", @@ -76,28 +90,28 @@ } }, "vm":{ - "9":{ + "11":{ "label":"ROOT", "fs_label":"ROOT", "blocks":"12943360" } }, "azure":{ - "9":{ + "11":{ "label":"ROOT", "fs_label":"ROOT", "blocks":"58875904" } }, "vagrant":{ - "9":{ + "11":{ "label":"ROOT", "fs_label":"ROOT", "blocks":"33845248" } }, "onmetal":{ - "7":{ + "9":{ "label":"config-2", "fs_label":"config-2", "type":"data", @@ -130,6 +144,12 @@ "type":"blank" }, "9":{ + "type":"blank" + }, + "10":{ + "type":"blank" + }, + "11":{ "label":"ROOT", "fs_label":"ROOT", "type":"0fc63daf-8483-4772-8e79-3d69d8477de4", @@ -137,4 +157,4 @@ } } } -} +} diff --git a/build_library/disk_layout_uki.json b/build_library/disk_layout_uki.json index 5d4a16e79f5..c387a7a6f4c 100644 --- a/build_library/disk_layout_uki.json +++ b/build_library/disk_layout_uki.json @@ -13,7 +13,7 @@ "label": "EFI-SYSTEM", "fs_label": "EFI-SYSTEM", "type": "efi", - "blocks": "262144", + "blocks": "524288", "fs_type": "vfat", "mount": "/boot", "features": [] @@ -23,23 +23,37 @@ "uuid": "7130c94a-213a-4e5a-8e26-6cce9662f132", "type": "flatcar-rootfs", "blocks": "2097152", - "fs_blocks": "260094", + "fs_blocks": "262144", "fs_type": "btrfs", "fs_compression": "zstd", "mount": "/usr", + "verity_hash": "3", "features": [ "prioritize", "verity" ] }, "3": { + "label": "HASH-A", + "uuid": "b736baf1-cdb4-4535-beba-ddaaa30ad7b7", + "type": "dps-usr-verity", + "blocks": "20480" + }, + "4": { "label": "USR-B", "uuid": "e03dd35c-7c2d-4a47-b3fe-27f15780a57c", "type": "flatcar-rootfs", "blocks": "2097152", - "fs_blocks": "262144" + "fs_blocks": "262144", + "verity_hash": "5" }, - "4": { + "5": { + "label": "HASH-B", + "uuid": "35bdf78b-c453-4661-98e6-f834f534ef5b", + "type": "dps-usr-verity", + "blocks": "20480" + }, + "6": { "label": "OEM", "fs_label": "OEM", "type": "data", @@ -48,7 +62,7 @@ "fs_compression": "zlib", "mount": "/oem" }, - "5": { + "7": { "label": "ROOT", "fs_label": "ROOT", "type": "dps-root", @@ -58,21 +72,21 @@ } }, "vm": { - "5": { + "7": { "label": "ROOT", "fs_label": "ROOT", "blocks": "12943360" } }, "azure": { - "5": { + "7": { "label": "ROOT", "fs_label": "ROOT", "blocks": "58875904" } }, "vagrant": { - "5": { + "7": { "label": "ROOT", "fs_label": "ROOT", "blocks": "33845248" @@ -92,6 +106,12 @@ "type": "blank" }, "5": { + "type": "blank" + }, + "6": { + "type": "blank" + }, + "7": { "label": "ROOT", "fs_label": "ROOT", "type": "0fc63daf-8483-4772-8e79-3d69d8477de4", diff --git a/build_library/disk_util b/build_library/disk_util index 9a37343cb10..8617e7c3b94 100755 --- a/build_library/disk_util +++ b/build_library/disk_util @@ -34,6 +34,11 @@ DPS_ROOT_GUIDS = { 'aarch64': 'B921B045-1DF0-41C3-AF44-4C6F280D3FAE', } +DPS_USR_VERITY_GUIDS = { + 'x86_64': '77FF5F63-E7B6-4633-ACF4-1565B864C0E6', + 'aarch64': '6E11A4E7-FBCA-4DED-B9E9-E1A512BB664E', +} + # Map BOARD names used by the build system to machine architecture values. BOARD_TO_ARCH = { 'amd64-usr': 'x86_64', @@ -44,18 +49,24 @@ def _resolve_dps_types(config, arch): """Replace symbolic DPS partition type names with architecture-specific GUIDs. Currently supported placeholders: - dps-root → DPS root partition GUID for the target architecture + dps-root → DPS root partition GUID for the target architecture + dps-usr-verity → DPS /usr verity hash partition GUID """ + dps_map = { + 'dps-root': DPS_ROOT_GUIDS, + 'dps-usr-verity': DPS_USR_VERITY_GUIDS, + } for layout in config.get('layouts', {}).values(): for part in layout.values(): if not isinstance(part, dict): continue ptype = part.get('type', '') - if ptype == 'dps-root': - guid = DPS_ROOT_GUIDS.get(arch) + guids = dps_map.get(ptype) + if guids is not None: + guid = guids.get(arch) if guid is None: raise InvalidLayout( - 'No DPS root GUID defined for architecture %r' % arch) + 'No %s GUID defined for architecture %r' % (ptype, arch)) part['type'] = guid @@ -73,7 +84,7 @@ def LoadPartitionConfig(options): '_comment', 'type', 'num', 'label', 'blocks', 'block_size', 'fs_blocks', 'fs_block_size', 'fs_type', 'features', 'uuid', 'part_alignment', 'mount', 'binds', 'fs_subvolume', 'fs_bytes_per_inode', 'fs_inode_size', 'fs_label', - 'fs_compression')) + 'fs_compression', 'verity_hash')) integer_layout_keys = set(( 'blocks', 'block_size', 'fs_blocks', 'fs_block_size', 'part_alignment', 'fs_bytes_per_inode', 'fs_inode_size')) @@ -807,6 +818,12 @@ def Tune(options): def Verity(options): """Hash verity protected filesystems. + Only processes partitions that have a filesystem (fs_type) and either a + verity_hash cross-reference to a dedicated hash partition or the legacy + 'verity' feature flag for inline hash mode. At build time only the + active USR slot (the one with 'prioritize') is formatted, so the + inactive slot is skipped naturally. + Args: options: Flags passed to the script """ @@ -815,7 +832,26 @@ def Verity(options): GetPartitionTableFromImage(options, config, partitions) for part_num, part in partitions.items(): - if 'verity' not in part.get('features', []): + hash_part_num = part.get('verity_hash') + if hash_part_num is not None: + # partitions is keyed by string partition numbers (JSON object keys); + # normalize so a numeric verity_hash value still resolves correctly. + hash_part_num = str(hash_part_num).strip() + # A present-but-empty verity_hash is a malformed layout, not a request + # for legacy inline mode: silently falling back would produce an + # invalid verity layout that is hard to debug. Fail fast instead. + if not hash_part_num: + raise InvalidLayout( + "partition %r has an empty verity_hash; name a hash partition " + "number or omit the key" % part_num) + has_verity_feature = 'verity' in part.get('features', []) + + if not hash_part_num and not has_verity_feature: + continue + + # Skip partitions without a filesystem — at build time only the active + # slot (USR-A with 'prioritize') is formatted; USR-B is empty. + if not part.get('fs_type'): continue if not part['image_compat']: @@ -826,22 +862,61 @@ def Verity(options): elif part.get('fs_type', None) == 'btrfs': ReadWriteSubvol(options, part, disable_rw=True) - with PartitionLoop(options, part) as loop_dev: - verityout = SudoOutput(['veritysetup', 'format', '--hash=sha256', - '--data-block-size', part['fs_block_size'], - '--hash-block-size', part['fs_block_size'], - '--data-blocks', part['fs_blocks'], - '--hash-offset', part['fs_bytes'], - loop_dev, loop_dev]).decode('utf8') + if hash_part_num: + if hash_part_num not in partitions: + raise InvalidLayout( + "verity_hash references partition %r which is not in the layout" + % hash_part_num) + hash_part = partitions[hash_part_num] + if not hash_part['image_compat']: + raise InvalidLayout("Hash partition is incompatible with existing image") + else: + hash_part = None + + with contextlib.ExitStack() as loops: + data_loop = loops.enter_context(PartitionLoop(options, part)) + verity_cmd = ['veritysetup', 'format', '--hash=sha256', + '--data-block-size', part['fs_block_size'], + '--hash-block-size', part['fs_block_size'], + '--data-blocks', part['fs_blocks']] + if hash_part: + # Separate hash partition mode: write hash tree to a dedicated partition. + hash_loop = loops.enter_context(PartitionLoop(options, hash_part)) + else: + # Legacy inline mode: hash tree appended within the same partition. + hash_loop = data_loop + verity_cmd += ['--hash-offset', part['fs_bytes']] + verity_cmd += [data_loop, hash_loop] + + verityout = SudoOutput(verity_cmd).decode('utf8') print(verityout.strip()) - m = re.search(r'Root hash:\s+([a-f0-9]{64})$', verityout, re.IGNORECASE|re.MULTILINE) - if not m: - raise Exception("Failed to parse verity output!") - if options.root_hash != None: - with open(options.root_hash, "w") as hash_file: - hash_file.write(m.group(1)) - hash_file.write("\n") + # Read the filesystem UUID from the data partition for cmdline use. + if options.fs_uuid: + fs_uuid_out = SudoOutput( + ['blkid', '-s', 'UUID', '-o', 'value', data_loop]).decode('utf8').strip() + if not fs_uuid_out: + raise Exception("Failed to read filesystem UUID from %s" % data_loop) + with open(options.fs_uuid, 'w') as f: + f.write(fs_uuid_out + '\n') + + m = re.search(r'Root hash:\s+([a-f0-9]{64})$', verityout, re.IGNORECASE|re.MULTILINE) + if not m: + raise Exception("Failed to parse verity root hash!") + + if options.root_hash is not None: + with open(options.root_hash, "w") as hash_file: + hash_file.write(m.group(1)) + hash_file.write("\n") + + # Extract the verity superblock UUID from veritysetup output. + if options.verity_uuid: + m_uuid = re.search(r'UUID:\s+([a-f0-9-]{36})$', verityout, re.IGNORECASE|re.MULTILINE) + if not m_uuid: + raise Exception("Failed to parse verity UUID!") + with open(options.verity_uuid, 'w') as f: + f.write(m_uuid.group(1)) + f.write("\n") def Extract(options): @@ -1118,6 +1193,8 @@ def main(argv): a = actions.add_parser('verity', help='compute verity hashes') a.add_argument('disk_image', help='path to disk image file') a.add_argument('--root_hash', help='name of file to contain root hash') + a.add_argument('--verity_uuid', help='name of file to contain verity superblock UUID') + a.add_argument('--fs_uuid', help='name of file to contain data partition filesystem UUID') a.set_defaults(func=Verity) a = actions.add_parser('extract', help='extract a single partition') diff --git a/build_library/grub_install.sh b/build_library/grub_install.sh index b1dc56ab3e5..9a303b2d893 100755 --- a/build_library/grub_install.sh +++ b/build_library/grub_install.sh @@ -28,6 +28,10 @@ DEFINE_string copy_efi_grub "" \ "Copy the EFI GRUB image to the specified path." DEFINE_string copy_shim "" \ "Copy the shim image to the specified path." +DEFINE_string fs_uuid "" \ + "Path to the file containing the btrfs filesystem UUID for /usr." +DEFINE_string verity_uuid "" \ + "Path to the file containing the dm-verity superblock UUID for /usr." # Parse flags FLAGS "$@" || exit 1 diff --git a/build_library/rpm/build_image_util.sh b/build_library/rpm/build_image_util.sh index 4aadbcc7bbd..bb191ce6640 100644 --- a/build_library/rpm/build_image_util.sh +++ b/build_library/rpm/build_image_util.sh @@ -592,6 +592,18 @@ EOF sudo sed -i 's|/var/run/|/run/|g' "${root_fs_dir}/usr/lib/systemd/system/rpcbind.socket" fi + # Enable tridentd.socket - listens for trident API requests. + # trident is a required component of ACL images: a missing unit means the + # trident RPM was not installed, i.e. a broken image. Fail the build now + # rather than leave a dangling wants-symlink and ship an image whose + # provisioning socket never activates. + if [[ ! -f "${root_fs_dir}/usr/lib/systemd/system/tridentd.socket" ]]; then + die "tridentd.socket not found in image - trident RPM missing (trident is required for ACL)" + fi + info "RPM mode: Enabling tridentd.socket" + sudo mkdir -p "${root_fs_dir}/usr/lib/systemd/system/sockets.target.wants" + sudo ln -sf ../tridentd.socket "${root_fs_dir}/usr/lib/systemd/system/sockets.target.wants/tridentd.socket" + # Create /var/lib/nfs directories needed by rpc-statd and NFS server via tmpfiles # The nfs-utils RPM only creates v4recovery; sm and sm.bak are missing from the package # /var is stateful so we use tmpfiles.d to create these at boot, not mkdir at build time diff --git a/build_library/rpm/grub.cfg b/build_library/rpm/grub.cfg index 377c3915d61..41e0f2ad38e 100644 --- a/build_library/rpm/grub.cfg +++ b/build_library/rpm/grub.cfg @@ -116,17 +116,17 @@ set azl_kernel="@@KERNEL@@" # systemd-veritysetup-generator reads usrhash= to create /dev/mapper/usr set verity_usrhash="@@USRHASH@@" -# Azure Linux GRUB doesn't have gptprio.next command -# Boot directly from USR-A partition. A/B updates would require custom handling. -# USR-A partition UUID from disk_layout.json +# Filesystem UUID (btrfs) and verity superblock UUID, injected at build time. +# These are A/B agnostic: the update agent swaps which partition holds the +# active UUID, and systemd resolves UUID= to the correct device. +set usr_fs_uuid="@@FSUUID@@" +set verity_uuid="@@VERITYUUID@@" + +# Partition UUIDs for non-verity recovery menu entries only. set usr_a_uuid="7130c94a-213a-4e5a-8e26-6cce9662f132" set usr_b_uuid="e03dd35c-7c2d-4a47-b3fe-27f15780a57c" -# Verity hash offset: fs_blocks * fs_block_size = 260094 * 4096 = 1065345024 -# The hash tree is stored at the end of the USR partition after the filesystem data -set verity_hash_offset="1065345024" - -set verity_cmdline="@@MOUNTUSR@@ usrhash=$verity_usrhash systemd.verity_usr_data=PARTUUID=$usr_a_uuid systemd.verity_usr_hash=PARTUUID=$usr_a_uuid systemd.verity_usr_options=hash-offset=$verity_hash_offset,panic-on-corruption" +set verity_cmdline="@@MOUNTUSR@@ usrhash=$verity_usrhash systemd.verity_usr_data=UUID=$usr_fs_uuid systemd.verity_usr_hash=UUID=$verity_uuid systemd.verity_usr_options=panic-on-corruption" menuentry "Azure Container Linux default" --id=flatcar --unrestricted { linux$suf $azl_kernel $verity_cmdline $linux_cmdline diff --git a/build_library/rpm/grub_install.sh b/build_library/rpm/grub_install.sh index 563ebe6a46f..ce05f0d2120 100644 --- a/build_library/rpm/grub_install.sh +++ b/build_library/rpm/grub_install.sh @@ -125,9 +125,31 @@ grub_provision_rpm() { warn "RPM mode: Verity enabled but no hash file provided" sed_cmds+=(-e 's/@@USRHASH@@//') fi + + # Inject filesystem and verity UUIDs for A/B agnostic boot + if [[ -n "${FLAGS_fs_uuid}" && -f "${FLAGS_fs_uuid}" ]]; then + local grub_fs_uuid + grub_fs_uuid=$(cat "${FLAGS_fs_uuid}") + [[ -n "${grub_fs_uuid}" ]] || die "RPM mode: FS UUID file '${FLAGS_fs_uuid}' is empty" + info "RPM mode: Injecting FS UUID ${grub_fs_uuid}" + sed_cmds+=(-e "s/@@FSUUID@@/${grub_fs_uuid}/") + else + die "RPM mode: verity enabled but no FS UUID file provided" + fi + if [[ -n "${FLAGS_verity_uuid}" && -f "${FLAGS_verity_uuid}" ]]; then + local grub_verity_uuid + grub_verity_uuid=$(cat "${FLAGS_verity_uuid}") + [[ -n "${grub_verity_uuid}" ]] || die "RPM mode: verity UUID file '${FLAGS_verity_uuid}' is empty" + info "RPM mode: Injecting verity UUID ${grub_verity_uuid}" + sed_cmds+=(-e "s/@@VERITYUUID@@/${grub_verity_uuid}/") + else + die "RPM mode: verity enabled but no verity UUID file provided" + fi else sed_cmds+=(-e 's/@@MOUNTUSR@@/mount.usr/') sed_cmds+=(-e 's/@@USRHASH@@//') + sed_cmds+=(-e 's/@@FSUUID@@//') + sed_cmds+=(-e 's/@@VERITYUUID@@//') fi if [[ -n "${kernel_name}" ]]; then sed_cmds+=(-e "s|@@KERNEL@@|/flatcar/${kernel_name}|") diff --git a/build_library/rpm/package_catalog.yaml b/build_library/rpm/package_catalog.yaml index 9bddf920d7e..dcc93e3ad99 100644 --- a/build_library/rpm/package_catalog.yaml +++ b/build_library/rpm/package_catalog.yaml @@ -31,6 +31,7 @@ packages: - policycoreutils - ca-certificates - irqbalance + - trident sys-libs/systemd-libs: systemd-libs sys-apps/systemd-networkd: systemd-networkd net-misc/systemd-networkd: systemd-networkd diff --git a/build_library/rpm/rpm_install.sh b/build_library/rpm/rpm_install.sh index 2805920bdb2..183168f2fd1 100644 --- a/build_library/rpm/rpm_install.sh +++ b/build_library/rpm/rpm_install.sh @@ -948,7 +948,7 @@ EOF sudo rm -f "${root_fs_dir}/${policy_hotfix}" # Remove unnecessary SELinux policy modules (minimize the policy) - info "RPM mode: Minimizing SELinux policy". + info "RPM mode: Minimizing SELinux policy." sudo chroot "${root_fs_dir}" semodule -X 100 -r \ abrt accountsd acct acpi afs aide aisexec alsa amanda amavis amtu anaconda \ apache apcupsd apt aptcacher arpwatch asterisk auditadm automount avahi \ diff --git a/build_library/rpm/uki_install.sh b/build_library/rpm/uki_install.sh index a4ea402fa86..24f92f255af 100755 --- a/build_library/rpm/uki_install.sh +++ b/build_library/rpm/uki_install.sh @@ -19,6 +19,10 @@ DEFINE_boolean verity ${FLAGS_FALSE} \ "Indicates that boot commands should enable dm-verity." DEFINE_string verity_hash "" \ "Path to the file containing the dm-verity root hash for /usr." +DEFINE_string verity_uuid "" \ + "Path to the file containing the dm-verity superblock UUID." +DEFINE_string fs_uuid "" \ + "Path to the file containing the btrfs filesystem UUID for /usr." # Parse flags FLAGS "$@" || exit 1 @@ -134,60 +138,57 @@ OSREL fi info "UKI/RPM: Using ukify from $(command -v ukify)" - # Read partition UUID and compute verity hash offset from the - # canonical UKI disk layout so the values never drift from the source. - local disk_layout_file="${BUILD_LIBRARY_DIR}/disk_layout_uki.json" - if [[ ! -f "${disk_layout_file}" ]]; then - die "UKI/RPM: disk_layout_uki.json not found at ${disk_layout_file}" + # NOTE: The filesystem/verity UUIDs (FLAGS_fs_uuid, FLAGS_verity_uuid) + # are not used by the UKI cmdline — UKI slot identity is delivered via + # verity addons using PARTUUID. They are consumed only by the separate + # grub_install.sh entrypoint (GRUB boot path), which build_image_util + # invokes independently. Validate them here as a build-output sanity + # check so a missing UUID file fails fast during the UKI install rather + # than only surfacing later in a GRUB build. + if [[ ${FLAGS_verity} -eq ${FLAGS_TRUE} ]]; then + local fs_uuid_content verity_uuid_content + if [[ -z "${FLAGS_fs_uuid}" || ! -f "${FLAGS_fs_uuid}" ]]; then + die "UKI/RPM: FLAGS_fs_uuid not set or file missing (required for grub.cfg)" + fi + fs_uuid_content=$(tr -d '[:space:]' < "${FLAGS_fs_uuid}") + [[ -n "${fs_uuid_content}" ]] || die "UKI/RPM: FS UUID file '${FLAGS_fs_uuid}' is empty" + if [[ -z "${FLAGS_verity_uuid}" || ! -f "${FLAGS_verity_uuid}" ]]; then + die "UKI/RPM: FLAGS_verity_uuid not set or file missing (required for grub.cfg)" + fi + verity_uuid_content=$(tr -d '[:space:]' < "${FLAGS_verity_uuid}") + [[ -n "${verity_uuid_content}" ]] || die "UKI/RPM: verity UUID file '${FLAGS_verity_uuid}' is empty" fi - local usr_a_uuid - usr_a_uuid=$(jq -r '.layouts.base["2"].uuid' "${disk_layout_file}") - - local verity_hash_offset - verity_hash_offset=$(jq -r \ - '(.layouts.base["2"].fs_blocks | tonumber) as $b | (.metadata.fs_block_size | tonumber) as $s | ($b * $s)' \ - "${disk_layout_file}") - - info "UKI/RPM: USR-A uuid=${usr_a_uuid} verity hash-offset=${verity_hash_offset}" - local cmdline="" if [[ ${FLAGS_verity} -eq ${FLAGS_TRUE} ]]; then - local usr_hash="" - if [[ -n "${FLAGS_verity_hash}" && -f "${FLAGS_verity_hash}" ]]; then - usr_hash=$(cat "${FLAGS_verity_hash}") - info "UKI/RPM: Verity hash = ${usr_hash}" - else - die "UKI/RPM: Verity enabled but no hash file at ${FLAGS_verity_hash}" - fi + # mount.usr is in the main UKI (not the addon) because it is + # slot-independent — the mapper device name is the same for both + # slots. The addon supplies only the slot-specific PARTUUID and + # root hash. cmdline="mount.usr=/dev/mapper/usr mount.usrflags=ro" - cmdline+=" systemd.verity_usr_data=PARTUUID=${usr_a_uuid}" - cmdline+=" systemd.verity_usr_hash=PARTUUID=${usr_a_uuid}" - cmdline+=" systemd.verity_usr_options=hash-offset=${verity_hash_offset},panic-on-corruption" - cmdline+=" usrhash=${usr_hash}" else - cmdline="mount.usr=PARTUUID=${usr_a_uuid} mount.usrflags=ro" + die "UKI/RPM: UKI without verity is not supported" fi # Common base args — platform-agnostic, same for all image types. cmdline+=" root=LABEL=ROOT rootflags=rw" cmdline+=" consoleblank=0" - # NOTE: crashkernel=256M is delivered via a UKI addon (kdump.addon.efi) - # rather than baked into the main UKI cmdline. This allows disabling - # kdump by removing the addon from the ESP without rebuilding the UKI. - # See _uki_build_kdump_addon() below. - # OEM / platform identification (oem_id, ignition platform, console - # settings, etc.) are NOT baked into the main UKI. They are injected - # via a per-platform UKI addon built during image_to_vm.sh so that - # each VM image format gets the correct OEM-specific cmdline. - # See build_library/rpm/uki_addon.sh for the addon builder. + # NOTE: The main UKI cmdline contains only slot-independent args. + # Slot-specific args are delivered via UKI addons: # - # NOTE: first-boot args (flatcar.first_boot=detected) are deliberately - # NOT baked into the main UKI cmdline because they must only appear on - # the very first boot. Instead, a separate firstboot addon EFI is - # placed in the .extra.d/ directory; ignition-quench.service deletes it - # after Ignition completes, mirroring how GRUB uses the - # /boot/flatcar/first_boot marker file. - # See _uki_build_firstboot_addon() below. + # verity.addon.efi — Active slot's verity partition identity + # (PARTUUID for data + hash) and root hash. + # Trident swaps this to switch A/B slots. + # See _uki_build_verity_addons() below. + # oem.addon.efi — Platform identification (oem_id, console). + # Built per-platform during image_to_vm.sh. + # See build_library/rpm/uki_addon.sh. + # firstboot.addon.efi — Ignition trigger (flatcar.first_boot=detected). + # Deleted by ignition-quench after first boot. + # See _uki_build_firstboot_addon() below. + # kdump.addon.efi — crashkernel=256M (template, not active by default). + # See _uki_build_kdump_addon() below. + # fips.addon.efi — fips=1 (template, not active by default). + # See _uki_build_fips_addon() below. # Write cmdline to a temp file for ukify echo "${cmdline}" > "${uki_temp_dir}/cmdline.txt" @@ -225,15 +226,19 @@ OSREL local uki_output="${uki_temp_dir}/${uki_name}" info "UKI/RPM: Building UKI with ukify" - sudo ukify build \ + if ! sudo ukify build \ --stub="${efi_stub}" \ --linux="${kernel}" \ --initrd="${initrd}" \ --cmdline=@"${uki_temp_dir}/cmdline.txt" \ --os-release=@"${osrelease}" \ - --output="${uki_output}" + --output="${uki_output}"; then + sudo rm -rf "${uki_temp_dir}" + die "UKI/RPM: ukify build failed for ${uki_output}" + fi if [[ ! -f "${uki_output}" ]]; then + sudo rm -rf "${uki_temp_dir}" die "UKI/RPM: ukify failed to produce ${uki_output}" fi @@ -291,8 +296,13 @@ OSREL _uki_build_kdump_addon "${ESP_DIR}" _uki_build_debug_addon "${ESP_DIR}" "${uki_name}" + # Build A/B verity slot addons — slot A is active by default + if [[ ${FLAGS_verity} -eq ${FLAGS_TRUE} ]]; then + _uki_build_verity_addons "${ESP_DIR}" "${uki_name}" + fi + # Clean up - rm -rf "${uki_temp_dir}" + sudo rm -rf "${uki_temp_dir}" } # Build a self-removing firstboot addon that triggers Ignition on first boot. @@ -315,12 +325,16 @@ _uki_build_firstboot_addon() { # efi_stub existence was already verified by the caller. local efi_stub="${BOARD_ROOT}/usr/lib/systemd/boot/efi/linux${EFI_ARCH}.efi.stub" - sudo ukify build \ + if ! sudo ukify build \ --cmdline=@"${fb_temp_dir}/firstboot-cmdline.txt" \ --stub="${efi_stub}" \ - --output="${fb_temp_dir}/firstboot.addon.efi" + --output="${fb_temp_dir}/firstboot.addon.efi"; then + sudo rm -rf "${fb_temp_dir}" + die "UKI/RPM: ukify build failed for firstboot addon" + fi if [[ ! -f "${fb_temp_dir}/firstboot.addon.efi" ]]; then + sudo rm -rf "${fb_temp_dir}" die "UKI/RPM: ukify failed to produce firstboot.addon.efi" fi @@ -335,7 +349,7 @@ _uki_build_firstboot_addon() { sudo cp "${fb_temp_dir}/firstboot.addon.efi" "${template_dir}/firstboot.addon.efi" info "UKI/RPM: Saved firstboot addon template → acl/uki-addons/firstboot.addon.efi" - rm -rf "${fb_temp_dir}" + sudo rm -rf "${fb_temp_dir}" } # Build a reusable FIPS addon template for UKI systems. @@ -356,19 +370,23 @@ _uki_build_fips_addon() { local efi_stub="${BOARD_ROOT}/usr/lib/systemd/boot/efi/linux${EFI_ARCH}.efi.stub" - sudo ukify build \ + if ! sudo ukify build \ --cmdline=@"${fips_temp_dir}/fips-cmdline.txt" \ --stub="${efi_stub}" \ - --output="${fips_temp_dir}/fips.addon.efi" + --output="${fips_temp_dir}/fips.addon.efi"; then + sudo rm -rf "${fips_temp_dir}" + die "UKI/RPM: ukify build failed for fips addon" + fi if [[ ! -f "${fips_temp_dir}/fips.addon.efi" ]]; then + sudo rm -rf "${fips_temp_dir}" die "UKI/RPM: ukify failed to produce fips.addon.efi" fi sudo cp "${fips_temp_dir}/fips.addon.efi" "${template_dir}/fips.addon.efi" info "UKI/RPM: Saved FIPS addon template -> acl/uki-addons/fips.addon.efi" - rm -rf "${fips_temp_dir}" + sudo rm -rf "${fips_temp_dir}" } # Build a reusable kdump addon template for UKI systems. @@ -393,12 +411,16 @@ _uki_build_kdump_addon() { local efi_stub="${BOARD_ROOT}/usr/lib/systemd/boot/efi/linux${EFI_ARCH}.efi.stub" - sudo ukify build \ + if ! sudo ukify build \ --cmdline=@"${kdump_temp_dir}/kdump-cmdline.txt" \ --stub="${efi_stub}" \ - --output="${kdump_temp_dir}/kdump.addon.efi" + --output="${kdump_temp_dir}/kdump.addon.efi"; then + sudo rm -rf "${kdump_temp_dir}" + die "UKI/RPM: ukify build failed for kdump addon" + fi if [[ ! -f "${kdump_temp_dir}/kdump.addon.efi" ]]; then + sudo rm -rf "${kdump_temp_dir}" die "UKI/RPM: ukify failed to produce kdump.addon.efi" fi @@ -406,7 +428,7 @@ _uki_build_kdump_addon() { info "UKI/RPM: Saved kdump addon template -> acl/uki-addons/kdump.addon.efi" info "UKI/RPM: To enable kdump, copy to EFI/Linux/vmlinuz-.efi.extra.d/kdump.addon.efi" - rm -rf "${kdump_temp_dir}" + sudo rm -rf "${kdump_temp_dir}" } # Build an optional debug addon that appends extra kernel cmdline args. @@ -436,12 +458,16 @@ _uki_build_debug_addon() { local efi_stub="${BOARD_ROOT}/usr/lib/systemd/boot/efi/linux${EFI_ARCH}.efi.stub" - sudo ukify build \ + if ! sudo ukify build \ --cmdline=@"${debug_temp_dir}/debug-cmdline.txt" \ --stub="${efi_stub}" \ - --output="${debug_temp_dir}/debug.addon.efi" + --output="${debug_temp_dir}/debug.addon.efi"; then + sudo rm -rf "${debug_temp_dir}" + die "UKI/RPM: ukify build failed for debug addon" + fi if [[ ! -f "${debug_temp_dir}/debug.addon.efi" ]]; then + sudo rm -rf "${debug_temp_dir}" die "UKI/RPM: ukify failed to produce debug.addon.efi" fi @@ -449,7 +475,138 @@ _uki_build_debug_addon() { info "UKI/RPM: Installed debug addon → EFI/Linux/${uki_name}.extra.d/debug.addon.efi" info "UKI/RPM: debug cmdline = ${extra_cmdline}" - rm -rf "${debug_temp_dir}" + sudo rm -rf "${debug_temp_dir}" +} + +# Build verity slot addons for A/B partition switching. +# +# Each addon contains the slot-specific kernel cmdline args that tell systemd +# which partitions to use for dm-verity: +# systemd.verity_usr_data=PARTUUID= +# systemd.verity_usr_hash=PARTUUID= +# systemd.verity_usr_options=panic-on-corruption +# usrhash= +# +# mount.usr=/dev/mapper/usr is slot-independent and stays in the main UKI +# cmdline (not the addon). +# At boot, systemd-stub appends the active addon's cmdline to the main UKI +# cmdline. Trident swaps which addon is in .extra.d/ to switch slots. +_uki_build_verity_addons() { + local esp_dir="$1" + local uki_name="$2" + + info "UKI/RPM: Building verity slot addons" + + # Read the verity root hash — same for both slots at build time + local usr_hash="" + if [[ -n "${FLAGS_verity_hash}" && -f "${FLAGS_verity_hash}" ]]; then + usr_hash=$(cat "${FLAGS_verity_hash}") + info "UKI/RPM: Verity root hash = ${usr_hash}" + else + die "UKI/RPM: Verity enabled but no hash file at ${FLAGS_verity_hash}" + fi + + # Read partition UUIDs from the disk layout + local disk_layout_file="${BUILD_LIBRARY_DIR}/disk_layout_uki.json" + if [[ ! -f "${disk_layout_file}" ]]; then + die "UKI/RPM: disk_layout_uki.json not found at ${disk_layout_file}" + fi + + # Validate JSON structure before extracting UUIDs + if ! jq -e '.layouts.base' "${disk_layout_file}" >/dev/null 2>&1; then + die "UKI/RPM: disk_layout_uki.json missing or malformed .layouts.base" + fi + + # Helper: look up a partition UUID by label, dying on missing or duplicate + _lookup_partuuid() { + local label="$1" + local matches uuid + matches=$(jq -r "[.layouts.base[] | select(.label == \"${label}\")] | length" "${disk_layout_file}") + if [[ "${matches}" -eq 0 ]]; then + die "UKI/RPM: No partition with label '${label}' in disk layout" + fi + if [[ "${matches}" -ne 1 ]]; then + die "UKI/RPM: Expected exactly 1 '${label}' partition, found ${matches}" + fi + uuid=$(jq -r "[.layouts.base[] | select(.label == \"${label}\")] | .[0].uuid" "${disk_layout_file}") + if [[ -z "${uuid}" || "${uuid}" == "null" ]]; then + die "UKI/RPM: Partition '${label}' has no uuid field" + fi + echo "${uuid}" + } + + local usr_a_uuid usr_b_uuid hash_a_uuid hash_b_uuid + usr_a_uuid=$(_lookup_partuuid "USR-A") + hash_a_uuid=$(_lookup_partuuid "HASH-A") + usr_b_uuid=$(_lookup_partuuid "USR-B") + hash_b_uuid=$(_lookup_partuuid "HASH-B") + + info "UKI/RPM: Slot A: USR=${usr_a_uuid} HASH=${hash_a_uuid}" + info "UKI/RPM: Slot B: USR=${usr_b_uuid} HASH=${hash_b_uuid}" + + local efi_stub="${BOARD_ROOT}/usr/lib/systemd/boot/efi/linux${EFI_ARCH}.efi.stub" + + # Create temp dir after validation so die paths above don't leak it + local verity_temp_dir + verity_temp_dir=$(mktemp -d) + + local template_dir="${esp_dir}/acl/uki-addons" + sudo mkdir -p "${template_dir}" + + # Build addon for each slot + local slot slot_label data_uuid hash_uuid cmdline addon_file + for slot in a b; do + if [[ "${slot}" == "a" ]]; then + slot_label="A" + data_uuid="${usr_a_uuid}" + hash_uuid="${hash_a_uuid}" + else + slot_label="B" + data_uuid="${usr_b_uuid}" + hash_uuid="${hash_b_uuid}" + fi + + # mount.usr is in the main UKI; addon carries only slot-specific args + cmdline="systemd.verity_usr_data=PARTUUID=${data_uuid}" + cmdline+=" systemd.verity_usr_hash=PARTUUID=${hash_uuid}" + cmdline+=" systemd.verity_usr_options=panic-on-corruption" + cmdline+=" usrhash=${usr_hash}" + + info "UKI/RPM: Slot ${slot_label} cmdline = ${cmdline}" + + echo "${cmdline}" > "${verity_temp_dir}/verity-${slot}-cmdline.txt" + + addon_file="${verity_temp_dir}/verity-${slot}.addon.efi" + if ! sudo ukify build \ + --cmdline=@"${verity_temp_dir}/verity-${slot}-cmdline.txt" \ + --stub="${efi_stub}" \ + --output="${addon_file}"; then + sudo rm -rf "${verity_temp_dir}" + die "UKI/RPM: ukify build failed for verity-${slot}.addon.efi" + fi + + if [[ ! -f "${addon_file}" ]]; then + sudo rm -rf "${verity_temp_dir}" + die "UKI/RPM: ukify failed to produce verity-${slot}.addon.efi" + fi + + # Save template + sudo cp "${addon_file}" "${template_dir}/verity-${slot}.addon.efi" + info "UKI/RPM: Saved verity-${slot}.addon.efi → acl/uki-addons/" + done + + # Install slot A as the active verity addon + local addon_dir="${esp_dir}/EFI/Linux/${uki_name}.extra.d" + sudo mkdir -p "${addon_dir}" + sudo cp "${template_dir}/verity-a.addon.efi" "${addon_dir}/verity.addon.efi" + info "UKI/RPM: Activated slot A → EFI/Linux/${uki_name}.extra.d/verity.addon.efi" + + # NOTE: Both slot templates are built with the same root hash at image + # build time. Trident overwrites the active addon (and its root hash) + # when switching A/B slots at OS update time. The templates in + # acl/uki-addons/ are starting points, not the final runtime state. + + sudo rm -rf "${verity_temp_dir}" } info "Installing UKI packages for target ${FLAGS_target}" diff --git a/build_library/vm_image_util.sh b/build_library/vm_image_util.sh index 3b781c82b89..3f56567c946 100644 --- a/build_library/vm_image_util.sh +++ b/build_library/vm_image_util.sh @@ -555,6 +555,9 @@ install_oem_package() { "${oem_files_dir}" if [[ "${BOOTLOADER_MODE}" == "uki" ]]; then install_uki_oem_addon + install_uki_timeout_addon + elif [[ "${BOOTLOADER_MODE}" == "grub" ]]; then + install_grub_timeout_override fi return 0 fi @@ -600,6 +603,9 @@ install_oem_package() { # UKI addon (replacing the role of grub.cfg). if [[ "${BOOTLOADER_MODE}" == "uki" ]]; then install_uki_oem_addon + install_uki_timeout_addon + elif [[ "${BOOTLOADER_MODE}" == "grub" ]]; then + install_grub_timeout_override fi } @@ -756,6 +762,151 @@ install_uki_oem_addon() { "${oem_files_dir}" } +# Build and install a UKI addon that raises the systemd device-init timeout, +# scoped to the arm64 kola *test* image only (INJECT_DOCKER_SYSEXT=true). +# +# Under kola's parallel QEMU-TCG emulation on aarch64, heavy CPU contention can +# prevent udev from initialising the ESP/OEM/usr-verity devices within the +# default initrd device timeout, dropping the VM to an emergency shell. Baking +# systemd.default_device_timeout_sec=120 into a UKI addon on the test image's +# ESP fixes this without changing production or amd64 boot behaviour. +# +# Deliberately NOT applied to the production VM image (INJECT_DOCKER_SYSEXT=false) +# or to amd64: the failure is a CI-only TCG-emulation artefact that never occurs +# on real hardware. +install_uki_timeout_addon() { + if [[ "${BOOTLOADER_MODE}" != "uki" ]]; then + return 0 + fi + + # Scope: arm64 test image only. + if [[ "${ARCH}" != "arm64" ]] || [[ "${INJECT_DOCKER_SYSEXT:-false}" != "true" ]]; then + return 0 + fi + + local esp_dir="${VM_TMP_ROOT}/boot" + if [[ ! -d "${esp_dir}/EFI/Linux" ]]; then + warn "UKI timeout addon: ESP directory ${esp_dir}/EFI/Linux not found; skipping" + return 0 + fi + + # EFI architecture suffix for the systemd-boot stub. + local efi_arch + case "${ARCH}" in + amd64) efi_arch="x64" ;; + arm64) efi_arch="aa64" ;; + *) warn "UKI timeout addon: unsupported arch ${ARCH}; skipping"; return 0 ;; + esac + + local efi_stub="${VM_TMP_ROOT}/usr/lib/systemd/boot/efi/linux${efi_arch}.efi.stub" + if [[ ! -f "${efi_stub}" ]]; then + warn "UKI timeout addon: EFI stub not found at ${efi_stub}; skipping" + return 0 + fi + + # Detect the main UKI (vmlinuz-.efi) to locate its .extra.d dir. + # ACL test images ship exactly one UKI; require that here so the addon is + # never silently installed into the wrong .efi.extra.d (which would + # leave the timeout ineffective and resurrect the emergency-shell flake + # this addon exists to fix). Mirrors rpm/uki_install.sh's single-UKI rule. + local -a uki_matches + mapfile -t uki_matches < <(find "${esp_dir}/EFI/Linux/" -maxdepth 1 -name 'vmlinuz-*.efi' -printf '%f\n' | sort -V) + if [[ "${#uki_matches[@]}" -eq 0 ]]; then + warn "UKI timeout addon: no UKI (vmlinuz-*.efi) found in ESP; skipping" + return 0 + fi + if [[ "${#uki_matches[@]}" -gt 1 ]]; then + die "UKI timeout addon: expected exactly one UKI in ${esp_dir}/EFI/Linux/, found ${#uki_matches[@]}: ${uki_matches[*]}" + fi + local uki_name="${uki_matches[0]}" + + if ! command -v ukify &>/dev/null; then + die "UKI timeout addon: ukify not found on PATH" + fi + + info "UKI timeout addon: Building for arm64 test image (${VM_IMG_TYPE})" + + local addon_dir="${esp_dir}/EFI/Linux/${uki_name}.extra.d" + sudo mkdir -p "${addon_dir}" + + local timeout_temp_dir + timeout_temp_dir=$(mktemp -d) + + printf '%s\n' "systemd.default_device_timeout_sec=120" \ + > "${timeout_temp_dir}/timeout-cmdline.txt" + + if ! sudo ukify build \ + --cmdline=@"${timeout_temp_dir}/timeout-cmdline.txt" \ + --stub="${efi_stub}" \ + --output="${timeout_temp_dir}/timeout.addon.efi"; then + sudo rm -rf "${timeout_temp_dir}" + die "UKI timeout addon: ukify build failed" + fi + + if [[ ! -f "${timeout_temp_dir}/timeout.addon.efi" ]]; then + sudo rm -rf "${timeout_temp_dir}" + die "UKI timeout addon: ukify failed to produce timeout.addon.efi" + fi + + sudo cp "${timeout_temp_dir}/timeout.addon.efi" "${addon_dir}/timeout.addon.efi" + info "UKI timeout addon: Installed -> EFI/Linux/${uki_name}.extra.d/timeout.addon.efi" + info "UKI timeout addon: cmdline = systemd.default_device_timeout_sec=120" + + sudo rm -rf "${timeout_temp_dir}" +} + +# Append systemd.default_device_timeout_sec=120 to grub.cfg's shared cmdline, +# scoped to the arm64 kola *test* image only (INJECT_DOCKER_SYSEXT=true). +# +# GRUB has no addon mechanism like UKI's .extra.d, so unlike +# install_uki_timeout_addon this patches the already-written grub.cfg copies +# on the ESP directly (both are plain text, no rebuild/re-signing needed) - +# same CI-only QEMU-TCG device-init timeout this exists to work around. +# +# Deliberately NOT applied to the production VM image (INJECT_DOCKER_SYSEXT=false) +# or to amd64: the failure is a CI-only TCG-emulation artefact that never occurs +# on real hardware. +install_grub_timeout_override() { + if [[ "${BOOTLOADER_MODE}" != "grub" ]]; then + return 0 + fi + + # Scope: arm64 test image only. + if [[ "${ARCH}" != "arm64" ]] || [[ "${INJECT_DOCKER_SYSEXT:-false}" != "true" ]]; then + return 0 + fi + + local esp_dir="${VM_TMP_ROOT}/boot" + local -a grub_cfgs=( + "${esp_dir}/EFI/boot/grub.cfg" + "${esp_dir}/boot/grub2/grub.cfg" + ) + + local found=0 + for cfg in "${grub_cfgs[@]}"; do + if [[ ! -f "${cfg}" ]]; then + continue + fi + found=1 + if sudo grep -q 'systemd.default_device_timeout_sec=' "${cfg}"; then + info "GRUB timeout override: ${cfg} already patched; skipping" + continue + fi + sudo sed -i 's/^set linux_cmdline="\(.*\)"$/set linux_cmdline="\1 systemd.default_device_timeout_sec=120"/' "${cfg}" + if ! sudo grep -q 'systemd.default_device_timeout_sec=120' "${cfg}"; then + die "GRUB timeout override: sed substitution did not match in ${cfg}; linux_cmdline format may have changed" + fi + info "GRUB timeout override: patched ${cfg}" + done + + if [[ "${found}" -eq 0 ]]; then + warn "GRUB timeout override: no grub.cfg found under ${esp_dir}; skipping" + return 0 + fi + + info "GRUB timeout override: cmdline += systemd.default_device_timeout_sec=120" +} + # Any other tweaks required? run_fs_hook() { local fs_hook=$(_get_vm_opt FS_HOOK)