From 98687595f49841252988c79e092b453b5fa0ffd4 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Fri, 14 Aug 2026 07:51:55 -0500 Subject: [PATCH 01/20] fix: re-read the VM profile from context between steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute_test assigned the loop's local `profile` onto `ctx.profile` at the top of every iteration. A callable step that replaces the profile — `dataclasses.replace` on a frozen VMProfile returns a new object, so mutating it in place is not possible — had that replacement silently overwritten before the next step ran. Any handler that reconfigures the guest for subsequent steps was therefore a no-op. Read the profile out of the context instead of writing the stale local back over it. --- sev_verify/cli.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sev_verify/cli.py b/sev_verify/cli.py index 1e7677eb..8b26b5d1 100644 --- a/sev_verify/cli.py +++ b/sev_verify/cli.py @@ -378,7 +378,11 @@ def execute_test( for i, step in enumerate(steps): is_last = i == total_steps - 1 - ctx.profile = profile + # Re-read the profile from the context rather than overwriting it. + # A callable step may replace ctx.profile (dataclasses.replace on a + # frozen VMProfile yields a new object); assigning the stale local + # back over it silently discarded that change for every later step. + profile = ctx.profile ctx.launch = launch if _IS_TTY: From dd41633727d3d6b12b2d7044182baec2224d3478 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Fri, 14 Aug 2026 07:52:20 -0500 Subject: [PATCH 02/20] fix: honour expected_result when a VM launch raises run_vm_launch_step reported "error" unconditionally when vm_launch raised VMLaunchError, ignoring the step's expected_result. That made it impossible to write a step that expects a launch to be refused: QEMU exiting immediately raises rather than returning ok=False, so a step declaring expected_result="exit_code:1" errored on exactly the outcome it was asserting. Evaluate expected_result against exit code 1 and the exception text, and record exit_code=1 so the result is self-describing. Steps that do not declare an expectation still default to "exit_code:0" and so still report error, leaving existing behavior unchanged. --- sev_verify/runner.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sev_verify/runner.py b/sev_verify/runner.py index d019bda8..9a8fd56e 100644 --- a/sev_verify/runner.py +++ b/sev_verify/runner.py @@ -175,10 +175,16 @@ def run_vm_launch_step( launch = profile.vm_launch() except VMLaunchError as exc: duration_ms = int((time.monotonic() - start) * 1000) + # A launch that fails hard is still a launch outcome, so honour the + # step's expected_result. Reporting "error" unconditionally made it + # impossible to write a step that expects a launch to be refused — + # QEMU exiting immediately raises rather than returning ok=False. + passed = _check_expected_values(step, 1, str(exc)) return ( StepResult( step=step, - result="error", + result="pass" if passed else "error", + exit_code=1, stderr=str(exc), duration_ms=duration_ms, ), From ad3472d4e5736a8c6e2dd1f67e285bb88f94a2ff Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Fri, 14 Aug 2026 07:52:55 -0500 Subject: [PATCH 03/20] fix: stop a later test result masking a worse earlier one execute_certification folded per-test results with if tr.result != "pass": overall = tr.result which is last-writer-wins, not worst-wins. A run where the first test errors and a later one merely fails reported the certification as "fail", losing the error. The bug is latent today because both outcomes are non-passing, but it silently downgrades severity in reports, and any new non-passing state makes it worse. Fold with an explicit severity ordering instead. --- sev_verify/cli.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/sev_verify/cli.py b/sev_verify/cli.py index 8b26b5d1..93c5dde3 100644 --- a/sev_verify/cli.py +++ b/sev_verify/cli.py @@ -496,6 +496,20 @@ def execute_test( stop_vm(launch) +#: Ordering used to fold many test results into one certification result. +#: A later test must never mask a worse earlier one. "skip" is included for +#: forward compatibility — StepResult already has that state, and a test-level +#: skip belongs between pass and fail. +_RESULT_SEVERITY = {"pass": 0, "skip": 1, "fail": 2, "error": 3} + + +def _worse_result(current: str, candidate: str) -> str: + """Return whichever of the two results is more severe.""" + if _RESULT_SEVERITY.get(candidate, 0) > _RESULT_SEVERITY.get(current, 0): + return candidate + return current + + def execute_certification( cert: CertificationDefinition, guest_path: Path, @@ -533,8 +547,7 @@ def execute_certification( environment=environment, ) test_results.append(tr) - if tr.result != "pass": - overall = tr.result + overall = _worse_result(overall, tr.result) _flush("") icon = _RESULT_LABEL.get(overall, "????") From 1c1d156314c0e7e31f1e2cf481f46804c0e83812 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Thu, 13 Aug 2026 11:15:09 -0500 Subject: [PATCH 04/20] fix: stop waiting for a guest whose QEMU has already exited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vm_launch checks whether QEMU is still alive exactly once, at wait_ready_seconds, and never again. wait_for_guest then polls vsock until vsock_boot_timeout with no reference to the process at all. If QEMU exits after that single check, the harness spends the rest of the boot timeout pinging a VM that no longer exists. Measured on an EPYC 9654 with a deliberately corrupted ID block measurement, sampling the process at 1 Hz: +14.6s .. +17.2s QEMU alive +17.2s .. gone step ran +13.8s .. +195.9s QEMU lived 2.6s; the step took 182.1s. ~179s was spent waiting on a dead process. Policy violations are rejected at SNP_LAUNCH_START before guest memory is loaded, so QEMU dies inside the initial check and those launches already fail in ~2s — which is why only the measurement case was slow. Pass the process handle into wait_for_guest and abort as soon as it exits. The step drops from 182.1s to ~3s and the id-block suite from ~200s to ~21s. The diagnostics matter more than the speed. Firmware had already reported the exact cause: SNP_LAUNCH_FINISH ret=-5 fw_error=11 'Bad measurement' and the harness discarded it in favour of "Vsock agent on CID ... not ready after 180.0s". A negative test asserting exit_code:1 is satisfied by that timeout just as well as by a real rejection, so it would pass identically if the guest merely hung or if the ID block were ignored entirely. The failure message now carries QEMU's exit code and its stderr tail, so a rejected launch states the firmware's reason. process defaults to None, preserving the old behavior for any caller that does not supply it. --- sev_verify/guest_vsock.py | 22 +++++++++++++++++++++- sev_verify/vm_profile.py | 12 +++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/sev_verify/guest_vsock.py b/sev_verify/guest_vsock.py index 6604a95d..9fff3b70 100644 --- a/sev_verify/guest_vsock.py +++ b/sev_verify/guest_vsock.py @@ -14,6 +14,7 @@ import re import shlex import socket +import subprocess import time from pathlib import Path from typing import TYPE_CHECKING, Any @@ -142,9 +143,15 @@ def wait_for_guest( *, timeout: float | None = None, poll_interval: float = 2.0, + process: subprocess.Popen | None = None, ) -> None: """ Block until the guest vsock agent responds or timeout is reached. + + Pass ``process`` (the QEMU handle) to stop waiting the moment QEMU exits. + Without it, a guest that never starts — or one the firmware refuses to + launch — costs the full boot timeout and is then reported as an + indistinguishable "agent not ready", discarding the reason QEMU gave. """ boot_timeout = timeout if timeout is not None else profile.vsock_boot_timeout deadline = time.monotonic() + boot_timeout @@ -156,6 +163,11 @@ def wait_for_guest( return except GuestVsockError as exc: last_error = str(exc) + if process is not None and process.poll() is not None: + raise GuestVsockError( + f"QEMU exited with code {process.returncode} before the " + f"guest became ready" + ) from exc time.sleep(poll_interval) raise GuestVsockError( @@ -169,12 +181,20 @@ def check_guest_ready( *, timeout: float | None = None, poll_interval: float = 2.0, + process: subprocess.Popen | None = None, ) -> tuple[bool, str]: """ Return whether the guest vsock agent responds. + + ``process`` is forwarded to :func:`wait_for_guest`; see its docstring. """ try: - wait_for_guest(profile, timeout=timeout, poll_interval=poll_interval) + wait_for_guest( + profile, + timeout=timeout, + poll_interval=poll_interval, + process=process, + ) except GuestVsockError as exc: return False, str(exc) diff --git a/sev_verify/vm_profile.py b/sev_verify/vm_profile.py index ed500ac5..d042efb3 100644 --- a/sev_verify/vm_profile.py +++ b/sev_verify/vm_profile.py @@ -228,10 +228,20 @@ def vm_launch( # Import here to avoid circular import with guest_vsock. from .guest_vsock import check_guest_ready - booted, boot_error = check_guest_ready(self) + # Passing the process lets the wait abort as soon as QEMU dies, + # instead of polling vsock until the boot timeout expires against + # a VM that no longer exists. + booted, boot_error = check_guest_ready(self, process=process) if not booted: ok = False message = f"Guest did not boot: {boot_error}" + if process.poll() is not None: + # QEMU's own diagnosis is far more useful than "agent not + # ready" — for a rejected launch it names the firmware + # error, e.g. "SNP_LAUNCH_FINISH ... 'Bad measurement'". + stderr_tail = _read_guest_errors(self.guest_error_log).strip() + if stderr_tail: + message = f"{message}\nQEMU stderr:\n{stderr_tail}" elif message == "VM launch verified": message = "VM launched and guest booted" From d4d8f8004a9b548ee8de97e4425e73e48b0fe24b Mon Sep 17 00:00:00 2001 From: markg-github Date: Fri, 14 Aug 2026 13:33:21 -0500 Subject: [PATCH 05/20] fix: make error masking fix more robust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> fix: parameterize the QEMU process type annotation guest_vsock used a bare subprocess.Popen while VMLaunchResult.process is annotated subprocess.Popen[bytes]. Bare Popen reads as Popen[Any] to a type checker, losing the stream element type for no benefit — only .poll() and .returncode are used. Match the existing annotation. refactor: split the severity comparison into named locals The single-expression comparison ran to 107 characters and read poorly, with _RESULT_SEVERITY.get(..., unknown_severity) appearing twice on one line. Name both severities and compare them. Behavior is identical: an unrecognised state still ranks above every known one, so a typo surfaces loudly rather than masking a real failure. That rationale moves from a comment into the docstring, where it is visible to callers. --- sev_verify/cli.py | 13 +++++++++---- sev_verify/guest_vsock.py | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/sev_verify/cli.py b/sev_verify/cli.py index 93c5dde3..81db41d5 100644 --- a/sev_verify/cli.py +++ b/sev_verify/cli.py @@ -504,10 +504,15 @@ def execute_test( def _worse_result(current: str, candidate: str) -> str: - """Return whichever of the two results is more severe.""" - if _RESULT_SEVERITY.get(candidate, 0) > _RESULT_SEVERITY.get(current, 0): - return candidate - return current + """Return whichever of the two results is more severe. + + An unrecognised state ranks above every known one, so a typo surfaces + loudly rather than silently masking a real failure. + """ + unknown = max(_RESULT_SEVERITY.values()) + 1 + current_severity = _RESULT_SEVERITY.get(current, unknown) + candidate_severity = _RESULT_SEVERITY.get(candidate, unknown) + return candidate if candidate_severity > current_severity else current def execute_certification( diff --git a/sev_verify/guest_vsock.py b/sev_verify/guest_vsock.py index 9fff3b70..c8cf6464 100644 --- a/sev_verify/guest_vsock.py +++ b/sev_verify/guest_vsock.py @@ -143,7 +143,7 @@ def wait_for_guest( *, timeout: float | None = None, poll_interval: float = 2.0, - process: subprocess.Popen | None = None, + process: subprocess.Popen[bytes] | None = None, ) -> None: """ Block until the guest vsock agent responds or timeout is reached. @@ -181,7 +181,7 @@ def check_guest_ready( *, timeout: float | None = None, poll_interval: float = 2.0, - process: subprocess.Popen | None = None, + process: subprocess.Popen[bytes] | None = None, ) -> tuple[bool, str]: """ Return whether the guest vsock agent responds. From dfb78e34dcb4492b4e5172ec8d35d9087694dd75 Mon Sep 17 00:00:00 2001 From: Harika Nittala Date: Tue, 18 Aug 2026 17:58:56 -0700 Subject: [PATCH 06/20] fix: optimize host-fedora-41 image size Referenced qemu-kvm-core package to utilize the qemu package required for sev workflow, and removed qemu package to reduce the host artifact size during its build. Signed-off-by: Harika Nittala --- images/host-fedora-41/mkosi.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/images/host-fedora-41/mkosi.conf b/images/host-fedora-41/mkosi.conf index 2f06fd88..6a20017b 100644 --- a/images/host-fedora-41/mkosi.conf +++ b/images/host-fedora-41/mkosi.conf @@ -12,7 +12,7 @@ Packages= systemd-boot-unsigned systemd-networkd systemd-resolved - qemu + qemu-kvm-core edk2-ovmf dnf systemd-journal-remote From c1a1af3b7728278a429dd028cdbd051adfb21d40 Mon Sep 17 00:00:00 2001 From: Harika Nittala Date: Thu, 13 Aug 2026 15:27:24 -0700 Subject: [PATCH 07/20] feat: add fedora 44 release for sev certification workflow Signed-off-by: Harika Nittala --- .github/workflows/build-and-release.yml | 2 ++ images/guest-fedora-44/mkosi.conf | 23 +++++++++++++++++ images/host-fedora-44/mkosi.conf | 33 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 images/guest-fedora-44/mkosi.conf create mode 100644 images/host-fedora-44/mkosi.conf diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index abbf6495..d0cd1851 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -81,6 +81,8 @@ jobs: release: "26.04" - distro: opensuse release: "16.0" + - distro: fedora + release: "44" steps: - name: Checkout diff --git a/images/guest-fedora-44/mkosi.conf b/images/guest-fedora-44/mkosi.conf new file mode 100644 index 00000000..ea21f9a4 --- /dev/null +++ b/images/guest-fedora-44/mkosi.conf @@ -0,0 +1,23 @@ +[Include] +Include=../../modules/build/guest + +[Distribution] +Distribution=fedora +Release=44 + +[Content] +Packages= + kernel + selinux-policy-targeted + systemd + systemd-boot-unsigned + systemd-networkd + systemd-resolved + systemd-journal-remote + jq + xxd + python3 +KernelCommandLine="selinux=0" + +[Build] +Environment=VERSION="44" diff --git a/images/host-fedora-44/mkosi.conf b/images/host-fedora-44/mkosi.conf new file mode 100644 index 00000000..b8f5e484 --- /dev/null +++ b/images/host-fedora-44/mkosi.conf @@ -0,0 +1,33 @@ +[Include] +Include=../../modules/build/host + +[Distribution] +Distribution=fedora +Release=44 + +[Content] +Packages= + kernel + selinux-policy-targeted + systemd + systemd-boot-unsigned + systemd-networkd + systemd-resolved + qemu-kvm-core + edk2-ovmf + dnf + systemd-journal-remote + net-tools + openssh-server + xxd + python3 + python3-devel + python3-pip + python3-emoji + jq + avahi + g++ +KernelCommandLine="selinux=0" + +[Build] +Environment=VERSION="44" From 7b3143cff86c15189e29f44f87a1b7cb375f9588 Mon Sep 17 00:00:00 2001 From: Amanda Liem Date: Thu, 20 Aug 2026 17:13:34 +0000 Subject: [PATCH 08/20] build: cert-matrix comply with conventional-commit Update certification matrix update workflow to comply with conventional commit tag name. Selected docs: as this workflow should only ever touch .md files. Signed-off-by: Amanda Liem --- .github/workflows/update-certification-matrix.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/update-certification-matrix.yml b/.github/workflows/update-certification-matrix.yml index e63892ce..b7bb444f 100644 --- a/.github/workflows/update-certification-matrix.yml +++ b/.github/workflows/update-certification-matrix.yml @@ -145,7 +145,7 @@ jobs: updateTable(lines, table.tableStart, table.tableEnd, table.columnIndex); await github.rest.repos.createOrUpdateFileContents({ - ...repo, path: certPath, message: `cert: Update certification matrix for ${osName}`, + ...repo, path: certPath, message: `docs: Update certification matrix for ${osName}`, content: Buffer.from(lines.join('\n')).toString('base64'), sha, branch: branchName }); @@ -166,7 +166,7 @@ jobs: updateTable(readmeLines, readmeTableStart, readmeTableEnd, readmeColumnIndex); await github.rest.repos.createOrUpdateFileContents({ - ...repo, path: readmePath, message: `cert: Update master certification table for ${osName}`, + ...repo, path: readmePath, message: `docs: Update master certification table for ${osName}`, content: Buffer.from(readmeLines.join('\n')).toString('base64'), sha: readmeSha, branch: branchName }); } @@ -183,7 +183,7 @@ jobs: } else { await github.rest.pulls.create({ ...repo, - title: 'cert: Update certification matrix', + title: 'docs: Update certification matrix', head: branchName, base: defaultBranch, body: `Automatically updating certification matrix.\n\nStarted with: **${osName}** (${milestone}) - refs #${issue.number}` From 73040ce69ac0f9a83943f7c66ded948a4d91b6ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:12:01 +0000 Subject: [PATCH 09/20] docs: Update certification matrix for Fedora 44 --- docs/certifications.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/certifications.md b/docs/certifications.md index 21b31169..592672cf 100644 --- a/docs/certifications.md +++ b/docs/certifications.md @@ -16,6 +16,7 @@ AMD EPYC 7003 (Milan) | Debian 13 | ❌ | [N/A](https://github.com/AMDEPYC/sev-certify/issues/152) | | Debian Forky | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/228) | | Fedora 41 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/279) | +| Fedora 44 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/299) | | Rocky 10.0 | ❌ | N/A | | Rocky 10.1 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/230) | | Rocky 10.2 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/281) | From a6a5dfe82ac8ca990428b1faad737c52c00ee328 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:12:02 +0000 Subject: [PATCH 10/20] docs: Update master certification table for Fedora 44 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 41b7c4a0..d77df84d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This table contains operating systems that have undergone certification testing | Debian 13 | ❌ | [N/A](https://github.com/AMDEPYC/sev-certify/issues/152) | | Debian Forky | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/228) | | Fedora 41 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/279) | +| Fedora 44 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/299) | | Rocky 10.1 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/230) | | Rocky 10.2 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/281) | | Ubuntu 25.04 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/274) | From 1f0428254e855aafd0a137522b10fb7bb8fe0608 Mon Sep 17 00:00:00 2001 From: Harika Nittala Date: Wed, 19 Aug 2026 12:07:51 -0700 Subject: [PATCH 11/20] fix: updated regex to include '-' for opensuse-leap 16.0 expression Signed-off-by: Harika Nittala --- .github/workflows/update-certification-matrix.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/update-certification-matrix.yml b/.github/workflows/update-certification-matrix.yml index b7bb444f..95b88afb 100644 --- a/.github/workflows/update-certification-matrix.yml +++ b/.github/workflows/update-certification-matrix.yml @@ -23,7 +23,7 @@ jobs: // Extract and validate labels const procLabel = issue.labels.find(l => l.name.match(/^proc-\d+$/i)); - const distroLabel = issue.labels.find(l => l.name.match(/^os-[a-zA-Z]+(?:-[a-zA-Z0-9.]+)?$/i)); + const distroLabel = issue.labels.find(l => l.name.match(/^os-[a-zA-Z]+(?:-[a-zA-Z0-9.]+)*$/i)); if (!procLabel || !distroLabel) return console.log('Missing processor or OS label'); const procSeries = procLabel.name.replace(/^proc-/i, ''); @@ -219,7 +219,7 @@ jobs: // Find the OS label and processor label const osLabel = issue.labels.find(label => - label.name.match(/^os-[a-zA-Z]+(?:-[a-zA-Z0-9.]+)?$/i) + label.name.match(/^os-[a-zA-Z]+(?:-[a-zA-Z0-9.]+)*$/i) ); const procLabel = issue.labels.find(label => label.name.match(/^proc-\d+$/i) From 9fc02bbe7eef0ae4751aa43ba0803d514394cc5d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:04:43 +0000 Subject: [PATCH 12/20] docs: Update certification matrix for Opensuse leap 16.0 --- docs/certifications.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/certifications.md b/docs/certifications.md index 592672cf..4e8c40c4 100644 --- a/docs/certifications.md +++ b/docs/certifications.md @@ -17,6 +17,7 @@ AMD EPYC 7003 (Milan) | Debian Forky | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/228) | | Fedora 41 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/279) | | Fedora 44 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/299) | +| Opensuse leap 16.0 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/301) | | Rocky 10.0 | ❌ | N/A | | Rocky 10.1 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/230) | | Rocky 10.2 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/281) | From d81c7a0de73f812c5a9611e43cebed688a191279 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:04:44 +0000 Subject: [PATCH 13/20] docs: Update master certification table for Opensuse leap 16.0 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d77df84d..3d13f2d0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This table contains operating systems that have undergone certification testing | Debian Forky | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/228) | | Fedora 41 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/279) | | Fedora 44 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/299) | +| Opensuse leap 16.0 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/301) | | Rocky 10.1 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/230) | | Rocky 10.2 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/281) | | Ubuntu 25.04 | ✅ | [c3.0.0-0](https://github.com/AMDEPYC/sev-certify/issues/274) | From b72846f83b4c738f540059733ebcf76ac6d831cc Mon Sep 17 00:00:00 2001 From: Amanda Liem Date: Thu, 27 Aug 2026 14:56:49 +0000 Subject: [PATCH 14/20] build: test removing virtualization dep --- images/host-opensuse-16.0/mkosi.conf | 1 + .../mkosi.pkgmngr/etc/zypp/repos.d/virtualization.repo | 8 -------- images/host-opensuse-16.0/mkosi.prepare | 7 ------- 3 files changed, 1 insertion(+), 15 deletions(-) delete mode 100644 images/host-opensuse-16.0/mkosi.pkgmngr/etc/zypp/repos.d/virtualization.repo delete mode 100755 images/host-opensuse-16.0/mkosi.prepare diff --git a/images/host-opensuse-16.0/mkosi.conf b/images/host-opensuse-16.0/mkosi.conf index bc3c6558..73b1edd7 100644 --- a/images/host-opensuse-16.0/mkosi.conf +++ b/images/host-opensuse-16.0/mkosi.conf @@ -21,6 +21,7 @@ Packages= SUSEConnect shadow qemu + qemu-ovmf-x86_64 kernel rpm systemd-journal-remote diff --git a/images/host-opensuse-16.0/mkosi.pkgmngr/etc/zypp/repos.d/virtualization.repo b/images/host-opensuse-16.0/mkosi.pkgmngr/etc/zypp/repos.d/virtualization.repo deleted file mode 100644 index ec91eac8..00000000 --- a/images/host-opensuse-16.0/mkosi.pkgmngr/etc/zypp/repos.d/virtualization.repo +++ /dev/null @@ -1,8 +0,0 @@ -[Virtualization] -name=Virtualization (16.0) -enabled=1 -autorefresh=1 -baseurl=https://download.opensuse.org/repositories/Virtualization/16.0/ -gpgcheck=1 -gpgkey=https://download.opensuse.org/repositories/Virtualization/16.0/repodata/repomd.xml.key -priority=90 diff --git a/images/host-opensuse-16.0/mkosi.prepare b/images/host-opensuse-16.0/mkosi.prepare deleted file mode 100755 index a83c703e..00000000 --- a/images/host-opensuse-16.0/mkosi.prepare +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -set -euo pipefail - -if [[ "$1" == "final" ]]; then - zypper --non-interactive --gpg-auto-import-keys refresh Virtualization - zypper --non-interactive install --allow-vendor-change --from Virtualization qemu-ovmf-x86_64 -fi From ee7d9755ac5542b5b9be7dac7a2e5f9cc7762cc1 Mon Sep 17 00:00:00 2001 From: Amanda Liem Date: Mon, 6 Jul 2026 16:53:28 +0000 Subject: [PATCH 15/20] feat: convert snphost_config_commit to mixed-scope Convert the host-only TCB config test into a mixed-scope test that verifies config changes are reflected in guest attestation reports. Test by inspecting attestation reports in these cases: 1. Fresh VM (booted after config-set-lower): guest reported == host reported tcb guest current == host platform tcb signed by lowered host reported tcb 2. Live VM (same VM as 1 after config-reset): guest reported == restored host reported tcb guest current == host platform tcb Only the alternate lower tcb VCEK is fetched, as normal is covered by 3.0.0-0 attestation test and don't want to trigger KDS rate-limit. A final info-type config-reset ensures TCB cleanup regardless of test outcome. Signed-off-by: Amanda Liem --- docs/certifications.md | 2 +- docs/features/tcb-config-commit.md | 119 +++++ .../usr/local/lib/scripts/run-sev-verify.sh | 4 + sev_verify/README.md | 18 + .../c3_0/c3_0_0_1/snphost_config_commit.py | 416 ++++++++++++++++-- sev_verify/cert_tests/c3_0/manifest.toml | 3 +- sev_verify/cli.py | 66 ++- sev_verify/models.py | 9 + sev_verify/runner.py | 4 +- 9 files changed, 594 insertions(+), 47 deletions(-) create mode 100644 docs/features/tcb-config-commit.md diff --git a/docs/certifications.md b/docs/certifications.md index 4e8c40c4..3dcf6ac8 100644 --- a/docs/certifications.md +++ b/docs/certifications.md @@ -45,5 +45,5 @@ AMD EPYC 9005 (Turin) | Level | Features Certified | |---|---| | 3.0.0-0 | SEV-SNP Attestation | -| 3.0.0-1 | memfd numa, key derivation, vlek loading, snphost config, snphost commit | +| 3.0.0-1 | memfd numa, key derivation, vlek loading, [snphost config & commit](features/tcb-config-commit.md) | | 3.1.1-0 | Memory Hotplug, Vector Mitigation, Cloud Hypervisor | \ No newline at end of file diff --git a/docs/features/tcb-config-commit.md b/docs/features/tcb-config-commit.md new file mode 100644 index 00000000..2c849ee0 --- /dev/null +++ b/docs/features/tcb-config-commit.md @@ -0,0 +1,119 @@ +# TCB Config & Commit (`SNP_CONFIG` / `SNP_COMMIT`) + +**Description:** Host firmware commands that perform operations on the host's TCB (Trusted Computing Base) levels. These levels are recorded in SEV-SNP attestation reports, and determine the VCEK that signs the report. Both operate on **in-memory firmware state only** and reset on reboot. +**When to Use:** `SNP_CONFIG` for air-gapped/fleet VCEK management, `SNP_COMMIT` for provisional (in-memory) firmware updates. Note that provisional firmware updates are still in active development in the upstream linux kernel. +**How to Use:** `snphost` CLI (`config set`, `config reset`, `commit`) on the host. +**What sev-certify tests:** Base functionality test for these commands: lowers `ReportedTcb` via config set, boots a guest, verifies the attestation report + signing VCEK reflects the change, resets, and confirms a live guest's next report reflects the restored values. + +--- + +## What It Is + +AMD SEV-SNP records TCB (Trusted Computing Base) versions in attestation reports; the *reported* version also determines which [VCEK](#vcek) certificate signs the report. A TCB is a 64-bit value encoding security patch levels of each firmware component (bootloader, TEE, SNP, microcode, and [FMC](#fmc) on Turin). + +The [PSP](#psp) tracks three platform-wide TCB values: + +- **`CurrentTcb`** — the TCB of the firmware currently running. +- **`CommittedTcb`** — the anti-rollback floor; the platform will not run firmware below this level. +- **`ReportedTcb`** — the value used to select the signing VCEK. + +The [ABI](#abi) enforces `ReportedTcb <= CommittedTcb <= CurrentTcb`. + +It also tracks a fourth, per-VM value in each guest context ([GCTX](#gctx)): + +- **`LaunchTcb`** — `CurrentTcb` captured at the moment the guest was launched (or imported). It is stamped into the guest's attestation report as `LAUNCH_TCB` and caps key derivation for that guest (a derived key's `TCB_VERSION` may not exceed `LaunchTcb`). `SNP_CONFIG` and `SNP_COMMIT` do not change it — it is fixed for the life of the VM. + +`SNP_CONFIG` and `SNP_COMMIT` are host firmware commands that adjust `ReportedTcb` and `CommittedTcb` respectively. **Both only mutate in-memory firmware state — neither touches the firmware installed in flash — so both reset on reboot**, at which point the flash firmware reloads and the PSP's TCB values return to their installed defaults. (Making a committed floor truly permanent requires installing new firmware to flash, a separate operation from `snphost commit`.) + +- **`SNP_CONFIG` (set)** (`snphost config set`) — Overrides `ReportedTcb` to a value lower than `CommittedTcb`. Guests booted after this command have their attestation reports signed with the VCEK corresponding to the lowered TCB. +- **`SNP_CONFIG` (reset)** (`snphost config reset`) — Clears the override, restoring `ReportedTcb` to match `CurrentTcb`. Takes effect on live VMs immediately (the next attestation report reflects the restored value). +- **`SNP_COMMIT`** (`snphost commit`) — Commits the currently-running (provisionally-loaded) firmware, advancing `CommittedTcb` up to `CurrentTcb` so the new firmware's TCB is reflected in VCEK derivation and attestation reports. + +--- + +## Use Case 1 — Air-Gapped & Fleet VCEK Management (`SNP_CONFIG`) + +When firmware is updated across a cluster, the TCB changes and a new VCEK certificate is needed for attestation. In environments without connectivity to AMD's [Key Distribution Service (KDS)](#kds), or during rolling upgrades where hosts run mixed firmware versions, `SNP_CONFIG` lets operators pin `ReportedTcb` so existing VCEK certificates stay valid: + +1. **Defer VCEK refresh** — After a firmware update, set `ReportedTcb` back to the pre-update value so existing cached VCEK certificates remain valid. Update the cache during a planned maintenance window. +2. **Maintain mixed-version clusters** — During rolling upgrades, keep all hosts reporting the same TCB so a single cached VCEK per chip covers the entire fleet. +3. **Pre-stage certificates** — Fetch the new VCEK before applying firmware, then apply the update and let `ReportedTcb` advance naturally. + +Constraints: +- `ReportedTcb <= CommittedTcb` — you cannot set `ReportedTcb` above the committed floor. +- `SNP_CONFIG` does not persist across reboots; orchestration must re-apply it after each boot. + +## Use Case 2 — Provisional In-Memory Firmware Updates (`SNP_COMMIT`) + +`SNP_COMMIT` supports the **provisional firmware update** flow. The hypervisor can load a new firmware image *provisionally* via `DOWNLOAD_FIRMWARE_EX` ([AMD SEV-SNP Firmware ABI spec, Platform Management, p.24](https://www.amd.com/content/dam/amd/en/documents/developer/56860.pdf#page=24)) so it can later roll back to the previously loaded firmware if it chooses. + +> **Note:** Linux kernel support for `DOWNLOAD_FIRMWARE_EX` is still under active development upstream, so the provisional firmware update flow described here is not yet generally available. + +After executing a `DOWNLOAD_FIRMWARE_EX` operation, the hypervisor has two choices: + +- **Commit** — call `SNP_COMMIT`, which sets `CommittedTcb := CurrentTcb`. After this operation, the firmware will reject any downgrade below the newly committed level. Commit also sets `ReportedTcb := CurrentTcb` (ABI §8.3), so any `SNP_CONFIG` override in effect is cleared. +- **Roll back** — invoke `DOWNLOAD_FIRMWARE_EX` with the previously committed firmware image. + +Within a boot session `SNP_COMMIT` is a one-way ratchet — the floor can be raised but not lowered. But as noted above it lives in memory only: a reboot reloads the flash firmware and reverts `CommittedTcb` to the installed level, so making an update permanent still requires installing the new image to flash. + +--- + +## How To Use It + +The `snphost` CLI (from the [VirTEE](https://github.com/virtee/snphost) project) wraps the firmware commands. + +```sh +# View current TCB values (Reported + Platform) +snphost show tcb + +# --- Use Case 1: SNP_CONFIG --- +# Lower ReportedTcb (arguments: BL TEE SNP UCODE MASK_CHIP [FMC]) +# Example: decrement Boot Loader SPL by 1 from current value of 4 +snphost config set 3 2 27 25 0 + +# Reset ReportedTcb back to CurrentTcb +snphost config reset + +# --- Use Case 2: SNP_COMMIT --- +# Advance CommittedTcb to CurrentTcb (resets on reboot) +snphost commit +``` + +After `config set`, any guest requesting an attestation report will receive one signed with the VCEK corresponding to the lowered `ReportedTcb`. After `config reset`, the next attestation report (even from a running VM) reflects the restored values. + +## How We Test It + +The test is `snphost-config-commit` at certification level `3.0.0-1`, defined in: + +- **Test module:** [`sev_verify/cert_tests/c3_0/c3_0_0_1/snphost_config_commit.py`](../../sev_verify/cert_tests/c3_0/c3_0_0_1/snphost_config_commit.py) +- **Manifest entry:** [`sev_verify/cert_tests/c3_0/manifest.toml`](../../sev_verify/cert_tests/c3_0/manifest.toml) + +It is a **mixed-scope** test — it exercises host commands and verifies their effect inside a guest VM. See the test module and manifest above for the exact commands, flags, and assertions; what follows is the logical flow. + +**`SNP_CONFIG` path.** The test reads the current platform TCB, lowers a single TCB field via `config set`, and confirms host-side that `ReportedTcb` now diverges from the platform value. It then boots a guest and pulls an attestation report, checking that the guest's reported TCB tracks the lowered value while its current TCB still reflects the unchanged platform. To prove the firmware actually re-derived the signing key (rather than just rewriting report fields), it fetches the *alternate* VCEK for the lowered TCB from the KDS and does a signature-only verification. Only the alternate VCEK is fetched — the baseline one is already exercised by the `3.0.0-0` attestation test, and re-fetching risks KDS rate-limiting. The override is then cleared with `config reset`, and the *same live VM* is asked for a second report to confirm the reset takes effect immediately on running guests. + +**`SNP_COMMIT` path.** Before committing, the test guards against blessing a provisional firmware image: it compares `CommittedTcb` and `CurrentTcb` (read from the attestation report, the only output that carries `CommittedTcb`) and normally halts if they differ, since committing would advance the anti-rollback floor and remove the operator's ability to roll back. The teardown steps (stop VM, final `config reset`) are ordered *before* this gate precisely so that halting here cannot skip them — a failing `setup`-type step skips all later steps, so cleanup that must always run has to precede it. TCB state is therefore left clean whether the gate passes or halts. When `--allow-host-changes` is set, this guard is downgraded to a warning so the commit path can run to completion — advancing the floor is acceptable there because it resets on the next reboot. The test then runs `commit` and checks it returns success. + +We can only test the **no-op commit**. On a normal (non-provisional) host `CommittedTcb == CurrentTcb`, so `snphost commit` has nothing to commit: it leaves the floor where it is and — despite ABI §8.3 — does *not* reset `ReportedTcb`, so any `SNP_CONFIG` override remains in effect (verified empirically against snphost 0.7.0). The `ReportedTcb := CurrentTcb` reset described in the ABI is only observable when commit actually commits a provisionally-loaded firmware image, which requires kernel `DOWNLOAD_FIRMWARE_EX` support that is not yet generally available. The test therefore asserts only that a no-op `commit` succeeds; it does not assert the override-clearing side effect. Teardown (stop VM, final `config reset`) runs before the commit gate so it happens regardless of the commit path's outcome. + +--- + +## Glossary + + +**ABI (Application Binary Interface)** — The [AMD SEV-SNP Firmware ABI Specification](https://www.amd.com/content/dam/amd/en/documents/developer/56860.pdf) (publication #56860), which defines the PSP firmware commands, the guest context and attestation report layouts, and the TCB ordering rules referenced throughout this document. + + +**FMC** — A firmware component whose security patch level is one of the fields in the TCB version. Present only on "Turin" (Family 1Ah) and newer chips; earlier Genoa/Milan TCB versions omit it. + + +**GCTX (Guest Context)** — Per-VM firmware state the [PSP](#psp) maintains for each SEV-SNP guest. It holds values fixed at launch, including `LaunchTcb`, which is stamped into the guest's attestation report as `LAUNCH_TCB`. + + +**KDS (Key Distribution Service)** — AMD's public service that distributes [VCEK](#vcek) certificates. A verifier fetches the VCEK for a report's `ReportedTcb` from the KDS to check the report's signature. + + +**PSP (Platform Security Processor)** — The dedicated security co-processor on AMD SoCs that runs the SEV-SNP firmware, tracks the platform TCB values, and derives attestation signing keys. + + +**VCEK (Versioned Chip Endorsement Key)** — An attestation signing key derived from chip-unique secrets and a TCB version. The VCEK corresponding to a report's `ReportedTcb` signs that report; a verifier fetches the matching VCEK certificate from the [KDS](#kds) to validate the signature. diff --git a/modules/test/host/sev-verify/mkosi.extra/usr/local/lib/scripts/run-sev-verify.sh b/modules/test/host/sev-verify/mkosi.extra/usr/local/lib/scripts/run-sev-verify.sh index bea98b8e..c925d067 100755 --- a/modules/test/host/sev-verify/mkosi.extra/usr/local/lib/scripts/run-sev-verify.sh +++ b/modules/test/host/sev-verify/mkosi.extra/usr/local/lib/scripts/run-sev-verify.sh @@ -6,9 +6,13 @@ LOG_FILE="${RESULTS_DIR}/sev-verify.log" mkdir -p "$RESULTS_DIR" +# This is a dedicated test-host image (rebuilt/reprovisioned per run), so +# boot-session-only host changes are fine — notably letting `snphost commit` +# advance the committed TCB floor (it resets on reboot). python3 -m sev_verify \ /usr/local/lib/guest-image/guest.efi \ --output-dir "$RESULTS_DIR" \ + --allow-host-changes \ 2>&1 | tee "$LOG_FILE" exit "${PIPESTATUS[0]}" diff --git a/sev_verify/README.md b/sev_verify/README.md index e75e43dc..95026c61 100644 --- a/sev_verify/README.md +++ b/sev_verify/README.md @@ -3,6 +3,9 @@ Host-side testing harness for SEV-SNP certification. Reads TOML manifests that declare which tests to run, imports per-test Python modules that define executable steps, and orchestrates execution across host and guest environments. sev-verify uses a non-secure vsock channel between the host and the guest, which is launched as a CVM. Given the purpose of sev-certify, this is acceptable. The vsock channel properties are properties of the guest and the guest is purpose-built for sev-certify. As built, there is no incentive for an attacker to take advantage of the security weakness of the vsock channel. +> [!WARNING] +> This harness modifies host firmware/platform settings and is not intended for hosts running production workloads. It is meant for test and development servers, where its purpose is to validate operating systems. For safe platform readiness checks, use [snphost](https://github.com/virtee/snphost) instead. + ## Usage ```bash @@ -79,3 +82,18 @@ results/ Output (gitignored) ## Requirements Python 3.11+ (uses `tomllib` from stdlib). No external packages. + +## Flags + +Invoke as `python3 -m sev_verify [flags]`. There are no subcommands — a single positional argument plus optional flags. + +| Argument / flag | Default | Description | +| --- | --- | --- | +| `path_to_guest` | *(required)* | Path to the guest image/UKI to test. | +| `-v`, `--version` | all manifests | Version filter(s). Accepts `3.0` (all tests in cert 3.0), `3.0.0` (all `3.0.0-*` levels), or `3.0.0-0` (exact level). Comma-separated lists and repeated `-v` flags both work. If omitted, every `cert_tests/*/manifest.toml` runs. | +| `-o`, `--output-dir` | `results/` | Directory for JSON and Markdown result files. | +| `--artifacts-dir DIR` | `./artifacts` | Base directory for per-test artifact folders (see [Artifacts directory](#artifacts-directory)). | +| `--qemu-binary`, `--qemu PATH` | test `VMProfile`, then `qemu-system-x86_64` | Override the QEMU executable for every test that launches a VM. Path must exist. | +| `--ovmf PATH` | test `VMProfile`, then host search paths | Override the OVMF firmware `.fd` for every test that launches a VM. Path must exist. | +| `--allow-host-changes` | off | Allow tests to make host-level changes, such as firmware TCB settings (e.g. `snphost commit` advancing the committed TCB floor). These are boot-session-only and reset on reboot. Tests that may change host state are declared with `host_changes = true` in the manifest and listed at startup (grouped by level) along with whether this flag is active. | + diff --git a/sev_verify/cert_tests/c3_0/c3_0_0_1/snphost_config_commit.py b/sev_verify/cert_tests/c3_0/c3_0_0_1/snphost_config_commit.py index e7721f20..1b16532c 100644 --- a/sev_verify/cert_tests/c3_0/c3_0_0_1/snphost_config_commit.py +++ b/sev_verify/cert_tests/c3_0/c3_0_0_1/snphost_config_commit.py @@ -1,13 +1,9 @@ -"""snphost config/commit: Test SNP_CONFIG and SNP_COMMIT host commands. +"""snphost config/commit: Verify TCB config changes in guest attestation. -Exercises snphost config set/reset and commit against the platform's -SEV-SNP firmware via /dev/sev. All steps are host-side. - -TCB values are read from ``snphost show tcb`` at step-definition time -so each command is a concrete ``snphost config set `` invocation. - -After config changes, :func:`verify_match` / :func:`verify_differ` compare -Reported vs Platform TCB (same checks as ``python3 -m verify-*``). +Mixed-scope test: exercises ``snphost config set/reset`` and ``snphost +commit`` on the host and verifies the effect in guest attestation reports +(via ``snpguest report``). See ``docs/features/tcb-config-commit.md`` for +the full feature/spec background. """ import re @@ -15,18 +11,47 @@ import sys from sev_verify.models import BaseStep, Step, StepContext, StepHandlerResult +from sev_verify.vm_profile import VMProfile -_THIS_MODULE = __name__ # sev_verify.cert_tests.c3_0.c3_0_0_1.snphost_config_commit +_THIS_MODULE = __name__ -# Core TCB fields (excludes FMC, which is not affected by config set) +vm_profile = VMProfile( + image_path="", + memory_mb=2048, +) + +# Core TCB fields, present on every generation. _CORE_TCB_FIELDS = ("Boot Loader", "TEE", "SNP", "Microcode") +# FMC is a Turin-only TCB component (Family 1Ah, bits 7:0 of TCB_VERSION); +# absent / "None" on Milan/Genoa. Compared only when present on both sides. +_ALL_TCB_FIELDS = _CORE_TCB_FIELDS + ("FMC",) + + +def _tcb_fields_match(a: dict[str, str], b: dict[str, str]) -> bool: + """True iff every TCB field present on both dicts is equal. + + Core fields must exist on both sides — ``in`` rather than ``.get()`` so a + field missing from both (``None == None``) does not read as a match, since + a missing core field means a parse/format problem, not agreement. + + FMC is compared only when present on both sides: it is a real, independently + mutable component on Turin (so an FMC-only divergence must not read as a + match), but absent on older parts where there is nothing to compare. + """ + if not all(f in a and f in b for f in _CORE_TCB_FIELDS): + return False + return all(a[f] == b[f] for f in _ALL_TCB_FIELDS if f in a and f in b) # ── TCB parsing (shared by steps() and verify CLI) ────────────── def _parse_tcb_sections(output: str) -> dict[str, dict[str, str]]: - """Parse snphost show tcb output into {section: {field: value}}. + """Parse TCB output into ``{section: {field: value}}``. + + Works with both ``snphost show tcb`` (sections: Reported, Platform) + and ``snpguest display report`` (sections: Current, Committed, + Reported). Expected format:: @@ -55,15 +80,47 @@ def _run_snphost_tcb() -> subprocess.CompletedProcess: ) -def _read_platform_tcb() -> dict[str, str]: - """Read Platform TCB fields from snphost.""" +def _read_host_tcb() -> dict[str, dict[str, str]]: + """Read all TCB sections (Reported + Platform) from snphost.""" proc = _run_snphost_tcb() if proc.returncode != 0: raise RuntimeError(f"snphost show tcb failed: {proc.stderr.strip()}") sections = _parse_tcb_sections(proc.stdout) - if "Platform" not in sections: - raise RuntimeError("no Platform TCB section in snphost show tcb output") - return sections["Platform"] + for name in ("Reported", "Platform"): + if name not in sections: + raise RuntimeError(f"no {name} TCB section in snphost show tcb output") + return sections + + +def _read_platform_tcb() -> dict[str, str]: + """Read Platform TCB fields from snphost.""" + return _read_host_tcb()["Platform"] + + +def _parse_report_tcb_sections(report_path: str) -> dict[str, dict[str, str]]: + """Parse TCB sections from a guest attestation report binary. + + Runs ``snpguest display report `` on the host and extracts the + "Current TCB", "Committed TCB", and "Reported TCB" sections. + """ + proc = subprocess.run( + ["snpguest", "display", "report", report_path], + capture_output=True, text=True, timeout=10, + ) + if proc.returncode != 0: + raise RuntimeError( + f"snpguest display report failed: {proc.stderr.strip()}" + ) + sections = _parse_tcb_sections(proc.stdout) + for name in ("Current", "Committed", "Reported"): + if name not in sections: + raise RuntimeError( + f"no {name} TCB section in snpguest display report output" + ) + return {name: sections[name] for name in ("Current", "Committed", "Reported")} + + +# ── Host-side verification ────────────────────────────────────── def _verify_result(mode: str) -> StepHandlerResult: @@ -79,11 +136,11 @@ def _verify_result(mode: str) -> StepHandlerResult: reported = sections.get("Reported", {}) platform = sections.get("Platform", {}) - match = all(reported.get(f) == platform.get(f) for f in _CORE_TCB_FIELDS) + match = _tcb_fields_match(reported, platform) if mode == "verify-match" and not match: lines = [ - "FAIL: Reported TCB should match Platform after reset", + "FAIL: Reported should match Platform after reset", f" Reported: {reported}", f" Platform: {platform}", ] @@ -91,7 +148,7 @@ def _verify_result(mode: str) -> StepHandlerResult: if mode == "verify-differ" and match: return StepHandlerResult( exit_code=1, - stderr="FAIL: Reported TCB should differ from Platform after config set", + stderr="FAIL: Reported should differ from Platform after config set", ) return StepHandlerResult(exit_code=0) @@ -106,6 +163,206 @@ def verify_differ(_ctx: StepContext) -> StepHandlerResult: return _verify_result("verify-differ") +# ── Guest report verification ────────────────────────────────── + + +def _verify_guest_tcb( + report_path: str, + expect_match_reported: bool, +) -> StepHandlerResult: + """Compare guest report TCB sections against the corresponding host values. + + Check Guest Reported TCB == host Reported TCB + Check Guest Current TCB == host Platform TCB + """ + try: + guest = _parse_report_tcb_sections(report_path) + host = _read_host_tcb() + except RuntimeError as e: + return StepHandlerResult(exit_code=1, stderr=str(e)) + + guest_current = guest["Current"] + guest_reported = guest["Reported"] + host_reported = host["Reported"] + host_platform = host["Platform"] + + guest_reported_matches_host = _tcb_fields_match(guest_reported, host_reported) + guest_current_matches_platform = _tcb_fields_match(guest_current, host_platform) + reported_matches_platform = _tcb_fields_match(host_reported, host_platform) + + errors: list[str] = [] + + if not guest_reported_matches_host: + errors.append("FAIL: guest Reported != host Reported") + if not guest_current_matches_platform: + errors.append("FAIL: guest Current != host Platform") + + if expect_match_reported and not reported_matches_platform: + errors.append("FAIL: host Reported should match Platform after reset") + elif not expect_match_reported and reported_matches_platform: + errors.append("FAIL: host Reported should differ from Platform after config set") + + dump = [ + f" guest Reported: {guest_reported}", + f" guest Current: {guest_current}", + f" host Reported: {host_reported}", + f" host Platform: {host_platform}", + ] + if errors: + return StepHandlerResult(exit_code=1, stderr="\n".join(errors + dump)) + + label = "matches" if expect_match_reported else "differs from" + return StepHandlerResult( + exit_code=0, + stdout="\n".join( + [f"Guest TCB matches host (Reported {label} Platform)"] + dump + ), + ) + + +def verify_guest_report_lowered(ctx: StepContext) -> StepHandlerResult: + """Verify the fresh-VM report carries the lowered TCB values. + """ + report_path = ctx.artifact_dir / "report.bin" + if not report_path.exists(): + return StepHandlerResult(exit_code=1, stderr=f"report not found: {report_path}") + return _verify_guest_tcb(str(report_path), expect_match_reported=False) + + +def verify_guest_report_restored(ctx: StepContext) -> StepHandlerResult: + """Verify the live-VM report carries the restored (original) TCB values. + """ + report_path = ctx.artifact_dir / "report_after_reset.bin" + if not report_path.exists(): + return StepHandlerResult(exit_code=1, stderr=f"report not found: {report_path}") + return _verify_guest_tcb(str(report_path), expect_match_reported=True) + + +# ── VCEK signature verification ───────────────────────────────── + + +def verify_lowered_report_signature(ctx: StepContext) -> StepHandlerResult: + """Verify the fresh-VM report is signed by the VCEK for its lowered TCB. + """ + report_path = ctx.artifact_dir / "report.bin" + if not report_path.exists(): + return StepHandlerResult(exit_code=1, stderr=f"report not found: {report_path}") + + certs_dir = ctx.artifact_dir / "vcek_lowered" + certs_dir.mkdir(parents=True, exist_ok=True) + + fetch = subprocess.run( + ["snpguest", "fetch", "vcek", "pem", str(certs_dir), str(report_path)], + capture_output=True, text=True, timeout=120, + ) + if fetch.returncode != 0: + stderr = fetch.stderr.strip() + hint = " (KDS rate-limited; re-run in a minute)" if "429" in stderr else "" + return StepHandlerResult( + exit_code=1, + stderr=f"snpguest fetch vcek failed{hint}: {stderr}", + ) + + verify = subprocess.run( + [ + "snpguest", "verify", "attestation", "--signature", + str(certs_dir), str(report_path), + ], + capture_output=True, text=True, timeout=120, + ) + if verify.returncode != 0: + return StepHandlerResult( + exit_code=1, + stdout=verify.stdout, + stderr=f"FAIL: lowered-TCB report not signed by its VCEK: {verify.stderr.strip()}", + ) + + return StepHandlerResult( + exit_code=0, + stdout=f"Lowered-TCB report signature verified\n {verify.stdout.strip()}", + ) + + +# ── Commit precondition + commit ──────────────────────────────── + + +def verify_committed_equals_current(ctx: StepContext) -> StepHandlerResult: + """Precondition gate for the commit: require ``CommittedTcb == CurrentTcb``. + + A ``setup`` step (see module docstring): if Committed does not already + equal Current, committing would advance the floor and bless provisional + firmware, so this fails and the runner halts before the commit step — + unless ``--allow-host-changes`` (``ctx.allow_host_changes``) is set, which + downgrades it to a warning and lets the commit proceed. + """ + report_path = ctx.artifact_dir / "report_after_reset.bin" + if not report_path.exists(): + return StepHandlerResult(exit_code=1, stderr=f"report not found: {report_path}") + + try: + sections = _parse_report_tcb_sections(str(report_path)) + except RuntimeError as e: + return StepHandlerResult(exit_code=1, stderr=str(e)) + + committed = sections["Committed"] + current = sections["Current"] + + dump = f" Committed: {committed}\n Current: {current}" + + if _tcb_fields_match(committed, current): + return StepHandlerResult( + exit_code=0, + stdout=f"Committed == Current - commit is a no-op on the floor.\n{dump}", + ) + + # Committed < Current: provisional firmware. Committing would advance the + # floor. Allow it only when host-level changes are permitted. + if getattr(ctx, "allow_host_changes", False): + warning = ( + "WARNING: provisional firmware (Committed < Current). --allow-host-changes " + f"set; commit will ADVANCE the floor this boot (resets on reboot).\n{dump}" + ) + # stderr so the operator sees it live, not just in artifacts. + print(warning, file=sys.stderr) + return StepHandlerResult(exit_code=0, stdout=warning) + + return StepHandlerResult( + exit_code=1, + stderr=( + "FAIL: provisional firmware (Committed < Current). Committing would " + "advance the floor and bless the provisional image, blocking rollback. " + f"Pass --allow-host-changes for a host that will be rebooted.\n{dump}" + ), + ) + + +def commit_current_tcb(_ctx: StepContext) -> StepHandlerResult: + """Run ``snphost commit`` and check return code. + + Runs only after the ``verify-committed-equals-current`` precondition, so + on a normal host the commit is a no-op. Force bypass with + ``--allow-host-changes``. + """ + proc = subprocess.run( + ["snphost", "commit"], + capture_output=True, text=True, timeout=30, + ) + if proc.returncode != 0: + return StepHandlerResult( + exit_code=1, + stdout=proc.stdout, + stderr=f"snphost commit failed: {proc.stderr.strip()}", + ) + + return StepHandlerResult( + exit_code=0, + stdout="snphost commit succeeded", + ) + + +# ── CLI entry (unchanged) ────────────────────────────────────── + + def _verify_cli(mode: str) -> int: """CLI entry: print stderr from result and return exit code.""" r = _verify_result(mode) @@ -147,62 +404,147 @@ def steps() -> list[BaseStep]: lo_tee -= 1 elif ucode > 0: lo_ucode -= 1 + else: + raise RuntimeError( + "Cannot run test: all TCB fields (Boot Loader, TEE, SNP, Microcode) " + "are 0; need a non-zero field to lower." + ) return [ + # 1. Read current Platform TCB Step.for_host( name="show-tcb", type="setup", command="snphost show tcb", ), + # 2. Lower one TCB field Step.for_host( name="config-set-lower", type="required", command=_config_set(lo_bl, lo_tee, lo_snp, lo_ucode, fmc, 0), ), + # 3. Host-side check: Reported != Platform Step.for_callable( name="verify-differ after config-set-lower", type="required", handler="verify_differ", + timeout=30, + ), + # 4. Boot a fresh VM (TCB was lowered before boot) + Step.for_vm_launch( + name="Launch SEV-SNP guest", + type="required", + timeout=300, + ).add_hint( + "Address already in use", + "A previous VM may still be running. " + "Try: sudo kill $(pgrep -f 'qemu.*guest-cid')", + ), + # 5. Guest requests attestation report + Step.for_guest( + name="guest-report-after-lower", + type="required", + command="snpguest report report.bin request.bin --random", + timeout=300, + ), + # 6. Pull report from guest + Step.for_guest_pull( + name="pull-report-after-lower", + type="required", + guest_src="report.bin", + host_dest="report.bin", + timeout=120, + ), + # 7. Verify guest report TCB reflects the lowered config + Step.for_callable( + name="verify-guest-report-lowered", + type="required", + handler="verify_guest_report_lowered", + timeout=30, ), + # 8. Verify the lowered report is signed by its (alternate) VCEK + Step.for_callable( + name="verify-lowered-report-signature", + type="required", + handler="verify_lowered_report_signature", + # Covers both subprocesses (fetch + verify, 120s each) with headroom. + timeout=270, + ).add_hint("429", "Rate limited by KDS, re-run in a minute"), + # 9. Restore TCB via config reset Step.for_host( name="config-reset", type="required", command="snphost config reset", ), + # 10. Host-side check: Reported = Platform Step.for_callable( name="verify-match after config-reset", type="required", handler="verify_match", + timeout=30, ), - Step.for_host( - name="config-set-mask-chip-id", + # 11. Same live VM requests a second attestation report + Step.for_guest( + name="guest-report-after-reset", type="required", - command=_config_set(bl, tee, snp, ucode, fmc, 1), + command="snpguest report report_after_reset.bin request_after_reset.bin --random", + timeout=300, ), - Step.for_host( - name="config-set-mask-chip-key", + # 12. Pull second report from guest + Step.for_guest_pull( + name="pull-report-after-reset", type="required", - command=_config_set(bl, tee, snp, ucode, fmc, 2), + guest_src="report_after_reset.bin", + host_dest="report_after_reset.bin", + timeout=120, ), - Step.for_host( - name="config-set-mask-both", + # 13. Verify second guest report TCB reflects restored values + Step.for_callable( + name="verify-guest-report-restored", type="required", - command=_config_set(bl, tee, snp, ucode, fmc, 3), + handler="verify_guest_report_restored", + timeout=30, ), + # 14. Teardown: stop the VM. Placed BEFORE the commit gate below so + # cleanup can't be skipped — a `setup`-step failure at the gate + # marks every later step skipped, so anything that must always run + # has to precede it. Nothing in the commit path needs the guest: + # the gate reads the already-pulled report_after_reset.bin and + # commit is a host command. (The VM is also torn down in + # execute_test's finally; this is the explicit, visible path.) + Step.for_vm_stop( + name="Stop VM", + type="info", + timeout=60, + ), + # 15. Teardown: restore TCB with a final config reset. Also before the + # gate so it runs regardless of the commit precondition's outcome. + # config is already clean from step 9 and commit (step 17) does not + # touch ReportedTcb, so running the reset here keeps TCB clean. Step.for_host( - name="config-reset-masks", - type="required", + name="teardown-config-reset", + type="info", command="snphost config reset", ), + # 16. Commit precondition (setup) — require Committed == Current so the + # commit cannot advance the floor. A failure halts before the commit + # below; `--allow-host-changes` downgrades it to a warning. See + # verify_committed_equals_current. Step.for_callable( - name="verify-match after config-reset-masks", - type="required", - handler="verify_match", + name="verify-committed-equals-current", + type="setup", + handler="verify_committed_equals_current", + timeout=30, ), - Step.for_host( - name="commit", + # 17. Commit TCB — runs `snphost commit` and checks it returns 0. On a + # normal (non-provisional) host Committed == Current, so this is a + # no-op on the floor; that no-op success is all we can observe here. + # See commit_current_tcb. + Step.for_callable( + name="commit-current-tcb", type="required", - command="snphost commit", + handler="commit_current_tcb", + timeout=60, ), ] diff --git a/sev_verify/cert_tests/c3_0/manifest.toml b/sev_verify/cert_tests/c3_0/manifest.toml index 19539c23..22b5dee2 100644 --- a/sev_verify/cert_tests/c3_0/manifest.toml +++ b/sev_verify/cert_tests/c3_0/manifest.toml @@ -17,5 +17,6 @@ level = "3.0.0-0" name = "snphost-config-commit" description = "Test SNP_CONFIG and SNP_COMMIT host commands" module = "cert_tests.c3_0.c3_0_0_1.snphost_config_commit" -scope = "host" +scope = "mixed" level = "3.0.0-1" +host_changes = true diff --git a/sev_verify/cli.py b/sev_verify/cli.py index 81db41d5..b523e814 100644 --- a/sev_verify/cli.py +++ b/sev_verify/cli.py @@ -36,6 +36,7 @@ DEFAULT_QEMU_BINARY, find_ovmf_path, VMLaunchResult, + VMProfileError, stop_vm, ) @@ -115,6 +116,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: metavar="PATH", help="Override OVMF firmware .fd (overrides test VMProfile and host search paths)", ) + parser.add_argument( + "--allow-host-changes", + dest="allow_host_changes", + action="store_true", + default=False, + help="Allow making host-level changes, such as changing FW TCB settings.", + ) return parser.parse_args(argv) @@ -290,6 +298,36 @@ def _section(label: str) -> None: _flush(f"{prefix}{'─' * (_LINE_WIDTH - len(prefix))}") +def _print_host_changes_notice( + manifest_entries: list[tuple[Path, list[str]]], allow_host_changes: bool, +) -> None: + """List selected tests that may change host state, grouped by level. + + Sourced from each test's ``host_changes`` manifest flag so the set stays + authoritative. Prints nothing when no selected test touches the host. + """ + by_level: dict[str, list[str]] = {} + for manifest_path, level_filters in manifest_entries: + try: + cert = _filter_tests(load_manifest(manifest_path), level_filters) + except ValueError: + continue # a malformed manifest surfaces later in the run loop + for test in cert.tests: + if test.host_changes: + by_level.setdefault(test.level or cert.version, []).append(test.name) + + if not by_level: + return + + state = "enabled" if allow_host_changes else "disabled (tests skipped/downgraded)" + _flush(f" Host changes: --allow-host-changes {state}") + _flush(" Tests that may change host state (boot-session-only, reset on reboot):") + for level in sorted(by_level): + for name in by_level[level]: + _flush(f" • {level} {name}") + _flush("") + + def _fmt_duration(ms: int | None) -> str: """Format a duration for display.""" if ms is None: @@ -322,6 +360,7 @@ def execute_test( qemu_binary: str | None = None, ovmf_path: str | None = None, environment: dict[str, str | None] | None = None, + allow_host_changes: bool = False, ) -> TestResult: """Run a test, printing each step live as it executes.""" started_at = datetime.now(timezone.utc).isoformat() @@ -370,6 +409,7 @@ def execute_test( launch=None, cli_qemu_binary=qemu_binary, cli_ovmf_path=ovmf_path, + allow_host_changes=allow_host_changes, ) overall = "pass" @@ -431,19 +471,27 @@ def execute_test( ) else: if launch is None: - launch = profile.vm_launch() - if launch.ok and environment is not None: - update_environment_with_guest_os(environment, launch.profile) - if not launch.ok: + try: + launch = profile.vm_launch() + if launch.ok and environment is not None: + update_environment_with_guest_os(environment, launch.profile) + except VMProfileError as exc: + sr = StepResult( + step=step, + result="error", + stderr=str(exc), + duration_ms=0, + ) + if launch is not None and not launch.ok: sr = StepResult( step=step, result="error", stderr=launch.message, duration_ms=0, ) - elif step.kind == "guest": + elif launch is not None and step.kind == "guest": sr = run_guest_step(step, launch.profile) - else: + elif launch is not None: sr = run_guest_pull_step(step, launch.profile, artifact_dir) elif step.kind == "callable": sr = run_callable_step(step, ctx) @@ -523,6 +571,7 @@ def execute_certification( qemu_binary: str | None = None, ovmf_path: str | None = None, environment: dict[str, str | None] | None = None, + allow_host_changes: bool = False, ) -> CertificationResult: """Run all tests in a certification with live output.""" started_at = datetime.now(timezone.utc).isoformat() @@ -550,6 +599,7 @@ def execute_certification( qemu_binary=qemu_binary, ovmf_path=ovmf_path, environment=environment, + allow_host_changes=allow_host_changes, ) test_results.append(tr) overall = _worse_result(overall, tr.result) @@ -665,6 +715,8 @@ def main(argv: list[str] | None = None) -> int: _flush(f" OVMF: {effective_ovmf}") _flush("") + _print_host_changes_notice(manifest_entries, args.allow_host_changes) + total_tests = 0 total_passed = 0 @@ -683,6 +735,7 @@ def main(argv: list[str] | None = None) -> int: qemu_binary=qemu_override, ovmf_path=ovmf_override, environment=environment, + allow_host_changes=args.allow_host_changes, ) prereq_results.append(tr) _flush("") @@ -718,6 +771,7 @@ def main(argv: list[str] | None = None) -> int: qemu_binary=qemu_override, ovmf_path=ovmf_override, environment=environment, + allow_host_changes=args.allow_host_changes, ) cert_results.append(cr) total_tests += len(cr.test_results) diff --git a/sev_verify/models.py b/sev_verify/models.py index 275e5da2..853451fb 100644 --- a/sev_verify/models.py +++ b/sev_verify/models.py @@ -276,6 +276,11 @@ class TestDefinition: scope: Scope description: str = "" level: str = "" # certification level, e.g. "3.0.0-0" + # True when the test may change host-level state (e.g. firmware TCB + # settings). Such changes are gated behind ``--allow-host-changes`` and + # are boot-session-only (reset on reboot). Surfaced at startup so operators + # know which tests can touch the host. See StepContext.allow_host_changes. + host_changes: bool = False def __post_init__(self) -> None: if not self.name: @@ -369,6 +374,10 @@ class StepContext: # Global CLI overrides (same as ``python3 -m sev_verify --qemu-binary`` / ``--ovmf``). cli_qemu_binary: str | None = None cli_ovmf_path: str | None = None + # Set by ``--allow-host-changes``: permit tests to make host-level changes, + # such as changing firmware TCB settings (e.g. committing firmware TCB + # levels). These are boot-session-only and reset on the next reboot. + allow_host_changes: bool = False @dataclass diff --git a/sev_verify/runner.py b/sev_verify/runner.py index 9a8fd56e..e4f9321d 100644 --- a/sev_verify/runner.py +++ b/sev_verify/runner.py @@ -20,7 +20,7 @@ StepResult, TestDefinition, ) -from .vm_profile import VMProfile, VMLaunchError, VMLaunchResult, stop_vm +from .vm_profile import VMProfile, VMLaunchResult, VMProfileError, stop_vm def _check_expected_values(step: BaseStep, exit_code: int, stdout: str) -> bool: @@ -173,7 +173,7 @@ def run_vm_launch_step( start = time.monotonic() try: launch = profile.vm_launch() - except VMLaunchError as exc: + except VMProfileError as exc: duration_ms = int((time.monotonic() - start) * 1000) # A launch that fails hard is still a launch outcome, so honour the # step's expected_result. Reporting "error" unconditionally made it From fec0049c33fe3de6aedb1e223cee75488658a91d Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Wed, 1 Jul 2026 10:27:51 -0500 Subject: [PATCH 16/20] feat: add ID block support to sev_verify test harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds shared callables (calculate_measurement, generate_id_block) in sev_verify/id_block.py for use by any test that requires an ID block. Extends VMProfile with id_block/id_auth fields so vm_launch passes them to QEMU when present. Fixes the step loop in cli.py so callable steps can update ctx.profile before vm_launch sees it. fix: pass policy from ID block to QEMU sev-snp-guest object docs: clarify why profile is re-read from ctx each step iteration feat: add actual ID block test with report verification and negative launches Check expected_result on VMLaunchError in run_vm_launch_step so that vm_launch steps can declare expected_result="exit_code:1" for launches that should be rejected by firmware. Add id_block_test at cert level 3.0.0-2: - Positive: launch with valid ID block, verify guest_svn, policy, family_id, image_id in the attestation report via snpguest display - Negative: bad measurement (digest mismatch) - Negative: SMT=0 policy on SMT-active host (platform incompatibility) - Negative: ABI_MAJOR=255 (impossible firmware version) Add vm_stop (type=info) after each negative launch step so that launch is reset to None regardless of whether VMLaunchError fired or the launch unexpectedly succeeded. Co-Authored-By: Claude Opus 4.6 fix: validate guest measurement at each point of use guest_measurement.txt was read without any length or format check. A present-but-malformed file reached snpguest as an opaque argument, surfacing as a confusing subprocess error, and verify_report_fields read it with no guard at all — an absent file raised FileNotFoundError. Add read_measurement() with MeasurementMissing / MeasurementMalformed, validating the 96-character hex digest (48-byte MEASUREMENT field) at each consumer rather than at write time: the producer runs in an earlier step, so a check there says nothing about what a later step reads. Absence stays distinguishable from corruption. generate_id_block still exits 0 and skips when the file is missing (additive principle), but now fails when it is present and malformed. feat: verify ID block fields by parsing report.bin directly verify_id_block_fields shelled out to `snpguest display report` and recovered four fields with regexes over its human-readable output. That couples a hardware assertion to a CLI's formatting: the labels come from the sev crate's Display impl, not from snpguest itself, so a crate bump can silently turn "field mismatch" into "field not found" and fail the test for a reason that has nothing to do with the platform. Parse the binary structure instead. ATTESTATION_REPORT has a fixed layout, and unlike the CLI text it is self-describing — VERSION is the first four bytes, so every report states its own layout and can be checked on the spot rather than inferred from a tool probe. Layout is version-dependent, tracking firmware and so CPU generation, but versions have only ever appended fields: everything below 0x188 is common to v2 and v3, and the CPUID triple at 0x188 is v3+. All four fields this test needs sit in the stable head. --- sev_verify/attestation_report.py | 181 +++++++++ .../c3_0/c3_0_0_0/attestation_test.py | 8 +- .../cert_tests/c3_0/c3_0_0_2/__init__.py | 0 .../cert_tests/c3_0/c3_0_0_2/id_block_test.py | 362 ++++++++++++++++++ sev_verify/cli.py | 1 + sev_verify/cvm_props.py | 229 +++++++++++ sev_verify/vm_profile.py | 6 + 7 files changed, 785 insertions(+), 2 deletions(-) create mode 100644 sev_verify/attestation_report.py create mode 100644 sev_verify/cert_tests/c3_0/c3_0_0_2/__init__.py create mode 100644 sev_verify/cert_tests/c3_0/c3_0_0_2/id_block_test.py create mode 100644 sev_verify/cvm_props.py diff --git a/sev_verify/attestation_report.py b/sev_verify/attestation_report.py new file mode 100644 index 00000000..0c377380 --- /dev/null +++ b/sev_verify/attestation_report.py @@ -0,0 +1,181 @@ +"""Parse the SEV-SNP ATTESTATION_REPORT binary structure. + +Reading ``report.bin`` directly, rather than regexing ``snpguest display +report``, removes a dependency on a CLI's human-readable output format. The +binary layout is fixed by the SEV-SNP ABI and, unlike the CLI text, the report +is *self-describing*: VERSION is the first four bytes, so every report states +which layout it uses and can be checked on the spot. + +Layout is version-dependent in practice — the report version tracks firmware, +which tracks CPU generation — but versions have only ever *appended* fields. +Everything below 0x188 is common to v2 and v3; the CPUID family/model/stepping +triple at 0x188 exists only in v3+. + +Every offset here was validated against a real v3 report from an EPYC 9654 +(Genoa, CPUID 19h/11h), cross-checked against independently known values: +GUEST_SVN/POLICY/FAMILY_ID/IMAGE_ID against the values the ID block was built +with, REPORTED_TCB against ``snphost ok``, AUTHOR_KEY_DIGEST against the known +all-zero author key, and CPUID against the CPU model. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass +from pathlib import Path + +#: ATTESTATION_REPORT is a fixed-size structure. +REPORT_SIZE = 1184 + +#: Report versions whose layout we read. v3 is verified on hardware; v2 shares +#: the same layout for every field below 0x188. +KNOWN_VERSIONS = frozenset({2, 3}) + +# Field offsets. See module docstring for how these were validated. +_OFF_VERSION = 0x000 +_OFF_GUEST_SVN = 0x004 +_OFF_POLICY = 0x008 +_OFF_FAMILY_ID = 0x010 +_OFF_IMAGE_ID = 0x020 +_OFF_VMPL = 0x030 +_OFF_REPORT_DATA = 0x050 +_OFF_MEASUREMENT = 0x090 +_OFF_HOST_DATA = 0x0C0 +_OFF_ID_KEY_DIGEST = 0x0E0 +_OFF_AUTHOR_KEY_DIGEST = 0x110 +_OFF_REPORT_ID = 0x140 +_OFF_REPORTED_TCB = 0x180 +_OFF_CPUID_FAM = 0x188 # v3+ + +_LEN_ID = 16 +_LEN_MEASUREMENT = 48 +_LEN_DIGEST = 48 +_LEN_REPORT_DATA = 64 +_LEN_HOST_DATA = 32 +_LEN_REPORT_ID = 32 + + +class ReportError(Exception): + """Base class for attestation report problems.""" + + +class ReportMalformed(ReportError): + """The file is not a well-formed ATTESTATION_REPORT.""" + + +class ReportUnsupportedVersion(ReportError): + """The report declares a version whose layout we have not validated.""" + + +@dataclass(frozen=True) +class TcbVersion: + """Decoded SNP TCB_VERSION — the same four values ``snphost ok`` prints.""" + + bootloader: int + tee: int + snp: int + microcode: int + + @classmethod + def from_bytes(cls, raw: bytes) -> TcbVersion: + # byte 0 BOOT_LOADER, byte 1 TEE, bytes 2-5 reserved, + # byte 6 SNP, byte 7 MICROCODE. + return cls(bootloader=raw[0], tee=raw[1], snp=raw[6], microcode=raw[7]) + + def __str__(self) -> str: + return ( + f"bootloader={self.bootloader} tee={self.tee} " + f"snp={self.snp} microcode={self.microcode}" + ) + + +@dataclass(frozen=True) +class AttestationReport: + """The fields of an ATTESTATION_REPORT that we read.""" + + version: int + guest_svn: int + policy: int + family_id: bytes + image_id: bytes + vmpl: int + report_data: bytes + measurement: bytes + host_data: bytes + id_key_digest: bytes + author_key_digest: bytes + report_id: bytes + reported_tcb: TcbVersion + #: (family, model, stepping) — v3+ only, None on older reports. + cpuid: tuple[int, int, int] | None + + @property + def id_block_used(self) -> bool: + """True when ID_KEY_DIGEST is set, i.e. the guest launched with an ID block.""" + return any(self.id_key_digest) + + +def parse(data: bytes) -> AttestationReport: + """Parse raw report bytes. + + Raises: + ReportMalformed: wrong size. + ReportUnsupportedVersion: layout not validated for that version. + """ + if len(data) != REPORT_SIZE: + raise ReportMalformed( + f"expected a {REPORT_SIZE}-byte ATTESTATION_REPORT, got {len(data)} bytes" + ) + + (version,) = struct.unpack_from(" bytes: + return data[off:off + length] + + cpuid = None + if version >= 3: + cpuid = ( + data[_OFF_CPUID_FAM], + data[_OFF_CPUID_FAM + 1], + data[_OFF_CPUID_FAM + 2], + ) + + return AttestationReport( + version=version, + guest_svn=guest_svn, + policy=policy, + family_id=field(_OFF_FAMILY_ID, _LEN_ID), + image_id=field(_OFF_IMAGE_ID, _LEN_ID), + vmpl=vmpl, + report_data=field(_OFF_REPORT_DATA, _LEN_REPORT_DATA), + measurement=field(_OFF_MEASUREMENT, _LEN_MEASUREMENT), + host_data=field(_OFF_HOST_DATA, _LEN_HOST_DATA), + id_key_digest=field(_OFF_ID_KEY_DIGEST, _LEN_DIGEST), + author_key_digest=field(_OFF_AUTHOR_KEY_DIGEST, _LEN_DIGEST), + report_id=field(_OFF_REPORT_ID, _LEN_REPORT_ID), + reported_tcb=TcbVersion.from_bytes(field(_OFF_REPORTED_TCB, 8)), + cpuid=cpuid, + ) + + +def read(path: Path) -> AttestationReport: + """Read and parse an ATTESTATION_REPORT file. + + Raises: + ReportMalformed: file missing or wrong size. + ReportUnsupportedVersion: layout not validated for that version. + """ + try: + data = path.read_bytes() + except FileNotFoundError as exc: + raise ReportMalformed(f"{path.name} not found") from exc + return parse(data) diff --git a/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py b/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py index 4dc18888..1cb38428 100644 --- a/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py +++ b/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py @@ -20,6 +20,7 @@ import subprocess from pathlib import Path +from sev_verify.cvm_props import MeasurementError, read_measurement from sev_verify.models import BaseStep, Step, StepContext, StepHandlerResult from sev_verify.vm_profile import VMProfile, VMProfileError @@ -82,10 +83,13 @@ def verify_report_fields(ctx: StepContext) -> StepHandlerResult: to values computed in earlier ``callable`` or ``host`` steps. """ report_file = ctx.artifact_dir / "report.bin" - measurement_file = ctx.artifact_dir / "guest_measurement.txt" request_file = ctx.artifact_dir / "request.bin" - expected_measurement = measurement_file.read_text().strip() + try: + expected_measurement = f"0x{read_measurement(ctx.artifact_dir)}" + except MeasurementError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) + request_data = "0x" + str(request_file.read_bytes().hex()) result = subprocess.run( [ diff --git a/sev_verify/cert_tests/c3_0/c3_0_0_2/__init__.py b/sev_verify/cert_tests/c3_0/c3_0_0_2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sev_verify/cert_tests/c3_0/c3_0_0_2/id_block_test.py b/sev_verify/cert_tests/c3_0/c3_0_0_2/id_block_test.py new file mode 100644 index 00000000..ff5d1654 --- /dev/null +++ b/sev_verify/cert_tests/c3_0/c3_0_0_2/id_block_test.py @@ -0,0 +1,362 @@ +"""id_block_test: Verify ID block acceptance, report field binding, and rejection. + +Positive path: launch an SEV-SNP guest with a valid ID block, fetch the +attestation report, and verify that the hardware report reflects the ID block +fields (guest_svn, policy, family_id, image_id). + +Negative path: attempt three launches that must fail: + 1. ID block with a corrupted measurement (digest mismatch) + 2. Policy incompatible with the platform (SMT=0 on an SMT-active host) + 3. Impossibly high ABI major version (ABI_MAJOR=255) +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from dataclasses import replace +from pathlib import Path + +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, +) + +from sev_verify import attestation_report +from sev_verify.cvm_props import ( + DEFAULT_FAMILY_ID, + DEFAULT_GUEST_SVN, + DEFAULT_IMAGE_ID, + DEFAULT_POLICY, + MeasurementError, + calculate_measurement, + generate_id_block, + read_measurement, +) +from sev_verify.models import BaseStep, Step, StepContext, StepHandlerResult +from sev_verify.vm_profile import VMProfile + +vm_profile = VMProfile( + image_path="", + memory_mb=2048, +) + + +# ── Report field verification ───────────────────────────────────────────────── + + +def verify_id_block_fields(ctx: StepContext) -> StepHandlerResult: + """Compare ID block fields in the hardware attestation report to expectations. + + Reads report.bin directly (see :mod:`sev_verify.attestation_report`) rather + than parsing ``snpguest display report`` output, so the check does not + depend on a CLI's human-readable formatting. + """ + try: + report = attestation_report.read(ctx.artifact_dir / "report.bin") + except attestation_report.ReportError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) + + family_id = os.environ.get("ID_BLOCK_FAMILY_ID", DEFAULT_FAMILY_ID) + image_id = os.environ.get("ID_BLOCK_IMAGE_ID", DEFAULT_IMAGE_ID) + guest_svn = int(os.environ.get("ID_BLOCK_GUEST_SVN", DEFAULT_GUEST_SVN)) + policy_int = int(os.environ.get("ID_BLOCK_POLICY", DEFAULT_POLICY), 0) + + expected_family = family_id.encode("ascii").ljust(16, b"\x00") + expected_image = image_id.encode("ascii").ljust(16, b"\x00") + + errors = [] + if report.guest_svn != guest_svn: + errors.append(f"guest_svn: expected {guest_svn}, got {report.guest_svn}") + if report.policy != policy_int: + errors.append(f"policy: expected {hex(policy_int)}, got {hex(report.policy)}") + if report.family_id != expected_family: + errors.append( + f"family_id: expected {expected_family.hex()}, got {report.family_id.hex()}" + ) + if report.image_id != expected_image: + errors.append( + f"image_id: expected {expected_image.hex()}, got {report.image_id.hex()}" + ) + # An all-zero ID_KEY_DIGEST means the guest launched without an ID block at + # all. The four comparisons above would then all fail with zeros, which is + # a confusing way to report "no ID block was used". + if not report.id_block_used: + errors.append( + "id_key_digest is all zero — the guest launched without an ID block" + ) + + if errors: + return StepHandlerResult(exit_code=1, stderr="\n".join(errors)) + return StepHandlerResult( + exit_code=0, + stdout=( + f"All ID block fields match: svn={guest_svn} policy={hex(policy_int)} " + f"family_id={family_id!r} image_id={image_id!r}\n" + f" report v{report.version} vmpl={report.vmpl} " + f"cpuid={report.cpuid} tcb=({report.reported_tcb})\n" + f" id_key_digest={report.id_key_digest.hex()[:32]}..." + ), + ) + + +# ── Negative-test profile mutation helpers ──────────────────────────────────── + + +def _regenerate_id_block( + ctx: StepContext, measurement: str, policy: str, +) -> StepHandlerResult: + """Generate a fresh ID block with the given measurement and policy, update ctx.profile. + + ``measurement`` must be in snpguest's input form — 0x-prefixed hex. An + unprefixed string is decoded as base64, not hex. + """ + family_id = os.environ.get("ID_BLOCK_FAMILY_ID", DEFAULT_FAMILY_ID) + image_id = os.environ.get("ID_BLOCK_IMAGE_ID", DEFAULT_IMAGE_ID) + guest_svn = os.environ.get("ID_BLOCK_GUEST_SVN", DEFAULT_GUEST_SVN) + + id_key = ec.generate_private_key(ec.SECP384R1()) + auth_key = ec.generate_private_key(ec.SECP384R1()) + + id_block_file = ctx.artifact_dir / "neg-id-block.b64" + id_auth_file = ctx.artifact_dir / "neg-id-auth.b64" + + with tempfile.TemporaryDirectory() as tmpdir: + id_key_path = Path(tmpdir) / "id-key.pem" + auth_key_path = Path(tmpdir) / "auth-key.pem" + id_key_path.write_bytes( + id_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()) + ) + auth_key_path.write_bytes( + auth_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()) + ) + + result = subprocess.run( + [ + "snpguest", "generate", "id-block", + str(id_key_path), str(auth_key_path), + measurement, + "--family-id", family_id, + "--image-id", image_id, + "--svn", guest_svn, + "--policy", policy, + "--id-file", str(id_block_file), + "--auth-file", str(id_auth_file), + ], + capture_output=True, text=True, check=False, + ) + + if result.returncode != 0: + return StepHandlerResult( + exit_code=1, + stderr=f"snpguest generate id-block failed:\n{result.stderr}", + ) + + ctx.profile = replace( + ctx.profile, + id_block=id_block_file.read_text().strip(), + id_auth=id_auth_file.read_text().strip(), + policy=policy, + ) + return StepHandlerResult(exit_code=0) + + +def set_bad_measurement(ctx: StepContext) -> StepHandlerResult: + """Regenerate the ID block with a corrupted measurement to cause digest mismatch.""" + try: + real = read_measurement(ctx.artifact_dir) + except MeasurementError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) + + # Flip the first byte of the digest + flipped_byte = "00" if real[:2].lower() != "00" else "ff" + flipped = flipped_byte + real[2:] + + policy = os.environ.get("ID_BLOCK_POLICY", DEFAULT_POLICY) + hr = _regenerate_id_block(ctx, f"0x{flipped}", policy) + if hr.exit_code != 0: + return hr + return StepHandlerResult( + exit_code=0, + stdout=f"Set bad measurement: {flipped[:16]}... (real: {real[:16]}...)", + ) + + +def set_incompatible_policy(ctx: StepContext) -> StepHandlerResult: + """Regenerate the ID block with a policy the platform cannot satisfy. + + Checks whether SMT is active on the host. If so, regenerates the ID block + (and QEMU launch policy) with SMT=0 — the firmware must reject because the + platform cannot guarantee single-threaded execution. + """ + smt_path = Path("/sys/devices/system/cpu/smt/active") + if not smt_path.exists(): + return StepHandlerResult( + exit_code=1, + stderr="Cannot determine SMT status: /sys/devices/system/cpu/smt/active not found", + ) + smt_active = smt_path.read_text().strip() == "1" + if not smt_active: + return StepHandlerResult( + exit_code=1, + stderr="SMT is not active on this host; cannot test SMT policy incompatibility", + ) + + try: + measurement = read_measurement(ctx.artifact_dir) + except MeasurementError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) + + policy = os.environ.get("ID_BLOCK_POLICY", DEFAULT_POLICY) + policy_int = int(policy, 0) + # Clear SMT bit (16) — guest demands no SMT, but host has SMT active + incompatible_policy = hex(policy_int & ~(1 << 16)) + + hr = _regenerate_id_block(ctx, f"0x{measurement}", incompatible_policy) + if hr.exit_code != 0: + return hr + return StepHandlerResult( + exit_code=0, + stdout=f"Set incompatible policy {incompatible_policy} (SMT=0, host SMT active)", + ) + + +def set_bad_abi_version(ctx: StepContext) -> StepHandlerResult: + """Regenerate the ID block with an impossibly high ABI major version. + + The policy's ABI_MAJOR field (bits 15:8) specifies the minimum firmware + ABI version required. Setting it to 255 guarantees the firmware cannot + satisfy the requirement on any current platform. + """ + try: + measurement = read_measurement(ctx.artifact_dir) + except MeasurementError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) + + policy = os.environ.get("ID_BLOCK_POLICY", DEFAULT_POLICY) + policy_int = int(policy, 0) + # Set ABI_MAJOR (bits 15:8) to 255 + bad_policy = (policy_int & ~0xFF00) | (0xFF << 8) + bad_policy_hex = hex(bad_policy) + + hr = _regenerate_id_block(ctx, f"0x{measurement}", bad_policy_hex) + if hr.exit_code != 0: + return hr + return StepHandlerResult( + exit_code=0, + stdout=f"Set policy {bad_policy_hex} (ABI_MAJOR=255)", + ) + + +# ── Steps ───────────────────────────────────────────────────────────────────── + + +def steps() -> list[BaseStep]: + return [ + # ── Positive: launch with valid ID block, verify report fields ── + Step.for_callable( + name="Calculate measurement", + type="setup", + handler="calculate_measurement", + timeout=60, + ), + Step.for_callable( + name="Generate ID block", + type="setup", + handler="generate_id_block", + timeout=30, + ), + Step.for_vm_launch( + name="Launch with valid ID block", + type="setup", + timeout=300, + ).add_hint( + "Address already in use", + "A previous VM may still be running. " + "Try: sudo kill $(pgrep -f 'qemu.*guest-cid')", + ), + Step.for_guest( + name="Get attestation report", + type="required", + command="snpguest report report.bin request.bin --random", + timeout=60, + ), + Step.for_guest_pull( + name="Pull attestation report", + type="required", + guest_src="report.bin", + host_dest="report.bin", + timeout=120, + ), + Step.for_vm_stop( + name="Stop VM", + type="info", + timeout=60, + ), + Step.for_callable( + name="Verify ID block fields in report", + type="required", + handler="verify_id_block_fields", + timeout=30, + ), + + # ── Negative: bad measurement (digest mismatch) ── + Step.for_callable( + name="Set bad measurement in ID block", + type="required", + handler="set_bad_measurement", + timeout=30, + ), + Step.for_vm_launch( + name="Launch with bad measurement (expect rejection)", + type="required", + expected_result="exit_code:1", + timeout=300, + ), + Step.for_vm_stop( + name="Stop VM (after bad measurement)", + type="info", + timeout=60, + ), + + # ── Negative: incompatible policy (SMT=0 on SMT-active host) ── + Step.for_callable( + name="Set incompatible policy (SMT)", + type="required", + handler="set_incompatible_policy", + timeout=30, + ), + Step.for_vm_launch( + name="Launch with SMT-incompatible policy (expect rejection)", + type="required", + expected_result="exit_code:1", + timeout=300, + ), + Step.for_vm_stop( + name="Stop VM (after SMT policy)", + type="info", + timeout=60, + ), + + # ── Negative: impossible ABI version ── + Step.for_callable( + name="Set impossible ABI version", + type="required", + handler="set_bad_abi_version", + timeout=30, + ), + Step.for_vm_launch( + name="Launch with impossible ABI version (expect rejection)", + type="required", + expected_result="exit_code:1", + timeout=300, + ), + Step.for_vm_stop( + name="Stop VM (after ABI version)", + type="info", + timeout=60, + ), + ] diff --git a/sev_verify/cli.py b/sev_verify/cli.py index b523e814..175684b1 100644 --- a/sev_verify/cli.py +++ b/sev_verify/cli.py @@ -422,6 +422,7 @@ def execute_test( # A callable step may replace ctx.profile (dataclasses.replace on a # frozen VMProfile yields a new object); assigning the stale local # back over it silently discarded that change for every later step. + # e.g. generate_id_block setting id_block/id_auth/policy. profile = ctx.profile ctx.launch = launch diff --git a/sev_verify/cvm_props.py b/sev_verify/cvm_props.py new file mode 100644 index 00000000..76695481 --- /dev/null +++ b/sev_verify/cvm_props.py @@ -0,0 +1,229 @@ +"""Shared callables for ID block generation in sev_verify test modules. + +A test module that requires an ID block includes these steps in its steps() +list, in order, before vm_launch: + + Step.for_callable(name="Calculate measurement", type="setup", + handler="calculate_measurement", timeout=60), + Step.for_callable(name="Generate ID block", type="setup", + handler="generate_id_block", timeout=30), + +The calculate_measurement step writes guest_measurement.txt to ctx.artifact_dir. +The generate_id_block step reads it, generates ephemeral P-384 key pairs, calls +snpguest to produce id-block.b64 and id-auth.b64, and updates ctx.profile so +that the subsequent vm_launch step passes the ID block to QEMU. + +Both steps follow the additive principle: if OVMF is absent (no measurement +possible), calculate_measurement returns a non-zero exit code and — because it +is typed "setup" — the remaining steps are skipped cleanly. +""" + +from __future__ import annotations + +import string +import subprocess +import tempfile +from dataclasses import replace +from pathlib import Path + +# may need to change this library +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, +) + +from .models import StepContext, StepHandlerResult +from .vm_profile import VMProfile, VMProfileError + +_MEASUREMENT_FILE = "guest_measurement.txt" +_ID_BLOCK_FILE = "id-block.b64" +_ID_AUTH_FILE = "id-auth.b64" + +DEFAULT_FAMILY_ID = "sev-certify-fam0" +DEFAULT_IMAGE_ID = "sev-certify-img0" +DEFAULT_GUEST_SVN = "48" +DEFAULT_POLICY = "0xb0000" + +# The SNP attestation report MEASUREMENT field is 48 bytes, so the hex form is +# 96 characters. Fixed by the SNP spec, not by configuration. +MEASUREMENT_HEX_LEN = 96 + + +class MeasurementError(Exception): + """Base class for problems reading guest_measurement.txt.""" + + +class MeasurementMissing(MeasurementError): + """guest_measurement.txt does not exist.""" + + +class MeasurementMalformed(MeasurementError): + """guest_measurement.txt exists but does not hold a 48-byte hex digest.""" + + +def read_measurement(artifact_dir: Path) -> str: + """Read guest_measurement.txt and return the bare (unprefixed) hex digest. + + Validation lives here, at the point of use, rather than in + calculate_measurement. A check at write time says nothing about what a + later step is about to read: the steps are separated in time, so the file + can change (or be replaced) in between. + + snpguest writes the digest 0x-prefixed under ``--output-format hex``. The + prefix is stripped here so callers operate on a bare body; re-add it with + ``f"0x{...}"`` when handing the value back to snpguest, which decodes an + unprefixed string as base64 rather than hex. + + Raises: + MeasurementMissing: the file is absent. + MeasurementMalformed: the file is present but not a 48-byte hex digest. + """ + measurement_file = artifact_dir / _MEASUREMENT_FILE + try: + raw = measurement_file.read_text().strip() + except FileNotFoundError as exc: + raise MeasurementMissing(f"{_MEASUREMENT_FILE} not found") from exc + + body = raw[2:] if raw[:2].lower() == "0x" else raw + if len(body) != MEASUREMENT_HEX_LEN: + raise MeasurementMalformed( + f"{_MEASUREMENT_FILE}: expected a {MEASUREMENT_HEX_LEN}-character hex " + f"digest (48 bytes), got {len(body)} characters" + ) + if not all(c in string.hexdigits for c in body): + raise MeasurementMalformed( + f"{_MEASUREMENT_FILE}: contains non-hex characters" + ) + return body + + +def calculate_measurement(ctx: StepContext) -> StepHandlerResult: + """Calculate the expected guest launch measurement via snpguest. + + Resolves the OVMF path from ctx.profile, runs snpguest generate + measurement against the guest image, and writes the result to + guest_measurement.txt in ctx.artifact_dir. + """ + try: + ovmf_path = Path(ctx.profile.resolved_ovmf_path()) + except VMProfileError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) + + measurement_file = ctx.artifact_dir / _MEASUREMENT_FILE + result = subprocess.run( + [ + "snpguest", "generate", "measurement", + "--vcpu-type", "EPYC-v4", + "--ovmf", str(ovmf_path), + "--kernel", str(ctx.guest_path), + "--output-format", "hex", + "--measurement-file", str(measurement_file), + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return StepHandlerResult( + exit_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) + measurement = measurement_file.read_text().strip() + return StepHandlerResult( + exit_code=0, + stdout=f"Measurement: {measurement}", + ) + + +def generate_id_block(ctx: StepContext) -> StepHandlerResult: + """Generate an ID block and auth block for the current guest measurement. + + Reads guest_measurement.txt from ctx.artifact_dir (written by + calculate_measurement). Generates two ephemeral P-384 key pairs, invokes + snpguest generate id-block, and updates ctx.profile with the resulting + id_block and id_auth values so that vm_launch passes them to QEMU. + + ID block metadata is read from environment variables with the same defaults + used by the generate-id-block systemd service: + ID_BLOCK_FAMILY_ID, ID_BLOCK_IMAGE_ID, ID_BLOCK_GUEST_SVN, ID_BLOCK_POLICY + + If guest_measurement.txt is absent (calculate_measurement was skipped or + failed), this step exits 0 and leaves ctx.profile unchanged, so vm_launch + proceeds without an ID block. + + A file that is present but malformed is a different case and fails the + step: absence is an expected configuration, corruption is not. + """ + import os + + try: + measurement = read_measurement(ctx.artifact_dir) + except MeasurementMissing as exc: + return StepHandlerResult( + exit_code=0, + stdout=f"INFO: {exc} — skipping ID block generation", + ) + except MeasurementMalformed as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) + + family_id = os.environ.get("ID_BLOCK_FAMILY_ID", DEFAULT_FAMILY_ID) + image_id = os.environ.get("ID_BLOCK_IMAGE_ID", DEFAULT_IMAGE_ID) + guest_svn = os.environ.get("ID_BLOCK_GUEST_SVN", DEFAULT_GUEST_SVN) + policy = os.environ.get("ID_BLOCK_POLICY", DEFAULT_POLICY) + + id_key = ec.generate_private_key(ec.SECP384R1()) + auth_key = ec.generate_private_key(ec.SECP384R1()) + + id_block_file = ctx.artifact_dir / _ID_BLOCK_FILE + id_auth_file = ctx.artifact_dir / _ID_AUTH_FILE + + with tempfile.TemporaryDirectory() as tmpdir: + id_key_path = Path(tmpdir) / "id-key.pem" + auth_key_path = Path(tmpdir) / "auth-key.pem" + id_key_path.write_bytes( + id_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()) + ) + auth_key_path.write_bytes( + auth_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()) + ) + + result = subprocess.run( + [ + "snpguest", "generate", "id-block", + str(id_key_path), + str(auth_key_path), + f"0x{measurement}", + "--family-id", family_id, + "--image-id", image_id, + "--svn", guest_svn, + "--policy", policy, + "--id-file", str(id_block_file), + "--auth-file", str(id_auth_file), + ], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + return StepHandlerResult( + exit_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) + + id_block_b64 = id_block_file.read_text().strip() + id_auth_b64 = id_auth_file.read_text().strip() + + ctx.profile = replace(ctx.profile, id_block=id_block_b64, id_auth=id_auth_b64, policy=policy) + + return StepHandlerResult( + exit_code=0, + stdout=( + f"Generated ID block for measurement {measurement[:16]}...\n" + f" family_id={family_id} image_id={image_id} svn={guest_svn} policy={policy}" + ), + ) diff --git a/sev_verify/vm_profile.py b/sev_verify/vm_profile.py index d042efb3..1ebe3c9d 100644 --- a/sev_verify/vm_profile.py +++ b/sev_verify/vm_profile.py @@ -109,6 +109,9 @@ class VMProfile: policy: str | int | None = None auth_key_enabled: bool = False kernel_hashes: bool = True + # ID block parameters — set by generate_id_block() in sev_verify.cvm_props. + id_block: str | None = None + id_auth: str | None = None # Fixed SEV-SNP parameters used by the existing launch scripts. cbitpos: int = 51 reduced_phys_bits: int = 1 @@ -298,6 +301,9 @@ def _build_sev_snp_guest_object(profile: VMProfile) -> str: parts.append(f"policy={_format_policy(profile.policy)}") if profile.auth_key_enabled: parts.append("author-key-enabled=true") + if profile.id_block and profile.id_auth: + parts.append(f"id-block={profile.id_block}") + parts.append(f"id-auth={profile.id_auth}") return ",".join(parts) From e68a237c3b0aba1686e8cf4fef9f2e0a45abcc2f Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Wed, 26 Aug 2026 13:40:16 -0500 Subject: [PATCH 17/20] fix: gate report parsing on a validated processor generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the FMC/Turin review comments on #247. The gap was worse than "unsupported": TCB_VERSION is laid out differently on Turin, and the two layouts are indistinguishable from the eight bytes alone. byte Milan/Genoa Turin/Venice 0 BOOT_LOADER FMC 1 TEE BOOT_LOADER 2 -- TEE 3 -- SNP 6 SNP -- 7 MICROCODE MICROCODE Decoding a Turin report with the legacy layout yielded BOOT_LOADER=FMC, TEE=BOOT_LOADER, SNP=0 — plausible values, no error. The sev crate handles this by dispatching on processor Generation (from_legacy_bytes/from_turin_bytes); we hardcoded one layout. Resolve a generation from the host's CPUID family/model, which is authoritative for a report produced by a guest on this machine and, unlike the report's own CPUID copy, present regardless of report version. parse() takes it as an argument so it stays a pure function of its input, unit-testable without hardware. When the report is v3+ it carries CPUID too, and the two are cross-checked: a mismatch means the report did not come from this machine. SUPPORTED_GENERATIONS records what has been validated on hardware, not what we believe we could decode — currently Genoa alone. An unrecognised processor raises rather than assuming a transcribed layout is right: a certification harness reporting a pass on silicon it has never run on is the failure this gate exists to prevent. Layouts for generations not yet exercised are recorded alongside it, so adding one is an entry plus a validation note. Keyed on family/model pairs rather than family/model/stepping triples: AMD scopes SEV firmware images by family and model only, and snpguest's get_processor_model partitions the same way. The module docstring now also records that this is a short-term stand-in. snpguest already parses reports correctly and generation-aware via the sev crate but exposes them only as human-readable text; once it gains machine-readable output this module should be replaced by consuming that rather than extended to cover further generations. test: assert why a negative launch was rejected, not just that it failed Addresses the review comment asking whether the negative launches could check the reason for failure rather than accepting any launch error. expected_result="exit_code:1" is satisfied by anything that fails, including a boot timeout — so these steps could not distinguish a real firmware rejection from a hung guest, and would have passed even if the ID block were ignored entirely. The harness fix merged as #289 makes the firmware's own text reachable from both launch-failure paths (the raise path passes str(exc) through _check_expected_values; the ok=False path now carries QEMU's stderr tail), so it can be asserted directly. Observed on an EPYC 9654 at BIOS 1.10.6: bad measurement SNP_LAUNCH_FINISH ret=-5 fw_error=11 'Bad measurement' SMT policy SNP_LAUNCH_START ret=-22 fw_error=0 '' ABI version SNP_LAUNCH_START ret=-5 fw_error=7 'Policy is not allowed' The SMT case is refused by KVM before the firmware sees it, so it has no firmware string to match; it asserts SNP_LAUNCH_START instead, which still rules out a boot timeout. Worth noting that this test therefore exercises KVM's policy validation rather than the firmware's. refactor: drop the duplicated calculate_measurement feat: improve SMT sub-test fix: satisfy crypto module dependency This fixes sev-certify automated runs. For sev_verify only runs, it may or may not. See the updated sev_verify/readme.md. fix: decode reports whose firmware left the CPUID fields empty The parser refused any report whose CPUID bytes did not resolve to a validated generation, reporting it as an unsupported processor. That is wrong when the processor is fine and the firmware simply did not populate the field: SEV firmware 1.55 build 38 leaves all three bytes zero in version-3 reports, where build 39 fills them in. On such a platform every report was rejected, and the error blamed the CPU. The host's CPUID is authoritative — it describes the silicon this code runs on, which is what the TCB_VERSION layout depends on, and it is present regardless of report version. The report's copy is a cross-check, useful only when it resolves. So an unresolvable one is now recorded in a new cpuid_note field and decoding proceeds with the host's generation. The exception is kept for the case that does indicate a problem: both CPUIDs resolve to validated generations and disagree, meaning the report came from another machine. That branch only becomes reachable once a second generation is added to SUPPORTED_GENERATIONS; with one validated generation every disagreeing CPUID is unresolvable instead. That ordering is deliberate — a report is called foreign only when both generations are ones we have actually validated against. With no host generation supplied and an unresolvable CPUID, TCB_VERSION is now left undecoded rather than raising, matching what a v2 report already does. docs: say how to add a report version, not just a processor SUPPORTED_GENERATIONS explains how to validate and add a processor generation; KNOWN_VERSIONS only stated which versions were readable and refused the rest, leaving the reader to work out what adding one involves. Record the procedure, and that versions 4 and 5 already exist and are refused. Note the cheap case explicitly: the sev crate's ReportVariant mapping groups versions sharing a layout, so a version grouped with one already listed reads identically, which makes v4 straightforward and v5 the one needing scrutiny. Also note that additions are not always new offsets — v5's GuestPolicy and PlatformInfo changes are new bits in existing fields. State that version and generation are independent axes, since the two gates look redundant until it is clear that one decides which fields exist and the other how TCB_VERSION is ordered. feat: validate Turin and report version 5 on hardware Adds the second processor generation and the second report version, both confirmed on an EPYC 9575F rather than transcribed. The Turin TCB_VERSION layout had been taken from the sev crate and never exercised. A version 5 report from that machine carried REPORTED_TCB bytes 0103020600000062, which decode under the Turin layout to bootloader=3 tee=2 snp=6 microcode=98 fmc=1 — matching snphost show tcb exactly. The same bytes under the legacy layout give bootloader=1 tee=3 snp=0 microcode=98 and no FMC: plausible values, silently wrong. That is the failure this gate exists to prevent, now demonstrated rather than asserted. That report also showed version 5 moves none of the fields read here: TCB at 0x180 and CPUID at 0x188 both decoded correctly, so v5's additions are the new GuestPolicy and PlatformInfo bits the crate documents rather than relocations. Version 4 remains refused, never having been seen. Two consequences worth noting. The generation mismatch branch in parse() was unreachable while only one generation was validated; with two, a report from the wrong machine is now genuinely detected. And Turin firmware 1.58 populates the report CPUID fields that Genoa's BIOS-supplied 1.55 build 38 leaves zeroed, so both sides of that behaviour are now covered by tests. --- images/host-centos-10/mkosi.conf | 1 + images/host-debian-13/mkosi.conf | 1 + images/host-debian-forky/mkosi.conf | 1 + images/host-fedora-41/mkosi.conf | 1 + images/host-opensuse-16.0/mkosi.conf | 1 + images/host-rocky-10/mkosi.conf | 1 + images/host-ubuntu-25.04/mkosi.conf | 1 + images/host-ubuntu-25.10/mkosi.conf | 1 + images/host-ubuntu-26.04/mkosi.conf | 1 + pyproject.toml | 7 + sev_verify/README.md | 21 +- sev_verify/attestation_report.py | 329 +++++++++++++++++- .../c3_0/c3_0_0_0/attestation_test.py | 58 +-- .../cert_tests/c3_0/c3_0_0_2/id_block_test.py | 167 +++++++-- sev_verify/cert_tests/c3_0/manifest.toml | 7 + sev_verify/cvm_props.py | 4 +- 16 files changed, 500 insertions(+), 102 deletions(-) diff --git a/images/host-centos-10/mkosi.conf b/images/host-centos-10/mkosi.conf index 5d257020..62fbb4e6 100644 --- a/images/host-centos-10/mkosi.conf +++ b/images/host-centos-10/mkosi.conf @@ -22,5 +22,6 @@ Packages= xxd python3 python3-pip + python3-cryptography jq avahi diff --git a/images/host-debian-13/mkosi.conf b/images/host-debian-13/mkosi.conf index 14676970..60c72577 100644 --- a/images/host-debian-13/mkosi.conf +++ b/images/host-debian-13/mkosi.conf @@ -24,6 +24,7 @@ Packages= xxd python3 python3-pip + python3-cryptography python3-emoji jq avahi-daemon diff --git a/images/host-debian-forky/mkosi.conf b/images/host-debian-forky/mkosi.conf index 2c1288db..339bf0ba 100644 --- a/images/host-debian-forky/mkosi.conf +++ b/images/host-debian-forky/mkosi.conf @@ -25,6 +25,7 @@ Packages= python3 python3-dev python3-pip + python3-cryptography python3-emoji g++ jq diff --git a/images/host-fedora-41/mkosi.conf b/images/host-fedora-41/mkosi.conf index 6a20017b..6d7ad213 100644 --- a/images/host-fedora-41/mkosi.conf +++ b/images/host-fedora-41/mkosi.conf @@ -21,6 +21,7 @@ Packages= xxd python3 python3-pip + python3-cryptography python3-emoji jq avahi diff --git a/images/host-opensuse-16.0/mkosi.conf b/images/host-opensuse-16.0/mkosi.conf index 73b1edd7..02f76904 100644 --- a/images/host-opensuse-16.0/mkosi.conf +++ b/images/host-opensuse-16.0/mkosi.conf @@ -28,6 +28,7 @@ Packages= xxd python3 python3-pip + python3-cryptography python3-emoji jq avahi diff --git a/images/host-rocky-10/mkosi.conf b/images/host-rocky-10/mkosi.conf index c138358c..9730fd41 100644 --- a/images/host-rocky-10/mkosi.conf +++ b/images/host-rocky-10/mkosi.conf @@ -20,5 +20,6 @@ Packages= xxd python3 python3-pip + python3-cryptography jq avahi diff --git a/images/host-ubuntu-25.04/mkosi.conf b/images/host-ubuntu-25.04/mkosi.conf index 0d1c74f5..393122dd 100644 --- a/images/host-ubuntu-25.04/mkosi.conf +++ b/images/host-ubuntu-25.04/mkosi.conf @@ -23,6 +23,7 @@ Packages= xxd python3 python3-pip + python3-cryptography python3-emoji jq apt diff --git a/images/host-ubuntu-25.10/mkosi.conf b/images/host-ubuntu-25.10/mkosi.conf index 68989453..90d14036 100644 --- a/images/host-ubuntu-25.10/mkosi.conf +++ b/images/host-ubuntu-25.10/mkosi.conf @@ -23,6 +23,7 @@ Packages= xxd python3 python3-pip + python3-cryptography python3-emoji jq apt diff --git a/images/host-ubuntu-26.04/mkosi.conf b/images/host-ubuntu-26.04/mkosi.conf index 26ac6d91..27bd8571 100644 --- a/images/host-ubuntu-26.04/mkosi.conf +++ b/images/host-ubuntu-26.04/mkosi.conf @@ -24,6 +24,7 @@ Packages= python3 python3-dev python3-pip + python3-cryptography python3-emoji jq apt diff --git a/pyproject.toml b/pyproject.toml index 219735b5..96225539 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,13 @@ build-backend = "setuptools.build_meta" name = "sev-verify" version = "0.1.0" requires-python = ">=3.11" +dependencies = [ + # Ephemeral P-384 key generation for ID blocks (sev_verify.cvm_props). + # snpguest signs the ID block but cannot generate the keys, so this is + # needed on the host running the harness. Host images install the distro + # package (python3-cryptography) — see images/host-*/mkosi.conf. + "cryptography", +] [project.scripts] sev-verify = "sev_verify.cli:main" diff --git a/sev_verify/README.md b/sev_verify/README.md index 95026c61..b08ec548 100644 --- a/sev_verify/README.md +++ b/sev_verify/README.md @@ -81,7 +81,26 @@ results/ Output (gitignored) ## Requirements -Python 3.11+ (uses `tomllib` from stdlib). No external packages. +Python 3.11+ (uses `tomllib` from stdlib). + +One external package: **`cryptography`**, used by the ID block tests to generate +ephemeral P-384 key pairs. `snpguest` signs the ID block and computes key +digests but cannot generate keys, so this step cannot be delegated to the +tooling. + +Install it from the distribution rather than with pip. The harness runs from the +source tree as `python3 -m sev_verify`, which imports the package directly and +never consults the dependency list in `pyproject.toml` — that list applies only +if the project is installed (`pip install -e .`). + +``` +apt install python3-cryptography # Debian / Ubuntu +dnf install python3-cryptography # Fedora / RHEL / CentOS / Rocky +zypper install python3-cryptography # openSUSE +``` + +Host images install it through `Packages=` in `images/host-*/mkosi.conf`; a +freshly built image needs no extra step. ## Flags diff --git a/sev_verify/attestation_report.py b/sev_verify/attestation_report.py index 0c377380..85021e0a 100644 --- a/sev_verify/attestation_report.py +++ b/sev_verify/attestation_report.py @@ -11,11 +11,74 @@ Everything below 0x188 is common to v2 and v3; the CPUID family/model/stepping triple at 0x188 exists only in v3+. -Every offset here was validated against a real v3 report from an EPYC 9654 -(Genoa, CPUID 19h/11h), cross-checked against independently known values: +**TCB_VERSION is the exception, and it is not version-dependent but +*generation*-dependent.** The ``sev`` crate decodes it two different ways +(``from_legacy_bytes`` vs ``from_turin_bytes``), and the layouts are +incompatible and indistinguishable from the eight bytes alone: + +=========== ===================== ==================== +byte Milan / Genoa Turin / Venice +=========== ===================== ==================== +0 BOOT_LOADER FMC +1 TEE BOOT_LOADER +2 -- TEE +3 -- SNP +6 SNP -- +7 MICROCODE MICROCODE +=========== ===================== ==================== + +Decoding therefore requires knowing the processor generation. The authoritative +source is the **host's** CPUID — a report parsed here was produced by a guest on +this machine, and unlike the report's own CPUID copy it is present regardless of +report version. :func:`host_generation` reads it; :func:`parse` takes the result +as an argument so the parser itself stays a pure function of its input and can +be unit-tested without hardware. + +When the report is v3+ it also carries a CPUID copy, which :func:`parse` uses as +a cross-check — but only when it can be resolved. Firmware does not always fill +it in: SEV firmware 1.55 build 38 leaves all three bytes zero in version-3 +reports, and build 39 populates them. A report like that is perfectly decodable +using the host's generation, so it is decoded, and the failed cross-check is +recorded in ``cpuid_note`` rather than raised. Refusing it would reject a usable +report over a field the platform declined to fill. + +The error is reserved for the case that actually indicates a problem: both the +host's and the report's CPUID resolve to validated generations, and they +disagree. Then the report did not come from this machine and neither layout can +be trusted for it. Where no generation is supplied at all, the report's own +CPUID is used if it resolves; if it does not, TCB_VERSION is left undecoded, +matching what a v2 report — which carries no CPUID — already does. + +An unrecognised processor still raises when it is the *only* source, since +guessing a layout would produce plausible-looking but wrong values with no error. + +Offsets are confirmed against real reports rather than read off a spec. The +first such validation used a v3 report from an EPYC 9654 (Genoa, CPUID +19h/11h), cross-checked against independently known values: GUEST_SVN/POLICY/FAMILY_ID/IMAGE_ID against the values the ID block was built with, REPORTED_TCB against ``snphost ok``, AUTHOR_KEY_DIGEST against the known all-zero author key, and CPUID against the CPU model. + +The second used a **version 5** report from an EPYC 9575F (Turin, CPUID +1Ah/02h), which validated the Turin TCB layout for the first time. Its +REPORTED_TCB bytes were ``0103020600000062``, decoding under the Turin layout to +``bootloader=3 tee=2 snp=6 microcode=98 fmc=1`` — matching ``snphost show tcb`` +exactly. Decoded under the legacy layout the same bytes give +``bootloader=1 tee=3 snp=0 microcode=98`` with no FMC: plausible values, silently +wrong, which is precisely the failure this generation gate exists to prevent. +That report also confirmed v5 moved none of the fields read here. + +As further processors are exercised, extend :data:`SUPPORTED_GENERATIONS` and +record the validation here. + +.. note:: + + This module is a **short-term stand-in**. The right long-term source is + ``snpguest``, which already parses reports correctly and generation-aware via + the ``sev`` crate but currently exposes them only as human-readable text + (``println!("{}", att_report)``). Once snpguest gains machine-readable + output, this module should be replaced by consuming that output rather than + extended to cover further processor generations. """ from __future__ import annotations @@ -27,9 +90,71 @@ #: ATTESTATION_REPORT is a fixed-size structure. REPORT_SIZE = 1184 -#: Report versions whose layout we read. v3 is verified on hardware; v2 shares -#: the same layout for every field below 0x188. -KNOWN_VERSIONS = frozenset({2, 3}) +#: Report versions whose layout we read. v3 and v5 are verified on hardware; +#: v2 shares the same layout for every field below 0x188. +#: +#: Versions have only ever *appended* fields, so a newer report is very likely +#: readable with these offsets unchanged. "Very likely" is not a basis for a +#: certification result, so an unlisted version is refused rather than assumed +#: compatible — the same stance :data:`SUPPORTED_GENERATIONS` takes, for the +#: same reason. +#: +#: Note this is an axis independent of processor generation. The version decides +#: which fields exist and where; the generation decides how TCB_VERSION's eight +#: bytes are ordered. A v3 report can come from either a legacy-layout or a +#: Turin-layout processor, so both gates are needed and neither implies the +#: other. +#: +#: v5 was added after a real v5 report from an EPYC 9575F decoded correctly at +#: these offsets — REPORTED_TCB matched ``snphost show tcb`` and CPUID matched +#: the host's, confirming its additions moved nothing we read. v4 exists and is +#: still refused, never having been seen. To add one: +#: +#: 1. Check whether it shares framing with a version already listed. The +#: ``sev`` crate's ``ReportVariant`` mapping groups versions by layout — +#: currently ``2 => V2``, ``3 | 4 => V3``, ``_ => V5`` — so a version +#: sharing a variant with one listed here reads identically. That makes v4 +#: the cheap case and v5 the one needing real scrutiny. +#: 2. Remember additions are not always new offsets. v5 adds +#: ``page_swap_disabled`` to GuestPolicy and SEV-TIO to PlatformInfo, which +#: are new *bits in existing fields* and move nothing. +#: 3. Parse a real report of that version and check decoded values against +#: independently known ones, as the module docstring records for v3. +#: 4. Add the version here and record the validation in the docstring. +KNOWN_VERSIONS = frozenset({2, 3, 5}) + +#: TCB_VERSION byte layouts. See the module docstring for the two orderings. +TCB_LAYOUT_LEGACY = "legacy" +TCB_LAYOUT_TURIN = "turin" + +#: Processor generations this module has been **validated against**, keyed by +#: CPUID family and an inclusive model range. +#: +#: This is deliberately a record of what has been exercised on real hardware, +#: not of what we believe we could decode. A certification harness reporting a +#: pass on silicon it has never run on is the failure this gate exists to +#: prevent, so an unrecognised processor raises rather than being decoded on +#: the assumption that a transcribed layout is right. +#: +#: Keyed on family/model *pairs*, not family/model/stepping triples: AMD scopes +#: SEV firmware images by family and model only — ``amd_sev_fam19h_model1xh``, +#: ``amd_sev_fam1ah_model0xh`` — and stepping appears nowhere in that +#: partitioning. snpguest's ``get_processor_model`` (``src/fetch.rs``) splits +#: the same way for VCEK lookup. +#: +#: To add a generation: run the ID block test on that hardware, confirm the +#: decoded fields against ``snphost show tcb`` and the values the ID block was +#: built with, then add the entry and note the validation in the docstring. +#: The layouts for generations not yet exercised here, taken from the ``sev`` +#: crate, are: +#: +#: 0x19 / 0x00-0x0F Milan legacy +#: 0x19 / 0xA0-0xAF Bergamo/Siena legacy +SUPPORTED_GENERATIONS: tuple[tuple[int, range, str, str], ...] = ( + # (cpuid_family, model range, name, TCB layout) + (0x19, range(0x10, 0x20), "Genoa", TCB_LAYOUT_LEGACY), # EPYC 9654, v3 reports + (0x1A, range(0x00, 0x12), "Turin", TCB_LAYOUT_TURIN), # EPYC 9575F, v5 reports +) # Field offsets. See module docstring for how these were validated. _OFF_VERSION = 0x000 @@ -67,26 +192,52 @@ class ReportUnsupportedVersion(ReportError): """The report declares a version whose layout we have not validated.""" +class ReportUnsupportedCpu(ReportError): + """The report comes from a processor this module has not been validated on. + + Raised rather than decoding on the assumption that a transcribed layout is + correct — TCB_VERSION in particular is laid out differently on Turin, so a + wrong guess yields plausible values rather than an error. + """ + + @dataclass(frozen=True) class TcbVersion: - """Decoded SNP TCB_VERSION — the same four values ``snphost ok`` prints.""" + """Decoded SNP TCB_VERSION — the same values ``snphost ok`` prints. + + ``fmc`` exists only on Turin and later; it is ``None`` elsewhere, matching + the ``sev`` crate's ``Option``. + """ bootloader: int tee: int snp: int microcode: int + fmc: int | None = None @classmethod - def from_bytes(cls, raw: bytes) -> TcbVersion: - # byte 0 BOOT_LOADER, byte 1 TEE, bytes 2-5 reserved, - # byte 6 SNP, byte 7 MICROCODE. - return cls(bootloader=raw[0], tee=raw[1], snp=raw[6], microcode=raw[7]) + def from_bytes(cls, raw: bytes, layout: str) -> TcbVersion: + """Decode the 8-byte TCB_VERSION using the given generation layout.""" + if layout == TCB_LAYOUT_TURIN: + # byte 0 FMC, 1 BOOT_LOADER, 2 TEE, 3 SNP, 7 MICROCODE. + return cls( + fmc=raw[0], + bootloader=raw[1], + tee=raw[2], + snp=raw[3], + microcode=raw[7], + ) + if layout == TCB_LAYOUT_LEGACY: + # byte 0 BOOT_LOADER, 1 TEE, bytes 2-5 reserved, 6 SNP, 7 MICROCODE. + return cls(bootloader=raw[0], tee=raw[1], snp=raw[6], microcode=raw[7]) + raise ReportUnsupportedCpu(f"unknown TCB layout {layout!r}") def __str__(self) -> str: - return ( + base = ( f"bootloader={self.bootloader} tee={self.tee} " f"snp={self.snp} microcode={self.microcode}" ) + return base if self.fmc is None else f"fmc={self.fmc} {base}" @dataclass(frozen=True) @@ -105,9 +256,17 @@ class AttestationReport: id_key_digest: bytes author_key_digest: bytes report_id: bytes - reported_tcb: TcbVersion + #: ``None`` when the processor generation was unknown, since the byte + #: layout differs between generations and cannot be guessed. + reported_tcb: TcbVersion | None #: (family, model, stepping) — v3+ only, None on older reports. cpuid: tuple[int, int, int] | None + #: Validated processor generation this report was decoded as, or "unknown". + generation: str + #: Set when the report's own CPUID could not be used and the host's was + #: preferred — for instance when firmware leaves those bytes zero. ``None`` + #: when the report's CPUID was absent by design (v2) or agreed with the host. + cpuid_note: str | None = None @property def id_block_used(self) -> bool: @@ -115,12 +274,78 @@ def id_block_used(self) -> bool: return any(self.id_key_digest) -def parse(data: bytes) -> AttestationReport: +def resolve_generation(family: int, model: int) -> tuple[str, str]: + """Return ``(name, tcb_layout)`` for a CPUID family/model pair. + + Raises: + ReportUnsupportedCpu: the pair is not in :data:`SUPPORTED_GENERATIONS`. + """ + for fam, models, name, layout in SUPPORTED_GENERATIONS: + if family == fam and model in models: + return name, layout + + validated = ", ".join( + f"{name} (family 0x{fam:02X} model 0x{models[0]:02X}-0x{models[-1]:02X})" + for fam, models, name, _ in SUPPORTED_GENERATIONS + ) + raise ReportUnsupportedCpu( + f"CPUID family 0x{family:02X} model 0x{model:02X} has not been validated " + f"against. Validated: {validated}. TCB_VERSION is laid out differently " + f"across processor generations, so decoding anyway would produce " + f"plausible but wrong values. See SUPPORTED_GENERATIONS in " + f"sev_verify/attestation_report.py." + ) + + +def host_generation() -> tuple[str, str]: + """Return ``(name, tcb_layout)`` for the CPU this process is running on. + + Reads ``/proc/cpuinfo``. This is the authoritative source when parsing a + report produced by a guest on this machine: unlike the report's CPUID copy + it is present regardless of report version. + + Raises: + ReportUnsupportedCpu: family/model unreadable, or not validated. + """ + family = model = None + try: + with open("/proc/cpuinfo", encoding="utf-8") as f: + for line in f: + key, _, value = line.partition(":") + key = key.strip() + if key == "cpu family" and family is None: + family = int(value.strip()) + elif key == "model" and model is None: + model = int(value.strip()) + if family is not None and model is not None: + break + except OSError as exc: + raise ReportUnsupportedCpu(f"could not read /proc/cpuinfo: {exc}") from exc + + if family is None or model is None: + raise ReportUnsupportedCpu( + "could not determine CPU family/model from /proc/cpuinfo" + ) + return resolve_generation(family, model) + + +def parse( + data: bytes, *, generation: tuple[str, str] | None = None +) -> AttestationReport: """Parse raw report bytes. + Args: + data: the raw 1184-byte ATTESTATION_REPORT. + generation: ``(name, tcb_layout)``, normally from :func:`host_generation`. + Decides the TCB_VERSION byte layout. If omitted, it is taken from + the report's own CPUID when present (v3+); a v2 report then leaves + ``reported_tcb`` as ``None`` rather than guessing a layout. + Raises: ReportMalformed: wrong size. ReportUnsupportedVersion: layout not validated for that version. + ReportUnsupportedCpu: the report's CPUID disagrees with *generation*, or + names a processor that has not been validated against. """ if len(data) != REPORT_SIZE: raise ReportMalformed( @@ -149,6 +374,69 @@ def field(off: int, length: int) -> bytes: data[_OFF_CPUID_FAM + 2], ) + # Resolve the generation that decides the TCB_VERSION layout. + # + # The host's CPUID is authoritative when supplied: it describes the silicon + # this code is running on, which is the thing the layout actually depends + # on. The report's copy is a cross-check, and only a useful one when it can + # be resolved — firmware does not always populate it. Observed on SEV + # firmware 1.55 build 38, which leaves all three bytes zero in version-3 + # reports; build 39 fills them in. Refusing such a report would reject a + # decodable one over a field the platform declined to fill. + cpuid_note: str | None = None + if generation is not None and cpuid is not None: + try: + report_gen = resolve_generation(cpuid[0], cpuid[1]) + except ReportUnsupportedCpu: + # Unresolvable, so it contradicts nothing. Decode with the host's + # generation and record that the cross-check could not be made. + cpuid_note = ( + f"report CPUID family 0x{cpuid[0]:02X} model 0x{cpuid[1]:02X} " + f"stepping 0x{cpuid[2]:02X} does not resolve to a known " + f"generation; decoded as {generation[0]} from the host instead" + ) + else: + if report_gen != generation: + # Both resolve, and disagree: the report is not from this + # machine, and neither layout can be trusted for it. + # + # Note this branch is only reachable once SUPPORTED_GENERATIONS + # holds more than one entry. With a single validated generation + # every disagreeing CPUID is unresolvable instead, and takes the + # branch above. That is the conservative order: a report is only + # called foreign when both generations are ones we have actually + # validated against. + raise ReportUnsupportedCpu( + f"report CPUID family 0x{cpuid[0]:02X} model " + f"0x{cpuid[1]:02X} resolves to {report_gen[0]}, but this " + f"host is {generation[0]}. The report does not appear to " + f"come from this machine." + ) + elif generation is None and cpuid is not None: + # No host generation to fall back on. An unresolvable CPUID then leaves + # nothing to choose a layout with, so the TCB is left undecoded — the + # same outcome as a v2 report, which carries no CPUID at all — rather + # than raising for a v3 report where v2 would have been tolerated. + try: + generation = resolve_generation(cpuid[0], cpuid[1]) + except ReportUnsupportedCpu: + cpuid_note = ( + f"report CPUID family 0x{cpuid[0]:02X} model 0x{cpuid[1]:02X} " + f"stepping 0x{cpuid[2]:02X} does not resolve to a known " + f"generation and no host generation was supplied; " + f"TCB_VERSION left undecoded" + ) + + if generation is not None: + gen_name, tcb_layout = generation + reported_tcb = TcbVersion.from_bytes( + data[_OFF_REPORTED_TCB:_OFF_REPORTED_TCB + 8], tcb_layout + ) + else: + # v2 report and no generation supplied — the TCB layout is unknowable, + # so leave it undecoded rather than assume one. + gen_name, reported_tcb = "unknown", None + return AttestationReport( version=version, guest_svn=guest_svn, @@ -162,20 +450,29 @@ def field(off: int, length: int) -> bytes: id_key_digest=field(_OFF_ID_KEY_DIGEST, _LEN_DIGEST), author_key_digest=field(_OFF_AUTHOR_KEY_DIGEST, _LEN_DIGEST), report_id=field(_OFF_REPORT_ID, _LEN_REPORT_ID), - reported_tcb=TcbVersion.from_bytes(field(_OFF_REPORTED_TCB, 8)), + reported_tcb=reported_tcb, cpuid=cpuid, + generation=gen_name, + cpuid_note=cpuid_note, ) -def read(path: Path) -> AttestationReport: +def read( + path: Path, *, generation: tuple[str, str] | None = None +) -> AttestationReport: """Read and parse an ATTESTATION_REPORT file. + Args: + path: the report file. + generation: forwarded to :func:`parse`; see its docstring. + Raises: ReportMalformed: file missing or wrong size. ReportUnsupportedVersion: layout not validated for that version. + ReportUnsupportedCpu: CPUID mismatch, or processor not validated. """ try: data = path.read_bytes() except FileNotFoundError as exc: raise ReportMalformed(f"{path.name} not found") from exc - return parse(data) + return parse(data, generation=generation) diff --git a/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py b/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py index 1cb38428..8e596f46 100644 --- a/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py +++ b/sev_verify/cert_tests/c3_0/c3_0_0_0/attestation_test.py @@ -18,63 +18,23 @@ """ import subprocess -from pathlib import Path -from sev_verify.cvm_props import MeasurementError, read_measurement +# calculate_measurement is imported, not redefined: it is shared with the ID +# block test via cvm_props, and the handler for the step below resolves by name +# on this module, so the import is what makes it available. +from sev_verify.cvm_props import ( + MeasurementError, + calculate_measurement, + read_measurement, +) from sev_verify.models import BaseStep, Step, StepContext, StepHandlerResult -from sev_verify.vm_profile import VMProfile, VMProfileError +from sev_verify.vm_profile import VMProfile vm_profile = VMProfile( image_path="", memory_mb=4096, ) -def calculate_measurement(ctx: StepContext) -> StepHandlerResult: - """ - Calculate expected measurement using ``snpguest generate measurement``. - - Searches for an AMD SEV-compatible OVMF binary and runs snpguest to - produce a hex measurement of the guest image, stored in - ``ctx.expected_measurement`` for later attestation comparison. - """ - measurement_file = ctx.artifact_dir / "guest_measurement.txt" - ovmf_path = None - - try: - ovmf_path = Path(ctx.profile.resolved_ovmf_path()) - except VMProfileError as e: - return StepHandlerResult( - exit_code=1, - stderr=str(e), - ) - - result = subprocess.run( - [ - "snpguest", "generate", "measurement", - "--vcpu-type", "EPYC-v4", - "--ovmf", str(ovmf_path), - "--kernel", str(ctx.guest_path), - "--output-format", "hex", - "--measurement-file", str(measurement_file), - ], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - return StepHandlerResult( - exit_code=result.returncode, - stdout=result.stdout, - stderr=result.stderr, - ) - - expected_measurement = measurement_file.read_text().strip() - return StepHandlerResult( - exit_code=0, - stdout=f"Calculated expected measurement: {expected_measurement}", - ) - - def verify_report_fields(ctx: StepContext) -> StepHandlerResult: """ Example callable step: validate ``report.bin`` after ``guest_pull``. diff --git a/sev_verify/cert_tests/c3_0/c3_0_0_2/id_block_test.py b/sev_verify/cert_tests/c3_0/c3_0_0_2/id_block_test.py index ff5d1654..581c4e31 100644 --- a/sev_verify/cert_tests/c3_0/c3_0_0_2/id_block_test.py +++ b/sev_verify/cert_tests/c3_0/c3_0_0_2/id_block_test.py @@ -54,9 +54,16 @@ def verify_id_block_fields(ctx: StepContext) -> StepHandlerResult: Reads report.bin directly (see :mod:`sev_verify.attestation_report`) rather than parsing ``snpguest display report`` output, so the check does not depend on a CLI's human-readable formatting. + + The host's processor generation is passed in so the parser can cross-check + it against the CPUID the report carries, and so TCB_VERSION — whose byte + layout differs by generation — is never decoded on a guess. """ try: - report = attestation_report.read(ctx.artifact_dir / "report.bin") + report = attestation_report.read( + ctx.artifact_dir / "report.bin", + generation=attestation_report.host_generation(), + ) except attestation_report.ReportError as exc: return StepHandlerResult(exit_code=1, stderr=str(exc)) @@ -97,7 +104,8 @@ def verify_id_block_fields(ctx: StepContext) -> StepHandlerResult: f"All ID block fields match: svn={guest_svn} policy={hex(policy_int)} " f"family_id={family_id!r} image_id={image_id!r}\n" f" report v{report.version} vmpl={report.vmpl} " - f"cpuid={report.cpuid} tcb=({report.reported_tcb})\n" + f"cpuid={report.cpuid} gen={report.generation} " + f"tcb=({report.reported_tcb})\n" f" id_key_digest={report.id_key_digest.hex()[:32]}..." ), ) @@ -185,24 +193,81 @@ def set_bad_measurement(ctx: StepContext) -> StepHandlerResult: ) +_SMT_ACTIVE = Path("/sys/devices/system/cpu/smt/active") +_SMT_CONTROL = Path("/sys/devices/system/cpu/smt/control") + +#: smt/control values that explain *why* SMT is inactive. The first two mean it +#: was switched off, the last two that the capability is absent — a distinction +#: worth preserving in the report, since only the former could have been on. +_SMT_CONTROL_REASONS = { + "off": "SMT is disabled on this host", + "forceoff": "SMT is force-disabled and cannot be re-enabled without a reboot", + "notsupported": "this processor does not support SMT", + "notimplemented": "this architecture does not implement SMT runtime control", +} + + +def _read_sysfs(path: Path) -> str | None: + """Return the stripped contents of *path*, or None if it cannot be read.""" + try: + return path.read_text().strip() + except OSError: + return None + + +def _smt_status() -> tuple[bool, str]: + """Return whether host SMT is active, with a reason suitable for reporting. + + ``smt/active`` decides: it reports whether sibling threads are online right + now. ``smt/control`` is consulted only to explain why they are not, and is + deliberately not used as the decision — besides the four names above it can + also read as a thread count on architectures with partial SMT states. + """ + active = _read_sysfs(_SMT_ACTIVE) + control = _read_sysfs(_SMT_CONTROL) + + if active == "1": + return True, "SMT is active on this host" + + reason = _SMT_CONTROL_REASONS.get(control or "") + if reason is not None: + return False, reason + if active is None: + return False, ( + f"{_SMT_ACTIVE} is not present, so SMT state cannot be determined" + ) + return False, "SMT is not active on this host" + + +def smt_case_not_applicable(ctx: StepContext) -> StepHandlerResult: + """Record that the SMT policy case was left out of this run. + + Emitted in place of the SMT steps when the host cannot exercise them, so + that the omission appears in the results rather than the case simply being + absent. + """ + _, reason = _smt_status() + return StepHandlerResult( + exit_code=0, + stdout=f"SMT policy rejection case not run: {reason}", + ) + + def set_incompatible_policy(ctx: StepContext) -> StepHandlerResult: """Regenerate the ID block with a policy the platform cannot satisfy. - Checks whether SMT is active on the host. If so, regenerates the ID block - (and QEMU launch policy) with SMT=0 — the firmware must reject because the - platform cannot guarantee single-threaded execution. + Regenerates the ID block (and QEMU launch policy) with SMT=0 — the firmware + must reject because the platform cannot guarantee single-threaded execution. + + Only reached on an SMT-active host; steps() omits this case otherwise. The + check is repeated here so the handler is correct on its own terms rather + than relying on the caller. """ - smt_path = Path("/sys/devices/system/cpu/smt/active") - if not smt_path.exists(): - return StepHandlerResult( - exit_code=1, - stderr="Cannot determine SMT status: /sys/devices/system/cpu/smt/active not found", - ) - smt_active = smt_path.read_text().strip() == "1" + smt_active, reason = _smt_status() if not smt_active: return StepHandlerResult( exit_code=1, - stderr="SMT is not active on this host; cannot test SMT policy incompatibility", + stderr=f"Cannot test SMT policy incompatibility: {reason}", ) try: @@ -255,7 +320,9 @@ def set_bad_abi_version(ctx: StepContext) -> StepHandlerResult: def steps() -> list[BaseStep]: - return [ + smt_active, _ = _smt_status() + + steps_list: list[BaseStep] = [ # ── Positive: launch with valid ID block, verify report fields ── Step.for_callable( name="Calculate measurement", @@ -313,7 +380,10 @@ def steps() -> list[BaseStep]: Step.for_vm_launch( name="Launch with bad measurement (expect rejection)", type="required", - expected_result="exit_code:1", + # Assert the firmware's own reason, not merely that something + # failed: exit_code:1 alone is also satisfied by a boot timeout, so + # it cannot distinguish a real rejection from a hung guest. + expected_result="stdout_contains:Bad measurement", timeout=300, ), Step.for_vm_stop( @@ -321,26 +391,52 @@ def steps() -> list[BaseStep]: type="info", timeout=60, ), + ] - # ── Negative: incompatible policy (SMT=0 on SMT-active host) ── - Step.for_callable( - name="Set incompatible policy (SMT)", - type="required", - handler="set_incompatible_policy", - timeout=30, - ), - Step.for_vm_launch( - name="Launch with SMT-incompatible policy (expect rejection)", - type="required", - expected_result="exit_code:1", - timeout=300, - ), - Step.for_vm_stop( - name="Stop VM (after SMT policy)", - type="info", - timeout=60, - ), + # ── Negative: incompatible policy (SMT=0 on SMT-active host) ── + # + # Clearing the SMT bit only produces a rejection on a host where SMT is + # actually active, so elsewhere there is nothing to assert. The case is + # left out of the step list rather than run and failed, with an info step + # in its place: a case that vanishes silently is indistinguishable from one + # that passed. Reporting it as "pass" does overload that outcome — a + # first-class per-step "not applicable on this platform" result would say + # so plainly, and is the better home for this once one exists. + if smt_active: + steps_list += [ + Step.for_callable( + name="Set incompatible policy (SMT)", + type="required", + handler="set_incompatible_policy", + timeout=30, + ), + Step.for_vm_launch( + name="Launch with SMT-incompatible policy (expect rejection)", + type="required", + # This one is refused by KVM before the firmware sees it + # (SNP_LAUNCH_START ret=-22 fw_error=0 ''), so there is no + # firmware string to match. Assert the rejection happened at + # launch-start, which still rules out a boot timeout. + expected_result="stdout_contains:SNP_LAUNCH_START", + timeout=300, + ), + Step.for_vm_stop( + name="Stop VM (after SMT policy)", + type="info", + timeout=60, + ), + ] + else: + steps_list.append( + Step.for_callable( + name="SMT policy case not applicable", + type="info", + handler="smt_case_not_applicable", + timeout=10, + ) + ) + steps_list += [ # ── Negative: impossible ABI version ── Step.for_callable( name="Set impossible ABI version", @@ -351,7 +447,8 @@ def steps() -> list[BaseStep]: Step.for_vm_launch( name="Launch with impossible ABI version (expect rejection)", type="required", - expected_result="exit_code:1", + # Firmware rejects this one: SNP_LAUNCH_START fw_error=7. + expected_result="stdout_contains:Policy is not allowed", timeout=300, ), Step.for_vm_stop( @@ -360,3 +457,5 @@ def steps() -> list[BaseStep]: timeout=60, ), ] + + return steps_list diff --git a/sev_verify/cert_tests/c3_0/manifest.toml b/sev_verify/cert_tests/c3_0/manifest.toml index 22b5dee2..97b69076 100644 --- a/sev_verify/cert_tests/c3_0/manifest.toml +++ b/sev_verify/cert_tests/c3_0/manifest.toml @@ -20,3 +20,10 @@ module = "cert_tests.c3_0.c3_0_0_1.snphost_config_commit" scope = "mixed" level = "3.0.0-1" host_changes = true + +[[tests]] +name = "id-block-test" +description = "Verify ID block acceptance, report field binding, and launch rejection" +module = "cert_tests.c3_0.c3_0_0_2.id_block_test" +scope = "mixed" +level = "3.0.0-1" diff --git a/sev_verify/cvm_props.py b/sev_verify/cvm_props.py index 76695481..9e2d07de 100644 --- a/sev_verify/cvm_props.py +++ b/sev_verify/cvm_props.py @@ -146,8 +146,8 @@ def generate_id_block(ctx: StepContext) -> StepHandlerResult: snpguest generate id-block, and updates ctx.profile with the resulting id_block and id_auth values so that vm_launch passes them to QEMU. - ID block metadata is read from environment variables with the same defaults - used by the generate-id-block systemd service: + ID block metadata is read from environment variables, falling back to the + DEFAULT_* constants in this module: ID_BLOCK_FAMILY_ID, ID_BLOCK_IMAGE_ID, ID_BLOCK_GUEST_SVN, ID_BLOCK_POLICY If guest_measurement.txt is absent (calculate_measurement was skipped or From d8dc4757406abd6ad0ed3c3b073c9739a13b94b0 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Thu, 3 Sep 2026 10:45:37 -0500 Subject: [PATCH 18/20] docs: bring the sev_verify README in line with the package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Layout section listed five of the ten modules. attestation_report.py and cvm_props.py are introduced by this branch and were never added; environment.py, os_info.py and output.py predate it. "How it works" described a manifest entry as "name, scope, module path", but TestDefinition also carries level and host_changes. host_changes was documented only in the flags table, which is not where someone writing a test looks — and the judgement it requires is not obvious, so the distinction is stated: launching a guest does not count, changing platform configuration does. feat: decode newer report versions rather than refusing them An unknown report version was refused outright, the same treatment given an unknown processor. The two mistakes are not comparable. Misidentifying the processor generation means misreading bytes that are present: TCB_VERSION decodes to plausible but wrong values with nothing in the data to reveal it. That is worth refusing over. A newer report version is the opposite — fields have only ever been appended, so everything read here stays where it was and the cost is missing what is new rather than misreading what is old. The version is also self-describing, sitting in the first four bytes and always present, which is exactly what the generation is not. The gate had fired exactly once in practice, on Turin's version 5 reports, and it was wrong to fire: it would have refused a report that decodes correctly, which is how the Turin support in this branch was nearly missed. The sev crate is already permissive here, mapping any unrecognised version onto its newest variant. So a version at or above the validated range is decoded with the newest validated offsets and the assumption recorded in a new version_note field. Versions below the range are still refused, since there fields may genuinely not exist rather than merely going unread. KNOWN_VERSIONS now means validated, not accepted. --- sev_verify/README.md | 27 +++++++++----- sev_verify/attestation_report.py | 61 ++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/sev_verify/README.md b/sev_verify/README.md index b08ec548..2f3893d2 100644 --- a/sev_verify/README.md +++ b/sev_verify/README.md @@ -32,7 +32,12 @@ python3 -m sev_verify /path/to/guest.efi --output-dir /data/sev-artifacts -v 3.0 ## How it works -1. Discover manifests at `cert_tests/*/manifest.toml`. Each manifest declares test entries (name, scope, module path). +1. Discover manifests at `cert_tests/*/manifest.toml`. Each manifest declares test entries: + + - **`name`**, **`description`**, **`module`** — identity and the dotted path to the test module. + - **`scope`** — `host`, `guest`, or `mixed`. Anything other than `host` causes a `VMProfile` to be built (see step 4). + - **`level`** — certification level, e.g. `3.0.0-1`. Several tests may share one level. Also selects the artifacts directory (see [Artifacts directory](#artifacts-directory)). + - **`host_changes`** — set `true` when the test may alter host state that outlives it, such as `snphost commit` advancing the committed TCB floor. Such tests are listed at startup and gated on `--allow-host-changes`. Launching a guest does not count; changing platform configuration does. 2. For each test, import its Python module and call `steps()` to get the ordered list of **`BaseStep`** records. Each has a **`kind`** field (`host`, `guest`, `vm_launch`, …). Define steps with **`Step`** either **chained** (``Step(...).host(command=...)``, …) or **in one call** with ``Step.for_host(...)``, ``Step.for_callable(...)``, etc., so your editor shows every required parameter for that shape. Only the fields relevant to ``kind`` may be set; invalid combinations are rejected at construction. @@ -69,6 +74,11 @@ sev_verify/ Harness package runner.py load_test_execution_plan, run_step, run_vm_launch_step, … vm_profile.py VMProfile, QEMU argv, vm_launch / stop_vm guest_vsock.py vsock command channel to the guest + attestation_report.py Parse report.bin; TCB layout varies by CPU generation + cvm_props.py Measurement + ID block generation shared across tests + environment.py Host component versions recorded in the result + os_info.py Host and guest OS identity (guest read over vsock) + output.py JSON and Markdown result writers cert_tests/ Certification levels common/ Shared test modules snp_ok.py Example host-only test @@ -84,14 +94,13 @@ results/ Output (gitignored) Python 3.11+ (uses `tomllib` from stdlib). One external package: **`cryptography`**, used by the ID block tests to generate -ephemeral P-384 key pairs. `snpguest` signs the ID block and computes key -digests but cannot generate keys, so this step cannot be delegated to the -tooling. +the ephemeral P-384 key pairs that sign an ID block. `snpguest` signs and +computes key digests but cannot generate keys, so this cannot be delegated to +the tooling. -Install it from the distribution rather than with pip. The harness runs from the -source tree as `python3 -m sev_verify`, which imports the package directly and -never consults the dependency list in `pyproject.toml` — that list applies only -if the project is installed (`pip install -e .`). +Install it from the distribution, not with pip — the harness runs from the +source tree, so `pyproject.toml`'s dependency list is never consulted unless the +project is actually installed. ``` apt install python3-cryptography # Debian / Ubuntu @@ -99,7 +108,7 @@ dnf install python3-cryptography # Fedora / RHEL / CentOS / Rocky zypper install python3-cryptography # openSUSE ``` -Host images install it through `Packages=` in `images/host-*/mkosi.conf`; a +Host images install it through `Packages=` in `images/host-*/mkosi.conf`, so a freshly built image needs no extra step. ## Flags diff --git a/sev_verify/attestation_report.py b/sev_verify/attestation_report.py index 85021e0a..a3f9cf35 100644 --- a/sev_verify/attestation_report.py +++ b/sev_verify/attestation_report.py @@ -52,6 +52,13 @@ An unrecognised processor still raises when it is the *only* source, since guessing a layout would produce plausible-looking but wrong values with no error. +Report *versions* are treated more leniently than processors, deliberately. A +newer version is decoded with the newest validated offsets and the assumption +recorded in ``version_note``; only versions older than the validated range are +refused. The asymmetry is the point: misreading a generation corrupts values +silently, whereas an unrecognised version at worst leaves new fields unread, +and the version — unlike the generation — is stated in the report itself. + Offsets are confirmed against real reports rather than read off a spec. The first such validation used a v3 report from an EPYC 9654 (Genoa, CPUID 19h/11h), cross-checked against independently known values: @@ -93,11 +100,13 @@ #: Report versions whose layout we read. v3 and v5 are verified on hardware; #: v2 shares the same layout for every field below 0x188. #: -#: Versions have only ever *appended* fields, so a newer report is very likely -#: readable with these offsets unchanged. "Very likely" is not a basis for a -#: certification result, so an unlisted version is refused rather than assumed -#: compatible — the same stance :data:`SUPPORTED_GENERATIONS` takes, for the -#: same reason. +#: Membership here means *validated*, not *accepted*. A newer version is still +#: decoded — with these offsets, and a note on the result recording the +#: assumption — because versions have only ever appended fields, so the cost of +#: being wrong is missing something new rather than misreading something old. +#: Only versions *older* than this set are refused, where fields may genuinely +#: not exist. See :func:`parse` for why that is a weaker stance than the one +#: :data:`SUPPORTED_GENERATIONS` takes. #: #: Note this is an axis independent of processor generation. The version decides #: which fields exist and where; the generation decides how TCB_VERSION's eight @@ -107,8 +116,9 @@ #: #: v5 was added after a real v5 report from an EPYC 9575F decoded correctly at #: these offsets — REPORTED_TCB matched ``snphost show tcb`` and CPUID matched -#: the host's, confirming its additions moved nothing we read. v4 exists and is -#: still refused, never having been seen. To add one: +#: the host's, confirming its additions moved nothing we read. v4 has never been +#: seen; it decodes on the append-only assumption and says so. To promote a +#: version to validated: #: #: 1. Check whether it shares framing with a version already listed. The #: ``sev`` crate's ``ReportVariant`` mapping groups versions by layout — @@ -267,6 +277,10 @@ class AttestationReport: #: preferred — for instance when firmware leaves those bytes zero. ``None`` #: when the report's CPUID was absent by design (v2) or agreed with the host. cpuid_note: str | None = None + #: Set when the report declared a version newer than any validated here and + #: was decoded with the newest known field offsets. ``None`` when the + #: version was one this parser has been checked against. + version_note: str | None = None @property def id_block_used(self) -> bool: @@ -353,10 +367,36 @@ def parse( ) (version,) = struct.unpack_from(" bytes: cpuid=cpuid, generation=gen_name, cpuid_note=cpuid_note, + version_note=version_note, ) From ec4f016652d85e299b760407931fe2dc6f4c61e1 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Thu, 3 Sep 2026 10:45:55 -0500 Subject: [PATCH 19/20] refactor: validate the ID block metadata once and share it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ID_BLOCK_* environment variables were read in six places: once to build the ID block, once to check the resulting report, and four more times across the negative cases that rebuild it. Each site parsed them independently, and the checker derived its expectations from the environment rather than from what the generator had actually used — so the two could drift and the test would still report a clean pass or an unexplained mismatch. Read them once, in cvm_props.read_id_block_metadata(), and share the result. The conversions are now guarded. int() on a non-numeric value or encode("ascii") on a non-ASCII one previously raised out of the handler as a bare ValueError or UnicodeEncodeError; they now fail the step with a message naming the variable at fault. FAMILY_ID and IMAGE_ID are length-checked against the 16-byte field. ljust() pads but never truncates, so an over-long value produced an expectation longer than the report field, which could never match and reported itself as a byte diff that did not say why. Policy is carried as an int from the point it is read, rather than being re-parsed from a string at each negative case. VMProfile already accepts str | int and formats it, so nothing downstream changes. fix: move ID block test from 3.0.0-2 to 3.0.0-1 directory --- .../{c3_0_0_2 => c3_0_0_1}/id_block_test.py | 98 ++++++++------ .../cert_tests/c3_0/c3_0_0_2/__init__.py | 0 sev_verify/cert_tests/c3_0/manifest.toml | 2 +- sev_verify/cvm_props.py | 128 ++++++++++++++++-- 4 files changed, 170 insertions(+), 58 deletions(-) rename sev_verify/cert_tests/c3_0/{c3_0_0_2 => c3_0_0_1}/id_block_test.py (85%) delete mode 100644 sev_verify/cert_tests/c3_0/c3_0_0_2/__init__.py diff --git a/sev_verify/cert_tests/c3_0/c3_0_0_2/id_block_test.py b/sev_verify/cert_tests/c3_0/c3_0_0_1/id_block_test.py similarity index 85% rename from sev_verify/cert_tests/c3_0/c3_0_0_2/id_block_test.py rename to sev_verify/cert_tests/c3_0/c3_0_0_1/id_block_test.py index 581c4e31..6d7bd129 100644 --- a/sev_verify/cert_tests/c3_0/c3_0_0_2/id_block_test.py +++ b/sev_verify/cert_tests/c3_0/c3_0_0_1/id_block_test.py @@ -12,7 +12,6 @@ from __future__ import annotations -import os import subprocess import tempfile from dataclasses import replace @@ -27,13 +26,11 @@ from sev_verify import attestation_report from sev_verify.cvm_props import ( - DEFAULT_FAMILY_ID, - DEFAULT_GUEST_SVN, - DEFAULT_IMAGE_ID, - DEFAULT_POLICY, + IdBlockMetadataError, MeasurementError, calculate_measurement, generate_id_block, + read_id_block_metadata, read_measurement, ) from sev_verify.models import BaseStep, Step, StepContext, StepHandlerResult @@ -67,26 +64,29 @@ def verify_id_block_fields(ctx: StepContext) -> StepHandlerResult: except attestation_report.ReportError as exc: return StepHandlerResult(exit_code=1, stderr=str(exc)) - family_id = os.environ.get("ID_BLOCK_FAMILY_ID", DEFAULT_FAMILY_ID) - image_id = os.environ.get("ID_BLOCK_IMAGE_ID", DEFAULT_IMAGE_ID) - guest_svn = int(os.environ.get("ID_BLOCK_GUEST_SVN", DEFAULT_GUEST_SVN)) - policy_int = int(os.environ.get("ID_BLOCK_POLICY", DEFAULT_POLICY), 0) - - expected_family = family_id.encode("ascii").ljust(16, b"\x00") - expected_image = image_id.encode("ascii").ljust(16, b"\x00") + # Read through the same helper the generator used, so the expectations here + # cannot drift from the values the ID block was actually built with. + try: + meta = read_id_block_metadata() + except IdBlockMetadataError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) errors = [] - if report.guest_svn != guest_svn: - errors.append(f"guest_svn: expected {guest_svn}, got {report.guest_svn}") - if report.policy != policy_int: - errors.append(f"policy: expected {hex(policy_int)}, got {hex(report.policy)}") - if report.family_id != expected_family: + if report.guest_svn != meta.guest_svn: + errors.append(f"guest_svn: expected {meta.guest_svn}, got {report.guest_svn}") + if report.policy != meta.policy: + errors.append( + f"policy: expected {hex(meta.policy)}, got {hex(report.policy)}" + ) + if report.family_id != meta.family_id_bytes: errors.append( - f"family_id: expected {expected_family.hex()}, got {report.family_id.hex()}" + f"family_id: expected {meta.family_id_bytes.hex()}, " + f"got {report.family_id.hex()}" ) - if report.image_id != expected_image: + if report.image_id != meta.image_id_bytes: errors.append( - f"image_id: expected {expected_image.hex()}, got {report.image_id.hex()}" + f"image_id: expected {meta.image_id_bytes.hex()}, " + f"got {report.image_id.hex()}" ) # An all-zero ID_KEY_DIGEST means the guest launched without an ID block at # all. The four comparisons above would then all fail with zeros, which is @@ -101,8 +101,9 @@ def verify_id_block_fields(ctx: StepContext) -> StepHandlerResult: return StepHandlerResult( exit_code=0, stdout=( - f"All ID block fields match: svn={guest_svn} policy={hex(policy_int)} " - f"family_id={family_id!r} image_id={image_id!r}\n" + f"All ID block fields match: svn={meta.guest_svn} " + f"policy={hex(meta.policy)} family_id={meta.family_id!r} " + f"image_id={meta.image_id!r}\n" f" report v{report.version} vmpl={report.vmpl} " f"cpuid={report.cpuid} gen={report.generation} " f"tcb=({report.reported_tcb})\n" @@ -115,16 +116,20 @@ def verify_id_block_fields(ctx: StepContext) -> StepHandlerResult: def _regenerate_id_block( - ctx: StepContext, measurement: str, policy: str, + ctx: StepContext, measurement: str, policy: int, ) -> StepHandlerResult: """Generate a fresh ID block with the given measurement and policy, update ctx.profile. ``measurement`` must be in snpguest's input form — 0x-prefixed hex. An unprefixed string is decoded as base64, not hex. + + Only the policy varies between the negative cases; the identifying fields + come from the same validated source the original ID block was built from. """ - family_id = os.environ.get("ID_BLOCK_FAMILY_ID", DEFAULT_FAMILY_ID) - image_id = os.environ.get("ID_BLOCK_IMAGE_ID", DEFAULT_IMAGE_ID) - guest_svn = os.environ.get("ID_BLOCK_GUEST_SVN", DEFAULT_GUEST_SVN) + try: + meta = read_id_block_metadata() + except IdBlockMetadataError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) id_key = ec.generate_private_key(ec.SECP384R1()) auth_key = ec.generate_private_key(ec.SECP384R1()) @@ -147,10 +152,10 @@ def _regenerate_id_block( "snpguest", "generate", "id-block", str(id_key_path), str(auth_key_path), measurement, - "--family-id", family_id, - "--image-id", image_id, - "--svn", guest_svn, - "--policy", policy, + "--family-id", meta.family_id, + "--image-id", meta.image_id, + "--svn", str(meta.guest_svn), + "--policy", hex(policy), "--id-file", str(id_block_file), "--auth-file", str(id_auth_file), ], @@ -179,12 +184,16 @@ def set_bad_measurement(ctx: StepContext) -> StepHandlerResult: except MeasurementError as exc: return StepHandlerResult(exit_code=1, stderr=str(exc)) + try: + meta = read_id_block_metadata() + except IdBlockMetadataError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) + # Flip the first byte of the digest flipped_byte = "00" if real[:2].lower() != "00" else "ff" flipped = flipped_byte + real[2:] - policy = os.environ.get("ID_BLOCK_POLICY", DEFAULT_POLICY) - hr = _regenerate_id_block(ctx, f"0x{flipped}", policy) + hr = _regenerate_id_block(ctx, f"0x{flipped}", meta.policy) if hr.exit_code != 0: return hr return StepHandlerResult( @@ -275,17 +284,20 @@ def set_incompatible_policy(ctx: StepContext) -> StepHandlerResult: except MeasurementError as exc: return StepHandlerResult(exit_code=1, stderr=str(exc)) - policy = os.environ.get("ID_BLOCK_POLICY", DEFAULT_POLICY) - policy_int = int(policy, 0) + try: + meta = read_id_block_metadata() + except IdBlockMetadataError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) + # Clear SMT bit (16) — guest demands no SMT, but host has SMT active - incompatible_policy = hex(policy_int & ~(1 << 16)) + incompatible_policy = meta.policy & ~(1 << 16) hr = _regenerate_id_block(ctx, f"0x{measurement}", incompatible_policy) if hr.exit_code != 0: return hr return StepHandlerResult( exit_code=0, - stdout=f"Set incompatible policy {incompatible_policy} (SMT=0, host SMT active)", + stdout=f"Set incompatible policy {hex(incompatible_policy)} (SMT=0, host SMT active)", ) @@ -301,18 +313,20 @@ def set_bad_abi_version(ctx: StepContext) -> StepHandlerResult: except MeasurementError as exc: return StepHandlerResult(exit_code=1, stderr=str(exc)) - policy = os.environ.get("ID_BLOCK_POLICY", DEFAULT_POLICY) - policy_int = int(policy, 0) + try: + meta = read_id_block_metadata() + except IdBlockMetadataError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) + # Set ABI_MAJOR (bits 15:8) to 255 - bad_policy = (policy_int & ~0xFF00) | (0xFF << 8) - bad_policy_hex = hex(bad_policy) + bad_policy = (meta.policy & ~0xFF00) | (0xFF << 8) - hr = _regenerate_id_block(ctx, f"0x{measurement}", bad_policy_hex) + hr = _regenerate_id_block(ctx, f"0x{measurement}", bad_policy) if hr.exit_code != 0: return hr return StepHandlerResult( exit_code=0, - stdout=f"Set policy {bad_policy_hex} (ABI_MAJOR=255)", + stdout=f"Set policy {hex(bad_policy)} (ABI_MAJOR=255)", ) diff --git a/sev_verify/cert_tests/c3_0/c3_0_0_2/__init__.py b/sev_verify/cert_tests/c3_0/c3_0_0_2/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sev_verify/cert_tests/c3_0/manifest.toml b/sev_verify/cert_tests/c3_0/manifest.toml index 97b69076..4dcbc9b3 100644 --- a/sev_verify/cert_tests/c3_0/manifest.toml +++ b/sev_verify/cert_tests/c3_0/manifest.toml @@ -24,6 +24,6 @@ host_changes = true [[tests]] name = "id-block-test" description = "Verify ID block acceptance, report field binding, and launch rejection" -module = "cert_tests.c3_0.c3_0_0_2.id_block_test" +module = "cert_tests.c3_0.c3_0_0_1.id_block_test" scope = "mixed" level = "3.0.0-1" diff --git a/sev_verify/cvm_props.py b/sev_verify/cvm_props.py index 9e2d07de..3470a45b 100644 --- a/sev_verify/cvm_props.py +++ b/sev_verify/cvm_props.py @@ -20,13 +20,13 @@ from __future__ import annotations +import os import string import subprocess import tempfile -from dataclasses import replace +from dataclasses import dataclass, replace from pathlib import Path -# may need to change this library from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.serialization import ( Encoding, @@ -35,7 +35,7 @@ ) from .models import StepContext, StepHandlerResult -from .vm_profile import VMProfile, VMProfileError +from .vm_profile import VMProfileError _MEASUREMENT_FILE = "guest_measurement.txt" _ID_BLOCK_FILE = "id-block.b64" @@ -50,6 +50,94 @@ # 96 characters. Fixed by the SNP spec, not by configuration. MEASUREMENT_HEX_LEN = 96 +# FAMILY_ID and IMAGE_ID are 16-byte fields in both the ID block and the +# attestation report. Fixed by the SNP spec, not by configuration. +ID_FIELD_SIZE = 16 + + +class IdBlockMetadataError(Exception): + """An ID_BLOCK_* environment variable holds a value that cannot be used.""" + + +@dataclass(frozen=True) +class IdBlockMetadata: + """The ID block's identifying fields, validated and in usable form. + + Read once and shared between the step that builds an ID block and the step + that checks the resulting report, so the two cannot disagree about what was + asked for. Deriving expectations separately from the environment would let + the check pass against values the ID block was never built with. + """ + + family_id: str + image_id: str + guest_svn: int + policy: int + + @property + def family_id_bytes(self) -> bytes: + """FAMILY_ID as it appears in the report: ASCII, NUL-padded to 16 bytes.""" + return self.family_id.encode("ascii").ljust(ID_FIELD_SIZE, b"\x00") + + @property + def image_id_bytes(self) -> bytes: + """IMAGE_ID as it appears in the report: ASCII, NUL-padded to 16 bytes.""" + return self.image_id.encode("ascii").ljust(ID_FIELD_SIZE, b"\x00") + + +def _read_id_field(var: str, default: str) -> str: + """Read a 16-byte ID field, rejecting values that cannot encode into one. + + ``ljust`` pads but never truncates, so an over-long value would otherwise + produce an expectation longer than the report field and fail to match every + time, with a byte-diff that does not say why. + """ + value = os.environ.get(var, default) + try: + encoded = value.encode("ascii") + except UnicodeEncodeError as exc: + raise IdBlockMetadataError( + f"{var}: must be ASCII; {value!r} is not ({exc})" + ) from exc + if len(encoded) > ID_FIELD_SIZE: + raise IdBlockMetadataError( + f"{var}: must be at most {ID_FIELD_SIZE} bytes to fit the SNP field; " + f"{value!r} is {len(encoded)}" + ) + return value + + +def _read_int(var: str, default: str, *, base: int) -> int: + """Read an integer-valued variable, failing with the variable's name.""" + raw = os.environ.get(var, default) + try: + parsed = int(raw, base) + except (TypeError, ValueError) as exc: + raise IdBlockMetadataError( + f"{var}: expected an integer, got {raw!r}" + ) from exc + if parsed < 0: + raise IdBlockMetadataError(f"{var}: must not be negative, got {parsed}") + return parsed + + +def read_id_block_metadata() -> IdBlockMetadata: + """Read and validate the ID_BLOCK_* environment variables. + + Raises: + IdBlockMetadataError: a variable is set to something unusable. Raised + rather than allowed to surface as a ValueError or UnicodeEncodeError + from deep in a handler, so the step fails with a message naming the + variable at fault. + """ + return IdBlockMetadata( + family_id=_read_id_field("ID_BLOCK_FAMILY_ID", DEFAULT_FAMILY_ID), + image_id=_read_id_field("ID_BLOCK_IMAGE_ID", DEFAULT_IMAGE_ID), + guest_svn=_read_int("ID_BLOCK_GUEST_SVN", DEFAULT_GUEST_SVN, base=10), + # base=0 so 0x-prefixed, decimal and octal forms are all accepted. + policy=_read_int("ID_BLOCK_POLICY", DEFAULT_POLICY, base=0), + ) + class MeasurementError(Exception): """Base class for problems reading guest_measurement.txt.""" @@ -157,7 +245,10 @@ def generate_id_block(ctx: StepContext) -> StepHandlerResult: A file that is present but malformed is a different case and fails the step: absence is an expected configuration, corruption is not. """ - import os + try: + meta = read_id_block_metadata() + except IdBlockMetadataError as exc: + return StepHandlerResult(exit_code=1, stderr=str(exc)) try: measurement = read_measurement(ctx.artifact_dir) @@ -169,11 +260,15 @@ def generate_id_block(ctx: StepContext) -> StepHandlerResult: except MeasurementMalformed as exc: return StepHandlerResult(exit_code=1, stderr=str(exc)) - family_id = os.environ.get("ID_BLOCK_FAMILY_ID", DEFAULT_FAMILY_ID) - image_id = os.environ.get("ID_BLOCK_IMAGE_ID", DEFAULT_IMAGE_ID) - guest_svn = os.environ.get("ID_BLOCK_GUEST_SVN", DEFAULT_GUEST_SVN) - policy = os.environ.get("ID_BLOCK_POLICY", DEFAULT_POLICY) - + # snpguest requires both keys as positional arguments, so both are generated + # even though only the ID key matters here. The ID key signs the ID block; + # the author key signs the ID key. Firmware only validates the author key + # when AUTHOR_KEY_EN is set at SNP_LAUNCH_FINISH — QEMU's + # author-key-enabled=true, which VMProfile.auth_key_enabled leaves False. + # The ID block is therefore self-signed by design: the author key material + # rides along in the auth block, is never consulted, and AUTHOR_KEY_DIGEST + # stays zero in the report. Enabling it later needs no regeneration — + # snpguest has already signed the ID key with the author key. id_key = ec.generate_private_key(ec.SECP384R1()) auth_key = ec.generate_private_key(ec.SECP384R1()) @@ -196,10 +291,10 @@ def generate_id_block(ctx: StepContext) -> StepHandlerResult: str(id_key_path), str(auth_key_path), f"0x{measurement}", - "--family-id", family_id, - "--image-id", image_id, - "--svn", guest_svn, - "--policy", policy, + "--family-id", meta.family_id, + "--image-id", meta.image_id, + "--svn", str(meta.guest_svn), + "--policy", hex(meta.policy), "--id-file", str(id_block_file), "--auth-file", str(id_auth_file), ], @@ -218,12 +313,15 @@ def generate_id_block(ctx: StepContext) -> StepHandlerResult: id_block_b64 = id_block_file.read_text().strip() id_auth_b64 = id_auth_file.read_text().strip() - ctx.profile = replace(ctx.profile, id_block=id_block_b64, id_auth=id_auth_b64, policy=policy) + ctx.profile = replace( + ctx.profile, id_block=id_block_b64, id_auth=id_auth_b64, policy=meta.policy + ) return StepHandlerResult( exit_code=0, stdout=( f"Generated ID block for measurement {measurement[:16]}...\n" - f" family_id={family_id} image_id={image_id} svn={guest_svn} policy={policy}" + f" family_id={meta.family_id} image_id={meta.image_id} " + f"svn={meta.guest_svn} policy={hex(meta.policy)}" ), ) From 19c5c77bc21f9394bd7b62249d34c5b94576b5eb Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Thu, 10 Sep 2026 10:08:34 -0500 Subject: [PATCH 20/20] feat: support more CPU models, including Milan --- sev_verify/attestation_report.py | 93 ++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 41 deletions(-) diff --git a/sev_verify/attestation_report.py b/sev_verify/attestation_report.py index a3f9cf35..8222bb44 100644 --- a/sev_verify/attestation_report.py +++ b/sev_verify/attestation_report.py @@ -43,7 +43,7 @@ report over a field the platform declined to fill. The error is reserved for the case that actually indicates a problem: both the -host's and the report's CPUID resolve to validated generations, and they +host's and the report's CPUID resolve to known generations, and they disagree. Then the report did not come from this machine and neither layout can be trusted for it. Where no generation is supplied at all, the report's own CPUID is used if it resolves; if it does not, TCB_VERSION is left undecoded, @@ -75,8 +75,9 @@ wrong, which is precisely the failure this generation gate exists to prevent. That report also confirmed v5 moved none of the fields read here. -As further processors are exercised, extend :data:`SUPPORTED_GENERATIONS` and -record the validation here. +As further processors are exercised, record them against the relevant entry in +:data:`SUPPORTED_GENERATIONS` and note the validation here. A generation absent +from that table is refused outright. .. note:: @@ -137,33 +138,46 @@ TCB_LAYOUT_LEGACY = "legacy" TCB_LAYOUT_TURIN = "turin" -#: Processor generations this module has been **validated against**, keyed by -#: CPUID family and an inclusive model range. +#: Processor generations whose TCB_VERSION layout is known, keyed by CPUID +#: family and a model range. #: -#: This is deliberately a record of what has been exercised on real hardware, -#: not of what we believe we could decode. A certification harness reporting a -#: pass on silicon it has never run on is the failure this gate exists to -#: prevent, so an unrecognised processor raises rather than being decoded on -#: the assumption that a transcribed layout is right. +#: What this table selects is the **layout**, which is a property of the +#: generation rather than of an individual part, so the ranges are AMD's +#: generation boundaries — taken from the ``sev`` crate's ``identify_cpu`` — and +#: not a list of parts we have run on. Narrowing them to tested models would +#: reject other members of a generation whose layout is provably the same. The +#: gate exists to refuse an *unrecognised generation*, where guessing between +#: the two layouts would yield plausible but wrong values with no error; it is +#: not a claim that every model in range has been exercised. #: -#: Keyed on family/model *pairs*, not family/model/stepping triples: AMD scopes -#: SEV firmware images by family and model only — ``amd_sev_fam19h_model1xh``, -#: ``amd_sev_fam1ah_model0xh`` — and stepping appears nowhere in that -#: partitioning. snpguest's ``get_processor_model`` (``src/fetch.rs``) splits -#: the same way for VCEK lookup. +#: Which parts have actually been exercised is recorded per entry below, and the +#: distinction matters, because the entries do not rest on equal evidence: +#: +#: - Genoa and Bergamo/Siena are separate entries but AMD ships them the +#: *byte-identical* firmware image — ``amd_sev_fam19h_model1xh.sbin`` and +#: ``amd_sev_fam19h_modelaxh.sbin`` had the same SHA-256 on the test host — +#: so validating one substantiates the other by construction. They are kept +#: apart only so a report names the silicon it came from; the ``sev`` crate +#: folds both into "Genoa" because its ``Generation`` selects a KDS product +#: path, where they genuinely do share an endpoint. +#: - Milan has a *different* firmware image (``model0xh``), so its entry rests +#: on the weaker claim that its layout is legacy, per the ``sev`` crate. #: -#: To add a generation: run the ID block test on that hardware, confirm the -#: decoded fields against ``snphost show tcb`` and the values the ID block was -#: built with, then add the entry and note the validation in the docstring. -#: The layouts for generations not yet exercised here, taken from the ``sev`` -#: crate, are: +#: Keyed on family/model *pairs*, not family/model/stepping triples: AMD scopes +#: SEV firmware images by family and model only, and stepping appears nowhere in +#: that partitioning. snpguest's ``get_processor_model`` (``src/fetch.rs``) +#: splits the same way for VCEK lookup. #: -#: 0x19 / 0x00-0x0F Milan legacy -#: 0x19 / 0xA0-0xAF Bergamo/Siena legacy +#: To record a generation as exercised: run the ID block test on that hardware, +#: confirm the decoded fields against ``snphost show tcb`` and the values the ID +#: block was built with, then note the part in the entry's comment and in the +#: module docstring. SUPPORTED_GENERATIONS: tuple[tuple[int, range, str, str], ...] = ( # (cpuid_family, model range, name, TCB layout) - (0x19, range(0x10, 0x20), "Genoa", TCB_LAYOUT_LEGACY), # EPYC 9654, v3 reports - (0x1A, range(0x00, 0x12), "Turin", TCB_LAYOUT_TURIN), # EPYC 9575F, v5 reports + (0x19, range(0x00, 0x10), "Milan", TCB_LAYOUT_LEGACY), # layout inferred + (0x19, range(0x10, 0x20), "Genoa", TCB_LAYOUT_LEGACY), # EPYC 9654, v3 reports + (0x19, range(0xA0, 0xB0), "Bergamo/Siena", TCB_LAYOUT_LEGACY), # same fw image as Genoa + (0x1A, range(0x00, 0x12), "Turin", TCB_LAYOUT_TURIN), # EPYC 9575F, v5 reports ) # Field offsets. See module docstring for how these were validated. @@ -203,11 +217,10 @@ class ReportUnsupportedVersion(ReportError): class ReportUnsupportedCpu(ReportError): - """The report comes from a processor this module has not been validated on. + """The report comes from a generation whose TCB layout this module lacks. - Raised rather than decoding on the assumption that a transcribed layout is - correct — TCB_VERSION in particular is laid out differently on Turin, so a - wrong guess yields plausible values rather than an error. + Raised rather than decoding on a guess — TCB_VERSION is laid out differently + on Turin, so choosing wrongly yields plausible values rather than an error. """ @@ -271,7 +284,7 @@ class AttestationReport: reported_tcb: TcbVersion | None #: (family, model, stepping) — v3+ only, None on older reports. cpuid: tuple[int, int, int] | None - #: Validated processor generation this report was decoded as, or "unknown". + #: Processor generation this report was decoded as, or "unknown". generation: str #: Set when the report's own CPUID could not be used and the host's was #: preferred — for instance when firmware leaves those bytes zero. ``None`` @@ -298,13 +311,13 @@ def resolve_generation(family: int, model: int) -> tuple[str, str]: if family == fam and model in models: return name, layout - validated = ", ".join( + known = ", ".join( f"{name} (family 0x{fam:02X} model 0x{models[0]:02X}-0x{models[-1]:02X})" for fam, models, name, _ in SUPPORTED_GENERATIONS ) raise ReportUnsupportedCpu( - f"CPUID family 0x{family:02X} model 0x{model:02X} has not been validated " - f"against. Validated: {validated}. TCB_VERSION is laid out differently " + f"CPUID family 0x{family:02X} model 0x{model:02X} is not a generation " + f"this parser knows. Known: {known}. TCB_VERSION is laid out differently " f"across processor generations, so decoding anyway would produce " f"plausible but wrong values. See SUPPORTED_GENERATIONS in " f"sev_verify/attestation_report.py." @@ -319,7 +332,7 @@ def host_generation() -> tuple[str, str]: it is present regardless of report version. Raises: - ReportUnsupportedCpu: family/model unreadable, or not validated. + ReportUnsupportedCpu: family/model unreadable, or not a known generation. """ family = model = None try: @@ -359,7 +372,7 @@ def parse( ReportMalformed: wrong size. ReportUnsupportedVersion: layout not validated for that version. ReportUnsupportedCpu: the report's CPUID disagrees with *generation*, or - names a processor that has not been validated against. + names a processor whose generation is not in SUPPORTED_GENERATIONS. """ if len(data) != REPORT_SIZE: raise ReportMalformed( @@ -440,12 +453,10 @@ def field(off: int, length: int) -> bytes: # Both resolve, and disagree: the report is not from this # machine, and neither layout can be trusted for it. # - # Note this branch is only reachable once SUPPORTED_GENERATIONS - # holds more than one entry. With a single validated generation - # every disagreeing CPUID is unresolvable instead, and takes the - # branch above. That is the conservative order: a report is only - # called foreign when both generations are ones we have actually - # validated against. + # Only reachable when both sides resolve, which is why the + # unresolvable case above is tried first: a report is called + # foreign only when both generations are ones this table knows, + # never merely because one of them is unrecognised. raise ReportUnsupportedCpu( f"report CPUID family 0x{cpuid[0]:02X} model " f"0x{cpuid[1]:02X} resolves to {report_gen[0]}, but this " @@ -510,7 +521,7 @@ def read( Raises: ReportMalformed: file missing or wrong size. ReportUnsupportedVersion: layout not validated for that version. - ReportUnsupportedCpu: CPUID mismatch, or processor not validated. + ReportUnsupportedCpu: CPUID mismatch, or unknown generation. """ try: data = path.read_bytes()