feat(tee): certify the gateway measurement so it is signed evidence (#432) - #459
Merged
Conversation
…432) The NV index value travelled as an ordinary NV read, which no signature covers. That made the gateway measurement a local integrity control only: a compromised gateway could report any value and a relying party had no way to tell. certify_and_extend_gateway_measurement now certifies the index, extends it, and certifies again, so both values are TPM-signed. cmcp_verify.nv_certify.verify_gateway_measurement appraises the pair and checks post == H(pre || expected_gateway_digest), with nothing collector-asserted and no verifier-side state. Two certifies rather than one because TPM2_NV_Certify signs only the index's current value and cannot attest to a previous one, while extends accumulate across reboots, so there is no absolute value a verifier could expect. The two calls commit different qualifying data (pre / post) so each blob's role is signed rather than inferred from its position in the envelope. The shortcut that looks equivalent and is not: reading the index and committing the result into a quote's qualifying data means the collector asserts the value it read, and a compromised gateway is precisely the adversary. Only the platform attestation key certifies. A transient key gives a verifiable signature with no provenance, which is worse than an honest absence because it looks like evidence, so a platform without a certified key falls back to the unsigned extend and ships no evidence at all. TPMProvider.platform_attestation_key makes that distinction explicit instead of leaving callers to read the transient-key side channel. Startup now generates the signing key before measuring, because the certify calls commit the attestation nonce derived from that key. The key has no dependencies of its own, so this is ordering only. agent_manifest.verify_tpm_quote cannot appraise an NV certify: it rejects any attest type that is not TPM_ST_ATTEST_QUOTE and its parser assumes a TPML_PCR_SELECTION union. The chain is still delegated to agent_manifest.verify_cert_chain; only the TPM_ST_ATTEST_NV and TPMT_SIGNATURE wire formats are local, tracked upstream as agent-manifest#255. Also fixes the five pre-existing mypy errors in cmcp_verify/tpm.py, so mypy src is clean for the first time. They were a type[HashAlgorithm] inference making hash_cls() read as instantiating the abstract base. Not validated on hardware: TPM2_NV_Certify has not run against a TPM. The pytss nv_certify signature is written against the documented API, as the NV define and extend calls were before the 2026-08-01 run confirmed them. 23 new unit tests cover the appraisal, including every way the pair can be forged or degraded. Refs #432 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI lints src/ and tests/; my local check covered only src/, so a B008 in the new test helper reached CI. The default is now computed inside the function. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 1, 2026
imran-siddique
added a commit
that referenced
this pull request
Aug 1, 2026
#461) #459 shipped a certify path that could never have run. A validation run on a real Azure Trusted Launch vTPM found two defects, neither catchable by the unit tests as they were written. First, ESAPI.nv_certify takes in_scheme (a TPMT_SIG_SCHEME) and size as required positional arguments. The shipped call omitted both, so it raised TypeError before the TPM was ever reached. The fake in the unit tests accepted any signature, so the tests passed against code that could not work. A NULL scheme resolves to the signing key's own, which is RSASSA/SHA-256 for the Azure platform AK. Second, a freshly defined TPM_NT_EXTEND index is uninitialised, and TPM2_NV_Certify on it fails with TPM_RC_NV_UNINITIALIZED. So on a first gateway start there was no pre-value to certify at all. The index is now seeded once at provision time, which keeps the verifier's post == H(pre || digest) check free of a first-boot special case. The seed value is irrelevant to security: the pre-certify signs whatever the index holds and the verifier only checks the relation. The fake now enforces both constraints, so each defect has a regression test that fails without its fix. Verified by mutation rather than assumed. What the run established once both were fixed: - the platform AK at 0x81000003 can sign an NV certify, which was an open question for a restricted signing key - parse_nv_certify's field offsets are correct against a real blob, the highest-risk item since they came from the TCG structures spec and had never met real bytes; nvContents equals what TPM2_NV_Read returns - the extend relation holds across two consecutive starts, with run 2's pre equal to run 1's post, which is the accumulation the two-certify design exists for - verify_gateway_measurement passes all seven appraisal steps and rejects both a wrong expected digest and a replayed nonce on genuine evidence Limit on the run: the VM drew the Global Virtual TPM CA - 03 hierarchy, whose AK certificate carries no AIA, so verification was anchored on the leaf. That exercises the plumbing but proves no key provenance, which stays open as #453. Refs #460, #432 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 1, 2026
Closed
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.
Second and final half of #432. PR1 (#451) measured the gateway into a
TPM_NT_EXTENDNV index; this makes that measurement verifiable by a relying party.The problem PR1 left open
The NV index value travelled as an ordinary NV read, which no signature covers. So the measurement was a local integrity control only: a compromised gateway could report any value and a verifier had no way to tell. #451 said so explicitly rather than implying otherwise.
Two certifies, bracketing the extend
Per the decision recorded on #432 and in RFC section 5 P2:
Why two and not one:
TPM2_NV_Certifysigns only the index's current value and cannot attest to a previous one, while extends accumulate across reboots. So there is no absolute value a verifier could expect, and a single certify is uncheckable. Two give TPM-signed values whose relation is verifiable, with nothing collector-asserted and no verifier-side state.The two calls commit different qualifying data (
pre/post), so each blob's role is signed rather than inferred from its position in the envelope. Swapping them already fails the relation check; signing the role means a verifier never has to trust ordering.The shortcut that looks equivalent and is not: reading the index and committing the result into a quote's qualifying data. The collector would be asserting the value it read, and a compromised gateway is precisely the adversary. Recorded in the module docstring so it does not get "simplified" later.
Only the platform key certifies
A transient attestation key produces a verifiable signature with no provenance, which is worse than an honest absence because it looks like evidence. So
TPMProvider.platform_attestation_keyreturns the certified platform key orNone, never the transient fallback, and a platform without one falls back to the unsigned extend and ships no evidence at all. That distinction was previously only reachable through a_last_key_was_transientside channel.Startup ordering
The certify calls commit the attestation nonce, which is derived from the signing key, so the signing key is now generated before the measurement. It has no dependencies of its own, so this is ordering only, not a behaviour change. The documented sequence in
run_startupis updated: detect provider, signing key and nonce, measure and certify, produce the report.Why this is not delegated to agent-manifest
agent_manifest.verify_tpm_quoterejects any attest type that is notTPM_ST_ATTEST_QUOTE, andparse_tpm_quoteassumes the attested union is aTPML_PCR_SELECTIONfollowed by a PCR digest. An NV certify carriesTPMS_NV_CERTIFY_INFO, so neither can read it.The certificate chain is delegated to
agent_manifest.verify_cert_chain, matchingcmcp_verify.sev_snpandcmcp_verify.tdx. Only theTPM_ST_ATTEST_NVandTPMT_SIGNATUREwire formats are local, tracked upstream as agentrust-io/agent-manifest#255.Tests
23 new, 1021 passing overall. The appraisal is fail-closed at every step, and there is a test for each way the pair can be forged or degraded:
preandpostswappedAlso asserted: with no expected digest supplied, the result is
verifiedbut carries a note saying the measurement was not compared against a known-good value, so internal consistency is not reported as more than it is.Not validated on hardware
TPM2_NV_Certifyhas not run against a TPM. The pytssnv_certifysignature is written against the documented API, exactly asnv_define_spaceandnv_extendwere before the 2026-08-01 run confirmed them. Everything else here is covered by real tests. Worth one more Azure Trusted Launch run before this path is trusted; that run would also exercise the certify against the platform AK whose chain is host-dependent (#453).Incidental
mypy srcis clean for the first time (55 files). The five pre-existing errors incmcp_verify/tpm.pywere atype[HashAlgorithm]inference that madehash_cls()read as instantiating the abstract base; typing the lookup as a factory fixes it, andpadding.PSS.DIGEST_LENGTHreplaceshash_cls.digest_sizefor the same salt length with a correct type. That removes the reviewer note I have been carrying on three PRs.🤖 Generated with Claude Code