Skip to content

fix(audit): bind audit-chain root into the attested report_data#368

Merged
imran-siddique merged 1 commit into
mainfrom
fix/audit-chain-root-binding
Jun 30, 2026
Merged

fix(audit): bind audit-chain root into the attested report_data#368
imran-siddique merged 1 commit into
mainfrom
fix/audit-chain-root-binding

Conversation

@imran-siddique

Copy link
Copy Markdown
Contributor

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.py create_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").
  • The report_data that actually reached the claim came from the startup nonce built in startup.py (key_fingerprint || random_salt), which contains no chain root.
  • The verifier cmcp_verify/verify.py _check_audit_chain only checked that root/tip/length were present and non-empty. Nothing tied chain_root to 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_hash from 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_data and has the verifier recompute and check it.

Binding. The attestation report_data is 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 against cnf.jwk.x). This change repurposes the second 32 bytes from a random salt to a chain-root commitment:

report_data = jwk_thumbprint(key) (32)  ||  SHA-256(chain_root_bytes) (32)
  • 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_data field layout (a 64-byte area) is unchanged. Freshness is preserved without the salt because chain_root is unique per session (it hashes the session id and the session_start timestamp).

How the root now reaches report_data. create_session() builds this audit-bound nonce via make_audit_bound_nonce(key, chain_root) and now keeps the per-session report, storing it on the AuditChain. close_session() builds the claim from that per-session report instead of the shared startup report, so the report_data surfaced in trace.runtime.nonce carries 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_binding re-derives SHA-256(SHA-256-hex-decode(gateway.audit_chain.root)) and compares it constant-time (hmac.compare_digest) against report_data[32:64] (from trace.runtime.nonce). A mismatch is FATAL (CHAIN_ROOT_NOT_BOUND), not advisory. Software-only claims carry no runtime.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_root is the hash of the immutable session_start entry, 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 in gateway.audit_chain.tip / tool_transcript.hash and 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:

  • Verifying the attestation report signature itself via VCEK / DCAP. The platform verifiers still treat report_data as advisory at the hardware layer; this change adds the software-recomputable chain-root commitment but does not validate the report's hardware signature chain.
  • The cnf.jwk <-> report_data fatal binding promotion and the TDX report_data offset 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 whose chain_root does not match report_data[32:64] is rejected (CHAIN_ROOT_NOT_BOUND); a chain rebuilt with different entries (different root) no longer verifies against unchanged report_data; the matching happy path verifies; software-only is not-applicable.
  • test_session_manager.py: end-to-end SessionManager path - the per-session report's report_data commits 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.
  • Existing audit-chain, verify, and conformance tests still pass.

pytest tests/unit tests/conformance -q: 822 passed, 1 skipped, 2 pre-existing unrelated failures in test_agt_evidence.py (a datetime.now(datetime.UTC) typo present on main, not touched here). ruff check src/ --select E,F,W --ignore E501: all checks passed.

🤖 Generated with Claude Code

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>
@imran-siddique imran-siddique merged commit 4c811a1 into main Jun 30, 2026
12 checks passed
@imran-siddique imran-siddique deleted the fix/audit-chain-root-binding branch June 30, 2026 20:15
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