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 @@ -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
Expand Down
18 changes: 14 additions & 4 deletions LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<!-- The marked block below is shared verbatim with trace-spec and ca2a.
trace-spec/LIMITATIONS.md is the source and the limitations-parity
Expand Down Expand Up @@ -167,9 +167,19 @@ ceiling while four of its seven fields are enforced as minimums. Worth reading b
writing any policy over these bits.
<!-- shared:platform-state-appraisal end -->

**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](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.

## What cMCP does not do

Expand Down
1 change: 1 addition & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
68 changes: 68 additions & 0 deletions docs/spec/platform-policy.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/cmcp_verify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +26,7 @@
"EMBODIED_ACTION_PROFILE",
"EmbodiedActionEvidenceResult",
"ReceiptState",
"SnpPlatformPolicy",
"VerificationError",
"VerificationResult",
"VerificationStatus",
Expand Down
26 changes: 25 additions & 1 deletion src/cmcp_verify/azure_cvm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
50 changes: 50 additions & 0 deletions src/cmcp_verify/platform_policy.py
Original file line number Diff line number Diff line change
@@ -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,
)
23 changes: 23 additions & 0 deletions src/cmcp_verify/sev_snp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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".
Expand Down Expand Up @@ -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.
Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
17 changes: 17 additions & 0 deletions src/cmcp_verify/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading