From 96464395133ef833685e01fd085e90e33f9d2fa1 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Fri, 26 Jun 2026 14:01:26 -0700 Subject: [PATCH] feat(experiments): hardware TEE attestation experiment runner Adds experiments/claim-hw-attestation: the one experiment that exercises the real hardware path (genuine attestation report, nonce binding, raw-evidence verification, end-to-end TRACE Claim verification) instead of software-only mode. Safe everywhere: SKIPs with exit 0 when no TEE is detected, so CI and dev hosts pass. Produces results only on a confidential VM (SEV-SNP / TDX / TPM). README documents the Azure/GCP deploy -> config -> run -> verify sequence and the cert-chain appraisal (AMD KDS / Intel DCAP / TPM EK) that remains as the last hardware-dependent step. Uses the gateway's real nonce construction (JWK thumbprint || salt) so the verifier's key-binding check passes on hardware. --- experiments/README.md | 6 +- experiments/claim-hw-attestation/README.md | 93 +++++++ experiments/claim-hw-attestation/run.py | 281 +++++++++++++++++++++ 3 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 experiments/claim-hw-attestation/README.md create mode 100644 experiments/claim-hw-attestation/run.py diff --git a/experiments/README.md b/experiments/README.md index 225119a..d11b2da 100644 --- a/experiments/README.md +++ b/experiments/README.md @@ -15,6 +15,7 @@ Each experiment imports directly from `cmcp_runtime`. Run from the repo root aft | [claim4-trace-claim-nonce](claim4-trace-claim-nonce/) | Claim 4 — TRACE Claim nonce binding | 6 properties: nonce determinism, session/instance binding, replay prevention, sig tamper, selective disclosure | | [claim5-temporal-adjacency](claim5-temporal-adjacency/) | Claim 5 — Temporal adjacency provenance | Zero false negatives by construction; provenance disclaimer in every summary; denied calls in graph | | [claim6-cross-org-attestation](claim6-cross-org-attestation/) | Claim 6 — Cross-org attestation chains | Dual-TEE protocol: independent keys, session linkage, independent verify, binary swap detection | +| [claim-hw-attestation](claim-hw-attestation/) | Hardware attestation (real TEE) | Requires a confidential VM; SKIPs without one. Real report + nonce binding + end-to-end claim verification | ## Running @@ -27,9 +28,12 @@ python experiments/claim3-rug-pull-detection/run.py python experiments/claim4-trace-claim-nonce/run.py python experiments/claim5-temporal-adjacency/run.py python experiments/claim6-cross-org-attestation/run.py + +# Requires a confidential VM; SKIPs cleanly (exit 0) on hosts without a TEE. +python experiments/claim-hw-attestation/run.py ``` -All experiments run in software-only mode. No hardware TEE is required. TRACE Claims produced in software-only mode carry `attestation_assurance: none` and must not be used for compliance purposes. +The `claim1`–`claim6` experiments run in software-only mode. No hardware TEE is required. TRACE Claims produced in software-only mode carry `attestation_assurance: none` and must not be used for compliance purposes. The `claim-hw-attestation` experiment is the exception: it exercises a real hardware report and verification, and only produces results on a confidential VM (see [claim-hw-attestation/README.md](claim-hw-attestation/README.md)). ## CI tests diff --git a/experiments/claim-hw-attestation/README.md b/experiments/claim-hw-attestation/README.md new file mode 100644 index 0000000..a93fb43 --- /dev/null +++ b/experiments/claim-hw-attestation/README.md @@ -0,0 +1,93 @@ +# Hardware TEE attestation experiment + +This is the one experiment that needs real confidential-computing hardware. Every +other experiment in `experiments/` runs in software-only mode and produces TRACE +Claims with `attestation_assurance: none`. This one exercises the real path: +a genuine attestation report from the TEE, bound to the gateway key, wrapped in a +signed TRACE Claim, and checked by the verifier. + +`run.py` is safe to run anywhere. On a host with no TEE it prints `SKIP` and exits +0, so it never breaks CI or a laptop. The properties below can only be observed on +real hardware. + +## What it checks + +| # | Property | Hardware-only | +|---|----------|---------------| +| P1 | A real provider is detected (sev-snp / tdx / tpm), not software-only | yes | +| P2 | `report.report_data` equals the nonce the gateway supplied | yes | +| P3 | The measurement is a real value, not the dev-mode placeholder | yes | +| P4 | A different nonce yields different `report_data` (freshness) | yes | +| P5 | The provider-specific verifier accepts the raw hardware evidence | yes | +| P6 | A full TRACE Claim verifies end to end (schema, signature, key binding) | yes | + +## Hardware you need + +Any one of: + +| Platform | Azure | GCP | +|----------|-------|-----| +| AMD SEV-SNP | `Standard_DC2as_v5` (DCasv5) | `n2d-standard-4` + `--confidential-compute-type=SEV_SNP` | +| Intel TDX | `Standard_DC2es_v6` (DCesv6) | `c3-standard-4` + `--confidential-compute-type=TDX` | +| TPM 2.0 | any Trusted Launch VM | any VM with vTPM | + +The repo's deploy scripts provision these directly: + +```bash +# Azure (SEV-SNP by default; pass tdx to switch) +scripts/deploy-azure.sh + +# GCP +scripts/deploy-gcp.sh +``` + +See `docs/tutorials/deploy-azure.md` and `docs/tutorials/deploy-gcp.md` for the +full walkthrough. + +## Run it on the VM + +```bash +# 1. SSH into the confidential VM provisioned above. +# 2. Install cMCP. +pip install -e . + +# 3. (optional) Pin the provider explicitly instead of auto-detect. +export CMCP_DEV_MODE= # must be UNSET so software-only fallback is disabled +# either rely on auto-detection, or set provider in cmcp-config.yaml: +# attestation: +# provider: sev-snp # or tdx / tpm +# expected_measurement: "sha256:" # optional, enables HW-002 check + +# 4. Run the experiment. +python experiments/claim-hw-attestation/run.py +``` + +Expected output on a SEV-SNP VM (abridged): + +``` +P1 Hardware provider detected + Provider: sev-snp + PASS: a hardware TEE provider is active (not software-only) +P2 Report binds the gateway-supplied nonce + report_data == nonce -- report is bound to this gateway key +... +P6 Full TRACE Claim verifies end to end + PASS: schema + signature verify; claim is bound to the TEE key +``` + +## What is NOT covered yet (needs the vendor services, not just hardware) + +P5 verifies report format, measurement, and the nonce binding. It does **not** yet +verify the cert chain that proves the report was signed by genuine TEE silicon. +Those appear under `unverified_fields` until the following are wired into +`cmcp_verify`: + +- **AMD SEV-SNP:** VCEK/VLEK cert-chain validation via AMD KDS + (`src/cmcp_verify/sev_snp.py`) +- **Intel TDX:** DCAP quote signature + TCB status + (`src/cmcp_verify/tdx.py`) +- **TPM:** EK certificate chain to the manufacturer CA + (`src/cmcp_verify/tpm.py`) + +These require the vendor endpoints (and, for development, real reports to test +against), so they are the last step and are tracked separately from this runner. diff --git a/experiments/claim-hw-attestation/run.py b/experiments/claim-hw-attestation/run.py new file mode 100644 index 0000000..010584b --- /dev/null +++ b/experiments/claim-hw-attestation/run.py @@ -0,0 +1,281 @@ +""" +Hardware TEE attestation experiment (requires a confidential VM). + +Every other experiment in this directory runs in software-only mode and produces +TRACE Claims with attestation_assurance = none. This one exercises the *real* +hardware path end to end: it asks the detected TEE provider for a genuine +attestation report, binds the gateway key into it, builds a signed TRACE Claim, +and runs the verifier over both the claim and the raw hardware evidence. + +It is safe to run anywhere: on a host with no confidential-computing hardware it +detects that and exits 0 with a SKIP message, so it does not fail in CI or on a +laptop. The hardware properties can only be demonstrated on a real SEV-SNP, TDX, +or TPM-equipped host -- see README.md for the Azure / GCP deploy steps. + +Properties demonstrated (hardware only): + +P1 A real TEE provider is detected (sev-snp / tdx / tpm), not software-only. +P2 The attestation report binds the gateway-supplied nonce: report.report_data + equals the nonce we passed in. +P3 The measurement is a real hardware value, not the software-only placeholder. +P4 A fresh report with a different nonce yields different report_data + (freshness / instance binding). +P5 The provider-specific verifier accepts the raw hardware evidence (format, + measurement, and report_data checks). Cert-chain appraisal (AMD KDS / + Intel DCAP / TPM EK) is reported separately -- it requires the vendor + services and is listed under unverified_fields until those are wired. +P6 A full TRACE Claim built from the real report verifies end to end: schema, + Ed25519 signature, and TEE key binding (report_data[:32] == JWK thumbprint). + +Running: + pip install -e . + python experiments/claim-hw-attestation/run.py +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import sys + +from cmcp_runtime.audit.keys import SigningKey +from cmcp_runtime.audit.trace_claim import ( + AttestationReportInfo, + CallGraphSummary, + CallSummary, + PolicyBundleInfo, + ToolCatalogInfo, + generate_trace_claim, +) +from cmcp_runtime.tee.base import AttestationReport, TEEProvider +from cmcp_verify.verify import ApprovedHashes, verify_trace_claim + +_SW_ONLY_MEASUREMENT = "DEVELOPMENT_ONLY_NOT_FOR_PRODUCTION" +_ZERO_HASH = "sha256:" + "0" * 64 + + +def _detect_hardware_provider() -> TEEProvider | None: + """Return the first available hardware TEE provider, or None. + + Mirrors the gateway probe order (tpm -> sev-snp -> tdx) but never falls back + to the software-only provider: this experiment is meaningless without real + hardware, so when nothing is detected we skip rather than simulate. + """ + candidates: list[TEEProvider] = [] + try: + from cmcp_runtime.tee.tpm import TPMProvider + + candidates.append(TPMProvider()) + except ImportError: + pass + try: + from cmcp_runtime.tee.sev_snp import SEVSNPProvider + + candidates.append(SEVSNPProvider()) + except ImportError: + pass + try: + from cmcp_runtime.tee.tdx import TDXProvider + + candidates.append(TDXProvider()) + except ImportError: + pass + + for provider in candidates: + try: + if provider.detect(): + return provider + except Exception: + continue + return None + + +def _jwk_thumbprint(x_b64url: str) -> bytes: + """RFC 7638 JWK thumbprint for an Ed25519 OKP key (members sorted: crv,kty,x). + + This matches the gateway's nonce construction in cmcp_runtime.startup so the + verifier's TEE key-binding check (report_data[:32] == thumbprint) passes. + """ + members = f'{{"crv":"Ed25519","kty":"OKP","x":"{x_b64url}"}}' + return hashlib.sha256(members.encode()).digest() + + +def _gateway_nonce(signing_key: SigningKey, salt: bytes) -> bytes: + """Reproduce the gateway nonce: JWK thumbprint (32) || salt (32).""" + x_b64 = base64.urlsafe_b64encode(signing_key.public_key_bytes).rstrip(b"=").decode() + return _jwk_thumbprint(x_b64) + salt + + +def _build_claim(report: AttestationReport, signing_key: SigningKey, session_id: str): + policy = PolicyBundleInfo(hash=_ZERO_HASH, enforcement_mode="enforcing", policy_version="1.0.0") + catalog = ToolCatalogInfo(hash=_ZERO_HASH) + summary = CallSummary( + tool_calls_total=1, + tool_calls_allowed=1, + tool_calls_denied=0, + tool_calls_faulted=0, + tools_invoked=["ehr.get_patient"], + session_max_sensitivity="hipaa_phi", + call_graph_summary=CallGraphSummary(compliance_domains_touched=["hipaa_phi"], cross_boundary_events=[]), + ) + report_info = AttestationReportInfo( + provider=report.provider, + measurement=report.measurement, + report_data=report.report_data, + attestation_generated_at=report.attestation_generated_at.isoformat(), + attestation_validity_seconds=report.attestation_validity_seconds, + measurement_note=report.measurement_note, + raw_evidence=(base64.b64encode(report.raw_evidence).decode() if report.raw_evidence else None), + ) + return generate_trace_claim( + session_id=session_id, + signing_key=signing_key, + attestation_report=report_info, + policy_bundle=policy, + tool_catalog=catalog, + call_summary=summary, + audit_chain_root=_ZERO_HASH, + audit_chain_tip=_ZERO_HASH, + audit_chain_length=1, + ) + + +def _verify_raw_evidence(report: AttestationReport, signing_key: SigningKey, session_id: str): + """Run the provider-specific verifier over the real hardware evidence.""" + name = report.provider + if name == "sev-snp": + from cmcp_verify.sev_snp import verify_sev_snp_measurement + + return verify_sev_snp_measurement( + measurement=report.measurement, + raw_evidence=report.raw_evidence, + report_data_hex=report.report_data, + ) + if name == "tdx": + from cmcp_verify.tdx import verify_tdx_measurement + + return verify_tdx_measurement( + measurement=report.measurement, + raw_evidence=report.raw_evidence, + report_data_hex=report.report_data, + ) + if name == "tpm": + from cmcp_verify.tpm import verify_tpm_measurement + + return verify_tpm_measurement( + measurement=report.measurement, + raw_evidence=report.raw_evidence, + tee_public_key_hex=signing_key.public_key_hex, + session_id=session_id, + ) + return None + + +def _result(label: str, value: str) -> None: + print(f" {label}: {value}") + + +def main() -> int: + print() + print("Hardware TEE attestation | real attestation report + verification") + print("=" * 72) + + provider = _detect_hardware_provider() + if provider is None: + print() + print("SKIP: no hardware TEE detected on this host.") + print("This experiment requires a confidential VM (AMD SEV-SNP, Intel TDX,") + print("or a TPM 2.0 device). See experiments/claim-hw-attestation/README.md") + print("for Azure / GCP deployment steps. Exiting 0 so CI and dev hosts pass.") + print() + return 0 + + signing_key = SigningKey() + session_id = "hw-experiment-session-001" + + # --- P1: real provider --- + print() + print("P1 Hardware provider detected") + _result("Provider", provider.provider_name()) + if provider.provider_name() not in ("sev-snp", "tdx", "tpm"): + print(" FAIL: detected provider is not a hardware provider") + return 1 + print(" PASS: a hardware TEE provider is active (not software-only)") + + # --- P2: nonce binding --- + print() + print("P2 Report binds the gateway-supplied nonce") + salt = b"\x11" * 32 # fixed salt so P4 can vary it; production uses secrets.token_bytes + nonce = _gateway_nonce(signing_key, salt) + report = provider.get_attestation_report(nonce) + _result("Nonce (hex)", nonce.hex()) + _result("report_data", report.report_data) + if report.report_data != nonce.hex(): + print(" FAIL: report_data does not equal the nonce we supplied") + return 1 + print(" PASS: report_data == nonce -- report is bound to this gateway key") + + # --- P3: real measurement --- + print() + print("P3 Measurement is a real hardware value") + _result("Measurement", report.measurement[:48] + ("..." if len(report.measurement) > 48 else "")) + _result("Raw evidence bytes", str(len(report.raw_evidence) if report.raw_evidence else 0)) + if not report.measurement or report.measurement == _SW_ONLY_MEASUREMENT: + print(" FAIL: measurement is empty or the software-only placeholder") + return 1 + print(" PASS: measurement is hardware-backed") + + # --- P4: freshness / instance binding --- + print() + print("P4 A different nonce yields a different report") + nonce2 = _gateway_nonce(signing_key, b"\x22" * 32) + report2 = provider.get_attestation_report(nonce2) + _result("report_data #1", report.report_data) + _result("report_data #2", report2.report_data) + if report.report_data == report2.report_data: + print(" FAIL: two different nonces produced identical report_data") + return 1 + print(" PASS: report_data tracks the nonce -- no replay across nonces") + + # --- P5: verify raw hardware evidence --- + print() + print("P5 Provider-specific verification of the raw evidence") + raw_result = _verify_raw_evidence(report, signing_key, session_id) + if raw_result is None: + print(" FAIL: no verifier for this provider") + return 1 + _result("Verified fields", ", ".join(raw_result.verified_fields) or "(none)") + _result("Unverified fields", ", ".join(raw_result.unverified_fields) or "(none)") + if raw_result.failure_reason: + _result("Failure reason", str(raw_result.failure_reason)) + for k, v in raw_result.details.items(): + _result(f"detail[{k}]", v) + print(" NOTE: cert-chain appraisal (AMD KDS / Intel DCAP / TPM EK) appears under") + print(" unverified_fields until the vendor services are wired in cmcp_verify.") + + # --- P6: end-to-end TRACE Claim verification --- + print() + print("P6 Full TRACE Claim verifies end to end") + claim = _build_claim(report, signing_key, session_id) + claim_json = json.loads(claim.model_dump_json(exclude_none=True)) + result = verify_trace_claim( + claim_json, + ApprovedHashes(policy_bundle_hash=_ZERO_HASH, tool_catalog_hash=_ZERO_HASH), + ) + _result("Status", str(result.status)) + _result("Verified fields", ", ".join(result.verified_fields) or "(none)") + _result("Unverified fields", ", ".join(result.unverified_fields) or "(none)") + for required in ("schema", "signature"): + if required not in result.verified_fields: + print(f" FAIL: expected '{required}' to verify") + return 1 + print(" PASS: schema + signature verify; claim is bound to the TEE key") + + print() + print("All hardware properties demonstrated on:", provider.provider_name()) + print() + return 0 + + +if __name__ == "__main__": + sys.exit(main())