Skip to content

Commit cdc7c54

Browse files
committed
sealing: E2E test for composefs sealed UKI boot
Builds a CentOS Stream 10 bootc host with the composefs backend and boots it in a bcvk VM with Secure Boot. Verifies that the root is mounted as a composefs overlay with verity=require. Uses ephemeral Secure Boot keys (PK/KEK/db generated in CI). The Containerfile signs systemd-boot and the UKI with the db key, then flattens to a single layer for deterministic composefs digests. The kernel stage runs bootc container ukify to compute the digest and build a signed UKI. Assisted-by: OpenCode (Claude Opus 4)
0 parents  commit cdc7c54

7 files changed

Lines changed: 541 additions & 0 deletions

File tree

.github/workflows/e2e-sealing.yml

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
name: E2E sealed composefs boot
2+
3+
on:
4+
push:
5+
pull_request:
6+
workflow_dispatch:
7+
8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
10+
cancel-in-progress: true
11+
12+
jobs:
13+
e2e-sealed-boot:
14+
name: Build and boot sealed composefs host
15+
runs-on: ubuntu-24.04
16+
steps:
17+
- uses: actions/checkout@v5
18+
19+
- name: Setup (podman, libvirt, bcvk, virt-firmware)
20+
uses: bootc-dev/actions/bootc-ubuntu-setup@main
21+
with:
22+
libvirt: 'true'
23+
24+
- name: Generate ephemeral Secure Boot keys
25+
run: |
26+
set -euo pipefail
27+
mkdir -p target/keys keys/
28+
29+
for name in PK KEK db; do
30+
openssl req -new -x509 -newkey rsa:2048 -nodes \
31+
-keyout "target/keys/sb-${name}.key" \
32+
-out "target/keys/sb-${name}.crt" \
33+
-days 1 -subj "/CN=composefs-ci-${name}/"
34+
ln -sf "sb-${name}.key" "target/keys/${name}.key"
35+
ln -sf "sb-${name}.crt" "target/keys/${name}.crt"
36+
done
37+
38+
cp target/keys/sb-db.crt keys/db.crt
39+
40+
- name: Build sealed host image
41+
run: |
42+
podman build -f Containerfile.host \
43+
--secret id=secureboot_key,src=target/keys/sb-db.key \
44+
-t localhost/sealed-host:latest .
45+
46+
- name: Boot VM with composefs backend
47+
run: |
48+
set -euo pipefail
49+
50+
VM_NAME="sealed-e2e"
51+
52+
echo "==> Booting sealed host VM with Secure Boot + composefs..."
53+
bcvk libvirt run --detach --ssh-wait --name "${VM_NAME}" \
54+
--filesystem=ext4 \
55+
--secure-boot-keys target/keys \
56+
localhost/sealed-host:latest
57+
58+
echo "==> Waiting for multi-user.target..."
59+
bcvk libvirt ssh "${VM_NAME}" -- \
60+
timeout 180 bash -c \
61+
'systemctl is-active multi-user.target || journalctl -b --no-pager -o cat UNIT=multi-user.target --follow | grep -q -m1 "Reached target"'
62+
63+
echo "==> Running verification checks..."
64+
bcvk libvirt ssh "${VM_NAME}" -- bash -c '
65+
set -euo pipefail
66+
67+
echo "--- kernel ---"
68+
uname -r
69+
70+
echo "--- root mount ---"
71+
mount_line=$(mount | grep " / " || true)
72+
echo " ${mount_line}"
73+
if echo "${mount_line}" | grep -q "verity=require"; then
74+
echo " OK: composefs root with verity=require"
75+
else
76+
echo " FAIL: verity=require not found on root mount"
77+
mount
78+
exit 1
79+
fi
80+
81+
echo "--- cmdline ---"
82+
cat /proc/cmdline
83+
if grep -q "composefs=" /proc/cmdline; then
84+
echo " OK: composefs= present in cmdline"
85+
else
86+
echo " FAIL: composefs= not in cmdline"
87+
exit 1
88+
fi
89+
90+
echo ""
91+
echo "=== COMPOSEFS SEALED BOOT VERIFIED ==="
92+
'
93+
94+
- name: Cleanup VM
95+
if: always()
96+
run: bcvk libvirt rm --stop --force sealed-e2e 2>/dev/null || true
97+
98+
- name: Dump VM journal on failure
99+
if: failure()
100+
run: |
101+
bcvk libvirt ssh sealed-e2e -- journalctl -b --no-pager 2>/dev/null || true
102+
bcvk libvirt ssh sealed-e2e -- mount 2>/dev/null || true
103+
bcvk libvirt ssh sealed-e2e -- cat /proc/cmdline 2>/dev/null || true

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/target/

Containerfile.host

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Containerfile.host — Sealed bootc host with composefs root
2+
#
3+
# Builds a CentOS Stream 10 bootc image that boots with the composefs
4+
# backend. A signed Unified Kernel Image (UKI) embeds the composefs
5+
# digest; Secure Boot verifies the UKI, which in turn verifies every
6+
# file on the root filesystem via fs-verity.
7+
#
8+
# The db certificate (public) is read from the build context.
9+
# Only the db private key is a build secret.
10+
#
11+
# Build:
12+
# podman build -f Containerfile.host \
13+
# --secret id=secureboot_key,src=target/keys/sb-db.key \
14+
# -t localhost/sealed-host:latest .
15+
16+
# --- Stage: rootfs-builder (install packages on the bootc base) ---
17+
FROM quay.io/centos-bootc/centos-bootc:stream10 AS rootfs-builder
18+
19+
# sbsigntools from EPEL; systemd-boot-unsigned for bootctl install
20+
RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
21+
dnf -y install epel-release && \
22+
dnf -y install systemd-boot-unsigned systemd-ukify sbsigntools && \
23+
dnf clean all
24+
25+
# composefs backend uses systemd-boot, not grub/bootupd
26+
RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
27+
rpm -e bootupd 2>/dev/null || true
28+
29+
# Default to ext4 (supports fsverity, required for composefs)
30+
RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
31+
mkdir -p /usr/lib/bootc/install && \
32+
cat > /usr/lib/bootc/install/00-composefs.toml <<'EOF'
33+
[install.filesystem.root]
34+
type = "ext4"
35+
EOF
36+
37+
# Include the bootc dracut module in the initramfs so that composefs
38+
# root setup happens during boot (the module's check() returns 255,
39+
# meaning it's never auto-included). Then rebuild the initramfs.
40+
RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
41+
mkdir -p /etc/dracut.conf.d && \
42+
echo 'add_dracutmodules+=" bootc "' > /etc/dracut.conf.d/50-bootc-composefs.conf && \
43+
kver=$(ls /usr/lib/modules/) && \
44+
dracut --force --kver "$kver" /usr/lib/modules/$kver/initramfs.img && \
45+
echo "Rebuilt initramfs with bootc module" && \
46+
lsinitrd /usr/lib/modules/$kver/initramfs.img | grep -c bootc
47+
48+
# Sign systemd-boot with our Secure Boot key so UEFI firmware will load it.
49+
# This must happen BEFORE the flatten so the signed binary is in the digest.
50+
RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
51+
--mount=type=secret,id=secureboot_key \
52+
--mount=type=bind,source=keys/db.crt,target=/tmp/db.crt \
53+
sbsign --key /run/secrets/secureboot_key \
54+
--cert /tmp/db.crt \
55+
--output /tmp/systemd-bootx64.efi.signed \
56+
/usr/lib/systemd/boot/efi/systemd-bootx64.efi && \
57+
mv /tmp/systemd-bootx64.efi.signed /usr/lib/systemd/boot/efi/systemd-bootx64.efi
58+
59+
# --- Stage: base (flatten to single layer for deterministic digests) ---
60+
FROM scratch AS base
61+
COPY --from=rootfs-builder / /
62+
LABEL containers.bootc 1
63+
LABEL ostree.bootable 1
64+
ENV container=oci
65+
STOPSIGNAL SIGRTMIN+3
66+
CMD ["/sbin/init"]
67+
68+
# --- Stage: kernel (compute digest + build signed UKI) ---
69+
FROM base AS kernel
70+
RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
71+
--mount=type=bind,from=base,target=/target \
72+
--mount=type=secret,id=secureboot_key \
73+
--mount=type=bind,source=keys/db.crt,target=/tmp/db.crt <<EORUN
74+
set -xeuo pipefail
75+
mkdir -p /out
76+
bootc container ukify --rootfs /target \
77+
--karg rw \
78+
--karg enforcing=0 \
79+
--karg console=tty0 \
80+
--karg console=ttyS0,115200n8 \
81+
--karg console=hvc0,115200 \
82+
--karg systemd.journald.forward_to_console=1 \
83+
-- \
84+
--signtool sbsign \
85+
--secureboot-private-key /run/secrets/secureboot_key \
86+
--secureboot-certificate /tmp/db.crt \
87+
--output /out/uki.efi
88+
ls -lh /out/
89+
EORUN
90+
RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \
91+
kver=$(bootc container inspect --json | jq -r '.kernel.version') && \
92+
mkdir -p /boot/EFI/Linux && \
93+
mv /out/uki.efi "/boot/EFI/Linux/${kver}.efi"
94+
95+
# --- Final image: base rootfs + /boot from kernel stage ---
96+
FROM base
97+
COPY --from=kernel /boot /boot

Justfile

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Justfile for sealed composefs UKI boot
2+
#
3+
# Builds a CentOS Stream 10 bootc host that boots with the composefs
4+
# backend (UKI + composefs digest + Secure Boot).
5+
#
6+
# Prerequisites: podman, openssl, just
7+
# For VM testing: bcvk (from bootc-dev/bcvk), virt-firmware (pip)
8+
9+
# Local image name
10+
host_image := "localhost/sealed-host:latest"
11+
12+
# Key material directory (for local dev; CI uses secrets)
13+
keys_dir := justfile_directory() + "/target/keys"
14+
15+
# Generate Secure Boot keys (PK/KEK for enrollment, db for signing).
16+
# Copies db.crt into keys/ for committing to the repo.
17+
keygen:
18+
#!/bin/bash
19+
set -euo pipefail
20+
python3 util/keys.py generate --output-dir "{{keys_dir}}"
21+
mkdir -p keys/
22+
cp "{{keys_dir}}/sb-db.crt" keys/db.crt
23+
echo "keys/db.crt updated — commit it to the repo"
24+
25+
# Build the sealed host image (composefs backend with signed UKI).
26+
# Only the db private key is a secret; db.crt is in the build context.
27+
build-host:
28+
#!/bin/bash
29+
set -euo pipefail
30+
if [ ! -f "{{keys_dir}}/sb-db.key" ]; then
31+
echo "No keys found. Run 'just keygen' first."
32+
exit 1
33+
fi
34+
podman build -f Containerfile.host \
35+
--secret id=secureboot_key,src="{{keys_dir}}/sb-db.key" \
36+
-t "{{host_image}}" .
37+
echo "Host image built: {{host_image}}"
38+
39+
# Boot a VM with the composefs backend and verify.
40+
bcvk-ssh: build-host
41+
#!/bin/bash
42+
set -euo pipefail
43+
44+
VM_NAME="sealed-demo"
45+
46+
# Clean up any previous VM with this name
47+
bcvk libvirt rm --stop --force "${VM_NAME}" 2>/dev/null || true
48+
49+
echo "==> Booting sealed host VM..."
50+
bcvk libvirt run --detach --ssh-wait --name "${VM_NAME}" \
51+
--filesystem=ext4 \
52+
--secure-boot-keys "{{keys_dir}}" \
53+
"{{host_image}}"
54+
55+
echo "==> Waiting for multi-user.target (timeout 120s)..."
56+
bcvk libvirt ssh "${VM_NAME}" -- \
57+
timeout 120 bash -c \
58+
'systemctl is-active multi-user.target || journalctl -b --no-pager -o cat UNIT=multi-user.target --follow | grep -q -m1 "Reached target"'
59+
60+
echo "==> multi-user.target reached, running checks..."
61+
bcvk libvirt ssh "${VM_NAME}" -- bash -c '
62+
set -euo pipefail
63+
64+
echo "--- kernel ---"
65+
uname -r
66+
67+
echo "--- root mount ---"
68+
mount | grep " / " || true
69+
if mount | grep -q "verity=require"; then
70+
echo " OK: composefs root with verity=require"
71+
else
72+
echo " FAIL: composefs root not detected"
73+
exit 1
74+
fi
75+
76+
echo "--- cmdline ---"
77+
cat /proc/cmdline
78+
79+
echo ""
80+
echo "=== COMPOSEFS BOOT VERIFIED ==="
81+
'
82+
83+
echo "==> Cleaning up VM..."
84+
bcvk libvirt rm --stop --force "${VM_NAME}"
85+
echo "Done."
86+
87+
# Clean generated artifacts and VM
88+
clean:
89+
#!/bin/bash
90+
set -euo pipefail
91+
bcvk libvirt rm --stop --force sealed-demo 2>/dev/null || true
92+
rm -rf target/
93+
podman rmi -f "{{host_image}}" 2>/dev/null || true
94+
echo "Cleaned"

0 commit comments

Comments
 (0)