diff --git a/docs/skills/ddi-installer.md b/docs/skills/ddi-installer.md index f6fdfcf..68552df 100644 --- a/docs/skills/ddi-installer.md +++ b/docs/skills/ddi-installer.md @@ -73,9 +73,11 @@ container runtime pod sandboxes. `/usr/lib/repart.sysinstall.d/` if it is populated; otherwise it falls back to `/usr/lib/repart.d/`. The target recipes are staged at `/usr/lib/repart.d/` (`10-esp.conf`, `20-root-a.conf`, `30-var.conf`). -6. `20-root-a.conf` copies the DDI block-for-block from +6. `20-root-a.conf` (or `20-usr-a.conf`) copies the DDI block-for-block from `/dev/disk/by-partlabel/bluefin-installer-data` (the embedded DDI data - partition on the installer media). + partition on the installer media). The DDI payload is built as a dm-verity + protected image with the verity hash tree appended at offset 1,065,345,024, + and its root hash is pinned by `verity.usrhash` on the target UKI command line. 7. Target OS volume expansion is handled by `systemd-growfs`; the target OS stack includes `xfsprogs` so the root and `/var` filesystems can grow to fill their partitions on first boot. diff --git a/elements/oci/bluefin-server-ddi.bst b/elements/oci/bluefin-server-ddi.bst index 9f80157..6e7bb6a 100644 --- a/elements/oci/bluefin-server-ddi.bst +++ b/elements/oci/bluefin-server-ddi.bst @@ -1,14 +1,16 @@ kind: script description: | - Produce the Bluefin Server OS DDI payload filesystem image. + Produce the Bluefin Server OS DDI payload filesystem image as a dm-verity + /usr image pinned by verity.usrhash. - Output: bluefin-server-ddi-.raw.zst + SHA256SUMS + Output: bluefin-server-ddi-.raw.zst + SHA256SUMS + verity root hash The output .raw is a filesystem image consumed by installer repart config: files/installer/repart.d/20-root-a.conf via CopyBlocks=. build-depends: - base/base-stack.bst + - freedesktop-sdk.bst:components/cryptsetup.bst - freedesktop-sdk.bst:components/xfsprogs.bst - freedesktop-sdk.bst:components/findutils.bst - freedesktop-sdk.bst:components/kmod.bst @@ -85,24 +87,39 @@ config: chmod 0600 /layer/etc/ssh/ssh_host_*_key chmod 0644 /layer/etc/ssh/ssh_host_*.pub - # Sum actual file sizes across bind mounts (du -sb reports content bytes, not overlay blocks). - ROOT_BYTES=$(du -sb /layer | cut -f1) - echo "Computed root filesystem size: ${ROOT_BYTES} bytes" - - # 25% overhead and 100MB margin for XFS metadata, journal, and structures. - # ponytail: 25% overhead + 100MB margin for XFS. - TARGET_BYTES=$(( ROOT_BYTES + ROOT_BYTES / 4 + 100 * 1024 * 1024 )) - - # Align TARGET_BYTES to 4096-byte boundary (multiple of 512/4096) for sector compatibility - # ponytail: sector alignment is required by systemd-repart CopyBlocks=. - TARGET_BYTES=$(( (TARGET_BYTES + 4095) / 4096 * 4096 )) + # Enforce the hard budget for the /usr payload: filesystem occupies the + # first 1,065,345,024 bytes; the verity hash tree follows in the same partition. + HASH_OFFSET=1065345024 - # Pre-allocate the raw file to the exact target size. - # ponytail: pre-allocating is the standard way to enforce the target size of mkfs.xfs. - truncate -s "${TARGET_BYTES}" "${OUT}/${FNAME}" + ROOT_BYTES=$(du -sb /layer | cut -f1) + echo "Computed root filesystem size: ${ROOT_BYTES} bytes (budget: ${HASH_OFFSET} bytes)" + if [ "${ROOT_BYTES}" -ge "${HASH_OFFSET}" ]; then + echo "ERROR: /usr payload size (${ROOT_BYTES} bytes) exceeds the hard verity budget of ${HASH_OFFSET} bytes" >&2 + exit 1 + fi - echo "Allocating XFS image of ${TARGET_BYTES} bytes" - mkfs.xfs -f -L bluefin-root -p /layer "${OUT}/${FNAME}" + # Pre-allocate the raw file to the exact budget size for the XFS filesystem. + truncate -s "${HASH_OFFSET}" "${OUT}/${FNAME}" + + echo "Allocating XFS image of ${HASH_OFFSET} bytes" + mkfs.xfs -f -L bluefin-usr -p /layer "${OUT}/${FNAME}" + + # Append the dm-verity hash tree at offset 1,065,345,024. + # 1,065,345,024 bytes = 260,094 blocks of 4096 bytes. + DATA_BLOCKS=$(( HASH_OFFSET / 4096 )) + veritysetup format \ + --hash=sha256 \ + --data-block-size=4096 \ + --hash-block-size=4096 \ + --data-blocks="${DATA_BLOCKS}" \ + --hash-offset="${HASH_OFFSET}" \ + --root-hash-file="${OUT}/${FNAME}.verity" \ + "${OUT}/${FNAME}" "${OUT}/${FNAME}" + + ROOT_HASH="$(cat "${OUT}/${FNAME}.verity")" + echo "Verity root hash: ${ROOT_HASH}" + cp "${OUT}/${FNAME}.verity" "${OUT}/verity.usrhash" + cp "${OUT}/${FNAME}.verity" "${OUT}/bluefin-server-ddi-%{ddi-version}.verity" zstd --rm -T0 -19 -q "${OUT}/${FNAME}" -o "${OUT}/${FNAME}.zst" ( diff --git a/elements/oci/bluefin-server-installer.bst b/elements/oci/bluefin-server-installer.bst index 44d9f30..5a24de1 100644 --- a/elements/oci/bluefin-server-installer.bst +++ b/elements/oci/bluefin-server-installer.bst @@ -123,6 +123,13 @@ config: rm -f /target-root/tmp/ld.so.conf cp /target-root/etc/ld.so.cache /etc/ld.so.cache + # Read the verity root hash from the staged DDI payload + if [ ! -f /ddi/verity.usrhash ] && ! ls /ddi/*.verity >/dev/null 2>&1; then + echo "ERROR: verity root hash not found in /ddi" >&2 + exit 1 + fi + USR_HASH="$(cat /ddi/verity.usrhash 2>/dev/null || head -n1 /ddi/*.verity)" + # Run dracut in generic (non-hostonly) mode and explicitly add XFS, VirtIO, and NVMe storage drivers. dracut -v --reproducible --no-machineid --no-hostonly \ --add-drivers "virtio virtio_blk virtio_pci virtio_scsi nvme nvme_core xfs erofs overlay zfs spl" \ @@ -131,13 +138,13 @@ config: --kmoddir "/target-root/usr/lib/modules/${TARGET_KVER}" \ --kernel-image "${TARGET_KERNEL}" \ --kver "${TARGET_KVER}" \ - --kernel-cmdline "rw console=ttyS0,115200 console=tty0 quiet loglevel=3 audit=0" \ + --kernel-cmdline "rw console=ttyS0,115200 console=tty0 quiet loglevel=3 audit=0 verity.usr=PARTLABEL=USR-A verity.usrhash=${USR_HASH}" \ /tmp/bluefin-server-target.initramfs ukify build \ --linux="${TARGET_KERNEL}" \ --initrd=/tmp/bluefin-server-target.initramfs \ - --cmdline="rw console=ttyS0,115200 console=tty0 quiet loglevel=3 audit=0" \ + --cmdline="rw console=ttyS0,115200 console=tty0 quiet loglevel=3 audit=0 verity.usr=PARTLABEL=USR-A verity.usrhash=${USR_HASH}" \ --output=/target-root/boot/EFI/Linux/bluefin-server.efi cp /target-root/boot/EFI/Linux/bluefin-server.efi /layer/usr/lib/bluefin-server/bluefin-server.efi diff --git a/tests/unit/test_ddi_verity.py b/tests/unit/test_ddi_verity.py new file mode 100644 index 0000000..67c50ff --- /dev/null +++ b/tests/unit/test_ddi_verity.py @@ -0,0 +1,111 @@ +"""Contract tests for dm-verity /usr image payload pinned by verity.usrhash. + +Covers projectbluefin/server#135 (Phase 4 of the Flatcar base migration): +- DDI element emits an XFS /usr image with an appended verity hash tree at + offset 1,065,345,024 and prints the root hash. +- Build enforces a hard size budget of 1,065,345,024 bytes and fails loudly when exceeded. +- Target UKI cmdline carries verity.usr=PARTLABEL=USR-A and verity.usrhash=. +""" + +from __future__ import annotations + +import re +from pathlib import Path +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +DDI_ELEMENT = REPO_ROOT / "elements" / "oci" / "bluefin-server-ddi.bst" +INSTALLER_ELEMENT = REPO_ROOT / "elements" / "oci" / "bluefin-server-installer.bst" + +VERITY_HASH_OFFSET = 1065345024 +VERITY_BLOCK_SIZE = 4096 +EXPECTED_DATA_BLOCKS = 260094 + + +def _load_ddi_data() -> dict: + content = DDI_ELEMENT.read_text(encoding="utf-8") + return yaml.safe_load(content) + + +def test_verity_budget_and_blocks_alignment() -> None: + """Verify that the verity offset aligns with standard 4096-byte sectors.""" + assert VERITY_HASH_OFFSET % VERITY_BLOCK_SIZE == 0 + assert VERITY_HASH_OFFSET // VERITY_BLOCK_SIZE == EXPECTED_DATA_BLOCKS + + +def test_ddi_depends_on_cryptsetup() -> None: + """Verify bluefin-server-ddi.bst includes cryptsetup for veritysetup.""" + data = _load_ddi_data() + build_depends = data.get("build-depends", []) + dep_names = [] + for dep in build_depends: + if isinstance(dep, str): + dep_names.append(dep) + elif isinstance(dep, dict) and "filename" in dep: + dep_names.append(dep["filename"]) + + assert "freedesktop-sdk.bst:components/cryptsetup.bst" in dep_names, ( + "bluefin-server-ddi.bst must include cryptsetup.bst in build-depends " + "to format the dm-verity hash tree" + ) + + +def test_ddi_enforces_hard_size_budget() -> None: + """Verify that the DDI checks payload size against the 1,065,345,024 budget and fails loudly.""" + content = DDI_ELEMENT.read_text(encoding="utf-8") + + assert "HASH_OFFSET=1065345024" in content + assert re.search(r'\[\s*"\$\{ROOT_BYTES\}"\s+-ge\s*"\$\{HASH_OFFSET\}"\s*\]', content), ( + "DDI element must compare ROOT_BYTES against HASH_OFFSET" + ) + assert 'truncate -s "${HASH_OFFSET}"' in content or "truncate -s 1065345024" in content, ( + "DDI image must be pre-allocated to the exact hash-offset budget" + ) + + +def test_ddi_appends_verity_hash_tree() -> None: + """Verify veritysetup format is invoked with exact upstream parameters.""" + content = DDI_ELEMENT.read_text(encoding="utf-8") + + assert "veritysetup format" in content + assert "--hash=sha256" in content + assert "--data-block-size=4096" in content + assert "--hash-block-size=4096" in content + assert ( + '--data-blocks="${DATA_BLOCKS}"' in content + or f"--data-blocks={EXPECTED_DATA_BLOCKS}" in content + ) + assert ( + '--hash-offset="${HASH_OFFSET}"' in content + or f"--hash-offset={VERITY_HASH_OFFSET}" in content + ) + assert "--root-hash-file=" in content + + +def test_ddi_prints_and_exports_root_hash() -> None: + """Verify that the DDI prints the root hash and saves verity.usrhash for UKI assembly.""" + content = DDI_ELEMENT.read_text(encoding="utf-8") + + assert 'echo "Verity root hash: ' in content + assert "verity.usrhash" in content + + +def test_installer_reads_verity_usrhash_and_configures_uki() -> None: + """Verify that the installer reads the verity root hash and pins it in UKI cmdline.""" + content = INSTALLER_ELEMENT.read_text(encoding="utf-8") + + assert "/ddi/verity.usrhash" in content + assert "verity.usr=PARTLABEL=USR-A" in content + assert "verity.usrhash=" in content + + # UKI command line assertion + uki_match = re.search( + r'ukify build\s+.*?--cmdline="([^"]+)"\s+' + r"[ \t\\\r\n]+--output=/target-root/boot/EFI/Linux/bluefin-server\.efi", + content, + flags=re.DOTALL, + ) + assert uki_match, "ukify build command for target UKI must be present" + cmdline = uki_match.group(1) + assert "verity.usr=PARTLABEL=USR-A" in cmdline + assert "verity.usrhash=${USR_HASH}" in cmdline diff --git a/tests/unit/test_installer_contract.py b/tests/unit/test_installer_contract.py index d423c68..e3eddbd 100644 --- a/tests/unit/test_installer_contract.py +++ b/tests/unit/test_installer_contract.py @@ -48,7 +48,10 @@ def test_installer_runtime_and_boot_contracts() -> None: assert "freedesktop-sdk.bst:bootstrap/bash.bst" in installer_stack assert "console=ttyS0,115200 rw" in installer_element assert "unattended" not in published_uki_cmdline - assert target_uki_cmdline == "rw console=ttyS0,115200 console=tty0 quiet loglevel=3 audit=0" + assert target_uki_cmdline == ( + "rw console=ttyS0,115200 console=tty0 quiet loglevel=3 audit=0 " + "verity.usr=PARTLABEL=USR-A verity.usrhash=${USR_HASH}" + ) assert ( '-append "systemd.unit=system-install.target ' 'console=tty0 console=ttyS0,115200 rw unattended"'