Skip to content

feat(tee): implement TPM attest() and carry verifiable evidence (#73) - #74

Merged
imran-siddique merged 1 commit into
mainfrom
feat/tpm-attest
Aug 1, 2026
Merged

feat(tee): implement TPM attest() and carry verifiable evidence (#73)#74
imran-siddique merged 1 commit into
mainfrom
feat/tpm-attest

Conversation

@imran-siddique

Copy link
Copy Markdown
Contributor

Closes #73.

The problem

TpmProvider.detect() returned True on any host with a TPM device node while attest() 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. AttestationReport also 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 0x81000003 with its certificate chunk-read from NV 0x01C101D0, 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 where attest() can actually run (Linux, device node, tpm2-pytss), and AttestationUnsupported names the piece that is actually missing.

The chunked NV read is not incidental: TPM2_NV_Read is bounded by TPM2_PT_NV_BUFFER_MAX, so a single read of the 1596-byte certificate fails with TPM_RC_VALUE even 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 on software-only, which has none by construction.

The signed binding. extraData commits both the offered channel key and the nonce:

sha256("ca2a-tpm-v1|" || len32(public_key) || public_key || len32(nonce) || nonce)

The verifier re-derives this from the report's own fields and requires equality, which is what promotes public_key and nonce from 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; nonce is an arbitrary caller-supplied string, so that is reachable rather than theoretical.

Verification delegated. To agent_manifest.verify_tpm_quote rather than a third copy of one verifier (cmcp#447). cA2A keeps only TPMT_SIGNATURE, the envelope tpm2_quote -s and pytss signature.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 in ca2a_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-only in each case; cA2A raises. 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. The collector also cross-checks its own PCR read against the quote's pcrDigest, 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_read returns a TPML_DIGEST whose digests are iterated once; treating them as banks and descending a second level silently corrupts the measurement.
  • TPM2_NV_Read must be chunked; the fake context enforces the real TPM_RC_VALUE bound 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-manifest conflicts with the distribution's tpm2_pytss (older cryptography), and a venv fixes the install but then breaks the tpm2_pytss import, 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 under CA2A_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-identity returns a bare ekPub). 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 main and untouched here (verified by stashing): 7 in test_cedar.py from a local cedarpy API skew, and 1 in test_trace_binding.py from the known agentrust-trace-tests v0.1-vs-v0.2 profile skew.

Separately, section 8 of docs/tutorials/reproducing-the-claims.md looks 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 the detect() sentence there rather than silently rewriting claim text.

🤖 Generated with Claude Code

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
imran-siddique merged commit 37df664 into main Aug 1, 2026
12 checks passed
@imran-siddique
imran-siddique deleted the feat/tpm-attest branch August 1, 2026 02:28
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TPM provider: detect() selects a provider whose attest() always raises, and the report model cannot carry evidence

1 participant