Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 74 additions & 49 deletions src/cmcp_runtime/tee/sev_snp.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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("<I", buf, 0)[0]
if status != 0:
raise RuntimeError(f"SEV-SNP attestation failed: ioctl status={status:#x}")

# Report starts at offset _SNP_RESP_HEADER_SIZE
raw_evidence = bytes(buf[_SNP_RESP_HEADER_SIZE : _SNP_RESP_HEADER_SIZE + _SNP_REPORT_SIZE])
report_data = (nonce[:64] + b"\x00" * 64)[:64]
raw_evidence = _tsm_get_report(report_data)

# Parse report via ctypes struct for named field access (HW-006)
report = _SnpAttestationReport.from_buffer_copy(raw_evidence)
Expand All @@ -156,3 +176,8 @@ def get_attestation_report(self, nonce: bytes) -> 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
7 changes: 6 additions & 1 deletion src/cmcp_verify/sev_snp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 15 additions & 3 deletions tests/unit/test_sev_snp_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
52 changes: 10 additions & 42 deletions tests/unit/test_tee.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<I", resp_buf, 0, 0) # status = 0
resp_buf[_SNP_RESP_HEADER_SIZE:] = raw_evidence

def fake_ioctl(fd, req, buf):
buf[:] = resp_buf

mock_fcntl = MagicMock()
mock_fcntl.ioctl.side_effect = fake_ioctl
monkeypatch.setitem(sys.modules, "fcntl", mock_fcntl)
monkeypatch.setattr("cmcp_runtime.tee.sev_snp.sys.platform", "linux")
monkeypatch.setattr(snp_mod, "_tsm_get_report", lambda report_data: bytes(raw_evidence))

wrong_expected = "sha384:" + "0" * 96
provider = SEVSNPProvider(expected_measurement=wrong_expected)

with patch("builtins.open", MagicMock(
return_value=MagicMock(__enter__=lambda s: MagicMock(), __exit__=lambda s, *a: False)
)), pytest.raises(RuntimeError, match="measurement mismatch"):
with pytest.raises(RuntimeError, match="measurement mismatch"):
provider.get_attestation_report(b"\x00" * 32)


def test_sevsnp_accepts_matching_expected_measurement(monkeypatch):
"""HW-002: when expected_measurement matches, report is returned without error."""
import hashlib
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,
)

Expand All @@ -265,27 +245,15 @@ def test_sevsnp_accepts_matching_expected_measurement(monkeypatch):

raw_evidence = bytearray(_SNP_REPORT_SIZE)
raw_evidence[_SNP_MEASUREMENT_OFFSET:_SNP_MEASUREMENT_END] = measurement_bytes
monkeypatch.setattr(snp_mod, "_tsm_get_report", lambda report_data: bytes(raw_evidence))

resp_buf = bytearray(_SNP_RESP_HEADER_SIZE + _SNP_REPORT_SIZE)
struct.pack_into("<I", resp_buf, 0, 0)
resp_buf[_SNP_RESP_HEADER_SIZE:] = raw_evidence

def fake_ioctl(fd, req, buf):
buf[:] = resp_buf

mock_fcntl = MagicMock()
mock_fcntl.ioctl.side_effect = fake_ioctl
monkeypatch.setitem(sys.modules, "fcntl", mock_fcntl)
monkeypatch.setattr("cmcp_runtime.tee.sev_snp.sys.platform", "linux")

nonce = b"\x00" * 32
provider = SEVSNPProvider(expected_measurement=expected)

with patch("builtins.open", MagicMock(
return_value=MagicMock(__enter__=lambda s: MagicMock(), __exit__=lambda s, *a: False)
)):
report = provider.get_attestation_report(b"\x00" * 32)
report = provider.get_attestation_report(nonce)

assert report.measurement == expected
assert report.report_data == nonce.hex()
assert report.raw_evidence == bytes(raw_evidence)


def test_detect_provider_passes_expected_measurement_to_snp(dev_config):
Expand Down
37 changes: 21 additions & 16 deletions tests/unit/test_tee_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,27 +87,32 @@ def test_sev_snp_provider_name() -> 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) ────────────────────────────────────────
Expand Down