diff --git a/modules/build/common/snpguest/mkosi.build b/modules/build/common/snpguest/mkosi.build index 8b2eca40..8085e01e 100755 --- a/modules/build/common/snpguest/mkosi.build +++ b/modules/build/common/snpguest/mkosi.build @@ -8,3 +8,10 @@ LATEST_TAG="${SNPGUEST_TAG:-$(curl -s https://api.github.com/repos/virtee/snpgue # Download and install into DESTDIR with correct permissions curl -fsSL "https://github.com/virtee/snpguest/releases/download/${LATEST_TAG}/snpguest" \ | install -D -m 0755 /dev/stdin "${DESTDIR}/usr/local/bin/snpguest" + +# Record which tag was actually installed. Unless SNPGUEST_TAG is set, the +# line above resolves "latest" at build time, so two builds of identical +# source can contain different tooling with nothing to say so. sev_verify +# reads this file and reports it, making each image self-describing. +printf '%s\n' "${LATEST_TAG}" \ + | install -D -m 0644 /dev/stdin "${DESTDIR}/usr/local/share/sev-certify/snpguest-tag" 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..45e950be 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 @@ -159,6 +159,31 @@ def steps() -> list[BaseStep]: host_dest="request.bin", timeout=120, ), + # Capture the report a second way, through the kernel's vendor-neutral + # configfs-TSM interface, which returns the raw bytes without parsing + # them. This is diagnostic rather than a check: when the snpguest steps + # above fail because the report cannot be classified, they leave no + # artifact behind, and the report is then the one thing needed to find + # out why. Typed "info" so a kernel without configfs-TSM support costs + # nothing. + Step.for_guest( + name="Capture report via configfs-TSM", + type="info", + command=( + "D=/sys/kernel/config/tsm/report/sev_verify; " + "rmdir $D 2>/dev/null; mkdir $D || exit 1; " + "head -c 64 /dev/urandom > $D/inblob && cat $D/outblob > /tmp/tsm-report.bin; " + "rc=$?; rmdir $D 2>/dev/null; exit $rc" + ), + timeout=60, + ), + Step.for_guest_pull( + name="Pull configfs-TSM report", + type="info", + guest_src="/tmp/tsm-report.bin", + host_dest="tsm-report.bin", + timeout=120, + ), Step.for_host( name="Fetch certificate chain from kds", type="setup", diff --git a/sev_verify/cli.py b/sev_verify/cli.py index 81db41d5..a2367035 100644 --- a/sev_verify/cli.py +++ b/sev_verify/cli.py @@ -9,8 +9,8 @@ from datetime import datetime, timezone from pathlib import Path -from .environment import detect_environment -from .os_info import update_environment_with_guest_os +from .environment import detect_environment, find_recent_report, summarize_report +from .os_info import update_environment_with_guest_info from .models import ( CertificationDefinition, CertificationResult, @@ -407,7 +407,7 @@ def execute_test( sr, new_launch = run_vm_launch_step(step, profile) launch = new_launch if launch is not None and launch.ok and environment is not None: - update_environment_with_guest_os(environment, launch.profile) + update_environment_with_guest_info(environment, launch.profile) elif step.kind == "vm_stop": if launch is None: sr = StepResult( @@ -433,7 +433,7 @@ def execute_test( if launch is None: launch = profile.vm_launch() if launch.ok and environment is not None: - update_environment_with_guest_os(environment, launch.profile) + update_environment_with_guest_info(environment, launch.profile) if not launch.ok: sr = StepResult( step=step, @@ -711,6 +711,7 @@ def main(argv: list[str] | None = None) -> int: print(f"Warning: no tests match level filter(s) {levels!r} " f"in certification {cert.version}", file=sys.stderr) continue + run_started = time.time() cr = execute_certification( cert, guest_path, @@ -720,6 +721,14 @@ def main(argv: list[str] | None = None) -> int: environment=environment, ) cert_results.append(cr) + + # Describe a report this run produced, if any. Done here rather than in + # a test because it is environment, not a result: which report version + # and CPUID the firmware emitted determines how every consumer parses it. + if environment is not None and not environment.get("report_summary"): + report_path = find_recent_report(args.artifacts_dir, run_started) + if report_path is not None: + environment["report_summary"] = summarize_report(report_path) total_tests += len(cr.test_results) total_passed += sum(1 for tr in cr.test_results if tr.result == "pass") diff --git a/sev_verify/environment.py b/sev_verify/environment.py index 277bd145..2935c26c 100644 --- a/sev_verify/environment.py +++ b/sev_verify/environment.py @@ -2,11 +2,16 @@ from __future__ import annotations +import glob +import hashlib import os import platform +import re import shutil import subprocess +from pathlib import Path + from .os_info import get_host_os_info @@ -75,6 +80,264 @@ def _get_ovmf_version(path: str) -> str | None: return None +def _run_tool(args: list[str], timeout: int = 5) -> str | None: + """Run *args* and return stripped stdout, or None on any failure.""" + resolved = shutil.which(args[0]) + if not resolved: + return None + try: + proc = subprocess.run( + [resolved, *args[1:]], + capture_output=True, text=True, timeout=timeout, + ) + except Exception: + return None + if proc.returncode != 0: + return None + out = proc.stdout.strip() + return out or None + + +def _get_tool_version(tool: str) -> str | None: + """Return `` --version`` output, e.g. ``snpguest 0.10.0``.""" + out = _run_tool([tool, "--version"]) + return out.splitlines()[0].strip() if out else None + + +def _get_sev_firmware_version() -> str | None: + """Return the SEV firmware version the PSP is currently running. + + This is the firmware actually in effect, which is not necessarily the one + the BIOS supplied: the ``ccp`` driver loads ``/lib/firmware/amd/*.sbin`` at + boot when present, so it varies with the host OS image rather than with the + platform. Requires root; returns None otherwise. + """ + out = _run_tool(["snphost", "show", "version"]) + return out.splitlines()[0].strip() if out else None + + +def _get_reported_tcb() -> str | None: + """Return the reported TCB as a single line, e.g. ``bootloader=9 tee=0 …``. + + ``snphost show tcb`` prints a multi-line block; it is condensed here so the + value fits on one line of the environment report. + """ + out = _run_tool(["snphost", "show", "tcb"]) + if not out: + return None + wanted = { + "boot loader": "bootloader", + "tee": "tee", + "snp": "snp", + "microcode": "microcode", + "fmc": "fmc", + } + found: dict[str, str] = {} + for line in out.splitlines(): + key, sep, value = line.partition(":") + if not sep: + continue + short = wanted.get(key.strip().lower()) + if short and value.strip(): + found[short] = value.strip() + if not found: + return None + order = ("bootloader", "tee", "snp", "microcode", "fmc") + return " ".join(f"{k}={found[k]}" for k in order if k in found) + + +#: Blobs the ccp driver loads at boot, overriding BIOS-supplied firmware. +SEV_FIRMWARE_GLOB = "/lib/firmware/amd/amd_sev_*.sbin" + + +def _get_sev_firmware_source() -> str | None: + """Report which SEV firmware blobs are available for the driver to load. + + The ``ccp`` driver loads these at boot when present, replacing the firmware + the BIOS supplied; when none is present the platform keeps running the + BIOS-supplied firmware for the life of the boot. The absence is therefore + as meaningful as the presence, and is reported explicitly rather than as a + missing field: it says the runtime firmware is the platform's own, not the + host OS's choice, which changes how every other firmware-derived value in + this report should be read. + + Identified by digest as well as name, since two builds of the same blob + share a filename and may share an API version while differing in behaviour. + """ + paths = sorted(glob.glob(SEV_FIRMWARE_GLOB)) + if not paths: + return "none present — BIOS-supplied firmware retained" + parts: list[str] = [] + for path in paths: + try: + with open(path, "rb") as fh: + blob = fh.read() + except OSError: + continue + digest = hashlib.sha256(blob).hexdigest()[:12] + parts.append(f"{os.path.basename(path)} ({len(blob)}B sha256:{digest})") + return ", ".join(parts) or None + + +def _get_sev_firmware_log(max_lines: int = 4, max_chars: int = 400) -> str | None: + """Return the driver's own SEV lines from the kernel log, verbatim. + + Quoted rather than parsed. These lines carry the firmware build number that + ``snphost show version`` omits, and report whether an update was applied at + boot — but their wording varies across kernels, so a parser would silently + yield nothing on the versions it did not anticipate. Recording what the + driver actually said keeps the field useful even then. + + Requires a readable kernel log; returns None otherwise. + """ + out = _run_tool(["dmesg"], timeout=10) + if not out: + return None + matcher = re.compile(r"\bccp\b|SEV[- ]SNP API|SEV API|sev firmware", re.IGNORECASE) + lines = [ln.strip() for ln in out.splitlines() if matcher.search(ln)] + if not lines: + return None + joined = " | ".join(lines[-max_lines:]) + return joined[:max_chars] + ("…" if len(joined) > max_chars else "") + + +def _get_platform_identifier() -> str | None: + """Return the platform identifier from ``snphost show identifier``.""" + out = _run_tool(["snphost", "show", "identifier"]) + return out.splitlines()[0].strip() if out else None + + +# Offsets into the SNP attestation report, per the ABI specification (56860). +# CPUID_FAM_ID / MOD_ID / STEP exist only from report version 3 onwards. +_REPORT_CPUID_FAM = 0x188 +_REPORT_CPUID_MOD = 0x189 +_REPORT_CPUID_STEP = 0x18A +_REPORT_CHIP_ID = 0x1A0 +_REPORT_CHIP_ID_LEN = 64 +_REPORT_MIN_LEN = _REPORT_CHIP_ID + _REPORT_CHIP_ID_LEN + + +def summarize_report(path: str | os.PathLike[str]) -> str | None: + """Summarize a report: version, CPUID triple, and whether CHIP_ID is zeroed. + + These are the fields that decide how a report is *parsed*, as distinct from + what it attests. Consumers pick a TCB layout from the processor generation, + which they derive from the CPUID bytes, so a report carrying unexpected + values there is rejected before any of its contents are read. + + CHIP_ID is reported as zeroed or present because ``MASK_CHIP_ID`` zeroes it + and ``snphost show`` offers no way to read that setting back: a zeroed + CHIP_ID is the only externally visible sign that masking is in effect. + """ + try: + with open(path, "rb") as fh: + data = fh.read() + except Exception: + return None + if len(data) < _REPORT_MIN_LEN: + return None + + version = int.from_bytes(data[0:4], "little") + parts = [f"version={version}"] + if version >= 3: + parts.append( + "cpuid=0x{:02x}/0x{:02x}/0x{:02x}".format( + data[_REPORT_CPUID_FAM], + data[_REPORT_CPUID_MOD], + data[_REPORT_CPUID_STEP], + ) + ) + chip_id = data[_REPORT_CHIP_ID:_REPORT_CHIP_ID + _REPORT_CHIP_ID_LEN] + parts.append("chip_id=" + ("zeroed" if not any(chip_id) else "present")) + return " ".join(parts) + + +#: Report artifacts to describe, in no particular preference order — the newest +#: wins. ``tsm-report.bin`` comes from the kernel's configfs-TSM interface and +#: exists even when the snpguest-produced ``report.bin`` does not, since that +#: command declines to write a report it cannot classify. +REPORT_ARTIFACT_NAMES = ("report.bin", "tsm-report.bin") + + +def find_recent_report( + artifacts_root: str | os.PathLike[str], + since: float, +) -> "Path | None": + """Return the newest report artifact under *artifacts_root* newer than *since*. + + The mtime bound stops a report left behind by an earlier run being described + as though this run had produced it. + """ + candidates: list[Path] = [] + try: + for name in REPORT_ARTIFACT_NAMES: + candidates += [ + p for p in Path(artifacts_root).rglob(name) + if p.stat().st_mtime >= since + ] + except Exception: + return None + if not candidates: + return None + return max(candidates, key=lambda p: p.stat().st_mtime) + + +#: Written by modules/build/common/snpguest/mkosi.build at image build time. +SNPGUEST_TAG_FILE = "/usr/local/share/sev-certify/snpguest-tag" + + +def _get_snpguest_tag() -> str | None: + """Return the snpguest release tag this image was built with. + + The image build resolves "latest" at build time unless SNPGUEST_TAG is + pinned, so the installed tooling is not implied by the source revision. + Absent on hosts that were not built by this project. + """ + try: + with open(SNPGUEST_TAG_FILE) as fh: + return fh.read().strip() or None + except Exception: + return None + + +def _get_host_cpu() -> dict[str, str | None]: + """Return the host CPU model name and CPUID family/model/stepping. + + Reported because generation-dependent behaviour keys on family and model — + both in this harness and in the tooling it drives — so a failure that turns + on the processor generation is otherwise undiagnosable from a result alone. + """ + fields: dict[str, str] = {} + try: + with open("/proc/cpuinfo") as fh: + for line in fh: + key, sep, value = line.partition(":") + if not sep: + continue + key = key.strip().lower() + if key in ("model name", "cpu family", "model", "stepping"): + fields.setdefault(key, value.strip()) + if len(fields) == 4: + break + except Exception: + return {"host_cpu_model": None, "host_cpu_id": None} + + def _hex(name: str) -> str | None: + raw = fields.get(name) + try: + return f"0x{int(raw):x}" + except (TypeError, ValueError): + return None + + family, model, stepping = _hex("cpu family"), _hex("model"), _hex("stepping") + cpu_id = None + if family and model: + cpu_id = f"family {family} model {model}" + if stepping: + cpu_id += f" stepping {stepping}" + return {"host_cpu_model": fields.get("model name"), "host_cpu_id": cpu_id} + + def detect_environment( *, qemu_binary: str = "qemu-system-x86_64", @@ -85,6 +348,7 @@ def detect_environment( All detection is best-effort: failures produce ``None`` values. """ host_os = get_host_os_info() + host_cpu = _get_host_cpu() return { "qemu_version": _get_qemu_version(qemu_binary), "qemu_binary": qemu_binary, @@ -94,4 +358,16 @@ def detect_environment( "host_os_name": host_os.get("host_os_name"), "host_os_release": host_os.get("host_os_release"), "host_os_pretty_name": host_os.get("host_os_pretty_name"), + # SEV-specific facts. These are what distinguish two hosts that look + # identical by OS and QEMU version but behave differently under SNP. + "sev_firmware_version": _get_sev_firmware_version(), + "sev_firmware_source": _get_sev_firmware_source(), + "sev_firmware_log": _get_sev_firmware_log(), + "reported_tcb": _get_reported_tcb(), + "snphost_version": _get_tool_version("snphost"), + "snpguest_version": _get_tool_version("snpguest"), + "snpguest_tag": _get_snpguest_tag(), + "platform_identifier": _get_platform_identifier(), + "host_cpu_model": host_cpu["host_cpu_model"], + "host_cpu_id": host_cpu["host_cpu_id"], } diff --git a/sev_verify/os_info.py b/sev_verify/os_info.py index e2e6f65e..4111573a 100644 --- a/sev_verify/os_info.py +++ b/sev_verify/os_info.py @@ -109,6 +109,27 @@ def format_os_info(os_info: dict[str, str | None]) -> str | None: return name or release or None +def get_guest_snpguest_version(profile: "VMProfile") -> str | None: + """Read the guest's snpguest version via vsock. + + Reported separately from the host's because the two are installed + independently, and a guest-side attestation failure cannot be attributed + without knowing which build produced it. + + Requires an active guest with vsock agent; returns None otherwise. + """ + from .guest_vsock import run_guest_command, GuestVsockError + + try: + result = run_guest_command(profile, "snpguest --version", timeout=10) + except GuestVsockError: + return None + if result.exit_code != 0: + return None + first_line = result.stdout.strip().splitlines() + return first_line[0].strip() if first_line else None + + def update_environment_with_guest_os( environment: dict[str, str | None], profile: "VMProfile", @@ -126,3 +147,18 @@ def update_environment_with_guest_os( environment["guest_os_release"] = guest_info.get("guest_os_release") environment["guest_os_pretty_name"] = guest_info.get("guest_os_pretty_name") environment["guest_os_id"] = guest_info.get("guest_os_id") + + +def update_environment_with_guest_info( + environment: dict[str, str | None], + profile: "VMProfile", +) -> None: + """Update environment in-place with everything readable from the guest. + + Currently the guest OS identity and the guest's snpguest version. Both are + best-effort and are collected once, on the first successful launch. + """ + already_done = "guest_snpguest_version" in environment + update_environment_with_guest_os(environment, profile) + if not already_done: + environment["guest_snpguest_version"] = get_guest_snpguest_version(profile) diff --git a/sev_verify/output.py b/sev_verify/output.py index d44896a9..7d861a29 100644 --- a/sev_verify/output.py +++ b/sev_verify/output.py @@ -176,6 +176,49 @@ def write_markdown( env_lines.append(f"- **OVMF:** {environment['ovmf_version']}") elif environment.get("ovmf_path"): env_lines.append(f"- **OVMF:** {environment['ovmf_path']}") + if environment.get("host_cpu_model"): + cpu = environment["host_cpu_model"] + if environment.get("host_cpu_id"): + cpu = f"{cpu} ({environment['host_cpu_id']})" + env_lines.append(f"- **Host CPU:** {cpu}") + elif environment.get("host_cpu_id"): + env_lines.append(f"- **Host CPU:** {environment['host_cpu_id']}") + if environment.get("sev_firmware_version"): + env_lines.append( + f"- **SEV firmware:** {environment['sev_firmware_version']}" + ) + if environment.get("platform_identifier"): + env_lines.append( + f"- **Platform ID:** {environment['platform_identifier']}" + ) + if environment.get("report_summary"): + env_lines.append( + f"- **Attestation report:** {environment['report_summary']}" + ) + if environment.get("sev_firmware_source"): + env_lines.append( + f"- **SEV firmware blobs:** {environment['sev_firmware_source']}" + ) + if environment.get("sev_firmware_log"): + env_lines.append( + f"- **SEV driver log:** `{environment['sev_firmware_log']}`" + ) + if environment.get("reported_tcb"): + env_lines.append(f"- **Reported TCB:** {environment['reported_tcb']}") + if environment.get("snphost_version"): + env_lines.append(f"- **snphost:** {environment['snphost_version']}") + if environment.get("snpguest_version"): + env_lines.append( + f"- **snpguest (host):** {environment['snpguest_version']}" + ) + if environment.get("snpguest_tag"): + env_lines.append( + f"- **snpguest build tag:** {environment['snpguest_tag']}" + ) + if environment.get("guest_snpguest_version"): + env_lines.append( + f"- **snpguest (guest):** {environment['guest_snpguest_version']}" + ) if env_lines: w("## Environment") w("")