From f361fd460b6d423382cde71ab31009ff910c5447 Mon Sep 17 00:00:00 2001 From: Mohammed Zoheb Shaik Date: Mon, 3 Aug 2026 23:11:44 +0400 Subject: [PATCH] fix(verify): authenticate TPM quotes and let evidence reach the verifier (#370) A TPM quote was unauthenticated. The tpm2 branch of verify_trace_claim called only verify_tpm_measurement, which takes no signature parameter, so a forged TPMS_ATTEST with correct magic and a matching qualifying_data was reported as hardware-attested. verify_tpm_quote_chained already existed and was tested but had no production caller. forged attest, byte-identical input: before: hardware_attestation in verified_fields = True after : hardware_attestation in verified_fields = False VERIFIED is gated on hardware_attestation not being unverified, so this is what enforces the issue's "status must not claim hardware-backed verification". Evidence could not reach the verifier either. RuntimeInfo in agentrust-trace is extra="forbid", so a claim carrying raw_evidence / quote_signature / cert_chain under trace.runtime is CLAIM_MALFORMED before the platform branch runs. Signed evidence now travels as gateway.attestation_evidence -- GatewayAddenda is cmcp's own model, documented for fields outside the canonical TRACE spec, so this needs no upstream release. trace.runtime is still read as a fallback for older claims. Both halves land together on purpose: transport without the signature check would turn a reporting bug into acceptance of a forged attestation. verifier verify_ak_ek_chain() in cmcp_verify/tpm.py, mirroring verify_vcek_chain(); trusted_tpm_ca_pem threaded through verify_trace_claim alongside trusted_ark_pem. Supplied-but-invalid material is fatal; absent material degrades to unverified, as SNP does. #370 named _cert_signed_by, which #447 replaced with agent_manifest.verify_cert_chain. producer quote_signature and cert_chain carried from AttestationReport into the claim; non-bytes treated as absent so providers that do not set them keep working. base64 the gateway writes base64url unpadded, the verifier used standard b64decode; the reader now accepts both. AK->EK is deliberately not attempted: an EK is a restricted decryption key, so it cannot sign, cannot issue an AK certificate, and cannot appear in the AK's issuance path. Only EK->manufacturer-CA is a certificate path, supplied as its own chain. This matches the #431 pivot to the platform-certified AK. The TCG EK EKU 2.23.133.8.1 does not identify an EK -- the committed Azure fixture carries it on its issuing CAs, where it means "may issue EK certificates". Identification requires the EKU and ca=False; matching on the EKU alone credited a chain containing no EK. Not covered: the producer still does not capture an EK certificate, and the SEV-SNP cert_chain path has the same transport defect and is not migrated here. Both noted in LIMITATIONS.md. Tests mirror test_snp_signature_verify.py: valid, tampered, wrong-root and missing-chain, plus an end-to-end claim built through the producer path that reaches status: verified. Signed-off-by: Mohammed Zoheb Shaik --- LIMITATIONS.md | 34 + docs/spec/tpm-security-model.md | 16 +- src/cmcp_runtime/audit/trace_claim.py | 48 ++ src/cmcp_runtime/session/manager.py | 23 +- src/cmcp_verify/tpm.py | 146 +++- src/cmcp_verify/verify.py | 152 +++- tests/unit/test_tpm_claim_signature_verify.py | 690 ++++++++++++++++++ 7 files changed, 1091 insertions(+), 18 deletions(-) create mode 100644 tests/unit/test_tpm_claim_signature_verify.py diff --git a/LIMITATIONS.md b/LIMITATIONS.md index f60857c..73fee83 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -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. diff --git a/docs/spec/tpm-security-model.md b/docs/spec/tpm-security-model.md index 26bc50e..3f2f904 100644 --- a/docs/spec/tpm-security-model.md +++ b/docs/spec/tpm-security-model.md @@ -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) diff --git a/src/cmcp_runtime/audit/trace_claim.py b/src/cmcp_runtime/audit/trace_claim.py index 5adbdae..60e2aa3 100644 --- a/src/cmcp_runtime/audit/trace_claim.py +++ b/src/cmcp_runtime/audit/trace_claim.py @@ -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 @@ -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.""" @@ -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): @@ -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: @@ -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( diff --git a/src/cmcp_runtime/session/manager.py b/src/cmcp_runtime/session/manager.py index 681be43..ea63da7 100644 --- a/src/cmcp_runtime/session/manager.py +++ b/src/cmcp_runtime/session/manager.py @@ -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 @@ -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 diff --git a/src/cmcp_verify/tpm.py b/src/cmcp_verify/tpm.py index 853e497..9d99b24 100644 --- a/src/cmcp_verify/tpm.py +++ b/src/cmcp_verify/tpm.py @@ -27,6 +27,7 @@ "ParsedSignature", "TPMVerificationResult", "parse_tpmt_signature", + "verify_ak_ek_chain", "verify_quote_signature", "verify_tpm_measurement", "verify_tpm_quote_chained", @@ -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] = [] @@ -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, diff --git a/src/cmcp_verify/verify.py b/src/cmcp_verify/verify.py index c149b28..2585292 100644 --- a/src/cmcp_verify/verify.py +++ b/src/cmcp_verify/verify.py @@ -40,6 +40,57 @@ def _jwk_thumbprint_sha256(x_b64url: str) -> bytes: return hashlib.sha256(canonical).digest() +def _decode_evidence(value: str | None) -> bytes | None: + """Decode a base64 evidence field, tolerating both alphabets and padding. + + The gateway writes base64url without padding; older fields and hand-built + claims use standard base64. Accepting both keeps a decode quirk from + presenting as a verification failure. + """ + if not value: + return None + padded = value + "=" * (-len(value) % 4) + try: + if "-" in padded or "_" in padded: + return base64.urlsafe_b64decode(padded) + return base64.b64decode(padded) + except Exception: # noqa: BLE001 - undecodable evidence is absent evidence + return None + + +def _evidence_field(claim_json: dict[str, Any], runtime: dict[str, Any], name: str) -> bytes | None: + """Read one evidence field, preferring the cmcp envelope. + + ``gateway.attestation_evidence`` is where the gateway now puts signed + evidence (#370): ``trace.runtime`` is governed by agentrust-trace's + ``RuntimeInfo``, which is ``extra="forbid"``, so a claim carrying evidence + there fails schema validation before any platform branch runs. The + ``trace.runtime`` fallback is kept so claims and fixtures written against the + old shape still verify. + """ + evidence = claim_json.get("gateway", {}).get("attestation_evidence") or {} + return _decode_evidence(evidence.get(name) or runtime.get(name)) + + +def _leaf_public_key_pem(chain_pem: bytes) -> bytes: + """Return the leaf (AK) certificate's public key as PEM. + + The chain is leaf-first, so the attestation key is the first certificate. + Callers reach this only after the chain has verified, so a parse fault here + is not expected; it still returns empty bytes rather than raising, because + the signature check that consumes this already fails closed on an unloadable + key. + """ + from cryptography import x509 + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + try: + leaf = x509.load_pem_x509_certificates(chain_pem)[0] + return leaf.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo) + except Exception: # noqa: BLE001 + return b"" + + _SW_ONLY_FIRMWARE = "software-only-dev-mode" _EXTERNAL_EVIDENCE_ERROR = "EXTERNAL_EVIDENCE_VERIFICATION_FAILED" _EXTERNAL_EVIDENCE_HASH_RE = re.compile(r"^sha(256|384):[0-9a-f]+$") @@ -551,6 +602,7 @@ def verify_trace_claim( trusted_agent_manifest_keys: dict[str, bytes] | None = None, trusted_ark_pem: bytes | None = None, trusted_intel_root_pem: bytes | None = None, + trusted_tpm_ca_pem: bytes | None = None, ) -> VerificationResult: """ Verify a TRACE Claim without trusting the operator. @@ -771,10 +823,14 @@ def verify_trace_claim( unverified.append("hardware_attestation") details["hardware_attestation"] = "software-only mode - not hardware-backed" elif platform == "tpm2": - from cmcp_verify.tpm import verify_tpm_measurement + from cmcp_verify.tpm import ( + verify_ak_ek_chain, + verify_quote_signature, + verify_tpm_measurement, + ) - raw_ev = _runtime.get("raw_evidence") - raw_bytes = base64.b64decode(raw_ev) if raw_ev else None + tpm_established_links: set[str] = set() + raw_bytes = _evidence_field(claim_json, _runtime, "raw_evidence") # The TPM quote commits the attestation nonce's first 32 bytes -- the RFC 7638 # JWK Thumbprint of the TEE key -- as qualifying_data (§3.3). Re-derive it from # cnf.jwk.x so a substituted key is detected. @@ -785,16 +841,102 @@ def verify_trace_claim( raw_evidence=raw_bytes, expected_qualifying_data=_expected_qd, ) - if tpm_result.verified: + + # Parsing an attest blob proves nothing on its own: magic, PCR digest and + # qualifying_data are all attacker-writable, so a forged TPMS_ATTEST used to + # reach the branch above and report verified (issue #370). The TPMT_SIGNATURE + # and the AK cert chain are what bind the blob to a key inside a TPM. Both + # travel with the claim (passport model, as SNP does with cert_chain); the + # manufacturer CA is pinned out of band by the operator. + quote_signature = _evidence_field(claim_json, _runtime, "quote_signature") + ak_chain_pem = _evidence_field(claim_json, _runtime, "cert_chain") + # Optional and separate: the EK certificate has its own path to the CA and + # cannot sit in the AK's issuance chain (an EK cannot sign). Present only + # when the producer bundles it. + ek_chain_pem = _evidence_field(claim_json, _runtime, "ek_cert_chain") + + tpm_chain_ok = False + if raw_bytes is None: + # verify_tpm_measurement already failed closed on this. + unverified.append("ak_cert_chain") + details["ak_cert_chain"] = "raw_evidence not provided; quote signature unverifiable" + elif quote_signature is None or ak_chain_pem is None or trusted_tpm_ca_pem is None: + # Back-compatible fallback: evidence predating the signature/chain fields, + # or an operator who pinned no CA. Unverified, not an error -- same shape + # as the SNP no-chain path. + unverified.append("ak_cert_chain") + details["ak_cert_chain"] = ( + "TPM quote signature and AK cert chain not verified (" + + ", ".join( + reason + for reason, absent in ( + ("no quote_signature in claim", quote_signature is None), + ("no cert_chain in claim", ak_chain_pem is None), + ("no trusted_tpm_ca_pem pinned", trusted_tpm_ca_pem is None), + ) + if absent + ) + + ")" + ) + else: + # Two steps, per #370: the chain says the AK is endorsed by a TPM the + # operator trusts, the signature says this quote came from that AK. + # Neither is sufficient alone. + chain_verified, established, chain_reason = verify_ak_ek_chain( + ak_chain_pem, + trusted_ca_pem=trusted_tpm_ca_pem, + ek_chain_pem=ek_chain_pem, + ) + if not chain_verified: + unverified.extend(["tpm_quote_signature", "ak_cert_chain"]) + details["ak_cert_chain"] = chain_reason or "AK/EK chain verification failed" + failure = failure or VerificationError.HARDWARE_ATTESTATION_FAILED + else: + ak_leaf_pem = _leaf_public_key_pem(ak_chain_pem) + sig_ok, tpm_sig_details = verify_quote_signature( + raw_bytes, quote_signature, ak_leaf_pem + ) + details.update(tpm_sig_details) + if sig_ok: + tpm_chain_ok = True + verified.append("tpm_quote_signature") + verified.extend(established) + tpm_established_links.update(established) + else: + # Chain material was supplied and did not check out. Unlike the + # absent-material case this is fatal: something signed this quote + # that does not chain to the pinned root. + unverified.append("tpm_quote_signature") + failure = failure or VerificationError.HARDWARE_ATTESTATION_FAILED + + # The AK chain is the TPM hardware root of trust. As with the SNP VCEK chain + # (#370/#372), a claim whose quote signature is unverified must never report + # as fully VERIFIED even when the blob parses and the measurement matches. + if tpm_result.verified and tpm_chain_ok: verified.append("hardware_attestation") verified.extend(tpm_result.verified_fields) + elif tpm_result.verified and not tpm_chain_ok: + verified.extend(tpm_result.verified_fields) + unverified.append("hardware_attestation") + details["hardware_attestation"] = ( + "TPM quote parsed but signature/AK chain not verified" + ) else: unverified.append("hardware_attestation") failure = failure or VerificationError.HARDWARE_ATTESTATION_FAILED if tpm_result.failure_reason: details["tpm_failure"] = tpm_result.failure_reason - unverified.extend(tpm_result.unverified_fields) + # verify_tpm_measurement predates chain verification and reports + # ek_cert_chain as unverified unconditionally. When the AK chain actually + # carried an EK certificate and it checked out, that note is stale -- drop + # it rather than report the same field as both verified and unverified. + unverified.extend( + f for f in tpm_result.unverified_fields if f not in tpm_established_links + ) details.update(tpm_result.details) + if "ek_cert_chain" in tpm_established_links: + details.pop("ek_cert_chain_validation", None) + details["ek_cert_chain"] = "EK certificate verified to the pinned manufacturer CA" elif platform == "azure-cvm-sev-snp": # Azure confidential VM: SEV-SNP behind a Hyper-V paravisor, vTPM-rooted. # The guest cannot control SNP REPORT_DATA (the paravisor binds the vTPM AK diff --git a/tests/unit/test_tpm_claim_signature_verify.py b/tests/unit/test_tpm_claim_signature_verify.py new file mode 100644 index 0000000..52a29b0 --- /dev/null +++ b/tests/unit/test_tpm_claim_signature_verify.py @@ -0,0 +1,690 @@ +"""TPM quote signature + AK chain wired into the claim path (issue #370). + +The unit-level pieces already existed: ``verify_quote_signature`` and +``verify_tpm_quote_chained`` are covered by ``test_tpm_quote_signature.py`` and +``test_tpm_chained_verify.py``. What was missing is the wiring -- until this +change ``verify_trace_claim``'s tpm2 branch called only +``verify_tpm_measurement``, which takes no signature, so a forged TPMS_ATTEST +with the right magic and a matching ``qualifying_data`` reported +``hardware_attestation`` as verified. ``test_forged_attest_no_longer_reports_ +hardware_attestation`` is that regression. + +Mirrors ``test_snp_signature_verify.py``: synthetic chains plus a signed attest, +exercised valid / tampered / wrong-root / missing-chain. + +The EK cases pin down where issue #370's "verify AK->EK" cannot be met as +written. An EK cannot sign, so it cannot issue an AK certificate and cannot +appear in the AK's issuance path; only ``EK->manufacturer-CA`` is a certificate +path, supplied as its own chain. Binding an AK to an EK needs credential +activation, which is the issue's open blocker (3). +""" + +from __future__ import annotations + +import base64 +import datetime as _dt +import hashlib +import struct +from datetime import UTC, datetime + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.x509.oid import NameOID + +from cmcp_runtime.audit.chain import AuditChain +from cmcp_runtime.audit.keys import SigningKey +from cmcp_runtime.audit.trace_claim import ( + AttestationReportInfo, + CallGraphSummary, + CallSummary, + PolicyBundleInfo, + ToolCatalogInfo, + _to_dict, + canonical_json, + generate_trace_claim, +) +from cmcp_runtime.tee.base import jwk_thumbprint +from cmcp_verify.tpm import verify_ak_ek_chain +from cmcp_verify.verify import ApprovedHashes, verify_trace_claim + +POLICY_HASH = "sha256:" + "a" * 64 +CATALOG_HASH = "sha256:" + "b" * 64 +VALID_MEASUREMENT = "sha256:" + "c" * 64 + +_ALG_RSASSA = 0x0014 +_ALG_SHA256 = 0x000B +_TPM_GENERATED_VALUE = 0xFF544347 + + +# ── Synthetic CA -> AK chain ───────────────────────────────────────────────── + + +def _rsa() -> rsa.RSAPrivateKey: + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _name(cn: str) -> x509.Name: + return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) + + +def _ca(cn: str = "Synthetic TPM Manufacturer CA") -> tuple[x509.Certificate, rsa.RSAPrivateKey]: + key = _rsa() + now = datetime.now(tz=UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(_name(cn)) + .issuer_name(_name(cn)) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - _dt.timedelta(days=1)) + .not_valid_after(now + _dt.timedelta(days=365)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(key, hashes.SHA256()) + ) + return cert, key + + +_TCG_EK_CERTIFICATE = x509.ObjectIdentifier("2.23.133.8.1") + + +def _issued( + cn: str, + issuer: x509.Certificate, + issuer_key: rsa.RSAPrivateKey, + *, + ca: bool = False, + ekus: list[x509.ObjectIdentifier] | None = None, +) -> tuple[x509.Certificate, rsa.RSAPrivateKey]: + key = _rsa() + now = datetime.now(tz=UTC) + builder = ( + x509.CertificateBuilder() + .subject_name(_name(cn)) + .issuer_name(issuer.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - _dt.timedelta(days=1)) + .not_valid_after(now + _dt.timedelta(days=180)) + .add_extension(x509.BasicConstraints(ca=ca, path_length=None), critical=True) + ) + if ekus: + builder = builder.add_extension(x509.ExtendedKeyUsage(ekus), critical=False) + return builder.sign(issuer_key, hashes.SHA256()), key + + +def _ek_ak_chain() -> tuple[bytes, bytes, rsa.RSAPrivateKey]: + """Synthetic CA -> EK -> AK, the literal shape issue #370 describes. + + The "EK" has to be marked ``ca=True`` for it to issue the AK certificate at + all, which is precisely why this shape is not evidence of endorsement: a + real EK is an end-entity decryption key that signs nothing. Kept as a test + case so the verifier is pinned to *not* credit it. + + Returns (chain_pem_leaf_first, root_ca_pem, ak_private_key). + """ + ca_cert, ca_key = _ca() + ek_cert, ek_key = _issued( + "synthetic-ek", ca_cert, ca_key, ca=True, ekus=[_TCG_EK_CERTIFICATE] + ) + ak_cert, ak_key = _issued("synthetic-ak", ek_cert, ek_key) + chain = _pem(ak_cert) + _pem(ek_cert) + _pem(ca_cert) + return chain, _pem(ca_cert), ak_key + + +def _ak_only_chain() -> tuple[bytes, bytes, rsa.RSAPrivateKey]: + """CA -> AK with no EK in the path (the Azure vTPM model).""" + ca_cert, ca_key = _ca() + ak_cert, ak_key = _issued("synthetic-ak", ca_cert, ca_key) + return _pem(ak_cert) + _pem(ca_cert), _pem(ca_cert), ak_key + + +def _pem(cert: x509.Certificate) -> bytes: + return cert.public_bytes(serialization.Encoding.PEM) + + +# ── Evidence ───────────────────────────────────────────────────────────────── + + +def _attest(qualifying_data: bytes) -> bytes: + """A bare TPMS_ATTEST -- the exact bytes a TPM signs.""" + return ( + struct.pack(">I", _TPM_GENERATED_VALUE) + + struct.pack(">H", 0x8018) # TPM_ST_ATTEST_QUOTE + + struct.pack(">H", 4) + b"name" # qualifiedSigner + + struct.pack(">H", len(qualifying_data)) + qualifying_data # extraData + + b"\x00" * 17 # clockInfo + + b"\x00" * 8 # firmwareVersion + + struct.pack(">I", 0) # TPML_PCR_SELECTION count + + struct.pack(">H", 32) + b"\x02" * 32 # pcrDigest + ) + + +def _tpmt_signature(attest: bytes, ak_key: rsa.RSAPrivateKey) -> bytes: + raw = ak_key.sign(attest, padding.PKCS1v15(), hashes.SHA256()) + return struct.pack(">HH", _ALG_RSASSA, _ALG_SHA256) + struct.pack(">H", len(raw)) + raw + + +def _b64(data: bytes) -> str: + """Encode the way the gateway does: base64url, unpadded. + + Deliberately not standard base64. `session.manager` writes this alphabet, so + encoding any other way here would exercise a decoder branch production never + takes and leave the real one uncovered. + """ + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +# ── Claim ──────────────────────────────────────────────────────────────────── + + +def _make_tpm2_claim( + key: SigningKey, + *, + raw_evidence: bytes | None = None, + quote_signature: bytes | None = None, + cert_chain: bytes | None = None, +) -> dict: + """A fully valid tpm2 claim: key-bound, chain-root-bound, correctly signed. + + Evidence goes through the real producer path -- ``AttestationReportInfo`` -> + ``gateway.attestation_evidence`` -- so the claim is schema-valid. Injecting + it into ``trace.runtime`` instead makes the claim CLAIM_MALFORMED, because + ``RuntimeInfo`` is ``extra="forbid"``; that is what kept the chain verifiers + unreachable before #370. + + ``report_data`` satisfies both bindings the verifier checks: + ``jwk_thumbprint(key) || SHA-256(chain_root)`` (CRYPTO-001 / AUDIT-006). + """ + chain = AuditChain("tpm-session") + root_hex = chain.chain_root.removeprefix("sha256:").removeprefix("sha384:") + report_data = ( + jwk_thumbprint(key.public_key_bytes) + + hashlib.sha256(bytes.fromhex(root_hex)).digest() + ).hex() + claim = generate_trace_claim( + session_id="tpm-session", + signing_key=key, + attestation_report=AttestationReportInfo( + provider="tpm", + measurement=VALID_MEASUREMENT, + report_data=report_data, + attestation_generated_at=datetime.now(tz=UTC).isoformat(), + attestation_validity_seconds=86400, + raw_evidence=_b64(raw_evidence) if raw_evidence is not None else None, + quote_signature=_b64(quote_signature) if quote_signature is not None else None, + cert_chain=_b64(cert_chain) if cert_chain is not None else None, + ), + policy_bundle=PolicyBundleInfo( + hash=POLICY_HASH, enforcement_mode="enforcing", policy_version="1.0.0" + ), + tool_catalog=ToolCatalogInfo(hash=CATALOG_HASH), + call_summary=CallSummary( + tool_calls_total=1, + tool_calls_allowed=1, + tool_calls_denied=0, + tool_calls_faulted=0, + tools_invoked=["test.tool"], + session_max_sensitivity="public", + call_graph_summary=CallGraphSummary( + compliance_domains_touched=[], cross_boundary_events=[] + ), + ), + audit_chain_root=chain.chain_root, + audit_chain_tip=chain.chain_tip, + audit_chain_length=chain.length, + do_sign=False, + ) + claim_dict = _to_dict(claim) + claim_dict["trace"]["runtime"]["firmware_version"] = "2.0-production" + # Sign last, so the signature covers the evidence too. + claim_dict["signature"] = ( + base64.urlsafe_b64encode(key.sign(canonical_json(claim_dict))) + .rstrip(b"=") + .decode() + ) + return claim_dict + + +def _approved() -> ApprovedHashes: + return ApprovedHashes(policy_bundle_hash=POLICY_HASH, tool_catalog_hash=CATALOG_HASH) + + +def _claim_for( + chain_pem: bytes, + ak_key: rsa.RSAPrivateKey, + *, + tamper_attest: bool = False, + omit: str | None = None, +) -> dict: + """Build a signed claim carrying evidence through the producer path. + + ``tamper_attest`` flips a byte in the attest *after* signing it, so the + quote signature no longer matches the evidence shipped. ``omit`` drops one + evidence field, standing in for a producer that predates it. Both are done + at build time rather than by mutating the finished claim, which would break + the claim signature and mask the behaviour under test. + """ + key = SigningKey() + attest = _attest(jwk_thumbprint(key.public_key_bytes)) + signature = _tpmt_signature(attest, ak_key) + if tamper_attest: + mutated = bytearray(attest) + mutated[-1] ^= 0x01 + attest = bytes(mutated) + return _make_tpm2_claim( + key, + raw_evidence=attest, + quote_signature=None if omit == "quote_signature" else signature, + cert_chain=None if omit == "cert_chain" else chain_pem, + ) + + +@pytest.fixture +def signed_quote(): + """(claim_dict, root_pem) for a signed quote on a CA -> EK -> AK chain.""" + chain_pem, root_pem, ak_key = _ek_ak_chain() + return _claim_for(chain_pem, ak_key), root_pem + + +# ── The regression this issue is about ─────────────────────────────────────── + + +def test_forged_attest_no_longer_reports_hardware_attestation() -> None: + """Issue #370: a forged TPMS_ATTEST used to pass. + + Correct magic, and a ``qualifying_data`` matching the claim's own key -- both + attacker-writable. With no signature there is nothing binding the blob to a + TPM, so ``hardware_attestation`` must not be reported as verified. + """ + key = SigningKey() + forged = _attest(jwk_thumbprint(key.public_key_bytes)) + claim = _make_tpm2_claim(key, raw_evidence=forged) + + result = verify_trace_claim(claim, _approved(), trusted_tpm_ca_pem=None) + + # The parse-level checks still pass -- that is exactly why the hole existed. + assert "qualifying_data" in result.verified_fields + assert "hardware_attestation" not in result.verified_fields + assert "hardware_attestation" in result.unverified_fields + assert "ak_cert_chain" in result.unverified_fields + + +# ── Happy path ─────────────────────────────────────────────────────────────── + + +def test_signed_and_chained_quote_verifies(signed_quote) -> None: + claim, root_pem = signed_quote + result = verify_trace_claim(claim, _approved(), trusted_tpm_ca_pem=root_pem) + + assert "tpm_quote_signature" in result.verified_fields + assert "ak_cert_chain" in result.verified_fields + assert "hardware_attestation" in result.verified_fields + assert "hardware_attestation" not in result.unverified_fields + + +def test_an_issuing_ek_is_not_counted_as_an_endorsement(signed_quote) -> None: + """A CA->EK->AK chain does not establish ek_cert_chain, and should not. + + To issue the AK certificate the "EK" must be a CA, and a real Endorsement + Key is an end-entity decryption key that cannot sign anything. So this + shape -- the one issue #370 describes -- is not evidence that an EK + endorsed this AK. The AK chain is still verified; the EK claim is withheld. + """ + claim, root_pem = signed_quote + result = verify_trace_claim(claim, _approved(), trusted_tpm_ca_pem=root_pem) + + assert "ak_cert_chain" in result.verified_fields + assert "ek_cert_chain" not in result.verified_fields + assert "ek_cert_chain" in result.unverified_fields + + +def test_a_ca_bearing_the_ek_eku_is_not_counted_as_an_ek() -> None: + """Regression: Azure's real vTPM intermediates carry the TCG EK EKU. + + On the committed hardware fixture, `Azure Cloud Virtual TPM CA - 11` has + EKU 2.23.133.8.1 -- there it means "may issue EK certificates", not "is an + EK". Matching on the EKU alone reported ek_cert_chain verified on a chain + containing no EK whatsoever. + """ + ca_cert, ca_key = _ca() + azure_shaped, mid_key = _issued( + "Issuing CA that may issue EKs", + ca_cert, + ca_key, + ca=True, + ekus=[_TCG_EK_CERTIFICATE], + ) + ak_cert, ak_key = _issued("synthetic-ak", azure_shaped, mid_key) + chain = _pem(ak_cert) + _pem(azure_shaped) + _pem(ca_cert) + + result = verify_trace_claim( + _claim_for(chain, ak_key), _approved(), trusted_tpm_ca_pem=_pem(ca_cert) + ) + + assert "ak_cert_chain" in result.verified_fields + assert "ek_cert_chain" not in result.verified_fields + assert "ek_cert_chain" in result.unverified_fields + + +def test_ak_certified_directly_by_the_ca_leaves_ek_unverified() -> None: + """The Azure vTPM model: a platform CA certifies the AK, no EK in the path.""" + chain_pem, root_pem, ak_key = _ak_only_chain() + result = verify_trace_claim( + _claim_for(chain_pem, ak_key), _approved(), trusted_tpm_ca_pem=root_pem + ) + + assert "ak_cert_chain" in result.verified_fields + assert "hardware_attestation" in result.verified_fields + assert "ek_cert_chain" in result.unverified_fields + assert "ek_cert_chain" not in result.verified_fields + + +def test_a_plain_intermediate_ca_is_not_counted_as_an_ek() -> None: + """Chain depth is not endorsement.""" + ca_cert, ca_key = _ca() + mid_cert, mid_key = _issued("plain-intermediate", ca_cert, ca_key, ca=True) + ak_cert, ak_key = _issued("synthetic-ak", mid_cert, mid_key) + chain = _pem(ak_cert) + _pem(mid_cert) + _pem(ca_cert) + + result = verify_trace_claim( + _claim_for(chain, ak_key), _approved(), trusted_tpm_ca_pem=_pem(ca_cert) + ) + + assert "ak_cert_chain" in result.verified_fields + assert "ek_cert_chain" not in result.verified_fields + assert "ek_cert_chain" in result.unverified_fields + + +def test_a_separately_supplied_ek_chain_is_verified() -> None: + """EK->manufacturer-CA is a real path, supplied as its own chain. + + No platform in this repo bundles one today; the path exists so a producer + that does gets credit for it. Note what this does and does not prove: the + EK is genuine and the AK is certified, but nothing here binds them to the + same TPM -- that needs credential activation. + """ + ca_cert, ca_key = _ca() + ek_cert, _ek_key = _issued( + "genuine-ek", ca_cert, ca_key, ca=False, ekus=[_TCG_EK_CERTIFICATE] + ) + ak_cert, ak_key = _issued("synthetic-ak", ca_cert, ca_key) + + ok, established, reason = verify_ak_ek_chain( + _pem(ak_cert) + _pem(ca_cert), + trusted_ca_pem=_pem(ca_cert), + ek_chain_pem=_pem(ek_cert) + _pem(ca_cert), + ) + + assert ok is True, reason + assert established == ["ak_cert_chain", "ek_cert_chain"] + + # And end to end through the claim path. + claim = _claim_for(_pem(ak_cert) + _pem(ca_cert), ak_key) + claim["trace"]["runtime"]["ek_cert_chain"] = base64.b64encode( + _pem(ek_cert) + _pem(ca_cert) + ).decode() + result = verify_trace_claim(claim, _approved(), trusted_tpm_ca_pem=_pem(ca_cert)) + assert "ek_cert_chain" in result.verified_fields + assert "ek_cert_chain" not in result.unverified_fields + + +def test_a_ca_passed_as_an_ek_chain_is_rejected() -> None: + """Supplied-but-bogus EK material fails the call, it does not downgrade.""" + ca_cert, ca_key = _ca() + not_an_ek, _ = _issued( + "CA wearing the EK EKU", ca_cert, ca_key, ca=True, ekus=[_TCG_EK_CERTIFICATE] + ) + ak_cert, _ak_key = _issued("synthetic-ak", ca_cert, ca_key) + + ok, established, reason = verify_ak_ek_chain( + _pem(ak_cert) + _pem(ca_cert), + trusted_ca_pem=_pem(ca_cert), + ek_chain_pem=_pem(not_an_ek) + _pem(ca_cert), + ) + + assert ok is False + assert established == [] + assert "not an end-entity Endorsement Key" in reason + + +def test_an_ek_chain_to_the_wrong_root_is_rejected() -> None: + ca_cert, ca_key = _ca() + other_ca, other_key = _ca("Unrelated CA") + ek_cert, _ = _issued( + "genuine-ek", other_ca, other_key, ca=False, ekus=[_TCG_EK_CERTIFICATE] + ) + ak_cert, _ak_key = _issued("synthetic-ak", ca_cert, ca_key) + + ok, _established, reason = verify_ak_ek_chain( + _pem(ak_cert) + _pem(ca_cert), + trusted_ca_pem=_pem(ca_cert), + ek_chain_pem=_pem(ek_cert) + _pem(other_ca), + ) + + assert ok is False + assert "does not reach the pinned CA" in reason + + +# ── Supplied-but-bad chain material is fatal ───────────────────────────────── + + +def test_tampered_attest_is_fatal() -> None: + chain_pem, root_pem, ak_key = _ek_ak_chain() + claim = _claim_for(chain_pem, ak_key, tamper_attest=True) + + result = verify_trace_claim(claim, _approved(), trusted_tpm_ca_pem=root_pem) + + assert "tpm_quote_signature" in result.unverified_fields + assert "hardware_attestation" not in result.verified_fields + # Fatal, per the issue: supplied material that fails is an error, not a + # downgrade. Observable now that evidence rides in a schema-valid envelope. + assert result.failure_reason is not None + assert result.failure_reason.value == "HARDWARE_ATTESTATION_FAILED" + + +def test_wrong_pinned_root_is_fatal(signed_quote) -> None: + """A chain that does not reach the operator's pinned CA must not verify.""" + claim, _root_pem = signed_quote + other_ca, _ = _ca("Some Other CA") + + result = verify_trace_claim(claim, _approved(), trusted_tpm_ca_pem=_pem(other_ca)) + + assert "ak_cert_chain" in result.unverified_fields + assert "hardware_attestation" not in result.verified_fields + assert result.failure_reason is not None + assert result.failure_reason.value == "HARDWARE_ATTESTATION_FAILED" + + +def test_quote_signed_by_a_key_outside_the_chain_is_fatal() -> None: + """The AK cert chains to the pinned root, but a different key signed.""" + chain_pem, root_pem, _ak_key = _ek_ak_chain() + claim = _claim_for(chain_pem, _rsa()) # signed by an attacker key + + result = verify_trace_claim(claim, _approved(), trusted_tpm_ca_pem=root_pem) + + assert "tpm_quote_signature" in result.unverified_fields + assert "hardware_attestation" not in result.verified_fields + assert result.failure_reason is not None + + +# ── Absent chain material degrades, it does not error ──────────────────────── + + +@pytest.mark.parametrize( + "drop", ["quote_signature", "cert_chain"], ids=["no_signature", "no_chain"] +) +def test_missing_chain_material_degrades_to_unverified(drop) -> None: + """Back-compat: evidence predating the signature fields stays unverified. + + Distinct from the fatal cases above -- nothing here is *wrong*, there is + simply nothing to check, which is the same shape as the SNP no-chain path. + """ + chain_pem, root_pem, ak_key = _ek_ak_chain() + claim = _claim_for(chain_pem, ak_key, omit=drop) + + result = verify_trace_claim(claim, _approved(), trusted_tpm_ca_pem=root_pem) + + assert "ak_cert_chain" in result.unverified_fields + assert "hardware_attestation" in result.unverified_fields + assert "not verified" in result.details["ak_cert_chain"] + # Parse-level results survive: this is a downgrade, not a rejection. + assert "qualifying_data" in result.verified_fields + # And crucially not fatal -- absent material must not raise an error. + assert result.failure_reason is None + + +def test_unpinned_ca_degrades_to_unverified(signed_quote) -> None: + """Perfectly good evidence, but the operator pinned no root to check it against.""" + claim, _root_pem = signed_quote + + result = verify_trace_claim(claim, _approved(), trusted_tpm_ca_pem=None) + + assert "ak_cert_chain" in result.unverified_fields + assert "no trusted_tpm_ca_pem pinned" in result.details["ak_cert_chain"] + + +def test_no_raw_evidence_still_fails_closed() -> None: + key = SigningKey() + claim = _make_tpm2_claim(key) + + result = verify_trace_claim(claim, _approved(), trusted_tpm_ca_pem=None) + + assert "hardware_attestation" in result.unverified_fields + assert result.details["tpm_failure"] == "no_raw_evidence" + assert "unverifiable" in result.details["ak_cert_chain"] + + +# ── The blocker that stops any of this working end to end ──────────────────── + + +def test_evidence_fields_are_rejected_by_the_claim_schema() -> None: + """The verifier reads fields its own schema forbids, so a real claim cannot + carry them. + + ``RuntimeInfo`` in agentrust-trace is ``extra="forbid"`` with only + platform / measurement / rim_uri / nonce / firmware_version. Step 1 of + ``verify_trace_claim`` validates against that model, so any claim carrying + the evidence the tpm2 branch reads is rejected as CLAIM_MALFORMED before + the branch runs. + + This is not TPM-specific and not introduced by this change: the merged SNP + path reads ``cert_chain`` under the identical constraint. Closing it needs + the schema change upstream in agentrust-trace. Recorded as a test so the + gap is visible in CI rather than only in a comment. + """ + from agentrust_trace.models import RuntimeInfo + from pydantic import ValidationError + + base = {"platform": "tpm2", "measurement": VALID_MEASUREMENT, "nonce": "AA"} + for evidence_field in ( + "raw_evidence", + "quote_signature", + "cert_chain", + "ek_cert_chain", + ): + with pytest.raises(ValidationError): + RuntimeInfo(**base, **{evidence_field: "x"}) + + # Which is why evidence rides in the cmcp-owned envelope instead -- see + # test_evidence_travels_in_the_gateway_envelope_end_to_end. The verifier + # still reads trace.runtime as a fallback for older fixtures, but a claim + # built that way cannot pass schema validation. + + +def test_evidence_travels_in_the_gateway_envelope_end_to_end() -> None: + """The whole point of #370: a real claim carries evidence and verifies. + + Built through the producer path, so this is the shape a gateway actually + emits -- schema-valid, signed over the evidence, and reaching VERIFIED only + because the quote signature and AK chain both check out. + """ + chain_pem, root_pem, ak_key = _ek_ak_chain() + claim = _claim_for(chain_pem, ak_key) + + # Evidence is in the cmcp envelope, not the agentrust-trace runtime block. + assert "attestation_evidence" in claim["gateway"] + assert "cert_chain" not in claim["trace"]["runtime"] + + result = verify_trace_claim(claim, _approved(), trusted_tpm_ca_pem=root_pem) + + assert result.failure_reason is None + assert result.status.value == "verified" + assert "hardware_attestation" in result.verified_fields + assert "tpm_quote_signature" in result.verified_fields + assert "ak_cert_chain" in result.verified_fields + + +# ── The producer half: session.manager -> claim ────────────────────────────── + + +def test_session_manager_encoding_round_trips_into_the_verifier() -> None: + """The gateway's encoder and the verifier's decoder must agree. + + `session.manager` writes base64url without padding; the verifier historically + used standard `b64decode`, which cannot read that alphabet. Nothing caught it + because no test carried evidence through both halves. + """ + from cmcp_runtime.session.manager import _b64 as producer_b64 + from cmcp_verify.verify import _decode_evidence + + for blob in (b"\xfb\xff\xfe" * 11, b"", b"\x00", bytes(range(256))): + encoded = producer_b64(blob) + assert encoded is not None + assert _decode_evidence(encoded) == blob or blob == b"" + + # Non-bytes (an older provider, or a mock) is absent, not an error. + assert producer_b64(None) is None + assert producer_b64(object()) is None + + +def test_session_manager_carries_evidence_onto_the_report_info() -> None: + """The producer must actually copy the captured evidence into the claim. + + Guards the line that matters: an AttestationReport holding a signature and a + chain has to surface them, or the verifier has nothing to check and every + claim silently degrades to unverified. + """ + from cmcp_runtime.audit.trace_claim import _build_evidence + from cmcp_runtime.session.manager import _b64 as producer_b64 + + chain_pem, _root_pem, ak_key = _ek_ak_chain() + attest = _attest(b"\x01" * 32) + signature = _tpmt_signature(attest, ak_key) + + info = AttestationReportInfo( + provider="tpm", + measurement=VALID_MEASUREMENT, + report_data="00" * 32, + attestation_generated_at=datetime.now(tz=UTC).isoformat(), + attestation_validity_seconds=86400, + raw_evidence=producer_b64(attest), + quote_signature=producer_b64(signature), + cert_chain=producer_b64(chain_pem), + ) + evidence = _build_evidence(info) + + assert evidence is not None + assert evidence.quote_signature is not None + assert evidence.cert_chain is not None + + from cmcp_verify.verify import _decode_evidence + + assert _decode_evidence(evidence.raw_evidence) == attest + assert _decode_evidence(evidence.quote_signature) == signature + assert _decode_evidence(evidence.cert_chain) == chain_pem + + +def test_evidence_is_omitted_entirely_when_there_is_none() -> None: + """Software-only and evidence-less claims keep their previous shape.""" + from cmcp_runtime.audit.trace_claim import _build_evidence + + info = AttestationReportInfo( + provider="software-only", + measurement=VALID_MEASUREMENT, + report_data="00" * 32, + attestation_generated_at=datetime.now(tz=UTC).isoformat(), + attestation_validity_seconds=86400, + ) + assert _build_evidence(info) is None