Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **On SEV-SNP, TDX and Azure CVM the policy actually in force was committed to nothing (#552, follow-up to #432).** `gateway_measurement()` folds the installed code, the policy bundle and the effective configuration into one digest, and #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 whole premise is hardware-rooted policy enforcement, nothing signed said which policy was running.

No new commitment scheme was invented. `make_measurement_bound_nonce(tee_public_key, measurement_digest)` 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 not in the set: its NV index keeps an append-only history that `report_data` cannot.

This replaces the 32 random salt bytes on those providers, and freshness survives the change 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.

`report_data` carries one value and no history, so a report built before a bundle reload still looks well-formed on its own, and nothing in it says whether it is current. The gateway now re-attests on **every** policy-bundle reload, including one that finds the bundle unchanged, wired from `PolicyEvaluator._maybe_reload` through a new `on_reload` hook to `refresh_measurement_binding`. Re-signing an unchanged digest is not redundant: the same digest signed now and that digest signed an hour ago are different assertions, and only the latest report reaches a verifier. The cost is bounded by `policy_reload_interval_seconds` rather than by request rate, because `PolicyStore.reload_if_stale` stamps its clock before the attempt, and it is zero in the default configuration where reloading is off.

A refresh that cannot re-attest logs and keeps the previous report rather than refusing traffic: the stale binding no longer matches the recomputed digest, so a verifier rejects the claim, and failing closed here would trade a detectable weakness for an outage. That is the same trade AUDIT-006 already makes for a failed per-session attestation.

Recompute-and-compare is what catches a gateway that did not refresh, so it is now 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 must 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.

**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 when 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. Carrying both in one 64-byte field is not possible as the layout stands, and #552 explicitly scopes the audit-chain-root binding out, so this is left for the issue that takes that on.

**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 #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.

Not addressed here, per the issue: binding the audit-chain root, which is session activity rather than gateway identity and which AUDIT-006 already owns in the per-session report's `report_data[32:64]`, and validation on real SEV-SNP and TDX silicon. The contract lands with software-only proof: the round trip through `SoftwareOnlyProvider` composes the nonce, takes the report, and has an independent recompute match both halves, with a stale pre-reload measurement correctly rejected.

- **An approval record stopped verifying once its approvals expired (#533, follow-up to #531).** `verify_catalog_change` judged `approved_at` and `expires_at` against `time.time()`, so a record that was valid when the catalog was approved became permanently unverifiable, and the chain #517 exists to let an auditor replay could not be replayed. That made the record an authorization token with a lifetime rather than a provenance record.

The interval is now what it says it is: an assertion about when the signature could have been produced. The caller passes `validity_instant`, a pinned checkpoint or transparency-receipt timestamp where it has one, and where it passes nothing each approval is judged at its own `approved_at`. Requiring a pin instead would mean an auditor cannot verify a record without also holding the pin, which is a worse default than the one it replaces. The `now` parameter is gone with the wall clock, and `time` is no longer imported.
Expand Down
40 changes: 39 additions & 1 deletion docs/spec/attestation.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ nonce = JWK_thumbprint(tee_public_key) (32 bytes) || random_salt (32 bytes)
```

- `JWK_thumbprint(tee_public_key)`: the RFC 7638 JWK Thumbprint of the Ed25519 public key: SHA-256 over the canonical JSON of the required OKP members in lexicographic order (`crv`, `kty`, `x`). This is re-derivable by any verifier from `cnf.jwk.x`.
- `random_salt`: 32 random bytes generated once per enclave startup, so two enclave instances produce distinct nonces even with the same key (e.g. blue-green deploy).
- `random_salt`: 32 random bytes generated once per enclave startup, so two enclave instances produce distinct nonces even with the same key (e.g. blue-green deploy). On SEV-SNP, TDX and Azure CVM this half carries the gateway measurement instead; see §3.3.2.
- The 64-byte value is passed as the `report_data` / `user_data` / `reportdata` / `qualifying_data` field when requesting the hardware attestation report. The field name varies by provider; the semantic is the same: a caller-supplied value included in the signed measurement.

Verifier check (key binding, CRYPTO-001):
Expand All @@ -294,6 +294,44 @@ assert actual_nonce[:32] == expected_fingerprint

A TRACE Claim whose `cnf.jwk` public key was substituted after attestation fails this check, because the embedded `report_data` (hardware-signed) will not match the re-derived thumbprint. A claim produced by a different enclave instance carries a different key (and salt), so it fails too.

#### 3.3.2 Measurement binding (SEV-SNP, TDX, Azure CVM)

On platforms whose hardware report carries only a **launch** measurement, the 32-byte salt is replaced by the gateway measurement digest:

```
nonce = JWK_thumbprint(tee_public_key) (32 bytes) || gateway_measurement.digest (32 bytes)
```

`gateway_measurement.digest` is the SHA-256 over the installed code, the policy bundle and the effective configuration defined for the TPM tier (see `docs/spec/tpm-security-model.md`). It is already a raw 32-byte SHA-256, so it occupies the second half unreshaped and a verifier compares it against a digest it recomputes, not against a hash of one.

**Why the launch measurement is not sufficient.** `SNP_REPORT.measurement` and TDX's `MRTD` are fixed at boot. They do not move when the Cedar bundle reloads mid-session, so without this binding the policy actually in force is committed to nothing. The TPM tier solves the same problem with a `TPM_NT_EXTEND` NV index; these platforms have no such index.

**Applies to** the `sev-snp`, `tdx` and `azure-cvm-sev-snp` providers. The `tpm` provider keeps the random salt of §3.3, because its measurement is committed by the NV index instead, which keeps an append-only history that `report_data` does not.

**Freshness.** The salt is gone but freshness is not: 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.** `report_data` holds one value and no history, so a report produced before a bundle reload still looks well-formed on its own, and nothing in it says whether it is current. The gateway re-attests on **every** reload, including a reload that finds the bundle unchanged: the same digest signed now and that digest signed an hour ago are different assertions, and only the latest report reaches a verifier. The hook is `PolicyEvaluator._maybe_reload` calling `refresh_measurement_binding`, fired whenever `PolicyStore.reload_if_stale` reports that the bundle was re-read.

The cost is bounded by `policy_reload_interval_seconds`, not by request rate, because `reload_if_stale` stamps its clock before the attempt. It is zero in the default configuration, where reloading is off.

A gateway that fails to refresh is caught verifier-side, not runtime-side:

```
expected = gateway_measurement_digest_supplied_by_the_verifier
actual_nonce = base64url_decode(trace.runtime.nonce)
assert actual_nonce[32:64] == expected
```

The digest is compared directly, not re-hashed: it is already a raw 32-byte SHA-256. This is the one way the check differs from the AUDIT-006 one below it, which commits `SHA-256(chain_root)`.

`cmcp_verify.verify_trace_claim` implements this as step 7c, enabled by passing `expected_gateway_measurement` (raw 32 bytes, hex, or `sha256:`-prefixed hex). It is opt-in because the expected value has to be an out-of-band trust input, like `ApprovedHashes` or `trusted_ark_pem`: a check that read the expected digest out of the claim would be asking the claim to vouch for itself. A mismatch is `MEASUREMENT_NOT_BOUND` and is fatal in software-only mode too, since the digest is computed the same way there and a mismatch is a real disagreement about what is running.

A re-attestation that fails is logged and the previous report is kept rather than the gateway refusing traffic; the stale binding fails the check above, so the weakness is detectable rather than silent.

**Which report carries it.** This binding is on the gateway's **startup** report. A TRACE Claim for a session carries the per-session report instead when one was produced (AUDIT-006), and that report commits the audit-chain root in the same bytes. The startup report reaches a claim only where no per-session report was produced. Committing both gateway identity and session activity in one 64-byte field is not possible as the layout stands, and #552 scopes the audit-chain-root binding out.

**Not the per-session report.** AUDIT-006 (§3.3.1 and below) puts `SHA-256(chain_root)` in `report_data[32:64]` of the *per-session* report. This section governs the *startup* report. The two are different reports and do not contend for the field.

**Session binding** is carried separately, by `gateway.session_id` inside the Ed25519-signed claim body: not by the nonce. The hardware report is generated once per enclave instance at startup, before any session exists, so it cannot bind a specific `session_id`. Because the signature covers `session_id`, a claim cannot be presented under a different session without breaking verification. See §3.3.1.

#### 3.3.1 Session binding
Expand Down
12 changes: 11 additions & 1 deletion src/cmcp_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def build_server(ctx: RuntimeContext) -> MCPServer:
from cmcp_runtime.mcp.server import MCPServer
from cmcp_runtime.policy.evaluator import PolicyEvaluator
from cmcp_runtime.session.manager import SessionManager
from cmcp_runtime.tee.report_binding import refresh_measurement_binding

# Resolve provider string to canonical platform name for Cedar context.
# Falls back to the raw provider string if not in the map (e.g. future providers).
Expand All @@ -65,7 +66,16 @@ def build_server(ctx: RuntimeContext) -> MCPServer:
# chain is backed by the durable SQLite store and TEE-anchored at creation.
session_manager = SessionManager(ctx)
session, audit_chain = session_manager.create_session()
policy_evaluator = PolicyEvaluator(bundle=ctx.policy_bundle, config=ctx.config)
# #552: a policy hot-reload changes what the gateway is running, and on SEV-SNP,
# TDX and Azure CVM that fact lives only in the current attestation report's
# report_data. Wire the reload to a re-attestation so the committed measurement
# is the live one; without this the binding is correct at startup and stale from
# the first reload onwards. A no-op on every other provider.
policy_evaluator = PolicyEvaluator(
bundle=ctx.policy_bundle,
config=ctx.config,
on_reload=lambda: refresh_measurement_binding(ctx),
)
proxy = CMCPProxy(
catalog=ctx.catalog,
policy_evaluator=policy_evaluator,
Expand Down
58 changes: 56 additions & 2 deletions src/cmcp_runtime/policy/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -50,8 +51,20 @@ class is instantiated. CedarBackend receives the already-loaded policy content
so the measured hash covers exactly the bytes that will be evaluated.
"""

def __init__(self, bundle: PolicyBundle | PolicyStore, config: Config) -> None:
def __init__(
self,
bundle: PolicyBundle | PolicyStore,
config: Config,
# Return value is ignored, so the hook is free to report what it did.
on_reload: Callable[[], object] | None = None,
) -> None:
self._mode = config.attestation.enforcement_mode
# #552: called after every policy-bundle reload so the platform can re-commit
# what is now in force. On SEV-SNP, TDX and Azure CVM the gateway measurement
# lives in the attestation report's report_data, which has no append-only
# history, so the report is only ever as current as the last time it was
# produced. None where nothing needs telling.
self._on_reload = on_reload
# #479: same effective vocabulary SessionManager derives from this same
# Config, so a session's max_sensitivity and this sensitivity_level_int
# can never disagree about what a custom label ranks as.
Expand Down Expand Up @@ -87,7 +100,7 @@ def __init__(self, bundle: PolicyBundle | PolicyStore, config: Config) -> None:

def _maybe_reload(self) -> None:
"""Check for a stale bundle and rebuild the CedarBackend if the hash changed."""
self._store.reload_if_stale()
reloaded = self._store.reload_if_stale()
bundle = self._store.bundle
if bundle.bundle_hash != self._current_hash:
combined_policy = "\n\n".join(
Expand All @@ -97,6 +110,47 @@ def _maybe_reload(self) -> None:
self._annotations = parse_policy_annotations(combined_policy)
self._current_hash = bundle.bundle_hash
logger.info("PolicyEvaluator backend refreshed: new_hash=%s", self._current_hash)
# #552 asks for a refresh on **every** policy-bundle reload, not only on the
# ones that moved the hash, so this sits outside the branch above.
# ``reload_if_stale`` returns True exactly when the bundle was re-read from
# disk, which is what "a reload" means here: False when reloading is off,
# when the interval has not elapsed, and when the read failed.
if reloaded:
self._notify_reload()

def _notify_reload(self) -> None:
"""Re-commit what is running after a policy-bundle reload (#552).

Fires on every reload, including one that found the bundle unchanged. The
TPM tier can afford to skip those because its NV index accumulates history;
``report_data`` holds one value and no history, so what a verifier gets is
only ever the last report the gateway produced. Re-signing on each reload is
what keeps that report an assertion about now rather than about whenever the
policy last happened to change.

This runs on the enforcement path, so the tool call that observes the reload
pays for the re-attestation. It is bounded by the reload interval, not by
request rate: ``reload_if_stale`` stamps its clock before the attempt, so the
cost is one TEE call per ``policy_reload_interval_seconds``, and none at all
in the default configuration where reloading is off.

A failing callback is logged and swallowed on purpose. The callback re-binds
the gateway measurement into a hardware report; if the TEE cannot produce one
right now, refusing traffic would trade a *detectable* weakness for an outage.
Stale report_data no longer matches the measurement a verifier recomputes, so
the claim is rejected at verification instead. This is the same trade AUDIT-006
makes in SessionManager.create_session for a failed per-session attestation.
"""
if self._on_reload is None:
return
try:
self._on_reload()
except Exception: # noqa: BLE001 - enforcement must not depend on the TEE
logger.warning(
"#552: post-reload attestation hook failed; report_data still commits "
"the previous policy bundle and verification will reject it",
exc_info=True,
)

def _advice_for_deny(self, policy_ids: tuple[str, ...]) -> dict[str, str]:
"""
Expand Down
Loading