diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba065d..f01fceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ca2a-runtime`: config, error registry, and delegation credential model - `ca2a_runtime.provenance`: linked delegation-record DAG with tamper and reparent detection, bound to authority via `cross_check_chain` - `experiments/`: reproducible claim suite C1-C6. C1 (attenuation), C2 (cross-chain replay), and C5 (provenance DAG) are fully reproducible; C3, C4, C6 SKIP until their Tier 2/3 dependency lands. Each claim has a CI test. +- SEV-SNP attestation backend (Tier 3): `ca2a_runtime.tee.sev_snp` (report parsing, `SevSnpProvider`) and `ca2a_verify.sev_snp` (VCEK chain verification, ECDSA-P384 report-signature verification, measurement/report-data binding), all fail-closed. Chain path validated against the real AMD Milan root; report-signature path validated with synthetic vectors. Report generation requires a real SEV-SNP guest. - Repository scaffold: governance, CI/CD, docs framework, and packaging at parity with the agentrust-io house standard ### Not yet implemented - Runtime peer-delegation enforcement (Tier 2, see ROADMAP.md) - Sealed peer channel (Tier 2) -- Real hardware attestation backends (Tier 3, shared critical path with cmcp) +- Intel TDX and TPM attestation backends (Tier 3); end-to-end SEV-SNP validation against real hardware vectors [Unreleased]: https://github.com/agentrust-io/ca2a/commits/main diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 36d0403..f70aa1f 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -11,7 +11,7 @@ cA2A is a pre-release profile in active design. This document states plainly wha - **Runtime peer-delegation enforcement.** The runtime does not yet accept a delegation credential on a live inbound peer call, verify it in the request path, and intersect the delegated scope with a local Cedar policy. This is Tier 2 on the roadmap. - **Sealed peer channel.** Payloads are not yet sealed to a peer's attested measurement. Until this lands, do not send confidential task payloads across a trust boundary and assume they are protected. -- **Real hardware attestation.** Attestation verification fails closed and the hardware signature/quote step is not implemented (SEV-SNP VCEK chain, Intel TDX quote via QVL/PCS, TPM AK cert + checkquote). Until at least one real backend lands, cA2A must not be described as attested across trust domains. This is Tier 3 and is a shared critical path with cmcp. +- **Real hardware attestation.** The **SEV-SNP verifier is implemented**: report parsing, VCEK certificate chain verification, ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed. The chain path is validated against the genuine AMD Milan root chain; the report-signature path is validated with synthetic vectors, since a real report plus VCEK pair needs SEV-SNP hardware. Report generation (`SevSnpProvider.attest`) still requires a real SEV-SNP guest. **Intel TDX and TPM backends are not yet implemented (Tier 3).** Until a backend verifies a real quote end to end against a golden measurement on hardware, cA2A must not be described as fully attested across trust domains. ## Out of scope diff --git a/ROADMAP.md b/ROADMAP.md index 184cebb..8afcb00 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,9 @@ Already implemented and tested elsewhere; cA2A depends on it rather than reimple Real hardware attestation verification (SEV-SNP VCEK chain, Intel TDX quote via QVL/PCS, TPM AK cert + checkquote). This is a dependency for any cross-operator trust claim, single-agent or multi-agent, and is shared with cmcp. At least one real hardware backend must land before cA2A is marketed as attested across trust domains, so the demo matches the claim. +- **SEV-SNP verifier: landed.** Report parsing, VCEK chain verification (validated against the real AMD Milan root), ECDSA-P384 report-signature verification, and measurement/report-data binding, all fail-closed. Report generation still requires a real SEV-SNP guest. See `ca2a_verify.sev_snp` and [docs/spec/attestation.md](docs/spec/attestation.md). +- **Pending:** Intel TDX and TPM backends; end-to-end validation of the report-signature path against real hardware vectors; then unblock claim C6 (cross-operator attestation). + ## v1.0: Stable profile - Stable delegation credential and TRACE link schema with documented versioning guarantees diff --git a/docs/spec/attestation.md b/docs/spec/attestation.md index e65da93..608c904 100644 --- a/docs/spec/attestation.md +++ b/docs/spec/attestation.md @@ -16,14 +16,24 @@ An `AttestationReport` carries `platform`, `measurement`, the bound `public_key` | Provider | Platform | Status | |---|---|---| | `software-only` | none | Available; for development and CI. Reports `platform: software-only`, never a hardware platform string. | +| `sev-snp` | AMD SEV-SNP | Verifier implemented (see below). Report generation requires a real SEV-SNP guest. | | `tpm` | TPM 2.0 / vTPM | Tier 3, not yet implemented | -| `sev-snp` | AMD SEV-SNP | Tier 3, not yet implemented | | `tdx` | Intel TDX | Tier 3, not yet implemented | | `opaque` | OPAQUE Confidential Runtime | Tier 3, explicit opt-in, not auto-selected | +## SEV-SNP verification + +`ca2a_verify.sev_snp.verify_sev_snp_report` appraises an AMD SEV-SNP attestation report offline, in three fail-closed steps: + +1. **Certificate chain.** The VCEK is verified up to a trusted AMD root (ARK) through `ARK -> ASK -> VCEK`. Each certificate must be validly issued by the next, and the root must match a trusted anchor by fingerprint. +2. **Report signature.** The ECDSA-P384 signature (stored as little-endian `r` and `s`) is verified against the VCEK public key over the report body (`report[:0x2A0]`). +3. **Binding.** The launch `measurement` and the `report_data` (which carries the runtime key and nonce) are checked against expected values. + +**What is validated.** The chain-verification path is exercised against the genuine AMD Milan ARK/ASK root chain fetched from AMD KDS (`tests/fixtures/sev_snp/`). The report-signature path is exercised end to end with a synthetic VCEK and report, because a genuine report plus VCEK pair requires real SEV-SNP hardware. Producing a report (`SevSnpProvider.attest`) fails closed off hardware (`AttestationUnsupported`). + ## Fail closed -Hardware providers `detect()` to False until their backend lands, so they are never selected automatically, and verification fails closed when evidence is absent. This is deliberate: cA2A must not be described as attested across trust domains until at least one real hardware backend verifies a quote. See [LIMITATIONS.md](../../LIMITATIONS.md). +Providers without a backend `detect()` to False, so they are never selected automatically, and verification fails closed when evidence is absent or invalid. This is deliberate: cA2A must not be described as attested across trust domains until a real hardware backend verifies a quote against a golden measurement. TDX and TPM backends remain Tier 3. See [LIMITATIONS.md](../../LIMITATIONS.md). ## Why this is the critical path diff --git a/src/ca2a_runtime/tee/__init__.py b/src/ca2a_runtime/tee/__init__.py index 2d36fb5..788da18 100644 --- a/src/ca2a_runtime/tee/__init__.py +++ b/src/ca2a_runtime/tee/__init__.py @@ -6,5 +6,6 @@ """ from ca2a_runtime.tee.base import AttestationReport, BaseProvider +from ca2a_runtime.tee.sev_snp import SevSnpProvider, SevSnpReport -__all__ = ["AttestationReport", "BaseProvider"] +__all__ = ["AttestationReport", "BaseProvider", "SevSnpProvider", "SevSnpReport"] diff --git a/src/ca2a_runtime/tee/sev_snp.py b/src/ca2a_runtime/tee/sev_snp.py new file mode 100644 index 0000000..6d96696 --- /dev/null +++ b/src/ca2a_runtime/tee/sev_snp.py @@ -0,0 +1,105 @@ +"""AMD SEV-SNP attestation report parsing and the SEV-SNP provider. + +This module parses the SEV-SNP ``ATTESTATION_REPORT`` structure (1184 bytes, +AMD SEV-SNP ABI) and exposes the fields the cA2A verifier appraises: the launch +measurement, the report data (which binds the runtime key and nonce), and the +ECDSA-P384 signature over the report body. The verification and certificate +chain appraisal live in :mod:`ca2a_verify.sev_snp`. + +Producing a report requires a real SEV-SNP guest (``/dev/sev-guest``), so +:meth:`SevSnpProvider.attest` fails closed off hardware. The verifier does not +need hardware and is exercised against the real AMD root certificate chain plus +synthetic report vectors in the test suite. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +from ca2a_runtime.errors import AttestationFailed, AttestationUnsupported +from ca2a_runtime.tee.base import AttestationReport, BaseProvider + +# Layout of the SEV-SNP ATTESTATION_REPORT (offsets in bytes). +REPORT_SIZE = 0x4A0 # 1184 +SIG_OFFSET = 0x2A0 # signature covers report[:SIG_OFFSET] +REPORT_DATA_OFFSET = 0x50 +REPORT_DATA_LEN = 64 +MEASUREMENT_OFFSET = 0x90 +MEASUREMENT_LEN = 48 +# ECDSA-P384 signature: r then s, each in a 72-byte little-endian field. +SIG_COMPONENT_LEN = 72 +SIG_ALGO_ECDSA_P384_SHA384 = 1 + +SEV_GUEST_DEVICE = "/dev/sev-guest" + + +@dataclass(frozen=True) +class SevSnpReport: + """The parsed subset of a SEV-SNP attestation report cA2A appraises.""" + + version: int + guest_svn: int + policy: int + vmpl: int + signature_algo: int + measurement: bytes + report_data: bytes + raw: bytes + + @property + def signed_body(self) -> bytes: + """The bytes the report signature is computed over.""" + return self.raw[:SIG_OFFSET] + + @property + def signature_rs(self) -> tuple[int, int]: + """The (r, s) ECDSA signature components, decoded from little-endian.""" + r = int.from_bytes(self.raw[SIG_OFFSET : SIG_OFFSET + SIG_COMPONENT_LEN], "little") + s = int.from_bytes( + self.raw[SIG_OFFSET + SIG_COMPONENT_LEN : SIG_OFFSET + 2 * SIG_COMPONENT_LEN], + "little", + ) + return r, s + + @classmethod + def parse(cls, blob: bytes) -> SevSnpReport: + """Parse a raw report, raising AttestationFailed on any malformed input.""" + if len(blob) < REPORT_SIZE: + raise AttestationFailed( + "SEV-SNP report too short", + detail=f"got {len(blob)} bytes, need at least {REPORT_SIZE}", + ) + version, guest_svn, policy = struct.unpack_from(" bool: + import os + + return os.path.exists(SEV_GUEST_DEVICE) + + def attest(self, public_key: str, nonce: str) -> AttestationReport: + raise AttestationUnsupported( + "SEV-SNP report generation requires a real SEV-SNP guest", + detail=f"{SEV_GUEST_DEVICE} not present; run on an AMD SEV-SNP confidential VM", + ) diff --git a/src/ca2a_verify/sev_snp.py b/src/ca2a_verify/sev_snp.py new file mode 100644 index 0000000..259a2e9 --- /dev/null +++ b/src/ca2a_verify/sev_snp.py @@ -0,0 +1,103 @@ +"""Offline appraisal of an AMD SEV-SNP attestation report. + +Appraisal has three parts, all fail-closed: + +1. Certificate chain: the VCEK is verified up to a trusted AMD root (ARK) via + ARK (self-signed) -> ASK -> VCEK. The chain-verification path is exercised + against the real AMD root chain in the test suite. +2. Report signature: the ECDSA-P384 report signature is verified against the + VCEK public key over the report body. +3. Binding: the launch measurement and the report data (which carries the + runtime key and nonce) are checked against expected values. + +Any missing or mismatched step raises AttestationFailed. The verifier needs no +hardware. +""" + +from __future__ import annotations + +from cryptography import x509 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature +from cryptography.hazmat.primitives.hashes import SHA256, SHA384 + +from ca2a_runtime.errors import AttestationFailed +from ca2a_runtime.tee.sev_snp import SEV_GUEST_DEVICE, SIG_ALGO_ECDSA_P384_SHA384, SevSnpReport + +__all__ = ["SEV_GUEST_DEVICE", "verify_cert_chain", "verify_sev_snp_report"] + + +def verify_cert_chain( + chain: list[x509.Certificate], trusted_roots: list[x509.Certificate] +) -> None: + """Verify a leaf-to-root certificate chain against a set of trusted roots. + + ``chain`` is ordered leaf first (VCEK), root last (ARK). Each certificate + must be directly issued by the next, and the final certificate must match a + trusted root by fingerprint. Raises AttestationFailed on any failure. + """ + if not chain: + raise AttestationFailed("empty certificate chain") + + for i in range(len(chain) - 1): + child, issuer = chain[i], chain[i + 1] + try: + child.verify_directly_issued_by(issuer) + except (ValueError, TypeError, InvalidSignature) as exc: + raise AttestationFailed( + f"certificate at position {i} is not validly issued by the next", + detail=str(exc), + ) from exc + + root = chain[-1] + trusted = {c.fingerprint(SHA256()) for c in trusted_roots} + if root.fingerprint(SHA256()) not in trusted: + raise AttestationFailed( + "chain root is not a trusted AMD root", + detail=root.subject.rfc4514_string(), + ) + + +def verify_sev_snp_report( + report_bytes: bytes, + vcek_chain: list[x509.Certificate], + *, + trusted_roots: list[x509.Certificate], + expected_measurement: bytes | None = None, + expected_report_data: bytes | None = None, +) -> SevSnpReport: + """Appraise a SEV-SNP report offline. Raises AttestationFailed on any failure. + + ``vcek_chain`` is ordered leaf (VCEK) first, root (ARK) last. + """ + report = SevSnpReport.parse(report_bytes) + + if report.signature_algo != SIG_ALGO_ECDSA_P384_SHA384: + raise AttestationFailed( + "unsupported report signature algorithm", + detail=f"algo={report.signature_algo}, expected {SIG_ALGO_ECDSA_P384_SHA384}", + ) + + verify_cert_chain(vcek_chain, trusted_roots) + + vcek_key = vcek_chain[0].public_key() + if not isinstance(vcek_key, ec.EllipticCurvePublicKey): + raise AttestationFailed("VCEK does not carry an elliptic-curve public key") + + r, s = report.signature_rs + der_sig = encode_dss_signature(r, s) + try: + vcek_key.verify(der_sig, report.signed_body, ec.ECDSA(SHA384())) + except InvalidSignature as exc: + raise AttestationFailed("SEV-SNP report signature failed to verify") from exc + + if expected_measurement is not None and report.measurement != expected_measurement: + raise AttestationFailed( + "measurement does not match the expected value", + detail=f"got {report.measurement.hex()}", + ) + if expected_report_data is not None and report.report_data != expected_report_data: + raise AttestationFailed("report data does not match the expected binding") + + return report diff --git a/tests/fixtures/sev_snp/amd_milan_cert_chain.pem b/tests/fixtures/sev_snp/amd_milan_cert_chain.pem new file mode 100644 index 0000000..148ca46 --- /dev/null +++ b/tests/fixtures/sev_snp/amd_milan_cert_chain.pem @@ -0,0 +1,74 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGYzCCBBKgAwIBAgIDAQAAMEYGCSqGSIb3DQEBCjA5oA8wDQYJYIZIAWUDBAIC +BQChHDAaBgkqhkiG9w0BAQgwDQYJYIZIAWUDBAICBQCiAwIBMKMDAgEBMHsxFDAS +BgNVBAsMC0VuZ2luZWVyaW5nMQswCQYDVQQGEwJVUzEUMBIGA1UEBwwLU2FudGEg +Q2xhcmExCzAJBgNVBAgMAkNBMR8wHQYDVQQKDBZBZHZhbmNlZCBNaWNybyBEZXZp +Y2VzMRIwEAYDVQQDDAlBUkstTWlsYW4wHhcNMjAxMDIyMTcyMzA1WhcNNDUxMDIy +MTcyMzA1WjB7MRQwEgYDVQQLDAtFbmdpbmVlcmluZzELMAkGA1UEBhMCVVMxFDAS +BgNVBAcMC1NhbnRhIENsYXJhMQswCQYDVQQIDAJDQTEfMB0GA1UECgwWQWR2YW5j +ZWQgTWljcm8gRGV2aWNlczESMBAGA1UEAwwJQVJLLU1pbGFuMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEA0Ld52RJOdeiJlqK2JdsVmD7FktuotWwX1fNg +W41XY9Xz1HEhSUmhLz9Cu9DHRlvgJSNxbeYYsnJfvyjx1MfU0V5tkKiU1EesNFta +1kTA0szNisdYc9isqk7mXT5+KfGRbfc4V/9zRIcE8jlHN61S1ju8X93+6dxDUrG2 +SzxqJ4BhqyYmUDruPXJSX4vUc01P7j98MpqOS95rORdGHeI52Naz5m2B+O+vjsC0 +60d37jY9LFeuOP4Meri8qgfi2S5kKqg/aF6aPtuAZQVR7u3KFYXP59XmJgtcog05 +gmI0T/OitLhuzVvpZcLph0odh/1IPXqx3+MnjD97A7fXpqGd/y8KxX7jksTEzAOg +bKAeam3lm+3yKIcTYMlsRMXPcjNbIvmsBykD//xSniusuHBkgnlENEWx1UcbQQrs ++gVDkuVPhsnzIRNgYvM48Y+7LGiJYnrmE8xcrexekBxrva2V9TJQqnN3Q53kt5vi +Qi3+gCfmkwC0F0tirIZbLkXPrPwzZ0M9eNxhIySb2npJfgnqz55I0u33wh4r0ZNQ +eTGfw03MBUtyuzGesGkcw+loqMaq1qR4tjGbPYxCvpCq7+OgpCCoMNit2uLo9M18 +fHz10lOMT8nWAUvRZFzteXCm+7PHdYPlmQwUw3LvenJ/ILXoQPHfbkH0CyPfhl1j +WhJFZasCAwEAAaN+MHwwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSFrBrRQ/fI +rFXUxR1BSKvVeErUUzAPBgNVHRMBAf8EBTADAQH/MDoGA1UdHwQzMDEwL6AtoCuG +KWh0dHBzOi8va2RzaW50Zi5hbWQuY29tL3ZjZWsvdjEvTWlsYW4vY3JsMEYGCSqG +SIb3DQEBCjA5oA8wDQYJYIZIAWUDBAICBQChHDAaBgkqhkiG9w0BAQgwDQYJYIZI +AWUDBAICBQCiAwIBMKMDAgEBA4ICAQC6m0kDp6zv4Ojfgy+zleehsx6ol0ocgVel +ETobpx+EuCsqVFRPK1jZ1sp/lyd9+0fQ0r66n7kagRk4Ca39g66WGTJMeJdqYriw +STjjDCKVPSesWXYPVAyDhmP5n2v+BYipZWhpvqpaiO+EGK5IBP+578QeW/sSokrK +dHaLAxG2LhZxj9aF73fqC7OAJZ5aPonw4RE299FVarh1Tx2eT3wSgkDgutCTB1Yq +zT5DuwvAe+co2CIVIzMDamYuSFjPN0BCgojl7V+bTou7dMsqIu/TW/rPCX9/EUcp +KGKqPQ3P+N9r1hjEFY1plBg93t53OOo49GNI+V1zvXPLI6xIFVsh+mto2RtgEX/e +pmMKTNN6psW88qg7c1hTWtN6MbRuQ0vm+O+/2tKBF2h8THb94OvvHHoFDpbCELlq +HnIYhxy0YKXGyaW1NjfULxrrmxVW4wcn5E8GddmvNa6yYm8scJagEi13mhGu4Jqh +3QU3sf8iUSUr09xQDwHtOQUVIqx4maBZPBtSMf+qUDtjXSSq8lfWcd8bLr9mdsUn +JZJ0+tuPMKmBnSH860llKk+VpVQsgqbzDIvOLvD6W1Umq25boxCYJ+TuBoa4s+HH +CViAvgT9kf/rBq1d+ivj6skkHxuzcxbk1xv6ZGxrteJxVH7KlX7YRdZ6eARKwLe4 +AFZEAwoKCQ== +-----END CERTIFICATE----- diff --git a/tests/unit/test_sev_snp.py b/tests/unit/test_sev_snp.py new file mode 100644 index 0000000..a1fe604 --- /dev/null +++ b/tests/unit/test_sev_snp.py @@ -0,0 +1,159 @@ +"""Tests for SEV-SNP report parsing and offline appraisal. + +The certificate-chain verification is exercised against the real AMD Milan root +chain (tests/fixtures/sev_snp/amd_milan_cert_chain.pem). The report-signature +path is exercised end to end with a synthetic VCEK chain and a synthetic report, +because a genuine report + VCEK pair requires real SEV-SNP hardware. +""" + +from __future__ import annotations + +import struct +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature +from cryptography.hazmat.primitives.hashes import SHA384 +from cryptography.x509.oid import NameOID + +from ca2a_runtime.errors import AttestationFailed, AttestationUnsupported +from ca2a_runtime.tee.sev_snp import REPORT_SIZE, SIG_OFFSET, SevSnpProvider, SevSnpReport +from ca2a_verify.sev_snp import verify_cert_chain, verify_sev_snp_report + +FIXTURE = Path(__file__).parent.parent / "fixtures" / "sev_snp" / "amd_milan_cert_chain.pem" + + +def _cert(subject: str, issuer: str, subject_key: ec.EllipticCurvePrivateKey, + issuer_key: ec.EllipticCurvePrivateKey) -> x509.Certificate: + now = datetime.now(UTC) + return ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)])) + .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer)])) + .public_key(subject_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=1)) + .not_valid_after(now + timedelta(days=3650)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(issuer_key, SHA384()) + ) + + +def _make_report(vcek_key: ec.EllipticCurvePrivateKey, *, measurement: bytes, + report_data: bytes, algo: int = 1) -> bytes: + body = bytearray(SIG_OFFSET) + struct.pack_into(" dict[str, object]: + ark_key = ec.generate_private_key(ec.SECP384R1()) + ask_key = ec.generate_private_key(ec.SECP384R1()) + vcek_key = ec.generate_private_key(ec.SECP384R1()) + ark = _cert("test-ARK", "test-ARK", ark_key, ark_key) + ask = _cert("test-ASK", "test-ARK", ask_key, ark_key) + vcek = _cert("test-VCEK", "test-ASK", vcek_key, ask_key) + return {"vcek_key": vcek_key, "chain": [vcek, ask, ark], "root": ark} + + +def test_synthetic_report_verifies(synthetic_chain: dict) -> None: + m = b"\x11" * 48 + rd = b"\x22" * 64 + report = _make_report(synthetic_chain["vcek_key"], measurement=m, report_data=rd) + parsed = verify_sev_snp_report( + report, synthetic_chain["chain"], trusted_roots=[synthetic_chain["root"]], + expected_measurement=m, expected_report_data=rd, + ) + assert parsed.measurement == m + assert parsed.report_data == rd + + +def test_tampered_report_fails(synthetic_chain: dict) -> None: + report = bytearray(_make_report(synthetic_chain["vcek_key"], measurement=b"\x11" * 48, + report_data=b"\x22" * 64)) + report[0x90] ^= 0xFF # flip a measurement byte after signing + with pytest.raises(AttestationFailed): + verify_sev_snp_report(bytes(report), synthetic_chain["chain"], + trusted_roots=[synthetic_chain["root"]]) + + +def test_wrong_expected_measurement_fails(synthetic_chain: dict) -> None: + report = _make_report(synthetic_chain["vcek_key"], measurement=b"\x11" * 48, + report_data=b"\x22" * 64) + with pytest.raises(AttestationFailed): + verify_sev_snp_report(report, synthetic_chain["chain"], + trusted_roots=[synthetic_chain["root"]], + expected_measurement=b"\x99" * 48) + + +def test_untrusted_root_fails(synthetic_chain: dict) -> None: + other_root = _cert("other", "other", ec.generate_private_key(ec.SECP384R1()), + ec.generate_private_key(ec.SECP384R1())) + report = _make_report(synthetic_chain["vcek_key"], measurement=b"\x11" * 48, + report_data=b"\x22" * 64) + with pytest.raises(AttestationFailed): + verify_sev_snp_report(report, synthetic_chain["chain"], trusted_roots=[other_root]) + + +def test_broken_chain_fails(synthetic_chain: dict) -> None: + # VCEK not issued by the presented intermediate. + stray_key = ec.generate_private_key(ec.SECP384R1()) + stray = _cert("stray", "stray", stray_key, stray_key) + report = _make_report(synthetic_chain["vcek_key"], measurement=b"\x11" * 48, + report_data=b"\x22" * 64) + chain = [synthetic_chain["chain"][0], stray, synthetic_chain["root"]] + with pytest.raises(AttestationFailed): + verify_sev_snp_report(report, chain, trusted_roots=[synthetic_chain["root"]]) + + +def test_short_report_rejected() -> None: + with pytest.raises(AttestationFailed): + SevSnpReport.parse(b"\x00" * 100) + + +def test_unsupported_algo_rejected(synthetic_chain: dict) -> None: + report = _make_report(synthetic_chain["vcek_key"], measurement=b"\x11" * 48, + report_data=b"\x22" * 64, algo=0) + with pytest.raises(AttestationFailed): + verify_sev_snp_report(report, synthetic_chain["chain"], + trusted_roots=[synthetic_chain["root"]]) + + +def test_real_amd_root_chain_verifies() -> None: + # Real-vector check: the ASK is validly issued by the self-signed ARK root + # fetched from AMD KDS. This exercises the chain-verification code against + # the genuine AMD trust anchor (RSA), not a synthetic one. + certs = x509.load_pem_x509_certificates(FIXTURE.read_bytes()) + ask = next(c for c in certs if "ASK" in c.subject.rfc4514_string() or "SEV" in c.subject.rfc4514_string()) + ark = next(c for c in certs if c.subject == c.issuer) + verify_cert_chain([ask, ark], trusted_roots=[ark]) + + +def test_real_amd_untrusted_root_rejected() -> None: + certs = x509.load_pem_x509_certificates(FIXTURE.read_bytes()) + ark = next(c for c in certs if c.subject == c.issuer) + ask = next(c for c in certs if c.subject != c.issuer) + other = _cert("other", "other", ec.generate_private_key(ec.SECP384R1()), + ec.generate_private_key(ec.SECP384R1())) + with pytest.raises(AttestationFailed): + verify_cert_chain([ask, ark], trusted_roots=[other]) + + +def test_provider_detect_and_attest() -> None: + assert SevSnpProvider.detect() is False # no /dev/sev-guest in this environment + with pytest.raises(AttestationUnsupported): + SevSnpProvider().attest("deadbeef", "nonce")