feat(tee): implement TPM attest() and carry verifiable evidence (#73) - #74
Merged
Conversation
TpmProvider.detect() returned True on any host with a TPM device node while attest() raised unconditionally, so on every Azure Trusted Launch VM and most modern client hardware the provider was selected and then failed, with an error claiming no TPM was present when one was. AttestationReport also had no field for a quote, a signature, or a chain, so even a working collector could not supply anything a relying party could verify. attest() now produces a real quote, ported from cmcp's hardware-validated path: the platform attestation key at persistent handle 0x81000003 with its certificate chunk-read from NV 0x01C101D0 (a single read of a 1596-byte cert fails with TPM_RC_VALUE, since TPM2_NV_Read is bounded by TPM2_PT_NV_BUFFER_MAX), AIA chain assembly so verification stays offline, and a transient restricted signing key where no certified platform key exists. detect() is True only where attest() can actually run. The quote commits both the offered channel key and the nonce, so public_key and nonce become signed facts rather than assertions and sealing to "a key from a verified report" is actually hardware-rooted. Fields are length-prefixed: with a delimiter, a value containing it shifts the split without changing the digest, and nonce is an arbitrary caller-supplied string. Verification delegates to agent_manifest.verify_tpm_quote rather than being a third copy of one verifier (cmcp#447). cA2A keeps only TPMT_SIGNATURE, which agent-manifest does not model. Trust anchors stay caller-supplied per #440, with the hardware-validated Azure root as an opt-in constant. Two deliberate departures from cmcp: no SHA-1 PCR fallback and no unsigned-PCR-read tier. cmcp downgrades to software-only in each case; cA2A raises, because a report labelled sha256: that measured SHA-1 banks is a mislabel waiting to happen and a tpm report that can never verify is worse than an honest error. Not demonstrated: collect-then-verify in one process on hardware. Installing agent-manifest on the Azure test VM conflicts with the distribution's tpm2_pytss, so the two halves could not run together there. Recorded in LIMITATIONS.md. Refs #73 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
imran-siddique
added a commit
that referenced
this pull request
Aug 1, 2026
…easurement (#75) Ran the TPM path on a real Azure Trusted Launch vTPM (Standard_D2s_v7, eastus2, 2026-08-01). Most of what #74 shipped holds up on hardware. One thing I wrote there does not. tpm_roots.py described AZURE_VTPM_ROOT_2023_PEM as "the one root cA2A has validated on hardware". That is not true fleet-wide. On this host the AK certificate at NV 0x01C101D0 is 994 bytes, is issued by CN=Global Virtual TPM CA - 03, and carries no AIA extension at all. With nothing to walk, the collector ships the leaf alone; tpm2_getcap handles-nv-index showed only 0x01C101D0, so the intermediates are not in NV either. No chain reaches the pinned root and verify_tpm_report fails closed with "AK chain root is not among the supplied trusted TPM roots", which is the correct outcome since key provenance genuinely cannot be established there. The earlier observation (Standard_D2s_v5, eastus, 1596-byte certificate under Azure Cloud Virtual TPM CA - 11 with a walkable AIA chain to that root) is also real. Azure runs more than one vTPM CA generation, so a deployment must pin the hierarchy its own hosts present rather than assuming this one. What passed on hardware, now recorded rather than hedged: the collector produced a genuine platform-AK quote, the shipped certificate certifies the quoting key, parse_tpmt_signature unwrapped a real TPMT_SIGNATURE (RSASSA/SHA-256) and the bare signature verified against the shipped key, a tampered attest blob was rejected, and extraData equalled the derived key-and-nonce binding. Also retires the caveat that collector and verifier could not run in one process: building tpm2-pytss from source inside a venv resolves the conflict with agent-manifest's cryptography. The blocker on that host is the missing chain, not tooling. No code change; the verifier behaved correctly throughout. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #73.
The problem
TpmProvider.detect()returned True on any host with a TPM device node whileattest()raised unconditionally. On every Azure Trusted Launch VM and most modern client hardware the provider was therefore selected and then failed, and the error text said no TPM was present when one was.AttestationReportalso had no field for a quote, a signature, or a chain, so a relying party could not verify anything even in principle: a measurement string with no signed evidence behind it is an assertion.What this does
Collector. Ported from cmcp's hardware-validated path: prefers the platform attestation key at persistent handle
0x81000003with its certificate chunk-read from NV0x01C101D0, assembles the chain by walking each certificate's AIA extension so verification stays offline later, and falls back to a transient restricted signing key where no certified platform key exists.detect()now returns True only whereattest()can actually run (Linux, device node, tpm2-pytss), andAttestationUnsupportednames the piece that is actually missing.The chunked NV read is not incidental:
TPM2_NV_Readis bounded byTPM2_PT_NV_BUFFER_MAX, so a single read of the 1596-byte certificate fails withTPM_RC_VALUEeven though the index is plainly readable.Evidence fields.
raw_evidence,quote_signature,attestation_key_pem,attestation_key_chain_pem, named to match cmcp's model so evidence is portable between the runtimes. Absent onsoftware-only, which has none by construction.The signed binding.
extraDatacommits both the offered channel key and the nonce:The verifier re-derives this from the report's own fields and requires equality, which is what promotes
public_keyandnoncefrom claim to signed fact. Committing the nonce alone (what cmcp does) would sign for freshness only, leaving the offered key unsigned, and sealing to "a key from a verified report" would not be hardware-rooted. Fields are length-prefixed rather than delimiter-joined because a delimiter lets a value containing it shift the split without changing the digest, so("a|b","c")and("a","b|c")would commit identical bytes;nonceis an arbitrary caller-supplied string, so that is reachable rather than theoretical.Verification delegated. To
agent_manifest.verify_tpm_quoterather than a third copy of one verifier (cmcp#447). cA2A keeps onlyTPMT_SIGNATURE, the envelopetpm2_quote -sand pytsssignature.marshal()emit, which agent-manifest does not model. Trust anchors stay caller-supplied per #440, with the hardware-validated Azure root as an opt-in constant inca2a_verify/tpm_roots.py; supplying no root is refused rather than treated as trust-anything.Two deliberate departures from cmcp
No SHA-1 PCR fallback, and no unsigned-PCR-read tier. cmcp downgrades the report to
software-onlyin each case; cA2A raises. A report labelledsha256:that measured SHA-1 banks is a mislabel waiting to happen, and atpmreport that can never verify is worse than an honest error. The collector also cross-checks its own PCR read against the quote'spcrDigest, so a PCR selection mismatch is caught before evidence ships.Tests
38 new tests. Beyond the happy paths and the negatives (substituted key, substituted nonce, stale nonce, measurement disagreeing with the quote, missing evidence, missing chain, no root, untrusted root, tampered attest), two lock in the TPM interaction shapes that were wrong on real hardware in cmcp:
pcr_readreturns aTPML_DIGESTwhose digests are iterated once; treating them as banks and descending a second level silently corrupts the measurement.TPM2_NV_Readmust be chunked; the fake context enforces the realTPM_RC_VALUEbound so a regression to a single full-size read fails the test.Writing them found a real defect in my own encoding: the first version joined fields with
|and collided as described above. Fixed to length-prefixing before commit.What is not demonstrated
Collect-then-verify in one process on hardware. On the Azure test VM, installing
agent-manifestconflicts with the distribution'stpm2_pytss(oldercryptography), and a venv fixes the install but then breaks thetpm2_pytssimport, so the two halves could not run together there. Both halves are exercised against synthetic self-consistent vectors, and a genuine Azure vTPM quote parses and verifies underCA2A_TPM_FIXTURE_DIR. Recorded in LIMITATIONS.md rather than rounded up.Also recorded there: a transient-key quote is a verifiable signature with no provenance and is rejected, so provenance needs a platform-provisioned certified AK. Azure Trusted Launch has one; a GCP Shielded VM does not (probed 2026-07-31: no EK cert, no persistent handles,
get-shielded-identityreturns a bareekPub). The TCG event log is 0 bytes on both clouds.Docs
Corrected where they stated the opposite of the code:
detect()returning False for every hardware provider was asserted in the attestation spec, the component model, failure modes, and two tutorials. The attestation spec now records the binding, since it is wire format.Note for reviewers
Local test run: 243 passed, 8 failed, 3 skipped. All 8 failures are pre-existing on
mainand untouched here (verified by stashing): 7 intest_cedar.pyfrom a localcedarpyAPI skew, and 1 intest_trace_binding.pyfrom the knownagentrust-trace-testsv0.1-vs-v0.2 profile skew.Separately, section 8 of
docs/tutorials/reproducing-the-claims.mdlooks stale against the current claim6 script for reasons unrelated to this PR: the doc says the experiment SKIPs, but the script passes 4/4 on synthetic vectors. I corrected only thedetect()sentence there rather than silently rewriting claim text.🤖 Generated with Claude Code