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
34 changes: 34 additions & 0 deletions LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,40 @@ The catalog binds each tool name to a specific upstream server identity, which p
- **Threat classes T1 through T4 are not covered.** These are the rogue administrator, host OS compromise, post-incident audit log reconstruction, and policy substitution threats described in the [threat model](docs/spec/threat-model.md). All four require a hardware TEE to close. In software-only mode, all four remain open.
- **TRACE Claims are partially verified only.** The `cmcp_verify` library returns `status: partially_verified` and reports `hardware_attestation: software-only mode -- not hardware-backed`. Claims produced in dev mode must not be presented as hardware-attested proof to auditors or regulators.

## What attestation verification establishes, and what it does not

`cmcp_verify` reports `status: verified` only when the platform evidence is
cryptographically checked; a claim whose report signature or certificate chain
is unverified stays `partially_verified` and is never presented as
hardware-backed (issue #370). What that check covers differs by platform:

- **AMD SEV-SNP** — report signature plus the VCEK → ASK → ARK chain, with the
ARK pinned by the operator.
- **Intel TDX** — DCAP quote signature and the PCK chain to a pinned Intel SGX
Root CA.
- **TPM 2.0** — the `TPMT_SIGNATURE` over the `TPMS_ATTEST`, verified with the
attestation key, plus the AK certificate chain to a manufacturer CA the
operator pins via `trusted_tpm_ca_pem`. Absent signature or chain material
degrades to `unverified`; supplied material that fails is fatal.

Two gaps are worth stating plainly for the TPM path:

- **The attestation key is not bound to a specific TPM.** A verified AK chain
proves the key was certified under a CA you pinned. It does not prove the key
lives in the endorsed TPM. That binding is TPM credential activation
(`TPM2_MakeCredential` / `TPM2_ActivateCredential`), a live challenge-response
an offline verifier cannot perform, and it is not implemented. A CA that
mis-issues, or a platform CA that certifies a software key, is not caught.
- **Evidence rides in the cmcp envelope, not the TRACE runtime block.**
`RuntimeInfo` in `agentrust-trace` is `extra="forbid"`, so a claim carrying
`raw_evidence` / `quote_signature` / `cert_chain` under `trace.runtime` is
rejected as `CLAIM_MALFORMED` before platform verification runs. Signed
evidence therefore travels as `gateway.attestation_evidence`, a cmcp-owned
field. The verifier still reads `trace.runtime` as a fallback so older claims
keep working, but that path cannot pass schema validation. **The SEV-SNP
`cert_chain` path has not been migrated and remains subject to this**, so its
VCEK chain verification does not engage for a schema-valid claim.

## What cMCP does not do

- **cMCP is not a WAF.** It does not inspect HTTP traffic for SQL injection, XSS, or other web application attack patterns. It operates at the MCP tool call layer, not the HTTP layer.
Expand Down
16 changes: 10 additions & 6 deletions docs/spec/tpm-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,21 @@ The gateway observed a platform boot state, expressed as a digest over PCRs 0 th

## 4. What is not claimable, and why

### 4.1 The evidence is not cryptographically bound to the TPM (#429, #430)
### 4.1 The evidence is not cryptographically bound to the TPM (#429, #430) — closed in the verifier

`ESAPI.quote()` returns `Tuple[TPM2B_ATTEST, TPMT_SIGNATURE]`. The signature is discarded at the call site, and `verify_tpm_measurement` has no signature parameter. Verification is structural parsing plus a nonce comparison.
Historically `ESAPI.quote()` returned `Tuple[TPM2B_ATTEST, TPMT_SIGNATURE]` and the signature was discarded at the call site, so verification was structural parsing plus a nonce comparison. Any code executing in the gateway process could synthesise attestation data with arbitrary PCR values and the expected nonce, and it verified.

Compounding this, the quote is called with `object_handle=ectx.get_capability(TPM2_ALG.NULL)`, which is a capability query rather than a loaded key handle. The call raises, a bare `except` sets `raw_evidence = None`, and the provider degrades silently. The quote path is effectively dead code.
That hole is closed. The producer now captures the marshalled `TPMT_SIGNATURE`, the AK public key, and the AK certificate chain onto `AttestationReport` (`quote_signature`, `attestation_key_pem`, `attestation_key_chain_pem`). `verify_trace_claim`'s `tpm2` branch verifies the signature over the `TPMS_ATTEST` and the AK chain against an operator-pinned root supplied as `trusted_tpm_ca_pem` (#370), and `hardware_attestation` is reported verified only when both check out — the same gating the SNP VCEK chain uses. Supplied-but-invalid chain material is fatal; absent chain material degrades to `unverified` for back-compatibility with evidence predating these fields.

Consequence: any code executing in the gateway process can synthesise attestation data with arbitrary PCR values and the expected nonce, and it verifies. Local code execution is the primary adversary on a device without confidential compute, so this removes the security value of the TPM path.
One transport gap remains. `RuntimeInfo` in `agentrust-trace` is `extra="forbid"` and carries no evidence fields, so a claim built through `_build_runtime()` cannot yet carry `raw_evidence`, `quote_signature`, or `cert_chain`. The verifier reads them from the raw claim dict, which means the chained path only engages for evidence supplied out of band. Closing this needs the `RuntimeInfo` schema change upstream and is the same constraint the SNP `cert_chain` field is under.

### 4.2 There is no attestation key identity (#431)
### 4.2 There is no attestation key identity (#431) — partially closed

No AK is provisioned. `ek_cert_chain` is unconditionally listed as unverified with the note `requires_ca_lookup`. Even with a verified signature, nothing distinguishes a discrete TPM from a software emulator or an attacker-generated key.
An AK certificate chain is now provisioned and verified to a pinned manufacturer root (`tpm_roots.py`), so a verified quote does distinguish a key certified by a known TPM vendor from an attacker-generated one. `verify_ak_ek_chain` will additionally verify an EK certificate's own path to the pinned CA when a producer bundles one, at which point `ek_cert_chain` is genuinely established rather than carrying the old `requires_ca_lookup` note.

What is still open is the **binding** between the two, and it cannot be closed with certificates. An EK is a restricted decryption key: it cannot sign, so it cannot issue an AK certificate and cannot appear in the AK's issuance path. Issue #370's "verify AK->EK" is therefore only half realisable — `EK->manufacturer-CA` is a real path, `AK->EK` is not. TPM 2.0 binds them by credential activation (`TPM2_MakeCredential` / `TPM2_ActivateCredential`), a challenge-response the verifier runs, and that is not implemented. Verifying both chains proves each key is individually certified; it does not prove they live in the same TPM. A platform that certifies an AK without activation is trusted on the strength of its CA alone.

One sharp edge worth recording, because it produced a false pass during implementation: the TCG EK EKU (`2.23.133.8.1`) does **not** identify an Endorsement Key. Azure's real vTPM chain carries it on the *issuing CAs* (`Azure Cloud Virtual TPM CA - 11` and `... CA 2025`), where it means "may issue EK certificates". Matching on the EKU alone reported `ek_cert_chain` verified for a chain containing no EK at all. Identification requires the EKU **and** `ca=False`.

### 4.3 The gateway is not measured (#432, measurement landed, binding open)

Expand Down
48 changes: 48 additions & 0 deletions src/cmcp_runtime/audit/trace_claim.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ class AttestationReportInfo:
attestation_generated_at: str
attestation_validity_seconds: int
measurement_note: str | None = None
# Base64 evidence, surfaced in the claim as gateway.attestation_evidence.
raw_evidence: str | None = None
quote_signature: str | None = None
cert_chain: str | None = None
ek_cert_chain: str | None = None


@dataclass
Expand Down Expand Up @@ -204,6 +208,29 @@ class CallLogSummary(BaseModel):
suspicious_sequences_detected: int


class AttestationEvidence(BaseModel):
"""Signed platform evidence, base64, carried in the cmcp envelope.

This does not live under ``trace.runtime`` because ``RuntimeInfo`` belongs to
agentrust-trace and is ``extra="forbid"`` -- a claim carrying evidence there
is rejected as CLAIM_MALFORMED before platform verification runs, which is
what kept the chain verifiers unreachable (#370). ``GatewayAddenda`` is
cmcp's own model and is the documented home for fields outside the canonical
TRACE spec, so the evidence rides here instead.

Every field is optional: evidence predating these fields, and platforms whose
report carries its own signature envelope, degrade to ``unverified`` rather
than erroring.
"""

model_config = ConfigDict(extra="forbid")

raw_evidence: str | None = None # the exact bytes the platform signed
quote_signature: str | None = None # marshalled TPMT_SIGNATURE (TPM)
cert_chain: str | None = None # leaf-first PEM: AK/VCEK -> ... -> root
ek_cert_chain: str | None = None # EK's own path to the manufacturer CA


class GatewayAddenda(BaseModel):
"""cmcp-specific fields outside the canonical TRACE spec."""

Expand All @@ -223,6 +250,7 @@ class GatewayAddenda(BaseModel):
call_log_summary: CallLogSummary | None = None
agent_identity: AgentIdentityOut | None = None
kill_switch_triggered: bool = False
attestation_evidence: AttestationEvidence | None = None


class RuntimeClaim(BaseModel):
Expand Down Expand Up @@ -266,6 +294,25 @@ def sign_trace_claim(claim: RuntimeClaim, signing_key: Any) -> str:
# ── Builder helpers ────────────────────────────────────────────────────────────


def _build_evidence(report: AttestationReportInfo) -> AttestationEvidence | None:
"""Collect the signed evidence, or None when the report carries none.

Returning None keeps it out of the serialized claim entirely (the claim is
dumped with ``exclude_none``), so software-only and evidence-less claims are
byte-identical to what they were before this field existed.
"""
if not any(
(report.raw_evidence, report.quote_signature, report.cert_chain, report.ek_cert_chain)
):
return None
return AttestationEvidence(
raw_evidence=report.raw_evidence,
quote_signature=report.quote_signature,
cert_chain=report.cert_chain,
ek_cert_chain=report.ek_cert_chain,
)


def _build_runtime(report: AttestationReportInfo) -> RuntimeInfo:
provider = report.provider
if provider not in _PROVIDER_MAP:
Expand Down Expand Up @@ -422,6 +469,7 @@ def generate_trace_claim(
attestation_stale=attestation_stale,
catalog_exceptions=catalog_exceptions or [],
kill_switch_triggered=kill_switch_triggered,
attestation_evidence=_build_evidence(attestation_report),
call_log_summary=call_log_summary,
agent_identity=(
AgentIdentityOut(
Expand Down
23 changes: 18 additions & 5 deletions src/cmcp_runtime/session/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@

logger = logging.getLogger(__name__)


def _b64(data: object) -> str | None:
"""base64url, unpadded, or None. Evidence fields are absent, not empty.

Anything that is not actual bytes is treated as absent. Providers are free to
leave these unset, and a report object that does not carry them at all (an
older provider, or a test double) must not break claim construction.
"""
if not isinstance(data, (bytes, bytearray)):
return None
return base64.urlsafe_b64encode(bytes(data)).rstrip(b"=").decode()


# Module-level counter so sequence numbers are monotonic across all sessions
# within a single gateway process lifetime.
_CLAIM_SEQUENCE: int = 0
Expand Down Expand Up @@ -206,11 +219,11 @@ def close_session(
attestation_generated_at=generated_at_str,
attestation_validity_seconds=report.attestation_validity_seconds,
measurement_note=report.measurement_note,
raw_evidence=(
base64.urlsafe_b64encode(report.raw_evidence).rstrip(b"=").decode()
if report.raw_evidence is not None
else None
),
raw_evidence=_b64(report.raw_evidence),
# #370: without these the quote is unauthenticated -- the verifier has
# the signature and chain checks but nothing to run them on.
quote_signature=_b64(report.quote_signature),
cert_chain=_b64(report.attestation_key_chain_pem),
)

bundle = ctx.policy_bundle
Expand Down
146 changes: 144 additions & 2 deletions src/cmcp_verify/tpm.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"ParsedSignature",
"TPMVerificationResult",
"parse_tpmt_signature",
"verify_ak_ek_chain",
"verify_quote_signature",
"verify_tpm_measurement",
"verify_tpm_quote_chained",
Expand Down Expand Up @@ -61,8 +62,10 @@ def verify_tpm_measurement(
it here, so a key substituted after attestation is detected.
- PCR digest in quote matches measurement field

EK cert chain validation: always marked as unverified_fields (requires
manufacturer CA lookup - out of scope for Phase 1).
EK cert chain validation: always marked as unverified_fields here, because
this function sees no certificates. :func:`verify_ak_ek_chain` is what
establishes it, and ``verify_trace_claim`` clears this field's unverified
note when that succeeds.
"""
verified_fields: list[str] = []
unverified_fields: list[str] = []
Expand Down Expand Up @@ -295,6 +298,145 @@ def verify_quote_signature(
# ---------------------------------------------------------------------------


# TCG OIDs that say what a certificate is for. An Endorsement Key certificate is
# the manufacturer's statement that a specific key is resident in a specific TPM,
# so it is the hop that turns "signed by a certified key" into "signed by a TPM".
#
# The EKU alone does NOT identify an EK. Azure's real vTPM chain carries
# 2.23.133.8.1 on its *intermediate CAs* -- there it means "this CA may issue EK
# certificates", not "this is an EK". Keying on the OID by itself reports
# ek_cert_chain verified on a chain with no EK in it, which is a false claim of
# hardware-backed verification. A genuine EK certificate is an end-entity
# certificate, so ``ca=False`` in basicConstraints is required as well.
_TCG_EK_CERTIFICATE = "2.23.133.8.1"
_TCG_AIK_CERTIFICATE = "2.23.133.8.3"


def verify_ak_ek_chain(
ak_chain_pem: bytes,
*,
trusted_ca_pem: bytes,
ek_chain_pem: bytes | None = None,
) -> tuple[bool, list[str], str | None]:
"""Verify AK -> EK -> manufacturer CA with the CA pinned by the caller.

Mirrors :func:`cmcp_verify.sev_snp.verify_vcek_chain`: a thin cMCP wrapper
that delegates the actual path building to agent-manifest's shared,
algorithm-agnostic ``verify_cert_chain`` and pins the root by fingerprint.
(Issue #370 named ``_cert_signed_by`` in ``sev_snp.py``; that helper was
replaced by the shared verifier in the agent-manifest 0.5 refactor, so this
reuses its successor rather than reviving a deleted private function.)

Issue #370 asks for "AK->EK and EK->manufacturer-CA". Only the second half
is a certificate path, and the split matters:

* ``ak_chain_pem`` -- AK -> ... -> pinned CA. Establishes
``ak_cert_chain``: the attestation key is certified by a path the
operator's root anchors.
* ``ek_chain_pem`` -- the Endorsement Key certificate and its own path to
the pinned CA, supplied separately. Establishes ``ek_cert_chain``: this
EK is a genuine one the manufacturer vouched for.

**AK->EK is deliberately not attempted here, because it is not a
certificate link.** An EK is a restricted *decryption* key: it cannot sign,
so it cannot issue an AK certificate, and an end-entity EK can never appear
in the AK's issuance path. TPM 2.0 binds an AK to an EK by credential
activation (``TPM2_MakeCredential`` / ``TPM2_ActivateCredential``), a
challenge-response the verifier runs. That is blocker (3) on the issue and
remains an open design decision. Verifying both chains here proves each key
is individually certified; it does not prove they live in the same TPM.

A supplied EK chain must present a real end-entity EK certificate: the TCG
EK EKU **and** ``ca=False``. The EKU alone is not enough -- Azure's vTPM
intermediate CAs carry ``2.23.133.8.1`` to mean "may issue EK
certificates", and accepting that would report ``ek_cert_chain`` verified
for a chain holding no EK at all.

Returns ``(ok, established_links, reason)`` and never raises. A supplied
but invalid EK chain fails the whole call rather than downgrading, so bad
material is never silently ignored.
"""
from agent_manifest import verify_cert_chain
from cryptography import x509

try:
chain = x509.load_pem_x509_certificates(ak_chain_pem)
except Exception as exc: # noqa: BLE001
return False, [], f"AK cert chain is not parseable: {exc}"
if not chain:
return False, [], "AK cert chain contained no certificate"

try:
roots = x509.load_pem_x509_certificates(trusted_ca_pem)
except Exception as exc: # noqa: BLE001
return False, [], f"trusted_tpm_ca_pem is not parseable: {exc}"
if not roots:
return False, [], "trusted_tpm_ca_pem contained no certificate"

try:
verify_cert_chain(chain, roots)
except Exception as exc: # noqa: BLE001 - CertChainError or parse fault -> fail closed
return False, [], str(exc)

established = ["ak_cert_chain"]

if ek_chain_pem is not None:
try:
ek_chain = x509.load_pem_x509_certificates(ek_chain_pem)
except Exception as exc: # noqa: BLE001
return False, [], f"EK cert chain is not parseable: {exc}"
if not ek_chain:
return False, [], "EK cert chain contained no certificate"
if not _is_endorsement_key_cert(ek_chain[0]):
return False, [], (
"EK chain leaf is not an end-entity Endorsement Key certificate "
"(needs TCG EK EKU 2.23.133.8.1 and ca=False)"
)
try:
verify_cert_chain(ek_chain, roots)
except Exception as exc: # noqa: BLE001
return False, [], f"EK chain does not reach the pinned CA: {exc}"
established.append("ek_cert_chain")

return True, established, None


def _is_endorsement_key_cert(cert: Any) -> bool:
"""True only for an end-entity certificate bearing the TCG EK EKU.

Both conditions are load-bearing. The EKU alone matches Azure's issuing CAs,
which advertise it as a permission to issue EK certificates; requiring
``ca=False`` keeps a CA from being counted as an endorsement of a TPM.
"""
return _has_eku(cert, _TCG_EK_CERTIFICATE) and not _is_ca(cert)


def _is_ca(cert: Any) -> bool:
"""True when basicConstraints marks *cert* as a CA. Absent extension: not a CA."""
from cryptography import x509

try:
return bool(cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
except x509.ExtensionNotFound:
return False
except Exception: # noqa: BLE001
# Unreadable constraints: treat as a CA so it cannot pass as an EK.
return True


def _has_eku(cert: Any, oid_dotted: str) -> bool:
"""True when *cert* carries *oid_dotted* in its extended key usage."""
from cryptography import x509

try:
eku = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
except x509.ExtensionNotFound:
return False
except Exception: # noqa: BLE001 - a malformed extension is not an endorsement
return False
return any(usage.dotted_string == oid_dotted for usage in eku)


def verify_tpm_quote_chained(
attest: bytes,
signature_blob: bytes,
Expand Down
Loading
Loading