fix(audit): bind audit-chain root into the attested report_data#368
Merged
Conversation
The chain root that lands in a TRACE claim (gateway.audit_chain.root) was not committed to any hardware-signed value. create_session() submitted a chain-root nonce to the TEE but discarded the report; the report_data that actually reached the claim came from the startup nonce (key_fingerprint || random_salt), which contains no chain root. The verifier only checked that root/tip/length were non-empty. An operator could rebuild an internally-consistent chain (recomputing every entry hash from genesis), re-sign the claim with the in-enclave key, and pass verification. Bind the root into the attested report_data and check it on the verifier: - tee/base.py: add make_audit_bound_nonce(key, chain_root) producing the 64-byte nonce jwk_thumbprint(key)(32) || SHA-256(chain_root)(32). The first half preserves the existing key binding (report_data[:32]); the second half commits the chain root. Layout stays 64 bytes, so SEV-SNP/TDX report_data field sizes are unchanged. - session/manager.py: create_session() now builds the audit-bound nonce and KEEPS the resulting per-session report on the chain; close_session() builds the claim from that report so report_data carries the chain-root commitment. Falls back to the shared startup report (with a warning) if the TEE call fails, so the runtime freshness path is unaffected. - audit/chain.py: AuditChain carries the per-session attestation report. - cmcp_verify/verify.py: new _check_audit_chain_binding recomputes SHA-256(chain_root) and compares it constant-time against report_data[32:64]. A mismatch is FATAL (CHAIN_ROOT_NOT_BOUND); software-only carries no nonce so the binding is reported not-applicable, consistent with the key binding. Commit point: the binding commits the chain root as of session start (chain_root is the immutable session_start entry hash), valid for the session's lifetime. Tests: a claim whose root does not match report_data is rejected; a rebuilt chain (different root) no longer verifies against unchanged report_data; the matching happy path verifies; end-to-end SessionManager path; existing audit-chain/conformance tests still pass. Co-Authored-By: Claude Opus 4.8 (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.
Problem
The audit-chain root carried in a TRACE claim (
gateway.audit_chain.root) was not committed to any hardware-signed value, so it could be substituted without detection:session/manager.pycreate_session()computed a per-session nonce that encoded the chain root, submitted it to the TEE, and then discarded the report ("the report itself is not stored here").report_datathat actually reached the claim came from the startup nonce built instartup.py(key_fingerprint || random_salt), which contains no chain root.cmcp_verify/verify.py_check_audit_chainonly checked thatroot/tip/lengthwere present and non-empty. Nothing tiedchain_rootto a hardware-attested value.Net effect: the anti-substitution property did not hold. An operator could rebuild an internally consistent chain (recomputing every
entry_hash/prev_entry_hashfrom genesis), re-sign the claim with the in-enclave key, and pass verification.Design
The fix makes the chain root part of the hardware-signed
report_dataand has the verifier recompute and check it.Binding. The attestation
report_datais a 64-byte nonce. The first 32 bytes already carry the RFC 7638 JWK thumbprint of the gateway key (the existing CRYPTO-001 key binding, checked againstcnf.jwk.x). This change repurposes the second 32 bytes from a random salt to a chain-root commitment:report_data[:32]-> gateway key fingerprint (unchanged; key binding still verified).report_data[32:64]->SHA-256(chain_root), the new commitment.The total length stays 64 bytes, so the SEV-SNP / TDX hardware
report_datafield layout (a 64-byte area) is unchanged. Freshness is preserved without the salt becausechain_rootis unique per session (it hashes the session id and thesession_starttimestamp).How the root now reaches
report_data.create_session()builds this audit-bound nonce viamake_audit_bound_nonce(key, chain_root)and now keeps the per-session report, storing it on theAuditChain.close_session()builds the claim from that per-session report instead of the shared startup report, so thereport_datasurfaced intrace.runtime.noncecarries the chain-root commitment. If the per-session TEE call fails, it falls back to the startup report and logs a warning; the in-enclave local anchor (AUDIT-002) is still set, and a strict verifier will reject the claim for the missing binding.How the verifier checks it. New
_check_audit_chain_bindingre-derivesSHA-256(SHA-256-hex-decode(gateway.audit_chain.root))and compares it constant-time (hmac.compare_digest) againstreport_data[32:64](fromtrace.runtime.nonce). A mismatch is FATAL (CHAIN_ROOT_NOT_BOUND), not advisory. Software-only claims carry noruntime.nonce, so the binding is reported as not-applicable, consistent with how the key binding treats dev mode.Commit-point semantics. The report commits to the chain root as of session start.
chain_rootis the hash of the immutablesession_startentry, fixed at session creation and never altered as the chain grows, so binding once at session start is well defined and the report stays valid for the session lifetime. (The chain tip continues to be carried ingateway.audit_chain.tip/tool_transcript.hashand bound by the claim signature; this change is specifically about anchoring the root in hardware.)Out of scope (flagged, not attempted)
These Wave-2 items are intentionally not included here:
report_dataas advisory at the hardware layer; this change adds the software-recomputable chain-root commitment but does not validate the report's hardware signature chain.cnf.jwk<->report_datafatal binding promotion and the TDXreport_dataoffset hardening.Remaining gap: when the per-session TEE call fails,
close_session()falls back to the startup report, which has no chain-root commitment. This is the documented fail-open-at-runtime / fail-closed-at-verify behavior (the verifier rejects the unbound claim for hardware platforms). The runtime does not itself refuse to issue such a claim.Tests
test_verify.py: a claim whosechain_rootdoes not matchreport_data[32:64]is rejected (CHAIN_ROOT_NOT_BOUND); a chain rebuilt with different entries (different root) no longer verifies against unchangedreport_data; the matching happy path verifies; software-only is not-applicable.test_session_manager.py: end-to-end SessionManager path - the per-session report'sreport_datacommits the chain root and the claim passes the verifier binding; TEE-failure fallback path.test_audit_chain_anchor.py: updated for the new 64-byte audit-bound nonce layout.pytest tests/unit tests/conformance -q: 822 passed, 1 skipped, 2 pre-existing unrelated failures intest_agt_evidence.py(adatetime.now(datetime.UTC)typo present onmain, not touched here).ruff check src/ --select E,F,W --ignore E501: all checks passed.🤖 Generated with Claude Code