Skip to content

fix(tee): bind the gateway measurement into SEV-SNP and TDX report_data - #563

Merged
imran-siddique merged 2 commits into
agentrust-io:mainfrom
zohebk8s:fix/552-measurement-bound-report-data
Aug 24, 2026
Merged

fix(tee): bind the gateway measurement into SEV-SNP and TDX report_data#563
imran-siddique merged 2 commits into
agentrust-io:mainfrom
zohebk8s:fix/552-measurement-bound-report-data

Conversation

@zohebk8s

Copy link
Copy Markdown
Collaborator

Closes #552. Both bullets of the proposed change, wired for the three providers the issue names. The report the binding lands on is not the one a session claim carries; see Not covered.

Why

gateway_measurement() folds installed code, the policy bundle and the effective config into one digest. #432 extends it into a TPM_NT_EXTEND NV index and certifies it with a TPM2_NV_Certify pair, validated on real Azure Trusted Launch vTPM hardware. The "what to measure" question is answered and proven.

It ran for the tpm provider only. At startup.py:160, _measure_gateway returned early on tee_provider.provider_name() != "tpm".

The docstring's reason was that SEV-SNP and TDX commit their own binding through the report's fields. That is true and it is not equivalent. Those fields carry the launch measurement, which is boot-time. It does not move when the Cedar bundle reloads mid-session via PolicyEvaluator._maybe_reload(). So on SEV-SNP, TDX and Azure CVM, the policy actually in force was committed to nothing.

What

No new commitment scheme. The existing, already-validated digest goes into those platforms' report_data.

make_measurement_bound_nonce(tee_public_key, measurement_digest) in tee/base.py, using the layout make_audit_bound_nonce already uses:

jwk_thumbprint(pubkey) (32) || measurement_digest (32)

gateway_measurement().digest is already a raw 32-byte SHA-256, so it drops into the second half unreshaped and a verifier compares it against a digest it recomputes rather than against a hash of one. It sits beside make_audit_bound_nonce and reuses the same jwk_thumbprint helper, so the first half is literally the same code path.

Applied to sev-snp, tdx and azure-cvm-sev-snp. All three place the whole 64-byte nonce in report_data. SEV-SNP and TDX write it straight into REPORT_DATA and REPORTDATA. Azure CVM cannot, since the paravisor owns SNP REPORT_DATA, and commits sha256(nonce) into the AK-signed quote instead, which is what cmcp_verify.azure_cvm already re-derives. tpm keeps the random salt: its NV index has an append-only history that report_data does not.

Freshness survives losing the salt. The gateway generates a new signing key on every start, so report_data[:32] still differs between two starts of byte-identical code, policy and config.

Refreshed on every policy-bundle reload, not only the reloads that moved the hash. report_data holds one value and no history, so nothing in a report says whether it is current: the same digest signed now and that digest signed an hour ago are different assertions, and only the latest report reaches a verifier. PolicyEvaluator._maybe_reload calls a new on_reload hook whenever PolicyStore.reload_if_stale reports the bundle was re-read. The cost is bounded by policy_reload_interval_seconds rather than by request rate, because reload_if_stale stamps its clock before the attempt, and it is zero in the default configuration where reloading is off.

Recompute-and-compare is a check, not a note in the spec. verify_trace_claim gains an optional expected_gateway_measurement (raw 32 bytes, hex, or sha256:-prefixed) and a MEASUREMENT_NOT_BOUND failure reason, as step 7c. Opt-in, because the expected digest has to be an out-of-band trust input like ApprovedHashes: a check that read it out of the claim would be asking the claim to vouch for itself.

Tests

40, software-only by design. SoftwareOnlyProvider echoes the nonce into report_data, which is the collector-side shape SEV-SNP and TDX give, so the round trip proves the contract and leaves the silicon question where the issue leaves it.

The five properties prototyped on the issue: a deterministic measurement across repeat calls, a one-character Cedar change moving the policy component and only the policy component, a full round trip whose independent recompute matches both halves of report_data, a mid-session reload producing a report that reflects the new measurement, and a stale pre-reload measurement correctly rejected on recompute.

Verified by mutation, twelve guards altered one at a time, each killed. Restoring the narrower "refresh only when the hash changed" reading fails 2, re-hashing the digest verifier-side fails 3, and reverting to the tpm-only early return fails 2.

Three checks exist because the change was audited against the issue text rather than against itself:

  • the reload hook was only ever exercised with reload_if_stale stubbed, so a real PolicyStore over a real on-disk bundle now reloads an unchanged bundle and asserts the hook still fires, which is the second bullet with nothing mocked on the seam
  • startup._jwk_thumbprint_sha256 and tee.base.jwk_thumbprint each build report_data[:32] and nothing forced them to agree, so a test does
  • all three providers are asserted to place the whole 64-byte nonce in report_data, since the binding is only real if it reaches the field the hardware signs

Full suite: 1242 passed, 9 skipped. Ruff and mypy clean. Nine failures are pre-existing and unrelated, all from the installed agent_manifest SDK in my environment (module 'agent_manifest' has no attribute 'COSE_MANIFEST_VERSION'); tests/unit/test_agent_manifest_cose.py and tests/unit/test_intent_binding.py fail to collect for the same reason.

Decisions worth reviewing

An unmeasurable gateway is now fatal at startup on these three providers, in production, as it has been on tpm since #432. Extending the measurement to a platform extends the consequence of not having one. CMCP_DEV_MODE=1 still downgrades it to a warning, which is what an editable install with no RECORD metadata needs. This will stop an existing SEV-SNP deployment that runs an editable install without dev mode, so it is the one behaviour change here.

A refresh that cannot re-attest is fail-open, unlike startup. It logs and keeps the previous report rather than refusing traffic. Failing closed there would trade a detectable weakness for an outage: stale report_data no longer matches the recomputed digest, so a verifier rejects the claim. This is the same trade AUDIT-006 already makes for a failed per-session attestation in SessionManager.create_session.

Re-attesting on an unchanged bundle is deliberate, and it is the literal reading of the second bullet. The narrower reading, refreshing only when the hash moved, would leave the last report standing for as long as policy happens not to change, which is exactly the staleness the absent history cannot express.

ctx.gateway_measurement and ctx.attestation_report are two assignments, not one atomic swap. Neither is touched until the new report is in hand, so a failure at any earlier step leaves both unchanged, but a concurrent reader could see the new measurement beside the old report for one interpreter step. Benign today because nothing reads the pair together; the docstring records it rather than overclaiming.

Not covered

  • The binding is on the gateway's startup report. A TRACE Claim for a session carries the per-session report where one was produced, and AUDIT-006 already commits the audit-chain root in the same report_data[32:64]. So on the normal session path a verifier sees the chain-root commitment, not the measurement, and step 7c applies to claims that fall back to the startup report. Both cannot occupy one 64-byte field as the layout stands. tee: bind the gateway measurement into SEV-SNP and TDX report_data, refreshed on policy reload #552 scopes the audit-chain-root binding out, so this is left for the issue that takes that on rather than resolved either way here.
  • Hardware validation on real SEV-SNP and TDX silicon, out of scope per the issue. The contract lands with software-only proof.
  • Unrelated, noticed in passing: _VALID_PROVIDERS in startup.py is missing azure-cvm-sev-snp, though the value is present in both _ALLOWED_PROVIDERS and _PROVIDER_MAP. A gateway that auto-detects the Azure confidential-VM provider hits ATTESTATION_PROVIDER_INVALID and exits. Not touched here; happy to raise it separately.

gateway_measurement() folds installed code, the policy bundle and the effective
config into one digest, and agentrust-io#432 extended it into a TPM_NT_EXTEND NV index and
had the TPM certify it. That path is validated on real Azure Trusted Launch vTPM
hardware, and it ran for the tpm provider only: _measure_gateway returned early
for every other provider.

The stated reason was that SEV-SNP and TDX commit their own binding through the
report's fields. That is true and it is not equivalent. Those fields carry the
launch measurement, which is fixed at boot and does not move when the Cedar
bundle reloads mid-session through PolicyEvaluator._maybe_reload(). So on
exactly the platforms whose premise is hardware-rooted policy enforcement,
nothing signed said which policy was running.

No new commitment scheme is invented. make_measurement_bound_nonce() puts the
already-validated digest into the second half of the attestation nonce, in the
same 64-byte layout make_audit_bound_nonce already uses:

    jwk_thumbprint(pubkey) (32) || measurement_digest (32)

gateway_measurement().digest is a raw 32-byte SHA-256, so it drops in unreshaped
and a verifier compares it against a digest it recomputes rather than against a
hash of one. The tpm provider is deliberately outside the set: its NV index
keeps an append-only history that report_data cannot.

This replaces the 32 random salt bytes on those providers. Freshness survives
for a reason worth stating rather than assuming: the gateway generates a new
signing key on every start, so report_data[:32] still differs between two starts
of byte-identical code, policy and config.

Refreshed on every policy-bundle reload, not only on the reloads that moved the
hash. report_data holds one value and no history, so nothing in a report says
whether it is current: the same digest signed now and that digest signed an hour
ago are different assertions, and only the latest report reaches a verifier.
PolicyEvaluator._maybe_reload calls the new on_reload hook whenever
PolicyStore.reload_if_stale reports the bundle was re-read, and the hook runs
refresh_measurement_binding. The cost is bounded by
policy_reload_interval_seconds rather than by request rate, because
reload_if_stale stamps its clock before the attempt, and it is zero in the
default configuration where reloading is off.

Recompute-and-compare is what catches a gateway that did not refresh, so it is a
check rather than a note in the spec. verify_trace_claim gains an optional
expected_gateway_measurement (raw 32 bytes, hex, or sha256:-prefixed) and a
MEASUREMENT_NOT_BOUND failure reason. It is opt-in because the expected digest
has to be an out-of-band trust input like ApprovedHashes: a check that read it
out of the claim would be asking the claim to vouch for itself. The digest is
compared directly rather than re-hashed, which is the one way it differs from
the AUDIT-006 check beside it, and a mismatch is fatal in software-only mode too
since the digest is computed the same way there.

A refresh that cannot re-attest logs and keeps the previous report rather than
refusing traffic. Failing closed there would trade a detectable weakness for an
outage: stale report_data no longer matches the recomputed digest, so a verifier
rejects the claim. That is the same trade AUDIT-006 already makes for a failed
per-session attestation. Neither ctx.gateway_measurement nor ctx.attestation_report
is touched until the new report is in hand, so a failure at any earlier step leaves
both unchanged. They are two assignments rather than one atomic swap, which the
docstring now states exactly instead of overclaiming: a concurrent reader could see
the new measurement beside the old report for one interpreter step. Benign today
because nothing reads the pair together, and recorded so it stops being an accident
if something later does.

Behaviour change: an unmeasurable gateway on sev-snp, tdx or azure-cvm-sev-snp
is now fatal at startup in production, as it has been on tpm since agentrust-io#432, because
extending the measurement to a platform extends the consequence of not having
one. CMCP_DEV_MODE=1 still downgrades it to a warning, which is what an editable
install with no RECORD metadata needs.

Known limit, stated rather than implied: the binding is on the gateway's startup
report. A TRACE Claim for a session carries the per-session report where one was
produced, and AUDIT-006 already commits the audit-chain root in the same
report_data[32:64]. So on the normal session path a verifier sees the chain-root
commitment, not the measurement, and the new check applies to claims that fall
back to the startup report. Carrying both in one 64-byte field is not possible
as the layout stands, and agentrust-io#552 scopes the audit-chain-root binding out, so this
is left for the issue that takes that on.

Also out of scope per the issue: validation on real SEV-SNP and TDX silicon.

Tests: 40, software-only by design. SoftwareOnlyProvider echoes the nonce into
report_data, which is the collector-side shape SEV-SNP and TDX give, so the round
trip proves the contract and leaves the silicon question where the issue leaves
it. They cover the five properties prototyped on the issue: a deterministic
measurement across repeat calls, a one-character Cedar change moving the policy
component and only the policy component, a full round trip whose independent
recompute matches both halves of report_data, a mid-session reload producing a
report that reflects the new measurement, and a stale pre-reload measurement
correctly rejected on recompute.

Verified by mutation, twelve guards altered one at a time. Each is killed:
measuring only on tpm fails 2 tests; never using the measurement-bound nonce
fails 1; dropping the post-reload hook call fails 2; refreshing only when the
hash changed, which is the non-literal reading of the second bullet, fails 2;
firing the hook on evaluations that did not reload fails 3; letting a failing
hook escape fails 1; skipping re-attestation on an unchanged digest fails 1;
installing the measurement before the report is in hand fails 1; skipping the
nonce length check fails 1; re-hashing the digest verifier-side fails 3;
accepting an expected digest of any length fails 1; and treating a software-only
mismatch as not applicable fails 1.

Three checks were added after auditing the change against the issue text rather
than against itself. The reload hook was only ever exercised with reload_if_stale
stubbed, so a real PolicyStore over a real on-disk bundle now reloads an unchanged
bundle and asserts the hook still fires, which is the whole of the second bullet
with nothing mocked on the seam. startup._jwk_thumbprint_sha256 and
tee.base.jwk_thumbprint each build report_data[:32] and nothing forced them to
agree, so a test does. And all three providers are asserted to place the whole
64-byte nonce in report_data, since the binding is only real if it reaches the
field the hardware signs.

Closes agentrust-io#552.

Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
@zohebk8s
zohebk8s requested review from a team as code owners August 24, 2026 19:08
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🟡 Contributor Check: MEDIUM

Check Result
Profile MEDIUM
Credential LOW
Overall MEDIUM

Automated check by AgenTrust Contributor Check.

@github-actions github-actions Bot added the needs-review:MEDIUM Contributor check flagged MEDIUM risk label Aug 24, 2026
@codecov-commenter

codecov-commenter commented Aug 24, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Codecov reported 80% patch coverage on agentrust-io#563, 26 added lines with no test. The
gap was not evenly spread and one part of it mattered.

The whole of step 7c in verify_trace_claim was untested. _check_measurement_binding
had unit tests, but nothing ever passed expected_gateway_measurement through the
public entry point, so the wiring could have been broken in either direction and the
suite would have stayed green. Six tests now cover it: a match landing in
verified_fields, a mismatch landing in unverified_fields with the reason, hex and
sha256:-prefixed digests, an unparseable expectation failing closed rather than
skipping the check, software-only staying advisory, and no expectation supplied
leaving the field absent entirely. _make_signed_claim grows an optional report_data
so a caller can bind something other than the audit-chain root.

The rest were branches rather than wiring. The verifier's fail-closed paths for an
undecodable nonce and a nonce too short to hold a commitment, and the software-only
variants of both, which return advisory rather than fatal. The guard that stops a
provider returning a non-AttestationReport from displacing a good report, which is
the same guard AUDIT-006 uses. And the TPM fault paths in _extend_measurement:
tpm2-pytss absent, a MeasurementUnavailable from the extend, and any other TPM fault,
degrading in dev mode and aborting in production.

Those TPM lines are not new behaviour. They read as added because agentrust-io#552 split
_measure_gateway into _measure_gateway and _extend_measurement, and the diff
attributes the moved body to this branch. Covering them is still right: they were
untested before the split too.

Every line agentrust-io#563 adds is now covered. Patch coverage measured locally by
intersecting the branch diff with coverage's missing-line report, across all six
changed source files: 0 uncovered added lines, down from 26.

Suite: 50 tests in test_measurement_report_binding.py plus 6 in test_verify.py.
Full unit run 1258 passed, with the same 9 pre-existing agent_manifest SDK
failures as before this branch. Ruff and mypy clean. The twelve mutations from
agentrust-io#552 are all still killed.

Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed this properly rather than on the description, because it changes what the hardware signs.

The gap is real and stated correctly. SEV-SNP and TDX committing their own binding through the report's fields is true and is not equivalent, and the reason is exactly the one you give: those fields carry a launch measurement. It is fixed at boot and does not move when PolicyEvaluator._maybe_reload() swaps the Cedar bundle. So the policy actually in force on those three platforms was committed to nothing, while the tpm tier has had this since #432.

The layout choice checks out. make_audit_bound_nonce at tee/base.py:117 is jwk_thumbprint(pubkey) || SHA-256(chain_root); make_measurement_bound_nonce at :135 is jwk_thumbprint(pubkey) || measurement_digest through the same helper. Putting the raw digest in rather than a hash of it is the detail that matters, because it lets a verifier compare against a digest it recomputes instead of against a commitment it cannot open. And replacing the random salt without losing freshness holds for the reason you give: the signing key is per start, so report_data[:32] still differs across two starts of byte-identical code, policy and config.

The collision was the thing I came here to raise, and you had already found it. Both commitments target report_data[32:64] with no domain separation between them. Your Not covered says it plainly, scopes it to the issue that takes on the audit-chain-root binding, and does not pretend the layout resolves it. That is the right call. Recording it as a live constraint is worth more than a partial fix would have been.

Verified rather than taken: ran tests/unit/test_measurement_report_binding.py on a detached worktree, 50 passed, then applied your named mutation reverting _measure_gateway to the tpm-only early return. Exactly 2 failures, test_startup_binds_the_measurement_into_the_nonce_on_sev_snp and test_an_unmeasurable_gateway_aborts_startup_on_sev_snp, which is what your table claims. The mutation numbers are honest.

Three things I want to name because they are decisions, not details.

Re-attesting on an unchanged bundle is right, and your reasoning for it is the load-bearing part: report_data holds one value and no history, so "the digest signed now" and "the digest signed an hour ago" are different assertions that the field cannot distinguish. The narrower reading would leave a report standing for as long as policy happens not to change.

Fail-open on refresh against fail-closed at startup is the right asymmetry, and it is safe for the reason you state rather than by luck: stale report_data stops matching the recomputed digest, so a verifier rejects it. The weakness is detectable, and refusing traffic instead would trade that for an outage.

Making an unmeasurable gateway fatal on three more platforms is the one behaviour change here, and calling it out under Decisions worth reviewing rather than burying it in the changelog is what let me agree with it quickly.

On _VALID_PROVIDERS, thank you, and I am taking it rather than leaving it with you. Confirmed: startup.py:57 holds five names without azure-cvm-sev-snp, audit/trace_claim.py:25 holds six with it, and the comment on line 55 says the first mirrors the second. So startup.py:420 raises ATTESTATION_PROVIDER_INVALID and this PR adds binding for a provider that path cannot reach. Follow-up from me, with a test asserting the two sets agree so they cannot drift apart again.

Approving and merging.

@imran-siddique
imran-siddique merged commit ffedfb8 into agentrust-io:main Aug 24, 2026
12 checks passed
imran-siddique added a commit that referenced this pull request Aug 25, 2026
* fix(startup): _VALID_PROVIDERS was missing azure-cvm-sev-snp

The comment above _VALID_PROVIDERS says it mirrors the keys of _PROVIDER_MAP
in audit/trace_claim.py. It did not: the map carries azure-cvm-sev-snp and the
set did not, so _validate_attestation_report raised
ATTESTATION_PROVIDER_INVALID on every Azure confidential-VM report and the
gateway exited at startup.

Found by @zohebk8s while wiring #552's report_data measurement binding in #563,
which applies to azure-cvm-sev-snp and so added behaviour for a provider that
path could not select.

The test compares the two sets rather than asserting one membership, so the
next provider added to either side cannot drift the same way.

Signed-off-by: Imran Siddique <imran.siddique@opaque.co>

* style: satisfy ruff SIM300 in the provider-parity assert

ruff reads _VALID_PROVIDERS as the constant side because it is uppercase,
so the comparison had to put the computed set on the left.

Signed-off-by: Imran Siddique <imran.siddique@opaque.co>

---------

Signed-off-by: Imran Siddique <imran.siddique@opaque.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:MEDIUM Contributor check flagged MEDIUM risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tee: bind the gateway measurement into SEV-SNP and TDX report_data, refreshed on policy reload

4 participants