Skip to content

feat(tee): certify the gateway measurement so it is signed evidence (#432) - #459

Merged
imran-siddique merged 2 commits into
mainfrom
feat/nv-certify
Aug 1, 2026
Merged

feat(tee): certify the gateway measurement so it is signed evidence (#432)#459
imran-siddique merged 2 commits into
mainfrom
feat/nv-certify

Conversation

@imran-siddique

Copy link
Copy Markdown
Contributor

Second and final half of #432. PR1 (#451) measured the gateway into a TPM_NT_EXTEND NV 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:

V0 = NV_Certify(index)        <- TPM-signed
NV_Extend(gateway_digest)
V1 = NV_Certify(index)        <- TPM-signed

verifier: V1 == H(V0 || expected_gateway_digest)

Why two and not one: 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, 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_key returns the certified platform key or None, 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_transient side 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_startup is 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_quote rejects any attest type that is not TPM_ST_ATTEST_QUOTE, and parse_tpm_quote assumes the attested union is a TPML_PCR_SELECTION followed by a PCR digest. An NV certify carries TPMS_NV_CERTIFY_INFO, so neither can read it.

The certificate chain is delegated to agent_manifest.verify_cert_chain, matching cmcp_verify.sev_snp and cmcp_verify.tdx. Only the TPM_ST_ATTEST_NV and TPMT_SIGNATURE wire 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:

  • broken extend relation, and a different gateway digest than expected
  • a pair replayed from another session (nonce mismatch)
  • pre and post swapped
  • two certifies of different NV indices, which would otherwise let a clean index be paired with the real one's successor
  • tampered attest, and a signature from an unrelated key
  • untrusted root, and no roots at all
  • a PCR quote substituted for a certify, which the type check rejects; without it a quote's PCR-selection bytes would be misread as an index name and contents
  • malformed and wrong-version envelopes
  • an accumulated index (5 prior extends) still verifying, which is the case the whole design exists for

Also asserted: with no expected digest supplied, the result is verified but 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_Certify has not run against a TPM. The pytss nv_certify signature is written against the documented API, exactly as nv_define_space and nv_extend were 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 src is clean for the first time (55 files). The five pre-existing errors in cmcp_verify/tpm.py were a type[HashAlgorithm] inference that made hash_cls() read as instantiating the abstract base; typing the lookup as a factory fixes it, and padding.PSS.DIGEST_LENGTH replaces hash_cls.digest_size for the same salt length with a correct type. That removes the reviewer note I have been carrying on three PRs.

🤖 Generated with Claude Code

imran-siddique and others added 2 commits August 1, 2026 12:30
…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>
@imran-siddique
imran-siddique merged commit 528e718 into main Aug 1, 2026
11 checks passed
@imran-siddique
imran-siddique deleted the feat/nv-certify branch August 1, 2026 19:49
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>
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.

1 participant