diff --git a/test/integration-tests/README.md b/test/integration-tests/README.md index e5f7c76713..c02020ac8e 100644 --- a/test/integration-tests/README.md +++ b/test/integration-tests/README.md @@ -160,12 +160,19 @@ as root. The host must have coco enabled in firmware and in the kernel (for SEV- e.g. `kvm_amd.sev_snp=1`, with `/dev/sev` accessible). On a host without the requested technology, or when running in `mkosi vm` mode, the tests are skipped. +Inside each guest, `guest-test-runner.sh` runs the guest-side checks and reports +their results back to the host over a vsock socket, while the guest's overall +pass/fail is relayed separately through its exit status. The host side collects +both to produce the subtest result. + ``` meson test └─ integration-test-wrapper.py └─ mkosi boot ──► systemd-nspawn container └─ TEST-94-COCO.sh - └─ systemd-vmspawn --coco= ──► confidential VM + └─ TEST-94-COCO.*.sh + └─ systemd-vmspawn --coco= ──► confidential VM + └─ guest-test-runner.sh ``` ## Running the integration tests without building systemd from source diff --git a/test/units/TEST-94-COCO.sh b/test/units/TEST-94-COCO.sh index fc9750b635..cc8ab572f1 100755 --- a/test/units/TEST-94-COCO.sh +++ b/test/units/TEST-94-COCO.sh @@ -1,20 +1,23 @@ #!/usr/bin/env bash # SPDX-License-Identifier: LGPL-2.1-or-later # -# Launch a confidential VM with systemd-vmspawn --coco= and verify the guest reports the expected -# confidential-virtualization type. Runs only on coco-capable hardware: the harness --coco=any flag gates -# the run (skipped otherwise), binds /dev/kvm + the coco device(s) into this boot-mode container, and -# passes the resolved technology via $COCO_TYPE. +# Confidential Computing test. # -# The confidential guest boots the same mkosi test image. A one-shot unit injected into the guest checks -# systemd-detect-virt --cvm and reports the verdict via systemd's exit-status notification over vsock -# (SuccessAction=exit): the guest PID1 sends EXIT_STATUS to vmspawn's vmm notify socket and vmspawn exits -# with it. +# These only run on coco hardware and if the test environment isn't already virtualized, see +# the harness --coco= flag and the documentation in test/integration-tests/README.md. +# +# Subtests are split per platform and launch path to reduce the number of required boots. +# Test code runs on both sides: +# - host: test vmspawn by running confidential guests. TEST-94-COCO/lib.sh provides helpers. +# - guest: test guest side component coco behavior. Assertions via TEST-94-COCO/guest-test{,-runner}.sh. + set -eux set -o pipefail # shellcheck source=test/units/util.sh . "$(dirname "$0")"/util.sh +# shellcheck source=test/units/test-control.sh +. "$(dirname "$0")"/test-control.sh if [[ -v ASAN_OPTIONS ]]; then echo "vmspawn launches QEMU which doesn't work under ASan, skipping" | tee --append /skipped @@ -31,83 +34,19 @@ if ! find_qemu_binary; then exit 77 fi -# The integration-test-wrapper resolves the host's coco technology on the -# bare-metal host (where /sys/module is readable) and passes it via $COCO_TYPE. +# The harness binds the guest image artifacts read-only at /work/vm-images. +export IMAGE_DIR=/work/vm-images +if [[ ! -f "$IMAGE_DIR/image.raw" || ! -f "$IMAGE_DIR/image.vmlinuz" || ! -f "$IMAGE_DIR/image.initrd" ]]; then + echo "image artifacts not found in $IMAGE_DIR, skipping" | tee --append /skipped + exit 77 +fi + +# The integration-test-wrapper resolves the host's coco technology (where /sys/module is readable) and +# passes it via $COCO_TYPE. if [[ -z "${COCO_TYPE:-}" ]]; then echo "COCO_TYPE not set (run via the integration harness with --coco), skipping" | tee --append /skipped exit 77 fi echo "Host coco type: $COCO_TYPE" -# The harness binds the guest image read-only at /work/vm-images (the 'vm-images' test option). -IMAGE_DIR=/work/vm-images -if [[ ! -f "$IMAGE_DIR/image.raw" || ! -f "$IMAGE_DIR/image.vmlinuz" || ! -f "$IMAGE_DIR/image.initrd" ]]; then - echo "guest image not found in $IMAGE_DIR, skipping" | tee --append /skipped - exit 77 -fi -echo "Guest image directory: $IMAGE_DIR" - -MACHINE="coco-guest-$$" -WORKDIR="$(mktemp -d)" -at_exit() { - set +e - machinectl terminate "$MACHINE" 2>/dev/null || : - rm -rf "$WORKDIR" -} -trap at_exit EXIT INT TERM - -# One-shot unit for the guest: verify systemd-detect-virt --cvm matches the type vmspawn launched. -# Created via the systemd.extra-unit.* credential and pulled into the boot by a multi-user.target drop-in. -GUEST_PASS=123 -GUEST_UNIT="$(cat <<'EOF' -[Unit] -Description=coco guest self-check -After=basic.target -SuccessAction=exit -SuccessActionExitStatus=@GUEST_PASS@ -FailureAction=exit -FailureActionExitStatus=1 -[Service] -Type=oneshot -ExecStart=bash -c 'test "$(systemd-detect-virt --cvm)" = "@COCO_TYPE@"' -EOF -)" -GUEST_UNIT="${GUEST_UNIT//@COCO_TYPE@/$COCO_TYPE}" -GUEST_UNIT="${GUEST_UNIT//@GUEST_PASS@/$GUEST_PASS}" - -GUEST_DROPIN="$(cat <<'EOF' -[Unit] -Wants=coco-guest.service -After=coco-guest.service -EOF -)" - -# Launch the confidential guest and capture its console output. -# Direct linux boot should work on all coco platforms (the only boot path supported by SNP). -vmspawn_rc=0 -timeout -k 30 300 systemd-vmspawn \ - --machine="$MACHINE" \ - --coco="$COCO_TYPE" \ - --ram=1G \ - --ephemeral \ - --image="$IMAGE_DIR/image.raw" \ - --linux="$IMAGE_DIR/image.vmlinuz" \ - --initrd="$IMAGE_DIR/image.initrd" \ - --tpm=no \ - --console=read-only \ - --set-credential="systemd.extra-unit.coco-guest.service:$GUEST_UNIT" \ - --set-credential="systemd.unit-dropin.multi-user.target:$GUEST_DROPIN" \ - selinux=0 systemd.firstboot=no rw \ - 2>&1 | tee "$WORKDIR/console.log" || vmspawn_rc="${PIPESTATUS[0]}" -if [[ "$vmspawn_rc" -eq 124 || "$vmspawn_rc" -eq 137 ]]; then - echo "systemd-vmspawn was killed by timeout (exit $vmspawn_rc)" >&2 - cat "$WORKDIR/console.log" >&2 - exit 1 -elif [[ "$vmspawn_rc" -ne "$GUEST_PASS" ]]; then - echo "guest coco self-check did not pass: vmspawn exit $vmspawn_rc (expected $GUEST_PASS)" >&2 - cat "$WORKDIR/console.log" >&2 - exit 1 -fi -echo "Guest correctly reported confidential virtualization: $COCO_TYPE" - -touch /testok +run_subtests_and_exit diff --git a/test/units/TEST-94-COCO.snp-direct.sh b/test/units/TEST-94-COCO.snp-direct.sh new file mode 100755 index 0000000000..7933a8775f --- /dev/null +++ b/test/units/TEST-94-COCO.snp-direct.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# SEV-SNP, direct kernel boot: launch confidential guests with --linux=/--initrd=. + +set -eux +set -o pipefail + +# shellcheck source=test/units/TEST-94-COCO/lib.sh +. "$(dirname "$0")"/TEST-94-COCO/lib.sh + +if [[ "${COCO_TYPE:?}" != "sev-snp" ]]; then + echo "host coco type is '$COCO_TYPE', not sev-snp, skipping" + exit 77 +fi + +MACHINE="coco-snp-direct-$$" +WORKDIR="$(mktemp -d)" +mkdir -p "$WORKDIR/honest" "$WORKDIR/hostile" +at_exit() { + set +e + jobs -p | xargs -r kill 2>/dev/null + machinectl terminate "$MACHINE-honest" 2>/dev/null || : + machinectl terminate "$MACHINE-hostile" 2>/dev/null || : + rm -rf "$WORKDIR" +} +trap at_exit EXIT + +# General test case for SNP direct boot, covers: +# +# - detect_virt: systemd-detect-virt --cvm detects SNP as the CVM type +# - creds_vmspawn: credential transport by vmspawn is working (via initrd for SNP) +# - creds_cmdline: credential transport via cmdline is working (cmdline is measured on SNP) +vmspawn_boot_coco "$MACHINE-honest" "$COCO_TYPE" "$WORKDIR/honest" 'detect_virt creds_vmspawn creds_cmdline' \ + --image="$IMAGE_DIR/image.raw" \ + --linux="$IMAGE_DIR/image.vmlinuz" \ + --initrd="$IMAGE_DIR/image.initrd" \ + --set-credential="$COCO_CRED_TRUSTED_ID:$COCO_CRED_TRUSTED_VALUE" \ + selinux=0 systemd.firstboot=no rw \ + "systemd.set_credential=$COCO_CRED_CMDLINE_ID:$COCO_CRED_CMDLINE_VALUE" +echo "SEV-SNP direct-boot guest correctly reported confidential virtualization and trusted credentials" + +# Offensive test: +# +# - creds_hostile_*: ensure the guest isn't tricked into consuming untrusted/unmeasured credentials. +QEMU_EXTRA=( + -smbios "type=11,value=io.systemd.credential:$COCO_CRED_SMBIOS_ID=$COCO_CRED_SMBIOS_VALUE" + -smbios "type=11,value=io.systemd.credential:$COCO_CRED_TRUSTED_ID=$COCO_CRED_SMBIOS_VALUE" + -fw_cfg "name=opt/io.systemd.credentials/$COCO_CRED_FWCFG_ID,string=$COCO_CRED_FWCFG_VALUE" +) + +SYSTEMD_VMSPAWN_QEMU_EXTRA="${QEMU_EXTRA[*]}" \ +vmspawn_boot_coco "$MACHINE-hostile" "$COCO_TYPE" "$WORKDIR/hostile" \ + 'detect_virt creds_vmspawn creds_hostile_fwcfg creds_hostile_snp_smbios creds_hostile_shadow' \ + --image="$IMAGE_DIR/image.raw" \ + --linux="$IMAGE_DIR/image.vmlinuz" \ + --initrd="$IMAGE_DIR/image.initrd" \ + --set-credential="$COCO_CRED_TRUSTED_ID:$COCO_CRED_TRUSTED_VALUE" \ + selinux=0 systemd.firstboot=no rw +echo "SEV-SNP direct-boot guest correctly ignored the host-injected credentials" diff --git a/test/units/TEST-94-COCO.tdx-direct.sh b/test/units/TEST-94-COCO.tdx-direct.sh new file mode 100755 index 0000000000..3999ea96d0 --- /dev/null +++ b/test/units/TEST-94-COCO.tdx-direct.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# TDX, direct kernel boot: launch a confidential guest with --linux=/--initrd=. + +set -eux +set -o pipefail + +# shellcheck source=test/units/TEST-94-COCO/lib.sh +. "$(dirname "$0")"/TEST-94-COCO/lib.sh + +if [[ "${COCO_TYPE:?}" != "tdx" ]]; then + echo "host coco type is '$COCO_TYPE', not tdx, skipping" + exit 77 +fi + +MACHINE="coco-tdx-direct-$$" +WORKDIR="$(mktemp -d)" +at_exit() { + set +e + jobs -p | xargs -r kill 2>/dev/null + machinectl terminate "$MACHINE" 2>/dev/null || : + rm -rf "$WORKDIR" +} +trap at_exit EXIT + +vmspawn_boot_coco "$MACHINE" "$COCO_TYPE" "$WORKDIR" 'detect_virt' \ + --image="$IMAGE_DIR/image.raw" \ + --linux="$IMAGE_DIR/image.vmlinuz" \ + --initrd="$IMAGE_DIR/image.initrd" \ + selinux=0 systemd.firstboot=no rw +echo "TDX direct-boot guest correctly reported confidential virtualization" diff --git a/test/units/TEST-94-COCO/fixtures.sh b/test/units/TEST-94-COCO/fixtures.sh new file mode 100644 index 0000000000..5676aabd14 --- /dev/null +++ b/test/units/TEST-94-COCO/fixtures.sh @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# shellcheck shell=bash +# Every constant here is consumed by whichever side sources this file, never by this file itself. +# shellcheck disable=SC2034 +# +# Fixtures for the confidential computing integration test (TEST-94-COCO). Shared by the host-side +# boot scenarios, which deliver these credentials over trusted and untrusted channels alike, and by +# the guest-side checks, which assert where each one ended up, so the two sides cannot drift apart. + +if [[ "${BASH_SOURCE[0]}" -ef "$0" ]]; then + echo >&2 "This file should not be executed directly" + exit 1 +fi + +# Name of the guest self-check unit lib.sh injects. +COCO_GUEST_UNIT="coco-guest.service" + +# Credentials by channel +COCO_CRED_TRUSTED_ID="coco.trusted" +COCO_CRED_TRUSTED_VALUE="trusted-via-vmspawn" +COCO_CRED_CMDLINE_ID="coco.cmdline" +COCO_CRED_CMDLINE_VALUE="trusted-via-cmdline" +COCO_CRED_SMBIOS_ID="coco.hostile.smbios" +COCO_CRED_SMBIOS_VALUE="injected-via-smbios" +COCO_CRED_FWCFG_ID="coco.hostile.fwcfg" +COCO_CRED_FWCFG_VALUE="injected-via-fwcfg" diff --git a/test/units/TEST-94-COCO/guest-test-runner.sh b/test/units/TEST-94-COCO/guest-test-runner.sh new file mode 100755 index 0000000000..c0532963b4 --- /dev/null +++ b/test/units/TEST-94-COCO/guest-test-runner.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# Runs inside the confidential guest launched by TEST-94-COCO.sh. Runs the requested guest-side checks +# (testcase_coco_* from guest-test.sh), ships a per-check result record to the host over the vsock +# result socket, and lets the injected unit's SuccessAction/FailureAction relay the aggregate verdict +# to the host via vmspawn's exit status. +set -eux +set -o pipefail + +_COCO_DIR="$(dirname "$(readlink -f "$0")")" +# util.sh (assert_*) lives one level up in the shared units dir. +# shellcheck source=test/units/util.sh +. "$(dirname "$_COCO_DIR")/util.sh" +# shellcheck source=test/units/TEST-94-COCO/guest-test.sh +. "$_COCO_DIR/guest-test.sh" + +# Ship the accumulated result records to the host's vsock listener (CID 2 = host). Best-effort: the +# aggregate verdict travels independently via the unit's exit status, so a vsock hiccup never masks a +# pass/fail — it only drops the per-check breakdown. +_coco_ship_results() { + local records="${1:?}" + [[ -n "${COCO_RESULT_PORT:-}" ]] || return 0 + socat -u "OPEN:$records,rdonly" "VSOCK-CONNECT:2:$COCO_RESULT_PORT" || + echo >&2 "WARNING: failed to ship coco results over vsock port $COCO_RESULT_PORT" +} + +# Run the guest-side checks named by $COCO_TESTCASES (testcase_coco_* names without the prefix), +# record pass/fail per check, and ship the records. Each named check is something its boot scenario +# exists to prove, so an unknown name or an empty request fails the aggregate rather than letting the +# boot pass without having verified anything. +_coco_run_tests() { + local testcases testcase rc result failed=0 records + records="$(mktemp)" + + read -ra testcases <<<"${COCO_TESTCASES:-}" + if [[ "${#testcases[@]}" -eq 0 ]]; then + echo >&2 "No coco testcases requested via \$COCO_TESTCASES" + return 1 + fi + testcases=("${testcases[@]/#/testcase_coco_}") + + for testcase in "${testcases[@]}"; do + if ! declare -F "$testcase" >/dev/null; then + echo >&2 "Requested coco testcase '$testcase' is not defined" + printf 'ID=%s RESULT=missing\n' "$testcase" >>"$records" + failed=1 + continue + fi + # Subshell so a testcase's RETURN trap can't fire twice (see run_testcases in test-control.sh). + # Bash disables 'set -e' throughout a function called in a context that tests its exit status, + # so '("$testcase") || rc=$?' would let a testcase run past its first failed assertion and + # report only its last one. Reading $? from a standalone call avoids that context; the inner + # 'set -e' re-arms what the surrounding 'set +e' turned off. + set +e + (set -e; "$testcase") + rc=$? + set -e + if [[ "$rc" -eq 0 ]]; then + result=pass + else + result=fail + failed=1 + fi + echo "coco check $testcase: $result (rc=$rc)" + printf 'ID=%s RESULT=%s\n' "$testcase" "$result" >>"$records" + done + + _coco_ship_results "$records" + return "$failed" +} + +_coco_run_tests diff --git a/test/units/TEST-94-COCO/guest-test.sh b/test/units/TEST-94-COCO/guest-test.sh new file mode 100644 index 0000000000..c2f89258d8 --- /dev/null +++ b/test/units/TEST-94-COCO/guest-test.sh @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# shellcheck shell=bash +# +# Guest-side checks for the confidential computing integration test (TEST-94-COCO). Sourced by +# guest-test-runner.sh inside the confidential guest; each testcase_coco_* function is one +# sub-subtest, and a boot scenario names the checks it requires via $COCO_TESTCASES. + +if [[ "${BASH_SOURCE[0]}" -ef "$0" ]]; then + echo >&2 "This file should not be executed directly" + exit 1 +fi + +# shellcheck source=test/units/TEST-94-COCO/fixtures.sh +. "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/fixtures.sh" + +# _coco_assert_cred_absent ID +# Assert no credential named ID reached the guest at all, neither trusted nor encrypted. +_coco_assert_cred_absent() { + local id="${1:?}" + local system_creds_dir=/run/credentials/@system + local encrypted_creds_dir=/run/credentials/@encrypted + + assert_fail test -e "$system_creds_dir/$id" + assert_fail test -e "$encrypted_creds_dir/$id" +} + +# _coco_assert_smbios_injected ID +# Assert the guest received an SMBIOS type #11 OEM string carrying credential ID. The scenario +# injected it, so a miss means the injection was lost on the way in and concluding anything from the +# credential's absence afterwards would be meaningless. +_coco_assert_smbios_injected() { + local id="${1:?}" raw + + [[ -d /sys/firmware/dmi/entries ]] || modprobe dmi-sysfs || : + + for raw in /sys/firmware/dmi/entries/11-*/raw; do + [[ -e "$raw" ]] || continue + # The entry is a blob of NUL-separated strings: --text to search it anyway, --fixed-strings + # because credential names contain dots. Redirect rather than -q, which would exit early and + # risk SIGPIPE on the reader. + if grep --text --fixed-strings "io.systemd.credential:$id=" "$raw" >/dev/null; then + return 0 + fi + done + + echo "FAIL: no SMBIOS OEM string for credential '$id' in the guest's DMI type #11 tables" >&2 + return 1 +} + +# Assert the guest observes the confidential-virtualization technology the host launched it with. +# COCO_TYPE is provided by the injected guest unit (Environment=, see lib.sh). +testcase_coco_detect_virt() { + assert_eq "$(systemd-detect-virt --cvm)" "${COCO_TYPE:?}" +} + +# Assert a credential handed to systemd-vmspawn --set-credential lands in the trusted bucket. +# +# SNP: creds should be delivered via the initrd cpio channel. +testcase_coco_creds_vmspawn() { + assert_eq "$(systemd-creds --system cat "$COCO_CRED_TRUSTED_ID")" "$COCO_CRED_TRUSTED_VALUE" + assert_not_in "$COCO_CRED_TRUSTED_ID" "$(&2 "This file should not be executed directly" + exit 1 +fi + +# shellcheck source=test/units/TEST-94-COCO/fixtures.sh +. "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/fixtures.sh" + +# Exit status the guest test runner reports on success (its unit's SuccessActionExitStatus=). vmspawn +# forwards the guest PID1's EXIT_STATUS over the vsock notify socket and exits with it. +_COCO_GUEST_PASS=123 +# vsock port the host listens on for the guest's per-check result records, distinct from vmspawn's own +# (kernel-assigned) notify port. The host binds CID_ANY:PORT; the guest connects to CID 2 (host):PORT. +_COCO_RESULT_PORT=4321 + +# _coco_guest_unit COCO_TYPE UNITS_DIR TESTCASES +# Emit the one-shot unit that runs the guest test runner: passes the coco type, result port and +# the requested checks via the environment, and relays the aggregate verdict via +# SuccessAction/EXIT_STATUS. +_coco_guest_unit() { + local coco_type="${1:?}" units_dir="${2:?}" testcases="${3:?}" + cat <"$results" + + guest_unit="$(_coco_guest_unit "$coco_type" "$units_dir" "$testcases")" + guest_dropin="$(_coco_guest_dropin)" + + # Collect the per-check records the guest ships over vsock (single connection, guest closes when + # done). Best-effort: the aggregate exit status below is the authoritative pass/fail, so a vsock + # hiccup only costs the per-check breakdown, never the verdict. + socat -u "VSOCK-LISTEN:$_COCO_RESULT_PORT" "OPEN:$results,creat" & + listener_pid=$! + + timeout -k 30 300 systemd-vmspawn \ + --machine="$machine" \ + --coco="$coco_type" \ + --ram=1G \ + --ephemeral \ + --tpm=no \ + --console=read-only \ + --set-credential="systemd.extra-unit.$COCO_GUEST_UNIT:$guest_unit" \ + --set-credential="systemd.unit-dropin.multi-user.target:$guest_dropin" \ + "$@" \ + 2>&1 | tee "$console" || rc="${PIPESTATUS[0]}" + + kill "$listener_pid" 2>/dev/null || : + wait "$listener_pid" 2>/dev/null || : + + if [[ -s "$results" ]]; then + echo "coco guest per-check results:" + cat "$results" + fi + + if [[ "$rc" -eq 124 || "$rc" -eq 137 ]]; then + echo "systemd-vmspawn was killed by timeout (exit $rc)" >&2 + cat "$console" >&2 + return 1 + fi + if [[ "$rc" -ne "$_COCO_GUEST_PASS" ]]; then + echo "coco guest test runner failed: vmspawn exit $rc (expected $_COCO_GUEST_PASS)" >&2 + cat "$console" >&2 + return 1 + fi + return 0 +} diff --git a/test/units/test-control.sh b/test/units/test-control.sh index 1e352c44b3..a1f9de0f87 100644 --- a/test/units/test-control.sh +++ b/test/units/test-control.sh @@ -84,6 +84,8 @@ _show_summary() {( _record_subtest_rc() { local subtest="${1:?}" rc="${2:?}" + subtest="${subtest##*/}" + if [[ $rc -eq $_SUBTEST_SKIP_RC ]]; then echo "Subtest $subtest skipped" _SKIPPED_TESTS+=("$subtest")