diff --git a/CHANGELOG.md b/CHANGELOG.md index d390889..6a047c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Azure confidential-VM attestation (`cmcp_runtime.tee.azure_cvm.AzureCVMProvider` + `cmcp_verify.azure_cvm`), **hardware-validated on live Azure SEV-SNP silicon**. Azure runs SNP behind a Hyper-V paravisor with no `/dev/sev-guest`; the SNP report is read from the vTPM NV index `0x01400001` and the guest cannot control `REPORT_DATA` (the paravisor binds the vTPM AK there). cMCP's nonce (`jwk_thumbprint || audit-root`) is therefore committed into an AK-signed TPM quote's qualifying data, with the AK rooted in silicon via the SNP report (`REPORT_DATA == sha256(runtime_data)`) and the VCEK→ASK→ARK chain (reusing `cmcp_verify.sev_snp`). Auto-detected first (before TPM/SEV-SNP) since Azure exposes no `/dev/sev-guest`. Carries its own `runtime.platform` value `azure-cvm-sev-snp` (requires `agentrust-trace>=0.4`) so a consumer keying on `runtime.platform` knows the root of trust is vTPM-rooted, not a guest-controlled SNP `report_data`. - `tool_transcript.entries`: privacy-preserving per-call view in the TRACE Claim (one entry per tool call with `tool_name`, `data_class` from the catalog, and the policy `decision`), derived from the audit chain so no raw parameters or response bodies are exposed. `tool_transcript.hash` continues to bind the full transcript to the audit-chain tip. Adds `transcript_entries_hash()` for offline recomputation. (#126) +### Fixed + +- Bare-metal `SEVSNPProvider` now obtains the SNP report via the kernel configfs-TSM interface (`/sys/kernel/config/tsm/report`) instead of a `/dev/sev-guest` ioctl. The previous ioctl number and inline request ABI were incorrect and failed on real hardware with `ENOTTY` ("inappropriate ioctl for device"). **Hardware-validated on a non-paravisor SEV-SNP guest (GCP N2D, AMD Milan):** the guest-supplied nonce lands in the report's `REPORT_DATA` and the report verifies against the AMD VCEK. +- SNP report-version check in `cmcp_verify.sev_snp` now accepts version `>= 2` (was `(2, 3)` only). Real Milan hardware emits report version 5, which the old allowlist wrongly rejected as `invalid_snp_report_version`; the field offsets read are layout-stable across v2..v5 and the VCEK signature is the real gate. + ## [0.3.0] - 2026-06-30 ### Security diff --git a/src/cmcp_runtime/tee/sev_snp.py b/src/cmcp_runtime/tee/sev_snp.py index 3f32510..1839b8a 100644 --- a/src/cmcp_runtime/tee/sev_snp.py +++ b/src/cmcp_runtime/tee/sev_snp.py @@ -1,11 +1,21 @@ -"""AMD SEV-SNP TEE provider -- implements issue #89.""" +"""AMD SEV-SNP TEE provider -- implements issue #89. + +Uses the kernel configfs-TSM interface (`/sys/kernel/config/tsm/report`, +Linux 6.7+) to obtain the SNP attestation report. This supersedes the earlier +`/dev/sev-guest` ioctl path, which used an incorrect ioctl number and an inline +request ABI and failed on real hardware with ENOTTY ("inappropriate ioctl for +device"); the live kernel ABI passes pointers to separate request/response +structs. The configfs-TSM interface was hardware-validated on a real +non-paravisor SEV-SNP guest (GCP N2D, AMD Milan): the guest-supplied nonce lands +in the report's REPORT_DATA field and the report verifies against the AMD VCEK. +""" from __future__ import annotations +import contextlib import ctypes import hashlib import hmac -import struct import sys from datetime import UTC, datetime from pathlib import Path @@ -14,16 +24,11 @@ _SEV_GUEST_DEVICE = Path("/dev/sev-guest") -# SNP_GET_REPORT ioctl number: 0xC0A01181 -# Derived from: _IOWR(0x11, 0x01, struct snp_report_req) where req is 0xA0 bytes. -_SNP_GET_REPORT = 0xC0A01181 - -# Request structure: 96-byte user_data + 4-byte vmpl + 28-byte reserved = 128 bytes total -_SNP_REQ_USER_DATA_SIZE = 96 -_SNP_REQ_SIZE = 128 - -# Response structure: 4-byte status + 4-byte report_size + 24-byte reserved + report -_SNP_RESP_HEADER_SIZE = 32 +# Kernel configfs-TSM report interface (Linux 6.7+). A caller writes up to 64 +# bytes of report data to `inblob` and reads the raw platform report back from +# `outblob`; `provider` names the backend (expected "sev_guest" here). +_TSM_REPORT_DIR = Path("/sys/kernel/config/tsm/report") +_TSM_ENTRY = "cmcp" class _SnpAttestationReport(ctypes.LittleEndianStructure): @@ -72,16 +77,48 @@ class _SnpAttestationReport(ctypes.LittleEndianStructure): ) _SNP_REPORT_SIZE = ctypes.sizeof(_SnpAttestationReport) -_SNP_RESP_SIZE = _SNP_RESP_HEADER_SIZE + _SNP_REPORT_SIZE -# Byte range of the measurement field within the raw SNP report blob. -# Derived from the ctypes struct so they stay in sync with the field layout. -_SNP_MEASUREMENT_OFFSET: int = _SnpAttestationReport.measurement.offset -_SNP_MEASUREMENT_END: int = _SNP_MEASUREMENT_OFFSET + _SnpAttestationReport.measurement.size + +def _tsm_get_report(report_data: bytes) -> bytes: + """Fetch a raw SNP attestation report via the configfs-TSM interface. + + Writes *report_data* (<=64 bytes) to a fresh report entry's ``inblob`` and + reads the raw report from ``outblob``. Requires root (configfs) and a + registered ``sev_guest`` TSM provider. Returns the raw report bytes. + """ + if not _TSM_REPORT_DIR.is_dir(): + raise RuntimeError( + f"SEV-SNP attestation failed: configfs-TSM interface not present at " + f"{_TSM_REPORT_DIR} (needs Linux 6.7+ with the sev-guest driver loaded)." + ) + entry = _TSM_REPORT_DIR / _TSM_ENTRY + try: + entry.mkdir(exist_ok=True) + except OSError as exc: + raise RuntimeError( + f"SEV-SNP attestation failed: cannot create TSM report entry {entry}: {exc} " + "(no TSM provider registered, or not root)." + ) from exc + try: + (entry / "inblob").write_bytes(report_data) + provider = (entry / "provider").read_text().strip() + if provider and provider != "sev_guest": + raise RuntimeError( + f"SEV-SNP attestation failed: TSM provider is {provider!r}, not 'sev_guest'." + ) + outblob = (entry / "outblob").read_bytes() + finally: + with contextlib.suppress(OSError): + entry.rmdir() + if len(outblob) < _SNP_REPORT_SIZE: + raise RuntimeError( + f"SEV-SNP attestation failed: TSM outblob too short ({len(outblob)} bytes)." + ) + return outblob[:_SNP_REPORT_SIZE] class SEVSNPProvider(TEEProvider): - """AMD SEV-SNP attestation provider using the /dev/sev-guest ioctl interface.""" + """AMD SEV-SNP attestation provider using the kernel configfs-TSM interface.""" def __init__(self, expected_measurement: str | None = None) -> None: self._expected_measurement = expected_measurement @@ -90,7 +127,14 @@ def provider_name(self) -> str: return "sev-snp" def detect(self) -> bool: - """Return True if /dev/sev-guest exists (Linux only).""" + """Return True only on a genuine Linux SEV-SNP guest. + + Gate on the /dev/sev-guest device, which the sev-guest driver creates on + a real SNP guest (and which also registers the configfs-TSM provider used + for acquisition). The configfs-TSM report *directory* alone is NOT a valid + signal: it exists whenever tsm.ko is loaded (e.g. on ordinary CI runners) + with no provider registered, which would wrongly select this provider. + """ try: if sys.platform != "linux": return False @@ -100,38 +144,14 @@ def detect(self) -> bool: def get_attestation_report(self, nonce: bytes) -> AttestationReport: """ - Request an SNP attestation report via the SNP_GET_REPORT ioctl. + Request an SNP attestation report via configfs-TSM. - The nonce is placed in the 96-byte user_data field (first 64 bytes used, - zero-padded to 96). + The nonce is placed in the guest-controlled REPORT_DATA field (up to 64 + bytes). On a genuine SNP guest the hardware signs it into the report, so + a verifier can bind the report to this nonce. """ - try: - import fcntl # available on Linux only - except ImportError as exc: - raise RuntimeError(f"SEV-SNP attestation failed: {exc}") from exc - - # Build request: 96-byte user_data (nonce truncated/padded) + vmpl=0 + reserved - user_data = (nonce[:64] + b"\x00" * 96)[:96] - vmpl = 0 - # struct: 96s user_data, I vmpl, 28s reserved - req = struct.pack("96sI28s", user_data, vmpl, b"\x00" * 28) - - buf = bytearray(max(len(req), _SNP_RESP_SIZE)) - buf[: len(req)] = req - - try: - with open(_SEV_GUEST_DEVICE, "rb") as fd: - fcntl.ioctl(fd, _SNP_GET_REPORT, buf) # type: ignore[attr-defined] - except OSError as exc: - raise RuntimeError(f"SEV-SNP attestation failed: {exc}") from exc - - # Extract status (first 4 bytes) - status = struct.unpack_from(" AttestationReport: attestation_generated_at=datetime.now(tz=UTC), attestation_validity_seconds=86400, ) + + +# Retained for callers/tests that referenced the module-level constant. +_SNP_MEASUREMENT_OFFSET: int = _SnpAttestationReport.measurement.offset +_SNP_MEASUREMENT_END: int = _SNP_MEASUREMENT_OFFSET + _SnpAttestationReport.measurement.size diff --git a/src/cmcp_verify/sev_snp.py b/src/cmcp_verify/sev_snp.py index 10d92d0..1ee3b07 100644 --- a/src/cmcp_verify/sev_snp.py +++ b/src/cmcp_verify/sev_snp.py @@ -261,7 +261,12 @@ def verify_sev_snp_measurement( # Parse via ctypes struct for named field access (HW-006) report = _SnpAttestationReport.from_buffer_copy(raw_evidence[:_SNP_REPORT_MIN_SIZE]) - if report.version not in (2, 3): + # Accept report version >= 2. The fields we read (report_data 0x50, + # measurement 0x90, reported_tcb 0x180, chip_id 0x1a0, signature 0x2a0) + # are layout-stable across v2..v5; later firmware only appends. Real + # Milan hardware (GCP N2D) emits v5, which the old (2, 3) allowlist + # wrongly rejected. The VCEK signature check below is the real gate. + if report.version < 2: result.verified = False result.failure_reason = "invalid_snp_report_version" result.details["snp_report_version"] = str(report.version) diff --git a/tests/unit/test_sev_snp_verify.py b/tests/unit/test_sev_snp_verify.py index f7456cf..708a3da 100644 --- a/tests/unit/test_sev_snp_verify.py +++ b/tests/unit/test_sev_snp_verify.py @@ -105,14 +105,26 @@ def test_version3_matching_measurement_verified(): assert result.details["snp_report_version"] == "3" -def test_version5_report_fails(): +def test_version5_matching_measurement_verified(): + """Real Milan hardware (GCP N2D) emits report version 5; it must be accepted.""" raw_m = b"\x00" * 48 - measurement = "sha384:" + hashlib.sha384(raw_m).hexdigest() + expected = "sha384:" + hashlib.sha384(raw_m).hexdigest() report = make_snp_report(version=5, measurement_bytes=raw_m) + result = verify_sev_snp_measurement(expected, raw_evidence=report) + assert result.verified is True + assert "measurement" in result.verified_fields + assert result.details["snp_report_version"] == "5" + + +def test_version1_report_fails(): + """Pre-release version 1 (below the v2 field-layout baseline) is rejected.""" + raw_m = b"\x00" * 48 + measurement = "sha384:" + hashlib.sha384(raw_m).hexdigest() + report = make_snp_report(version=1, measurement_bytes=raw_m) result = verify_sev_snp_measurement(measurement, raw_evidence=report) assert result.verified is False assert result.failure_reason == "invalid_snp_report_version" - assert result.details["snp_report_version"] == "5" + assert result.details["snp_report_version"] == "1" def test_measurement_mismatch_fails(): diff --git a/tests/unit/test_tee.py b/tests/unit/test_tee.py index 3139da3..f43ac39 100644 --- a/tests/unit/test_tee.py +++ b/tests/unit/test_tee.py @@ -207,56 +207,36 @@ def test_sevsnp_stores_expected_measurement(): def test_sevsnp_rejects_mismatched_expected_measurement(monkeypatch): """HW-002: measurement mismatch raises RuntimeError before returning the report.""" - import struct - import sys - from unittest.mock import MagicMock - + from cmcp_runtime.tee import sev_snp as snp_mod from cmcp_runtime.tee.sev_snp import ( _SNP_MEASUREMENT_END, _SNP_MEASUREMENT_OFFSET, _SNP_REPORT_SIZE, - _SNP_RESP_HEADER_SIZE, SEVSNPProvider, ) - # Build a fake ioctl response with a known measurement + # Build a raw SNP report (as the configfs-TSM outblob) with a known measurement. measurement_bytes = b"\xab" * (_SNP_MEASUREMENT_END - _SNP_MEASUREMENT_OFFSET) raw_evidence = bytearray(_SNP_REPORT_SIZE) raw_evidence[_SNP_MEASUREMENT_OFFSET:_SNP_MEASUREMENT_END] = measurement_bytes - - resp_buf = bytearray(_SNP_RESP_HEADER_SIZE + _SNP_REPORT_SIZE) - struct.pack_into(" None: assert SEVSNPProvider().provider_name() == "sev-snp" -def test_sev_snp_get_report_raises_on_ioctl_failure(monkeypatch: pytest.MonkeyPatch) -> None: - """ioctl raising OSError must surface as RuntimeError.""" - mock_fcntl = MagicMock() - mock_fcntl.ioctl = MagicMock(side_effect=OSError("ioctl failed")) - monkeypatch.setitem(sys.modules, "fcntl", mock_fcntl) +def test_sev_snp_get_report_raises_when_tsm_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: + """A configfs-TSM failure (no interface / not root) must surface as RuntimeError.""" + from cmcp_runtime.tee import sev_snp as snp_mod - mock_fd = MagicMock() - mock_fd.__enter__ = MagicMock(return_value=mock_fd) - mock_fd.__exit__ = MagicMock(return_value=False) + def boom(_report_data: bytes) -> bytes: + raise RuntimeError("SEV-SNP attestation failed: configfs-TSM interface not present") - with patch("builtins.open", return_value=mock_fd), pytest.raises(RuntimeError, match="SEV-SNP attestation failed"): + monkeypatch.setattr(snp_mod, "_tsm_get_report", boom) + with pytest.raises(RuntimeError, match="SEV-SNP attestation failed"): SEVSNPProvider().get_attestation_report(b"\x00" * 32) -def test_sev_snp_get_report_raises_when_fcntl_unavailable( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """When fcntl is absent (non-Linux), get_attestation_report must raise RuntimeError.""" - monkeypatch.setitem(sys.modules, "fcntl", None) # type: ignore[arg-type] - with pytest.raises(RuntimeError, match="SEV-SNP attestation failed"): - SEVSNPProvider().get_attestation_report(b"\x00" * 32) +def test_sev_snp_get_report_success_via_tsm(monkeypatch: pytest.MonkeyPatch) -> None: + """get_attestation_report parses the configfs-TSM outblob and binds the nonce.""" + from cmcp_runtime.tee import sev_snp as snp_mod + from cmcp_runtime.tee.sev_snp import _SNP_REPORT_SIZE, _SnpAttestationReport + + nonce = bytes(range(64)) + raw = bytearray(_SNP_REPORT_SIZE) + raw[_SnpAttestationReport.report_data.offset:_SnpAttestationReport.report_data.offset + 64] = nonce + monkeypatch.setattr(snp_mod, "_tsm_get_report", lambda report_data: bytes(raw)) + + report = SEVSNPProvider().get_attestation_report(nonce) + assert report.provider == "sev-snp" + assert report.report_data == nonce.hex() + assert report.raw_evidence == bytes(raw) # ── TDXProvider struct layout (HW-007) ────────────────────────────────────────