From 1343e34ce577ddadbf32a2dbd61470b01aa6f4a5 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 17 Sep 2026 15:09:14 -0700 Subject: [PATCH 1/2] feat(verify): enforce verifier-owned SNP platform policy Signed-off-by: Imran Siddique --- CHANGELOG.md | 5 + LIMITATIONS.md | 18 +++- STATUS.md | 1 + docs/spec/platform-policy.md | 68 +++++++++++++ mkdocs.yml | 1 + src/cmcp_verify/__init__.py | 2 + src/cmcp_verify/azure_cvm.py | 26 ++++- src/cmcp_verify/platform_policy.py | 50 +++++++++ src/cmcp_verify/sev_snp.py | 23 +++++ src/cmcp_verify/verify.py | 17 ++++ tests/unit/test_azure_cvm_verify.py | 7 +- tests/unit/test_snp_platform_policy.py | 129 ++++++++++++++++++++++++ tests/unit/test_snp_signature_verify.py | 3 +- 13 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 docs/spec/platform-policy.md create mode 100644 src/cmcp_verify/platform_policy.py create mode 100644 tests/unit/test_snp_platform_policy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 390da074..dc9a94cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Add verifier-owned SNP platform policy to native and Azure verification and + the public TRACE verifier. Explicit requirements fail closed without signed + SNP evidence. The optional policy covers PLATFORM_INFO, not guest DEBUG, + firmware TCB currency, or runtime admission. + - **A response arriving during an operator reset raised the successor session.** The per-session mutation lock serialised a reset and a response elevation but did not order them, so whichever coroutine acquired it second won. A response diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 2d4d2594..624237be 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -137,7 +137,7 @@ Three gaps are worth stating plainly for the TPM path: it does not prove that owner authorization never redefined the index or that the signed pre-value has an approved history. -## Platform state is not appraised +## Platform-state appraisal is opt-in -**In cMCP.** [`agent-manifest`](https://manifest.agentrust-io.com/limitations/) parses these fields and can -enforce a policy over them as of 2026-08-20. cMCP does not yet call that appraisal, so -cMCP does not assert it for you. +**In cMCP.** The Python verifier accepts an explicit `SnpPlatformPolicy` through +`verify_trace_claim(..., snp_platform_policy=...)`. Both native SNP and Azure CVM +paths authenticate the SNP report before invoking the shared `agent-manifest` +appraisal. Missing trust roots or evidence, a violated policy, and non-SNP or +software evidence cannot satisfy this requirement. Successful appraisal adds +`platform_state` to `verified_fields` and the signed raw value to `details`. + +Without an explicit policy, no platform state is asserted. This is a +relying-party verification API, not a gateway startup or remote-tool admission +control. It does not appraise the separate SNP guest `POLICY` (including debug), +TCB versions, revocation, or GPU state. See [the verifier guide](docs/spec/platform-policy.md) +for the exact scope and an example. The new paths are tested using synthetic +signed reports; these tests do not establish live hardware protection. ## What cMCP does not do diff --git a/STATUS.md b/STATUS.md index 90cfd0bb..f733c79d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -27,6 +27,7 @@ picture is stated once. Developer Preview: interfaces may change before v1.0. | `GatewayClaim` (TRACE Claim) generation + signing | Shipped | Normative schema: [`schemas/trace-claim.schema.json`](schemas/trace-claim.schema.json). | | Offline verification (`cmcp_verify`) | Shipped | No operator trust required when the verifier independently checks the attestation report. | | Agent Manifest identity binding | Shipped | Optional; trust in the issuer key is an out-of-band PKI concern. | +| Explicit SNP platform-state appraisal | Python verifier API | `SnpPlatformPolicy` gates native SNP and Azure CVM evidence after signature and pinned-chain validation. Opt-in; missing authentication or a different provider cannot satisfy it. Covers `PLATFORM_INFO`, not guest debug policy, TCB, revocation, GPU state, or remote tools. See [platform policy](docs/spec/platform-policy.md). | | Attestation verifiers: `sev-snp`, `tdx` | Shipped | Verified end to end against genuine hardware evidence: an Azure CVM SEV-SNP report (VCEK chain to the AMD ARK-Milan root, ECDSA-P384 report signature, paravisor `REPORT_DATA` binding) and a GCP C3 Intel TDX DCAP v4 quote (PCK chain to the pinned Intel SGX Root CA, QE binding, quote signature). Runs are recorded in [`docs/testing/hardware-validation.md`](docs/testing/hardware-validation.md). This validates the *verifier* against real quotes; quote generation still requires the corresponding hardware, and TCB status stays in `unverified_fields`. | | Attestation verifier: `tpm` | Shipped in 0.4.0, with a host-dependent limit | **0.3.0 reported a forged TPM quote as hardware-attested and should not be used.** The `tpm2` branch of `verify_trace_claim` called only `verify_tpm_measurement`, which takes no signature parameter, so a `TPMS_ATTEST` with correct magic and matching `qualifying_data` passed with no signature and no chain (#370). `verify_tpm_quote_chained` existed and was hardware-validated on 2026-07-31 (an AK-signed quote from an Azure Trusted Launch vTPM verified end to end, tampered copies rejected, see [`docs/testing/hardware-validation.md`](docs/testing/hardware-validation.md)); nothing in production called it. Fixed in #469: the quote signature and the AK certificate chain now gate `hardware_attestation`, supplied-but-invalid material is fatal, and absent material degrades to `unverified` as SNP does. Signed evidence travels as `gateway.attestation_evidence`, which is why 0.4.0 is a break for older verifiers. **The remaining limit is the host, not the code (#453):** Azure Trusted Launch presents two AK certificate hierarchies concurrently at NV index `0x01C101D0`, and on the `Global Virtual TPM CA - 03` variant the AIA extension is absent entirely, so there is nothing to walk and no chain to a pinnable root. On such a host the chain cannot be established and the claim reports `unverified` rather than verified. Pin the root your own hosts present; a mixed fleet needs both. | | TPM gateway NV measurement pair | Primitive shipped; runtime claim integration not shipped | Startup validates the exact configured `TPM_NT_EXTEND` public area and collects a bracketing NV-certify pair when a platform AK is available. The standalone verifier requires an out-of-band trusted Name, exact range, expected digest, nonce, root, and AK chain. The current TRACE schema and `verify_trace_claim` do not carry or appraise this pair, and policy reload does not refresh it. Direct appraisal proves one signed transition for an authorized template, not index-incarnation continuity, approved pre-history, safe code, or runtime enforcement. | diff --git a/docs/spec/platform-policy.md b/docs/spec/platform-policy.md new file mode 100644 index 00000000..0d7bbf18 --- /dev/null +++ b/docs/spec/platform-policy.md @@ -0,0 +1,68 @@ +# Explicit SNP platform requirements + +A signed report establishes authenticity. A relying party must separately decide +which reported platform settings it accepts. The Python verifier can enforce +that choice for native AMD SEV-SNP and Azure vTPM-rooted SEV-SNP evidence: + +```python +from cmcp_verify import SnpPlatformPolicy, verify_trace_claim + +platform_policy = SnpPlatformPolicy( + require=frozenset({ + "ciphertext_hiding_dram_enabled", + "alias_check_complete", + }), + forbid=frozenset({"smt_enabled"}), + reject_unrecognized_bits=True, +) +result = verify_trace_claim( + claim, + approved, + trusted_ark_pem=pinned_amd_root, + snp_platform_policy=platform_policy, +) +``` + +This example is an explicit policy choice, not a universal safe-platform profile. +It may reject available hardware. Do not weaken it merely to obtain a passing +result without reconsidering the adversary model. `require` names fields that +must be true; `forbid` names fields that must be false. Supported field names +are those of `agent_manifest.PLATFORM_INFO_BITS`. Unknown names, contradictory +requirements, and malformed policy values are configuration errors. + +The policy belongs to the verifier. Do not deserialize it from untrusted claim +content and treat it as the relying party's decision. It is immutable after +construction, including when initialized from a mutable set. + +## Acceptance and rejection + +The verifier checks the pinned chain and report signature before appraising the +signed `PLATFORM_INFO` word. The native SNP and Azure CVM standalone APIs accept +the same object as `platform_policy=`. A supplied policy, including an empty +one, requires authenticated SNP evidence. Missing roots, missing evidence, and +claims selecting a non-SNP or software provider cannot bypass the requirement. + +An unmet policy sets `failure_reason` on the public verification result to +`HARDWARE_ATTESTATION_FAILED`, records the detail, and never returns `VERIFIED`. +A result can retain `PARTIALLY_VERIFIED` status for unrelated successful checks; +it is not permission to release data. Require no failure, `VERIFIED` status, +and `platform_state` in `verified_fields` for this particular gate. + +Successful appraisal records `platform_state` and the authenticated raw word +in `details["platform_info"]`. Omitting the policy preserves previous behavior +and does not assert platform state. The CLI and gateway startup do not configure +this policy; applications calling the Python verifier must supply it. + +## Limits + +This check covers only SNP `PLATFORM_INFO`. It does not cover the separate guest +`POLICY` word (including debug mode), minimum TCB versions, revocation, workload +correctness, key residency, CPU/GPU channel protection, or remote tools. It is an +offline evidence check, not proof of current liveness. Protect data only after +the complete admission policy, including freshness and channel binding, passes. + +`tests/unit/test_snp_platform_policy.py` exercises signed synthetic reports with +acceptable settings, missing required bits, forbidden bits, unknown bits, +missing trust roots, tampered platform state, and provider downgrade attempts. +The tests exercise real cryptographic verification with a synthetic PKI. They +do not constitute a new live hardware demonstration. diff --git a/mkdocs.yml b/mkdocs.yml index 1dfb8286..98a99dfc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -189,6 +189,7 @@ nav: - Phase 2 server: spec/phase2-server.md - Attestation and evidence: - Attestation: spec/attestation.md + - SNP platform policy: spec/platform-policy.md - TPM security model: spec/tpm-security-model.md - Verification library: spec/verification-library.md - Embodied action evidence: spec/embodied-action-evidence.md diff --git a/src/cmcp_verify/__init__.py b/src/cmcp_verify/__init__.py index 77346e91..b7cd9162 100644 --- a/src/cmcp_verify/__init__.py +++ b/src/cmcp_verify/__init__.py @@ -8,6 +8,7 @@ hash_embodied_action_payload, verify_embodied_action_evidence, ) +from cmcp_verify.platform_policy import SnpPlatformPolicy from cmcp_verify.verify import ( ApprovedHashes, AuditBundleResult, @@ -25,6 +26,7 @@ "EMBODIED_ACTION_PROFILE", "EmbodiedActionEvidenceResult", "ReceiptState", + "SnpPlatformPolicy", "VerificationError", "VerificationResult", "VerificationStatus", diff --git a/src/cmcp_verify/azure_cvm.py b/src/cmcp_verify/azure_cvm.py index 5ec3ffcc..a7caa0fc 100644 --- a/src/cmcp_verify/azure_cvm.py +++ b/src/cmcp_verify/azure_cvm.py @@ -25,10 +25,17 @@ import struct from dataclasses import dataclass, field -from agent_manifest import SNP_OFFSETS, SNP_REPORT_LEN, load_snp_cert_chain, parse_snp_report +from agent_manifest import ( + SNP_OFFSETS, + SNP_REPORT_LEN, + SnpVerificationError, + load_snp_cert_chain, + parse_snp_report, +) from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cmcp_verify.platform_policy import SnpPlatformPolicy from cmcp_verify.sev_snp import verify_snp_report_signature, verify_vcek_chain _SNP_REPORT_SIZE = SNP_REPORT_LEN @@ -117,6 +124,8 @@ def verify_azure_cvm_measurement( raw_evidence: bytes | None, report_data_hex: str | None = None, trusted_ark_pem: bytes | None = None, + *, + platform_policy: SnpPlatformPolicy | None = None, ) -> AzureCVMVerificationResult: """Verify Azure CVM (vTPM-rooted SEV-SNP) attestation evidence. Fail-closed.""" result = AzureCVMVerificationResult(verified=True) @@ -214,6 +223,10 @@ def verify_azure_cvm_measurement( if not chain_pem or trusted_ark_pem is None: result.unverified_fields.append("vcek_cert_chain") result.details["vcek_chain"] = "cert chain and/or pinned ARK not supplied" + if platform_policy is not None: + result.verified = False + result.failure_reason = "platform_policy_requires_authenticated_evidence" + result.unverified_fields.append("platform_state") return result try: from cryptography import x509 @@ -247,4 +260,15 @@ def verify_azure_cvm_measurement( result.verified_fields.append("vcek_cert_chain") result.verified_fields.append("report_signature") + if platform_policy is not None: + result.details["platform_info"] = f"0x{report.platform_info:x}" + try: + platform_policy.appraise(report.platform_info) + except SnpVerificationError as exc: + result.verified = False + result.failure_reason = "platform_policy_failed" + result.unverified_fields.append("platform_state") + result.details["platform_policy"] = str(exc) + return result + result.verified_fields.append("platform_state") return result diff --git a/src/cmcp_verify/platform_policy.py b/src/cmcp_verify/platform_policy.py new file mode 100644 index 00000000..6395078f --- /dev/null +++ b/src/cmcp_verify/platform_policy.py @@ -0,0 +1,50 @@ +"""Verifier-owned SNP platform requirements, separate from report authenticity.""" +from __future__ import annotations + +from dataclasses import dataclass + +from agent_manifest import ( + PLATFORM_INFO_BITS, + appraise_platform_info, + parse_platform_info, +) + + +@dataclass(frozen=True) +class SnpPlatformPolicy: + """Require named bits on/off in an authenticated SNP PLATFORM_INFO word. + + This is relying-party input, never a policy accepted from the claim issuer. + Passing even an empty policy requires authenticated SNP evidence. It does + not appraise guest POLICY, TCB versions, revocation, GPU state, or runtime + behavior. Names and their bit meanings come from agent-manifest. + """ + + require: frozenset[str] = frozenset() + forbid: frozenset[str] = frozenset() + reject_unrecognized_bits: bool = False + + def __post_init__(self) -> None: + for name in ("require", "forbid"): + value = getattr(self, name) + if not isinstance(value, (set, frozenset)) or not all( + isinstance(item, str) for item in value + ): + raise ValueError(f"{name} must be a set of PLATFORM_INFO field names") + object.__setattr__(self, name, frozenset(value)) + unknown = (self.require | self.forbid) - set(PLATFORM_INFO_BITS) + if unknown: + raise ValueError("unknown PLATFORM_INFO fields: " + ", ".join(sorted(unknown))) + if self.require & self.forbid: + raise ValueError("platform policy cannot require and forbid the same field") + if not isinstance(self.reject_unrecognized_bits, bool): + raise ValueError("reject_unrecognized_bits must be a boolean") + + def appraise(self, platform_info: int) -> None: + """Raise on an unmet requirement; caller must first authenticate the word.""" + appraise_platform_info( + parse_platform_info(platform_info), + require=set(self.require), + forbid=set(self.forbid), + reject_unrecognized_bits=self.reject_unrecognized_bits, + ) diff --git a/src/cmcp_verify/sev_snp.py b/src/cmcp_verify/sev_snp.py index 6650a335..16353567 100644 --- a/src/cmcp_verify/sev_snp.py +++ b/src/cmcp_verify/sev_snp.py @@ -20,6 +20,7 @@ from agent_manifest import ( SIG_ALGO_ECDSA_P384_SHA384, SNP_REPORT_LEN, + SnpVerificationError, load_snp_cert_chain, parse_snp_report, verify_snp_signature, @@ -28,6 +29,8 @@ from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.serialization import Encoding +from cmcp_verify.platform_policy import SnpPlatformPolicy + # The SNP report is signed over its leading bytes; the 512-byte signature field # occupies the tail. sizeof(report) == 0x4A0, signature == 0x200, so the signed # region is report[:0x2A0]. See AMD SEV-SNP ABI, Table "ATTESTATION_REPORT". @@ -112,6 +115,8 @@ def verify_sev_snp_measurement( report_data_hex: str | None = None, cert_chain_pem: bytes | None = None, trusted_ark_pem: bytes | None = None, + *, + platform_policy: SnpPlatformPolicy | None = None, ) -> SNPVerificationResult: """ Verify an AMD SEV-SNP attestation measurement. @@ -127,6 +132,9 @@ def verify_sev_snp_measurement( VCEK -> ASK -> ARK chain are verified and a failure is FATAL (fail closed). When the chain is not supplied, signature verification is reported as an unverified field rather than silently passing. + + An explicit platform_policy instead fails when that authentication is + unavailable, and appraises PLATFORM_INFO only after chain/signature checks. """ result = SNPVerificationResult(verified=True) @@ -226,6 +234,10 @@ def verify_sev_snp_measurement( if cert_chain_pem is None or trusted_ark_pem is None: result.unverified_fields.append("vcek_cert_chain") result.details["vcek_chain"] = "cert chain and/or pinned ARK not supplied" + if platform_policy is not None: + result.verified = False + result.failure_reason = "platform_policy_requires_authenticated_evidence" + result.unverified_fields.append("platform_state") return result try: @@ -259,4 +271,15 @@ def verify_sev_snp_measurement( result.verified_fields.append("vcek_cert_chain") result.verified_fields.append("report_signature") + if platform_policy is not None: + result.details["platform_info"] = f"0x{report.platform_info:x}" + try: + platform_policy.appraise(report.platform_info) + except SnpVerificationError as exc: + result.verified = False + result.failure_reason = "platform_policy_failed" + result.unverified_fields.append("platform_state") + result.details["platform_policy"] = str(exc) + return result + result.verified_fields.append("platform_state") return result diff --git a/src/cmcp_verify/verify.py b/src/cmcp_verify/verify.py index 29dd987b..fa087680 100644 --- a/src/cmcp_verify/verify.py +++ b/src/cmcp_verify/verify.py @@ -27,6 +27,7 @@ from cmcp_runtime.audit.trace_claim import RuntimeClaim from cmcp_runtime.config import EnforcementMode from cmcp_runtime.errors import ConfigError +from cmcp_verify.platform_policy import SnpPlatformPolicy logger = logging.getLogger(__name__) @@ -806,6 +807,7 @@ def verify_trace_claim( trusted_intel_root_pem: bytes | None = None, trusted_tpm_ca_pem: bytes | None = None, expected_gateway_measurement: str | bytes | None = None, + snp_platform_policy: SnpPlatformPolicy | None = None, ) -> VerificationResult: """ Verify a TRACE Claim without trusting the operator. @@ -828,6 +830,11 @@ def verify_trace_claim( claim. See _check_measurement_binding for which report carries it. 8. Platform-specific attestation verification (dispatched per-platform) + ``snp_platform_policy`` is a verifier-owned policy over authenticated SNP + PLATFORM_INFO (native SNP or Azure CVM). Supplying it requires a valid report + signature and pinned chain; a non-SNP/software claim cannot satisfy it. It + does not appraise guest policy, TCB versions, revocation, or GPU state. + Returns VerificationResult with status and details. Example usage: @@ -1237,6 +1244,7 @@ def verify_trace_claim( raw_evidence=raw_bytes, report_data_hex=report_data_hex, trusted_ark_pem=trusted_ark_pem, + platform_policy=snp_platform_policy, ) chain_ok = "vcek_cert_chain" not in azure_result.unverified_fields if azure_result.verified and chain_ok: @@ -1269,6 +1277,7 @@ def verify_trace_claim( report_data_hex=report_data_hex, cert_chain_pem=cert_chain_pem, trusted_ark_pem=trusted_ark_pem, + platform_policy=snp_platform_policy, ) # The VCEK chain is the SNP hardware root of trust. Even when the report # parses and the measurement matches, a claim whose chain is unverified @@ -1360,6 +1369,14 @@ def verify_trace_claim( failure = failure or VerificationError.UNSUPPORTED_PROVIDER # Determine overall status + if snp_platform_policy is not None and "platform_state" not in verified: + # A different platform, dev-mode marker, missing evidence, or failed + # authentication must not silently bypass a relying party's SNP floor. + failure = failure or VerificationError.HARDWARE_ATTESTATION_FAILED + if "platform_state" not in unverified: + unverified.append("platform_state") + details.setdefault("platform_policy", "required authenticated SNP platform state not established") + if failure is None: # Fail closed: a claim with no hardware-backed attestation (software-only # or any non-hardware-backed path) is never fully VERIFIED, even when it is diff --git a/tests/unit/test_azure_cvm_verify.py b/tests/unit/test_azure_cvm_verify.py index ce863c1a..e280a2b3 100644 --- a/tests/unit/test_azure_cvm_verify.py +++ b/tests/unit/test_azure_cvm_verify.py @@ -84,7 +84,7 @@ def _runtime_data(ak_pub: rsa.RSAPublicKey) -> bytes: def _signed_snp( - vcek_key, runtime: bytes, measurement_bytes: bytes = b"\x11" * 48 + vcek_key, runtime: bytes, measurement_bytes: bytes = b"\x11" * 48, platform_info: int = 0 ) -> tuple[bytes, str]: buf = bytearray(_REPORT_SIZE) buf[0x00:0x04] = (2).to_bytes(4, "little") @@ -92,6 +92,7 @@ def _signed_snp( buf[_MEAS_OFFSET : _MEAS_OFFSET + 48] = measurement_bytes[:48] # paravisor binding: REPORT_DATA[:32] == sha256(runtime_data) buf[_RD_OFFSET : _RD_OFFSET + 32] = hashlib.sha256(runtime).digest() + buf[0x40:0x48] = platform_info.to_bytes(8, "little") signed_region = bytes(buf[:_SNP_SIG_OFFSET]) der = vcek_key.sign(signed_region, ec.ECDSA(hashes.SHA384())) r, s = decode_dss_signature(der) @@ -118,11 +119,11 @@ def _tpmt_signature(ak_key: rsa.RSAPrivateKey, quote_msg: bytes) -> bytes: return struct.pack(">H", 0x0014) + struct.pack(">H", 0x000B) + struct.pack(">H", len(sig)) + sig -def _build_evidence(nonce: bytes, *, include_chain: bool = True, ak_key=None, quote_extra=None): +def _build_evidence(nonce: bytes, *, include_chain: bool = True, ak_key=None, quote_extra=None, platform_info: int = 0): chain_pem, ark_pem, vcek_key = _synthetic_chain() ak_key = ak_key or rsa.generate_private_key(public_exponent=65537, key_size=2048) runtime = _runtime_data(ak_key.public_key()) - snp, measurement = _signed_snp(vcek_key, runtime) + snp, measurement = _signed_snp(vcek_key, runtime, platform_info=platform_info) quote_msg = _tpm2b_attest( quote_extra if quote_extra is not None else hashlib.sha256(nonce).digest() ) diff --git a/tests/unit/test_snp_platform_policy.py b/tests/unit/test_snp_platform_policy.py new file mode 100644 index 00000000..40c7fd69 --- /dev/null +++ b/tests/unit/test_snp_platform_policy.py @@ -0,0 +1,129 @@ +"""Authenticated platform policy through both SNP paths and the public API. + +Synthetic PKI exercises real signatures; this is not hardware validation. +Bit positions here are literal AMD ABI positions, independent of the parser map. +""" +from __future__ import annotations + +import base64 +import json + +import pytest + +from cmcp_runtime.audit.keys import SigningKey +from cmcp_verify import SnpPlatformPolicy +from cmcp_verify.azure_cvm import verify_azure_cvm_measurement +from cmcp_verify.sev_snp import verify_sev_snp_measurement +from cmcp_verify.verify import VerificationError, VerificationStatus, verify_trace_claim +from tests.unit.test_azure_cvm_verify import _build_evidence +from tests.unit.test_evidence_envelope_all_platforms import _approved, _claim +from tests.unit.test_platform_nonce_binding import _resign +from tests.unit.test_snp_signature_verify import _signed_report, _synthetic_chain + +STRICT = SnpPlatformPolicy( + require=frozenset({"ciphertext_hiding_dram_enabled", "alias_check_complete"}), + forbid=frozenset({"smt_enabled"}), + reject_unrecognized_bits=True, +) +GOOD_STATE = (1 << 4) | (1 << 5) + + +def _evidence(provider, state, nonce): + if provider == "azure-cvm-sev-snp": + raw, measurement, root = _build_evidence(nonce, platform_info=state) + return raw, measurement, {"trusted_ark_pem": root} + chain, root, vcek = _synthetic_chain() + raw, measurement = _signed_report( + vcek, measurement_bytes=b"\x11" * 48, report_data=nonce, platform_info=state, + ) + return raw, measurement, {"trusted_ark_pem": root, "cert_chain_pem": chain} + + +@pytest.mark.parametrize("provider", ["sev-snp", "azure-cvm-sev-snp"]) +@pytest.mark.parametrize("state,allowed", [ + (GOOD_STATE, True), (0, False), (1 << 4, False), (1 << 5, False), + (GOOD_STATE | 1, False), (GOOD_STATE | (1 << 6), False), +]) +def test_signed_platform_state_gates_public_claim(provider, state, allowed): + key = SigningKey() + claim = _claim(provider, key=key) + runtime = claim["trace"]["runtime"] + nonce = base64.urlsafe_b64decode(runtime["nonce"] + "==") + raw, measurement, kwargs = _evidence(provider, state, nonce) + runtime["measurement"] = measurement + envelope = claim["gateway"]["attestation_evidence"] + envelope["raw_evidence"] = base64.b64encode(raw).decode() + chain = kwargs.pop("cert_chain_pem", None) + if chain: + envelope["cert_chain"] = base64.b64encode(chain).decode() + _resign(claim, key) + # Positive control: authentic reports from unsuitable machines used to pass + # because the platform policy did not exist. No-policy compatibility remains. + baseline = verify_trace_claim(claim, _approved(), **kwargs) + assert baseline.status == VerificationStatus.VERIFIED + assert "platform_state" not in baseline.verified_fields + result = verify_trace_claim(claim, _approved(), snp_platform_policy=STRICT, **kwargs) + if allowed: + assert result.status == VerificationStatus.VERIFIED + assert "platform_state" in result.verified_fields + assert result.details["platform_info"] == "0x30" + else: + assert result.failure_reason == VerificationError.HARDWARE_ATTESTATION_FAILED + assert result.status != VerificationStatus.VERIFIED + assert "platform_state" in result.unverified_fields + assert "hardware_attestation" not in result.verified_fields + + +@pytest.mark.parametrize("provider", ["sev-snp", "azure-cvm-sev-snp"]) +@pytest.mark.parametrize("attack", ["missing-root", "missing-report", "tamper-state"]) +def test_acceptable_bits_without_authentic_evidence_never_satisfy_policy(provider, attack): + nonce = b"n" * 64 + raw, measurement, kwargs = _evidence(provider, GOOD_STATE, nonce) + if attack == "missing-root": + kwargs.pop("trusted_ark_pem") + elif attack == "missing-report": + raw = None + elif provider == "sev-snp": + modified = bytearray(raw) + modified[0x40] ^= 4 # ECC is unconstrained: policy would still pass. + raw = bytes(modified) + else: + envelope = json.loads(raw) + report = bytearray(base64.b64decode(envelope["snp_report"])) + report[0x40] ^= 4 + envelope["snp_report"] = base64.b64encode(report).decode() + raw = json.dumps(envelope).encode() + verify = verify_sev_snp_measurement if provider == "sev-snp" else verify_azure_cvm_measurement + result = verify(measurement, raw, nonce.hex(), platform_policy=STRICT, **kwargs) + assert not result.verified + assert "platform_state" not in result.verified_fields + if attack == "missing-root": + assert result.failure_reason == "platform_policy_requires_authenticated_evidence" + elif attack == "tamper-state": + assert result.failure_reason == "report_signature_invalid" + + +@pytest.mark.parametrize("provider", ["software-only", "tdx", "tpm"]) +def test_snp_requirement_cannot_be_bypassed_by_selecting_another_provider(provider): + result = verify_trace_claim(_claim(provider), _approved(), snp_platform_policy=STRICT) + assert result.failure_reason is not None + assert result.status != VerificationStatus.VERIFIED + assert "platform_state" in result.unverified_fields + + +@pytest.mark.parametrize("kwargs", [ + {"require": {"typo"}}, {"forbid": {"smt"}}, + {"require": {"smt_enabled"}, "forbid": {"smt_enabled"}}, + {"require": "smt_enabled"}, {"require": {123}}, + {"reject_unrecognized_bits": "false"}, +]) +def test_invalid_policy_is_a_configuration_error(kwargs): + with pytest.raises(ValueError): + SnpPlatformPolicy(**kwargs) + + +def test_policy_snapshots_mutable_inputs(): + required = {"alias_check_complete"} + policy = SnpPlatformPolicy(require=required) + required.clear() + assert policy.require == frozenset({"alias_check_complete"}) diff --git a/tests/unit/test_snp_signature_verify.py b/tests/unit/test_snp_signature_verify.py index 611f03db..c072536e 100644 --- a/tests/unit/test_snp_signature_verify.py +++ b/tests/unit/test_snp_signature_verify.py @@ -72,12 +72,13 @@ def _synthetic_chain(): return chain_pem, ark.public_bytes(Encoding.PEM), vcek_key -def _signed_report(vcek_key, *, measurement_bytes: bytes, report_data: bytes) -> tuple[bytes, str]: +def _signed_report(vcek_key, *, measurement_bytes: bytes, report_data: bytes, platform_info: int = 0) -> tuple[bytes, str]: buf = bytearray(_REPORT_SIZE) buf[0x00:0x04] = (2).to_bytes(4, "little") # version = 2 buf[_SIG_ALGO_OFFSET : _SIG_ALGO_OFFSET + 4] = (1).to_bytes(4, "little") # ECDSA-P384/SHA384 buf[_MEAS_OFFSET : _MEAS_OFFSET + 48] = measurement_bytes[:48] buf[_RD_OFFSET : _RD_OFFSET + 64] = report_data[:64] + buf[0x40:0x48] = platform_info.to_bytes(8, "little") signed_region = bytes(buf[:_SNP_SIG_OFFSET]) der = vcek_key.sign(signed_region, ec.ECDSA(hashes.SHA384())) r, s = decode_dss_signature(der) From 7d04652a794e878f9578ca72951854494e81a784 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 17 Sep 2026 15:31:16 -0700 Subject: [PATCH 2/2] fix(docs): use site-safe platform policy link Signed-off-by: Imran Siddique --- LIMITATIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 624237be..2c4759cd 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -177,7 +177,7 @@ software evidence cannot satisfy this requirement. Successful appraisal adds Without an explicit policy, no platform state is asserted. This is a relying-party verification API, not a gateway startup or remote-tool admission control. It does not appraise the separate SNP guest `POLICY` (including debug), -TCB versions, revocation, or GPU state. See [the verifier guide](docs/spec/platform-policy.md) +TCB versions, revocation, or GPU state. See [the verifier guide](https://cmcp.agentrust-io.com/spec/platform-policy/) for the exact scope and an example. The new paths are tested using synthetic signed reports; these tests do not establish live hardware protection.