From 7baf7a0888909d5f8f43abca7fffce46ecffdece Mon Sep 17 00:00:00 2001 From: Igor Opaniuk Date: Mon, 31 Aug 2026 20:58:37 +0200 Subject: [PATCH 1/5] qdte-lite-native: package qdte-lite for headless DTB editing The capsule root certificate lives in a device tree embedded in a boot config ELF, so putting it there means editing a DTB inside a container the build has no tool to open. cbsp-boot-utilities can do it, but only through a fixed dump / set-property / replace sequence that has to be told which DTB to touch. qdte-lite opens these containers directly and can be asked what is in them, which is what the following commits need to find the certificate without hardcoding per-machine names. Packaging it is cheap because the fork was made for this: a pyproject with a console script, child interpreters spawned via sys.executable, and a --nogui path that never imports a GUI toolkit. Its device-tree layer is pylibfdt, so oe-core's python3-dtc is the only dependency and the recipe stays out of any dynamic layer. Carry one patch. v2.0.0 fails with FDT_ERR_NOSPACE on a tree whose serialized size happens to be an exact multiple of the 1024-byte step FdtSw grows its buffer in: every write method retries through check_space(), but as_fdt() calls fdt_finish() once and gives up if the strings block and header fixups do not fit in what is left. Nothing is wrong with such a tree, it just lands with zero slack, so this is a size lottery rather than a real limit and roughly one DTB in 256 loses it. The iq-x7181-evk 00019 boot binaries draw a losing ticket, which is why this only shows up on some machines and some firmware versions. Signed-off-by: Igor Opaniuk --- ...ose-size-lands-on-an-FdtSw-growth-bo.patch | 155 ++++++++++++++++++ .../qdte-lite/qdte-lite-native_2.0.0.bb | 14 ++ 2 files changed, 169 insertions(+) create mode 100644 recipes-devtools/qdte-lite/files/0001-fix-emit-trees-whose-size-lands-on-an-FdtSw-growth-bo.patch create mode 100644 recipes-devtools/qdte-lite/qdte-lite-native_2.0.0.bb diff --git a/recipes-devtools/qdte-lite/files/0001-fix-emit-trees-whose-size-lands-on-an-FdtSw-growth-bo.patch b/recipes-devtools/qdte-lite/files/0001-fix-emit-trees-whose-size-lands-on-an-FdtSw-growth-bo.patch new file mode 100644 index 000000000..928145bcf --- /dev/null +++ b/recipes-devtools/qdte-lite/files/0001-fix-emit-trees-whose-size-lands-on-an-FdtSw-growth-bo.patch @@ -0,0 +1,155 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Igor Opaniuk +Date: Wed, 2 Sep 2026 11:48:44 +0200 +Subject: [PATCH] fix: emit trees whose size lands on an FdtSw growth boundary + +Serializing a device tree fails with FDT_ERR_NOSPACE for particular +trees, and succeeds for almost identical ones. Two builds of the same +hamoa boot container differ here: the DTB is 153568 bytes in one and +153600 in the next, and only the second one fails. + +153600 is exactly 150 x 1024. FdtSw grows its buffer in INC_SIZE (1024) +byte steps, and every write method retries through check_space() when it +runs out, so writing the tree always succeeds. as_fdt() does not retry -- +it calls fdt_finish() once, and fdt_finish() still has to place the +strings block and fix up the header. A tree that fills the buffer to an +exact multiple of INC_SIZE leaves nothing for it, so emission fails on +size alone. Roughly one tree in every 256 lands there. + +That makes it a lottery rather than a limit, and an unusually confusing +one: the same code, tool and certificate work on one firmware drop and +fail on the next, and the exception surfaces from a load rather than +from the write that caused it, because the load path serializes the tree +to hash it. + +Seed the buffer past that point and retry with more room if the finish +still does not fit. + +The selftest gains --emit-sizes, which sweeps a padding property in +4-byte steps across three growth increments so several emissions land +exactly on a boundary, and asserts that at least one did -- a sweep that +missed every boundary would pass without testing anything. It fails on +the unfixed backend at precisely the three boundary sizes. + +Upstream-Status: Submitted [https://github.com/qualcomm/qdte-lite/pull/4] + +Signed-off-by: Igor Opaniuk +--- + qdte_lite/core/_fdt_selftest.py | 50 +++++++++++++++++++++++++++++++-- + qdte_lite/core/fdt_backend.py | 35 +++++++++++++++++------ + 2 files changed, 75 insertions(+), 10 deletions(-) + +diff --git a/qdte_lite/core/_fdt_selftest.py b/qdte_lite/core/_fdt_selftest.py +index f6c927a..21ed0da 100644 +--- a/qdte_lite/core/_fdt_selftest.py ++++ b/qdte_lite/core/_fdt_selftest.py +@@ -139,15 +139,61 @@ def check_roundtrip(paths): + print("ROUNDTRIP OK: %s (%d bytes)" % (path, len(blob_a))) + + ++def check_emit_sizes(): ++ """Emit trees whose serialized size lands on every offset across an ++ FdtSw growth boundary. ++ ++ FdtSw grows its buffer in INC_SIZE steps and every write method retries ++ through check_space(), but as_fdt() calls fdt_finish() once with no ++ retry. A tree that serializes to an exact multiple of INC_SIZE leaves no ++ slack for the strings block, so emission used to fail with ++ FDT_ERR_NOSPACE -- for that size only, which made it a lottery rather ++ than a limit. Sweep a padding property in 4-byte steps so several ++ emissions land exactly on a boundary. ++ """ ++ ++ import libfdt ++ ++ inc = libfdt.FdtSw.INC_SIZE ++ sizes, failures = set(), [] ++ for pad_cells in range(1, (inc * 3) // 4): ++ root = fdt_backend.FdtNode("/") ++ root.append(fdt_backend.FdtPropertyWords("pad", [0xDEADBEEF] * pad_cells)) ++ try: ++ blob = fdt_backend.Fdt(root).to_dtb() ++ except Exception as ex: ++ failures.append((pad_cells, ex)) ++ continue ++ sizes.add(len(blob)) ++ ++ on_boundary = sorted(n for n in sizes if n % inc == 0) ++ assert on_boundary, ( ++ "sweep never produced a blob on an INC_SIZE (%d) boundary, so this " ++ "would not have caught the bug it exists for" % inc ++ ) ++ assert not failures, "emission failed at %d size(s), first: pad=%d %s" % ( ++ len(failures), ++ failures[0][0], ++ failures[0][1], ++ ) ++ print( ++ "emit sizes: %d blobs, %d exactly on a %d-byte boundary, no failures" ++ % (len(sizes), len(on_boundary), inc) ++ ) ++ ++ + def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--parity", action="store_true") + mode.add_argument("--roundtrip", action="store_true") +- parser.add_argument("dtbs", nargs="+", help="DTB files to test") ++ mode.add_argument("--emit-sizes", action="store_true") ++ parser.add_argument("dtbs", nargs="*", help="DTB files to test") + opts = parser.parse_args(argv) + +- if opts.parity: ++ if opts.emit_sizes: ++ check_emit_sizes() ++ elif opts.parity: + check_parity(opts.dtbs) + else: + check_roundtrip(opts.dtbs) +diff --git a/qdte_lite/core/fdt_backend.py b/qdte_lite/core/fdt_backend.py +index 90fe5c8..1a8b869 100644 +--- a/qdte_lite/core/fdt_backend.py ++++ b/qdte_lite/core/fdt_backend.py +@@ -246,14 +246,33 @@ class Fdt: + """Serialize deterministically via libfdt's sequential writer. + When ``mappings`` is a dict, fill it with {path: (start, end)} + byte ranges of each node header / property record in the blob.""" +- sw = libfdt.FdtSw() +- for addr, size in self.memrsv: +- sw.add_reservemap_entry(addr, size) +- sw.finish_reservemap() +- sw.begin_node("") +- self._emit_node(sw, self.root) +- sw.end_node() +- fdt = sw.as_fdt() ++ # FdtSw grows its buffer in INC_SIZE steps, and every write method ++ # retries through check_space() when it runs out. as_fdt() does not: ++ # it calls fdt_finish() once and raises FDT_ERR_NOSPACE if the strings ++ # block and header fixups do not fit in what is left. A tree whose ++ # serialized size lands on a multiple of INC_SIZE leaves zero slack ++ # and fails, which makes this a size lottery rather than a real limit. ++ # ++ # Seed the buffer past the point where that can happen and retry with ++ # more room if it still does. ++ size_hint = libfdt.FdtSw.INC_SIZE ++ while True: ++ sw = libfdt.FdtSw(size_hint) ++ for addr, size in self.memrsv: ++ sw.add_reservemap_entry(addr, size) ++ sw.finish_reservemap() ++ sw.begin_node("") ++ self._emit_node(sw, self.root) ++ sw.end_node() ++ try: ++ fdt = sw.as_fdt() ++ break ++ except libfdt.FdtException as ex: ++ if ex.err != -libfdt.NOSPACE: ++ raise ++ # len(sw) is the grown buffer; ask for meaningfully more than ++ # the increment so this terminates in one further attempt. ++ size_hint = len(sw.as_bytearray()) + libfdt.FdtSw.INC_SIZE * 16 + fdt.pack() + buf = bytearray(fdt.as_bytearray()) + if self.boot_cpuid: diff --git a/recipes-devtools/qdte-lite/qdte-lite-native_2.0.0.bb b/recipes-devtools/qdte-lite/qdte-lite-native_2.0.0.bb new file mode 100644 index 000000000..10aa68548 --- /dev/null +++ b/recipes-devtools/qdte-lite/qdte-lite-native_2.0.0.bb @@ -0,0 +1,14 @@ +SUMMARY = "Device Tree Editor Lite (qdte-lite)" +DESCRIPTION = "Lightweight fork of the Qualcomm Device Tree Editor" +HOMEPAGE = "https://github.com/qualcomm/qdte-lite" +LICENSE = "BSD-3-Clause-Clear" +LIC_FILES_CHKSUM = "file://LICENSE.txt;md5=57272fa9cc740c745feb331231cca6f2" + +SRC_URI = "git://github.com/qualcomm/qdte-lite.git;protocol=https;branch=main;tag=v${PV} \ + file://0001-fix-emit-trees-whose-size-lands-on-an-FdtSw-growth-bo.patch \ + " +SRCREV = "21db12fa954d009b6e501d8bd12d22c8e6e7aaa1" + +inherit python_setuptools_build_meta native + +DEPENDS += "python3-dtc-native" From effb258d3eb92d5efd2edb54386df22aae467639 Mon Sep 17 00:00:00 2001 From: Igor Opaniuk Date: Mon, 31 Aug 2026 21:00:34 +0200 Subject: [PATCH 2/5] cbsp-boot-utilities: bump, and take the capsule flow out of meta-arm The capsule recipes sat in dynamic-layers/meta-arm for one reason: they needed edk2-basetools for GenFfs, GenFv and GenerateCapsule.py. That made capsule generation unavailable unless a consumer also carried meta-arm, which has nothing else to do with building a capsule. cbsp-boot-utilities has since grown its own equivalents -- fv_builder.py for the first two, generate-capsule for the third, byte-identical for this subset -- so the dependency is gone and with it the only reason for the recipes to live behind a layer that may not be present. Bump to pick that up and move them into the normal recipe tree. meta-arm is still needed for optee and trusted-firmware-a, so the layer stays; only the capsule pieces leave it. Signed-off-by: Igor Opaniuk --- classes-recipe/qcom-capsule.bbclass | 14 ++------------ .../firmware/firmware-qcom-capsule_%.bbappend | 0 .../firmware/firmware-qcom-capsule_1.0.bb | 0 .../cbsp-boot-utilities-native_1.0.bb | 8 +++++--- 4 files changed, 7 insertions(+), 15 deletions(-) rename {dynamic-layers/meta-arm/recipes-firmware => recipes-bsp}/firmware/firmware-qcom-capsule_%.bbappend (100%) rename {dynamic-layers/meta-arm/recipes-firmware => recipes-bsp}/firmware/firmware-qcom-capsule_1.0.bb (100%) rename {dynamic-layers/meta-arm/recipes-devtools => recipes-devtools}/cbsp-boot-utilities/cbsp-boot-utilities-native_1.0.bb (76%) diff --git a/classes-recipe/qcom-capsule.bbclass b/classes-recipe/qcom-capsule.bbclass index 9219760a3..87b5802ee 100644 --- a/classes-recipe/qcom-capsule.bbclass +++ b/classes-recipe/qcom-capsule.bbclass @@ -88,8 +88,7 @@ inherit python3native deploy CAPSULE_DIR = "${WORKDIR}/capsule_gen" -do_compile[depends] += "cbsp-boot-utilities-native:do_populate_sysroot \ - edk2-basetools-native:do_populate_sysroot" +do_compile[depends] += "cbsp-boot-utilities-native:do_populate_sysroot" do_compile[dirs] = "${CAPSULE_DIR}" do_compile[cleandirs] = "${CAPSULE_DIR}" @@ -257,15 +256,6 @@ patch_xblconfig_cert() { do_compile() { CBSP_DATA="${STAGING_DATADIR_NATIVE}/cbsp-boot-utilities" - EDK2_BASETOOLS="${STAGING_DATADIR_NATIVE}/edk2-basetools" - - # GenFfs/GenFv are staged to ${STAGING_BINDIR_NATIVE} (in PATH) by - # upstream meta-arm's edk2-basetools-native and resolved by - # qcom-capsule-tool via shutil.which. GenerateCapsule.py and its - # Common/ Python package live under ${EDK2_BASETOOLS}; add that to - # PYTHONPATH so `import Common` works when we invoke the script - # directly below. - export PYTHONPATH="${EDK2_BASETOOLS}${PYTHONPATH:+:$PYTHONPATH}" # Use a board-specific FvUpdate.xml if provided via SRC_URI:append or # generated from CAPSULE_ENTRIES, otherwise fall back to the default @@ -327,7 +317,7 @@ do_compile() { -oc "${CAPSULE_SUB_PUB}" \ -g "${CAPSULE_GUID}" - python3 "${EDK2_BASETOOLS}/GenerateCapsule.py" \ + qcom-capsule-tool generate-capsule \ -e \ -j config.json \ -o "${PN}.cap" \ diff --git a/dynamic-layers/meta-arm/recipes-firmware/firmware/firmware-qcom-capsule_%.bbappend b/recipes-bsp/firmware/firmware-qcom-capsule_%.bbappend similarity index 100% rename from dynamic-layers/meta-arm/recipes-firmware/firmware/firmware-qcom-capsule_%.bbappend rename to recipes-bsp/firmware/firmware-qcom-capsule_%.bbappend diff --git a/dynamic-layers/meta-arm/recipes-firmware/firmware/firmware-qcom-capsule_1.0.bb b/recipes-bsp/firmware/firmware-qcom-capsule_1.0.bb similarity index 100% rename from dynamic-layers/meta-arm/recipes-firmware/firmware/firmware-qcom-capsule_1.0.bb rename to recipes-bsp/firmware/firmware-qcom-capsule_1.0.bb diff --git a/dynamic-layers/meta-arm/recipes-devtools/cbsp-boot-utilities/cbsp-boot-utilities-native_1.0.bb b/recipes-devtools/cbsp-boot-utilities/cbsp-boot-utilities-native_1.0.bb similarity index 76% rename from dynamic-layers/meta-arm/recipes-devtools/cbsp-boot-utilities/cbsp-boot-utilities-native_1.0.bb rename to recipes-devtools/cbsp-boot-utilities/cbsp-boot-utilities-native_1.0.bb index d33aead02..0906f908a 100644 --- a/dynamic-layers/meta-arm/recipes-devtools/cbsp-boot-utilities/cbsp-boot-utilities-native_1.0.bb +++ b/recipes-devtools/cbsp-boot-utilities/cbsp-boot-utilities-native_1.0.bb @@ -6,18 +6,20 @@ LICENSE = "BSD-3-Clause-Clear" LIC_FILES_CHKSUM = "file://LICENSE;md5=8e1eb38e3de3966193d29f31f5d7e684" SRC_URI = "git://github.com/quic/cbsp-boot-utilities.git;protocol=https;branch=main" -SRCREV = "a19e5b6f75cd4aa08aa5ced82f9767f1858d766d" +SRCREV = "8a0f1deef97beae600910506bfba488976465828" S = "${UNPACKDIR}/${BPN}-${PV}/uefi_capsule_generation" inherit python_poetry_core native +# edk2-basetools is no longer needed: the tool now carries its own +# GenFfs/GenFv (fv_builder.py) and a generate-capsule that is a drop-in for +# edk2 BaseTools GenerateCapsule.py, producing identical bytes. requests +# went with the setup step that used to fetch them. DEPENDS += " \ dtc-native \ - edk2-basetools-native \ python3-dtc-native \ python3-pyelftools-native \ - python3-requests-native \ " # FvUpdate.xml ships alongside pyproject.toml (not inside the Python From c8a49679ce6c2e72ccf431d58060def28c408f8d Mon Sep 17 00:00:00 2001 From: Igor Opaniuk Date: Mon, 31 Aug 2026 21:01:06 +0200 Subject: [PATCH 3/5] qcom-oem-cert: inject the OEM root cert before the config ELFs are signed Three things have to happen in one order and cannot be reordered: the certificate goes into the boot config ELF, that ELF is signed, then the capsule is built and verified against the certificate. Editing a DTB inside the ELF invalidates any signature it already carried, so injecting after signing produces an image the boot ROM rejects; building the capsule before injection produces one the firmware will not authenticate. qcom-capsule.bbclass is the wrong place for step one, because by the time it runs the boot firmware is already deployed and whatever signs it has already finished. There is no seam left. A separate recipe between the boot firmware and the capsule creates one: it stages the injected ELFs and stops, leaving do_compile and do_deploy for a signing step to sit between. Injection itself no longer needs the class to know anything about the container. The DTB names are assigned during disassembly -- from container metadata in one case, from /compatible in another -- so asking the tool that assigns them beats configuring them per machine, which is what XBLCONFIG_DTB and XBLCONFIG_DTB_SECTION were doing and why they go away. Leaving the DER-to-cells conversion to bin-to-hex keeps the padding of a trailing partial cell in one place, where the two tools cannot disagree about it. Signed-off-by: Igor Opaniuk --- classes-recipe/qcom-capsule.bbclass | 97 +++----------- classes-recipe/qcom-oem-cert.bbclass | 125 ++++++++++++++++++ .../firmware-qcom-oem-cert_1.0.bb | 14 ++ 3 files changed, 155 insertions(+), 81 deletions(-) create mode 100644 classes-recipe/qcom-oem-cert.bbclass create mode 100644 recipes-bsp/firmware-boot/firmware-qcom-oem-cert_1.0.bb diff --git a/classes-recipe/qcom-capsule.bbclass b/classes-recipe/qcom-capsule.bbclass index 87b5802ee..56b6fd1c2 100644 --- a/classes-recipe/qcom-capsule.bbclass +++ b/classes-recipe/qcom-capsule.bbclass @@ -34,21 +34,15 @@ CAPSULE_ROOT_PUB ?= "" CAPSULE_SUB_PUB ?= "" # --------------------------------------------------------------------------- -# XBLConfig DTB certificate injection +# OEM root certificate injection # --------------------------------------------------------------------------- -# The class automatically detects the post-DDR DTB by parsing the output of -# xblconfig_parser.py dump (looks for the first entry matching post-ddr*.dtb). -# Both the filename and the section index are extracted from the dump output. -# -# XBLCONFIG_DTB overrides auto-detection when set to an explicit filename. -# XBLCONFIG_DTB_SECTION overrides the auto-detected section index. -# -# When a post-DDR DTB is found (auto or explicit), the class will: -# 1. dump XBLConfig sections -# 2. patch QcCapsuleRootCert in the DTB with the converted root cert -# 3. re-pack the updated DTB back into xbl_config.elf -XBLCONFIG_DTB ?= "" -XBLCONFIG_DTB_SECTION ?= "" +# QcCapsuleRootCert is injected into the boot config ELFs by +# firmware-qcom-oem-cert (classes-recipe/qcom-oem-cert.bbclass), not here. +# It has to happen there because the certificate must be in place before +# the config ELFs are signed, and this recipe runs after the boot firmware +# has already been deployed. This class consumes the result: the +# cert-bearing copies are staged over the pristine boot binaries so the +# capsule firmware volume is built from the same images the device runs. # --------------------------------------------------------------------------- # Boot binaries location @@ -88,7 +82,8 @@ inherit python3native deploy CAPSULE_DIR = "${WORKDIR}/capsule_gen" -do_compile[depends] += "cbsp-boot-utilities-native:do_populate_sysroot" +do_compile[depends] += "cbsp-boot-utilities-native:do_populate_sysroot \ + firmware-qcom-oem-cert:do_deploy" do_compile[dirs] = "${CAPSULE_DIR}" do_compile[cleandirs] = "${CAPSULE_DIR}" @@ -203,57 +198,6 @@ python generate_fvupdate() { do_compile[prefuncs] += "generate_fvupdate" -# Inject the OEM root certificate into xbl_config.elf. -# Dumps the config sections, auto-detects the post-DDR DTB (or uses -# XBLCONFIG_DTB / XBLCONFIG_DTB_SECTION overrides), patches QcCapsuleRootCert -# in that DTB, and repacks the updated DTB back into xbl_config.elf in place. -# $1 - path to xbl_config.elf (modified in place on success) -patch_xblconfig_cert() { - local xbl_config="$1" - local staged_dir - staged_dir=$(dirname "${xbl_config}") - - XBL_DUMP_LOG="${CAPSULE_DIR}/xbl_dump.log" - qcom-capsule-tool parse-config \ - "${xbl_config}" dump \ - --out-dir "${staged_dir}" | tee "${XBL_DUMP_LOG}" - - DTB_PATCH="${XBLCONFIG_DTB}" - DTB_SECTION="${XBLCONFIG_DTB_SECTION}" - if [ -z "${DTB_PATCH}" ]; then - # Parse a line like: - # [+] config_item[6] -> PH# 8 -> './post-ddr-kodiak-1.0.dtb' (90280 bytes) - POST_DDR_LINE=$(grep -m1 "post-ddr.*\.dtb" "${XBL_DUMP_LOG}" || true) - if [ -n "${POST_DDR_LINE}" ]; then - DTB_PATCH=$(echo "${POST_DDR_LINE}" | sed "s|.* -> '||;s|'.*||" | xargs basename) - DTB_SECTION=$(echo "${POST_DDR_LINE}" | sed "s/.*PH# \([0-9]*\).*/\1/") - fi - fi - - if [ -n "${DTB_PATCH}" ]; then - ORIG_DTB="${staged_dir}/${DTB_PATCH}" - UPDATED_DTB="${staged_dir}/${DTB_PATCH%.dtb}-updated.dtb" - - qcom-capsule-tool set-dtb-property \ - "${ORIG_DTB}" \ - /sw/uefi/uefiplat \ - QcCapsuleRootCert \ - "@list:${ROOT_INC}" \ - "${UPDATED_DTB}" - - qcom-capsule-tool parse-config \ - "${xbl_config}" replace \ - "${DTB_SECTION}" \ - "${UPDATED_DTB}" \ - "${staged_dir}/xbl_config_patched.elf" - - mv "${staged_dir}/xbl_config_patched.elf" \ - "${xbl_config}" - - touch "${CAPSULE_DIR}/.xbl_with_oem_cert" - fi -} - do_compile() { CBSP_DATA="${STAGING_DATADIR_NATIVE}/cbsp-boot-utilities" @@ -270,9 +214,6 @@ do_compile() { cd "${CAPSULE_DIR}" - ROOT_INC="${CAPSULE_DIR}/QcFMPRoot.inc" - qcom-capsule-tool bin-to-hex "${CAPSULE_ROOT_CER}" "${ROOT_INC}" - # Stage boot binaries so they are writable (XBLConfig patching modifies # xbl_config.elf in place) BOOTBINS_STAGED="${CAPSULE_DIR}/bootbins" @@ -289,10 +230,12 @@ do_compile() { "${BOOTBINS_STAGED}/dtb.bin" fi - # Inject OEM root cert into xbl_config.elf when present. Platforms - # without xbl_config.elf (e.g. hamoa) skip this step. - if [ -f "${BOOTBINS_STAGED}/xbl_config.elf" ]; then - patch_xblconfig_cert "${BOOTBINS_STAGED}/xbl_config.elf" + # Overlay the cert-bearing config ELFs deployed by + # firmware-qcom-oem-cert, so the capsule firmware volume carries the + # same images the device boots. Optional: hamoa has no xbl_config.elf. + if [ -f "${DEPLOY_DIR_IMAGE}/xbl_config-with-oem-cert.elf" ]; then + install -m 0644 "${DEPLOY_DIR_IMAGE}/xbl_config-with-oem-cert.elf" \ + "${BOOTBINS_STAGED}/${QCOM_XBL_CONFIG}" fi qcom-capsule-tool sysfw-version-create \ @@ -336,14 +279,6 @@ FILES:${PN} = "${nonarch_base_libdir}/firmware/efi/${PN}.cap" do_deploy() { install -d "${DEPLOYDIR}" install -m 0644 "${CAPSULE_DIR}/${PN}.cap" "${DEPLOYDIR}/" - - # When XBLConfig was injected with the OEM root cert, deploy the updated - # binary under a distinct name to avoid a deploy-manifest conflict with - # firmware-qcom-bootbins (which already owns xbl_config.elf). - if [ -f "${CAPSULE_DIR}/.xbl_with_oem_cert" ]; then - install -m 0644 "${CAPSULE_DIR}/bootbins/xbl_config.elf" \ - "${DEPLOYDIR}/xbl_config-with-oem-cert.elf" - fi } addtask deploy before do_build after do_compile diff --git a/classes-recipe/qcom-oem-cert.bbclass b/classes-recipe/qcom-oem-cert.bbclass new file mode 100644 index 000000000..a8256aed4 --- /dev/null +++ b/classes-recipe/qcom-oem-cert.bbclass @@ -0,0 +1,125 @@ +# +# Copyright (c) 2026 Qualcomm Innovation Center, Inc. All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause-Clear +# +# Inject the OEM capsule root certificate into the boot config ELFs. +# +# A recipe of its own, not part of qcom-capsule.bbclass, because the order +# is forced: the certificate must be in the config ELF before that ELF is +# signed (editing a DTB inside it invalidates any signature it carried), +# and the capsule built afterwards is verified against that same +# certificate. By the time the capsule recipe runs the boot firmware is +# already deployed and signed, so the injection cannot live there. Staging +# into ${OEM_CERT_STAGE} between do_compile and do_deploy leaves the seam a +# signing step needs. + +# Shared with qcom-capsule.bbclass; ci/capsule-test-keys.yml sets it for CI. +CAPSULE_ROOT_CER ?= "" + +BOOTBINS_DIR ?= "${DEPLOY_DIR_IMAGE}/${QCOM_BOOT_FILES_SUBDIR}" + +# Named for the signing step that belongs in this seam. +OEM_CERT_STAGE = "${B}/firmware-to-sign" + +inherit python3native deploy + +do_configure[noexec] = "1" +do_install[noexec] = "1" + +do_compile[depends] += "qdte-lite-native:do_populate_sysroot \ + cbsp-boot-utilities-native:do_populate_sysroot" +do_compile[depends] += "${@'${QCOM_BOOT_FIRMWARE}:do_deploy' if d.getVar('QCOM_BOOT_FIRMWARE') else ''}" +do_compile[cleandirs] = "${OEM_CERT_STAGE}" + +python () { + if not d.getVar('CAPSULE_ROOT_CER'): + raise bb.parse.SkipRecipe( + '%s: CAPSULE_ROOT_CER is not set. Point it at the DER-encoded OEM ' + 'root certificate (see ci/capsule-test-keys.yml for a ' + 'CI/development overlay).' % d.getVar('PN')) +} + +# Inject the certificate into one boot config ELF. +# +# The DTB names are asked for rather than configured per machine: they are +# assigned during disassembly, from container metadata in one container and +# from each DTB's /compatible in another, so hardcoding them goes stale. +# More than one line is possible, so the ops are joined with '&' and a +# single pass applies them all. +# +# bin-to-hex owns the DER-to-cells conversion so the padding of a trailing +# partial cell lives in one place rather than being reimplemented here. +# +# $1 - path to the config ELF or its .xz (rewritten in place) +patch_config_elf_cert() { + local config_elf="$1" + + local stem + stem=$(basename "${config_elf}") + stem="${stem%.xz}" + stem="${stem%.elf}" + + local targets + targets=$(qdte-lite --nogui --input_file "${config_elf}" \ + --find_property QcCapsuleRootCert) || { + bbwarn "No DTB in ${stem} defines QcCapsuleRootCert; skipping OEM cert injection." + return + } + + local root_inc="${B}/QcFMPRoot.inc" + qcom-capsule-tool bin-to-hex "${CAPSULE_ROOT_CER}" "${root_inc}" + + local modify_arg="" target + for target in ${targets}; do + if [ -z "${modify_arg}" ]; then + modify_arg="${target}=@list:${root_inc}" + else + modify_arg="${modify_arg}&${target}=@list:${root_inc}" + fi + done + + local outdir="${B}/qdte_out/${stem}" + rm -rf "${outdir}" + mkdir -p "${outdir}" + + qdte-lite --nogui \ + --input_file "${config_elf}" \ + --output_path "${outdir}" \ + --output_file "${stem}.elf" \ + --modify "${modify_arg}" + + # qdte-lite always writes a plain ELF; restore the input's compression. + case "${config_elf}" in + *.xz) xz -c "${outdir}/${stem}.elf" > "${config_elf}" ;; + *) install -m 0644 "${outdir}/${stem}.elf" "${config_elf}" ;; + esac +} + +do_compile() { + install -d "${OEM_CERT_STAGE}" + + # QCOM_XBL_CONFIG is xbl_config_kvm.elf on kvm machines. Both ELFs are + # optional: hamoa has no XBLConfig, UFS-boot platforms no uefi_dtbs.xz. + if [ -f "${BOOTBINS_DIR}/${QCOM_XBL_CONFIG}" ]; then + install -m 0644 "${BOOTBINS_DIR}/${QCOM_XBL_CONFIG}" "${OEM_CERT_STAGE}/" + patch_config_elf_cert "${OEM_CERT_STAGE}/${QCOM_XBL_CONFIG}" + fi + + if [ -z "$(ls -A ${OEM_CERT_STAGE} 2>/dev/null)" ]; then + bbfatal "No boot config ELF carrying QcCapsuleRootCert was found under ${BOOTBINS_DIR}." + fi +} + +do_deploy() { + install -d "${DEPLOYDIR}" + + # Fixed deploy name whatever the machine calls its XBLConfig, and + # distinct from it so this does not collide in the deploy manifest with + # the boot firmware recipe that owns the unmodified copy. + if [ -f "${OEM_CERT_STAGE}/${QCOM_XBL_CONFIG}" ]; then + install -m 0644 "${OEM_CERT_STAGE}/${QCOM_XBL_CONFIG}" \ + "${DEPLOYDIR}/xbl_config-with-oem-cert.elf" + fi +} +addtask deploy before do_build after do_compile diff --git a/recipes-bsp/firmware-boot/firmware-qcom-oem-cert_1.0.bb b/recipes-bsp/firmware-boot/firmware-qcom-oem-cert_1.0.bb new file mode 100644 index 000000000..7e420c4b7 --- /dev/null +++ b/recipes-bsp/firmware-boot/firmware-qcom-oem-cert_1.0.bb @@ -0,0 +1,14 @@ +DESCRIPTION = "Boot config ELFs carrying the OEM capsule root certificate" +LICENSE = "MIT" +LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302" + +COMPATIBLE_MACHINE = "hamoa|qcm6490|qcs615|qcs8300|qcs9100" + +# Deploy-only. PACKAGES = "" would leave do_package running with nothing to +# split, and buildhistory then fails listing a packages-split that was +# never created. +inherit nopackages + +inherit qcom-oem-cert + +PACKAGE_ARCH = "${MACHINE_ARCH}" From b1bbb3ad9422a3640427ea3228b3c19650f43b72 Mon Sep 17 00:00:00 2001 From: Xueqian Nie Date: Tue, 30 Jun 2026 07:23:51 +0000 Subject: [PATCH 4/5] qcom-oem-cert: inject OEM root cert into uefi_dtbs.elf for hamoa Hamoa and similar SPINOR-boot parts have no xbl_config.elf at all: their QcCapsuleRootCert lives in uefi_dtbs.elf, shipped xz-compressed. Without this the injection finds nothing to patch on those machines and the capsule is rejected at authentication time, with nothing in the build to suggest why. The container also holds the property more than once -- in a base DTB and in a .dtbo overlay, at different node paths -- so anything assuming a single target would silently patch half of it. --find_property already reports each as its own line and --modify takes them in one pass, so handling hamoa needs no machinery beyond staging the file. It deploys under its own name for the same reason xbl_config does: the boot firmware recipe owns the unmodified copy, and two recipes cannot deploy the same filename. image_types_qcom then prefers the cert-bearing one over the QCOM_UEFI_DTB variant, because a device flashed with the other will not take an update. Signed-off-by: Xueqian Nie Signed-off-by: Igor Opaniuk --- classes-recipe/image_types_qcom.bbclass | 10 +++++++++- classes-recipe/qcom-oem-cert.bbclass | 17 +++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/classes-recipe/image_types_qcom.bbclass b/classes-recipe/image_types_qcom.bbclass index e81e3874a..f2087256f 100644 --- a/classes-recipe/image_types_qcom.bbclass +++ b/classes-recipe/image_types_qcom.bbclass @@ -171,7 +171,15 @@ create_qcomflash_pkg() { fi # uefi dtb - if [ -n "${QCOM_UEFI_DTB}" ] && \ + # Prefer the OEM-cert-injected uefi_dtbs deployed by + # firmware-qcom-oem-cert when available. Mirrors the xbl_config + # substitution above, for SPI-NOR-boot targets (e.g. hamoa) that + # carry QcCapsuleRootCert in uefi_dtbs.elf rather than + # xbl_config.elf. + if [ -n "${QCOM_CAPSULE_FIRMWARE}" ] && \ + [ -f "${DEPLOY_DIR_IMAGE}/uefi_dtbs-with-oem-cert.xz" ]; then + install -m 0644 "${DEPLOY_DIR_IMAGE}/uefi_dtbs-with-oem-cert.xz" spinor/uefi_dtbs.xz + elif [ -n "${QCOM_UEFI_DTB}" ] && \ [ -f "${DEPLOY_DIR_IMAGE}/${QCOM_BOOT_FILES_SUBDIR}/spinor/${QCOM_UEFI_DTB}" ]; then install -m 0644 "${DEPLOY_DIR_IMAGE}/${QCOM_BOOT_FILES_SUBDIR}/spinor/${QCOM_UEFI_DTB}" spinor/uefi_dtbs.xz fi diff --git a/classes-recipe/qcom-oem-cert.bbclass b/classes-recipe/qcom-oem-cert.bbclass index a8256aed4..dc890bfb1 100644 --- a/classes-recipe/qcom-oem-cert.bbclass +++ b/classes-recipe/qcom-oem-cert.bbclass @@ -45,8 +45,9 @@ python () { # The DTB names are asked for rather than configured per machine: they are # assigned during disassembly, from container metadata in one container and # from each DTB's /compatible in another, so hardcoding them goes stale. -# More than one line is possible, so the ops are joined with '&' and a -# single pass applies them all. +# More than one line is normal -- on hamoa the property appears in a base +# DTB and in a .dtbo overlay, at different node paths -- and the ops are +# joined with '&' so a single pass applies them all. # # bin-to-hex owns the DER-to-cells conversion so the padding of a trailing # partial cell lives in one place rather than being reimplemented here. @@ -106,6 +107,13 @@ do_compile() { patch_config_elf_cert "${OEM_CERT_STAGE}/${QCOM_XBL_CONFIG}" fi + # uefi_dtbs.xz can sit in a SPI-NOR subdirectory of the boot bins. + UEFI_DTBS_XZ=$(find "${BOOTBINS_DIR}" -name "uefi_dtbs.xz" -print -quit) + if [ -n "${UEFI_DTBS_XZ}" ]; then + install -m 0644 "${UEFI_DTBS_XZ}" "${OEM_CERT_STAGE}/" + patch_config_elf_cert "${OEM_CERT_STAGE}/uefi_dtbs.xz" + fi + if [ -z "$(ls -A ${OEM_CERT_STAGE} 2>/dev/null)" ]; then bbfatal "No boot config ELF carrying QcCapsuleRootCert was found under ${BOOTBINS_DIR}." fi @@ -121,5 +129,10 @@ do_deploy() { install -m 0644 "${OEM_CERT_STAGE}/${QCOM_XBL_CONFIG}" \ "${DEPLOYDIR}/xbl_config-with-oem-cert.elf" fi + + if [ -f "${OEM_CERT_STAGE}/uefi_dtbs.xz" ]; then + install -m 0644 "${OEM_CERT_STAGE}/uefi_dtbs.xz" \ + "${DEPLOYDIR}/uefi_dtbs-with-oem-cert.xz" + fi } addtask deploy before do_build after do_compile From 0fd9dafcf67662d109df9006eb28d7cb7d4f673c Mon Sep 17 00:00:00 2001 From: Igor Opaniuk Date: Mon, 31 Aug 2026 21:01:06 +0200 Subject: [PATCH 5/5] firmware-qcom-capsule: stop hamoa's dtb entry leaking to other machines CAPSULE_FLASH_TYPE and CAPSULE_ENTRIES next to these definitions are machine-qualified; the CAPSULE_ENTRY_dtb[...] flags beside them are not, and cannot be -- varflags take no part in override resolution, so CAPSULE_ENTRY_dtb[dest_disk]:iq-x7181-evk does not exist. They therefore apply on every machine. Any other board that declares a "dtb" capsule entry inherits hamoa's SPINOR destinations, and nothing catches it: generate_fvupdate() only checks that an entry has a binary, a dest_disk and a dest_partition, all of which hamoa's values supply. The build succeeds and produces a capsule aimed at storage the machine may not even have. Guarding on MACHINEOVERRIDES gives the flags the scope the neighbouring overrides already have. Renaming the entry would also work, but the class keys the kernel dependency on the literal name "dtb". Fixes: a314263742be ("firmware-qcom-capsule: add iq-x7181-evk capsule entry definitions") Signed-off-by: Igor Opaniuk --- .../firmware/firmware-qcom-capsule_%.bbappend | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/recipes-bsp/firmware/firmware-qcom-capsule_%.bbappend b/recipes-bsp/firmware/firmware-qcom-capsule_%.bbappend index 373a63afe..06ead6f19 100644 --- a/recipes-bsp/firmware/firmware-qcom-capsule_%.bbappend +++ b/recipes-bsp/firmware/firmware-qcom-capsule_%.bbappend @@ -1,13 +1,29 @@ CAPSULE_FLASH_TYPE:iq-x7181-evk = "NORUFS" CAPSULE_ENTRIES:iq-x7181-evk = "dtb" -# Hamoa stores the Linux DTB FIT image in SPINOR. The firmware uses a -# main/backup model: dtb is always the active partition; dtb_BACKUP holds a -# rollback copy that is overwritten by before dtb is updated. -CAPSULE_ENTRY_dtb[binary] = "dtb.bin" -CAPSULE_ENTRY_dtb[dest_disk] = "SPINOR" -CAPSULE_ENTRY_dtb[dest_partition] = "dtb" -CAPSULE_ENTRY_dtb[dest_guid] = "{2A1A52FC-AA0B-401C-A808-5EA0F91068F8}" -CAPSULE_ENTRY_dtb[backup_disk] = "SPINOR" -CAPSULE_ENTRY_dtb[backup_partition] = "dtb_BACKUP" -CAPSULE_ENTRY_dtb[backup_guid] = "{A166F11A-2B39-4FAA-B7E7-F8AA080D0587}" +# Hamoa keeps the Linux DTB FIT image in SPINOR, with dtb as the active +# partition and dtb_BACKUP a rollback copy written before it. +# +# These are varflags, and varflags take no part in override resolution -- +# CAPSULE_ENTRY_dtb[dest_disk]:iq-x7181-evk does not exist. Assigned +# plainly they would apply everywhere, so any other board declaring a "dtb" +# entry would silently inherit these SPINOR destinations. Guard on +# MACHINEOVERRIDES to get the scope the override was meant to give. +QCOM_CAPSULE_DTB_ENTRY_MACHINE ?= "iq-x7181-evk" + +python () { + machine = d.getVar('QCOM_CAPSULE_DTB_ENTRY_MACHINE') + if machine not in (d.getVar('MACHINEOVERRIDES') or '').split(':'): + return + + for flag, value in ( + ('binary', 'dtb.bin'), + ('dest_disk', 'SPINOR'), + ('dest_partition', 'dtb'), + ('dest_guid', '{2A1A52FC-AA0B-401C-A808-5EA0F91068F8}'), + ('backup_disk', 'SPINOR'), + ('backup_partition', 'dtb_BACKUP'), + ('backup_guid', '{A166F11A-2B39-4FAA-B7E7-F8AA080D0587}'), + ): + d.setVarFlag('CAPSULE_ENTRY_dtb', flag, value) +}