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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
The schema is force-included in the wheel alongside the catalog entry schema, and `scripts/verify_python_distribution.py` checks both resolve to a file inside the installed distribution, so a wheel that drops one fails the release smoke test rather than a verifier at runtime. A unit test asserts the force-include mapping, since that line is the only thing putting these files next to the code.

`previous_record_hash` on the first record in a chain is the all-zero digest, `sha256:` followed by 64 zeros, stated in both the schema and the spec doc. It validated against the digest pattern before and meant nothing, which is the worst combination: representable and unspecified.
- **`cmcp verify` could not authenticate a TPM attestation-key certificate chain.** The
verifier library already required a caller-supplied `trusted_tpm_ca_pem`, but the CLI
had no way to provide it, so otherwise complete TPM 2.0 evidence remained partially
verified. `--trusted-tpm-ca` now accepts a verifier-owned PEM certificate bundle,
validates it before verification, and passes it only to the TPM trust input. It does
not configure AMD SEV-SNP or Intel TDX trust anchors.

- **A single trusted reviewer key could approve any catalog change, under any policy (#517, follow-up to #519).** `verify_catalog_change` read `threshold`, `distinct_principals`, and `distinct_roles` out of the record it was verifying, and checked `policy_hash` for digest shape only: never recomputed over the policy body, never compared to anything. A record declaring `threshold: 1` with arbitrary bytes in `policy_hash` verified on one signature, so the M-of-N property the module advertises was unenforced. This is the failure the spec doc already forbids for keys, "no record-embedded key can bootstrap trust", applied to the policy instead. M is only meaningful when it comes from verifier-side configuration.

Expand All @@ -26,7 +32,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
The distinctness rules did not close the property on their own, because nothing bound approvals to distinct keys. A threshold of three under a policy that does not require distinct principals was satisfied by one key signing the identical approval three times, and a threshold of two under a policy that does require distinct roles was satisfied by one key signing twice as two different roles, since a `TrustedReviewer` carrying no role lets the record assert whichever role it likes. A reviewer key now counts once per record whatever the policy says: a repeated signature is one approval presented N times, not N approvals. The check runs after the principal and role rules so those keep reporting the more specific cause.

The four tests that shipped with #519 passed whether or not any of these checks existed. The suite is now 31 tests, each guard verified by deleting it and confirming a test fails. One is xfailed on purpose: the schema sets a minimum of zero on the approval timestamps and the verifier does not, which is left failing until the schema-wiring decision lands.

- **The catalog-approval signing input was not the JCS it claims to be (#517).** `canonical_json` is documented as RFC 8785 compatible and serialized with `ensure_ascii=True`, which is the one thing JCS does not do: it emits an ASCII escape where the standard emits UTF-8. Any record carrying a non-ASCII `principal_id`, `issuer`, `role`, `catalog_id`, or `policy_id` was signed over different bytes than a conforming producer signs, so a record produced anywhere but here failed with an invalid-signature error that points nowhere near the encoding. `sort_keys` was the second divergence, ordering members by code point where JCS orders by UTF-16 code unit. The two disagree for any key outside the BMP, since a surrogate pair leads with `0xD800` and sorts below a BMP character above `0xE000`.

Members are now ordered on their UTF-16BE bytes and the output is UTF-8. Values JCS cannot pin down are refused rather than serialized into a signing input that two implementations would read differently: floating point numbers, integers beyond `2**53 - 1`, non-string keys, and unpaired surrogates, each as `CatalogApprovalError` rather than as an escaping `UnicodeEncodeError` or `TypeError`. Approval records carry none of those, so refusing them only closes a door.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ Environment variables:
| `cmcp start` | `--config PATH` (required) | Start the gateway |
| `cmcp validate-config` | `--config PATH` (required) | Validate `cmcp-config.yaml` without starting |
| `cmcp validate-bundle` | `--bundle-path PATH` (required), `--expected-hash sha256:<hex>` (required) | Verify a Cedar bundle hash before deployment |
| `cmcp verify` | `CLAIM_FILE` (required); `--policy-hash`, `--catalog-hash`, `--max-age`, `--trusted-key`, `--audit-bundle`, `--agent-manifest`, `--agent-manifest-trust-anchor` | Verify a signed TRACE Claim (signature, schema, freshness, audit chain, and pinned hashes) |
| `cmcp verify` | `CLAIM_FILE` (required); `--policy-hash`, `--catalog-hash`, `--max-age`, `--trusted-key`, `--trusted-tpm-ca`, `--audit-bundle`, `--agent-manifest`, `--agent-manifest-trust-anchor` | Verify a signed TRACE Claim (signature, schema, freshness, audit chain, pinned hashes, and trust anchors) |

---

Expand Down
11 changes: 10 additions & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,16 @@ cmcp verify claim.json \
--catalog-hash "$(python3 -c "import json; print(json.load(open('claim.json'))['gateway']['catalog']['hash'])")"
```

On a real TEE host the `hardware_attestation` check passes and the overall result becomes `verified`.
On a real TPM 2.0 host, pass a verifier-owned CA certificate bundle to authenticate the
attestation-key chain:

```bash
cmcp verify claim.json --trusted-tpm-ca /etc/cmcp/trust/tpm-ca-roots.pem
```

The TPM trust bundle is necessary but not sufficient: the claim must also carry a valid
signed quote and attestation-key evidence. This option is TPM-only; it does not configure
AMD SEV-SNP or Intel TDX trust anchors.

The `cmcp_verify` Python library is also available for programmatic checks (`from cmcp_verify import verify_trace_claim, ApprovedHashes`).

Expand Down
23 changes: 23 additions & 0 deletions docs/tutorials/verifying-a-trace-claim.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ result = verify_trace_claim(

---

## Verify a TPM claim from the CLI

For a TPM 2.0 claim, supply the attestation-key CA certificates your verifier trusts:

```bash
cmcp verify claim.json \
--policy-hash sha256:abc123... \
--catalog-hash sha256:def456... \
--trusted-tpm-ca /etc/cmcp/trust/tpm-ca-roots.pem
```

The PEM file may contain one or more verifier-approved CA certificates. Keep it in a
verifier-controlled trust store; do not obtain the trust bundle from the claim or the
runtime that produced the claim. A valid CA bundle is one input to TPM verification,
not a substitute for the signed quote and attestation-key evidence carried by the
claim.

`--trusted-tpm-ca` is deliberately TPM-only. It does not configure AMD SEV-SNP or
Intel TDX trust anchors, and it does not change how claims from those platforms are
evaluated.

---

## Read the VerificationResult

`VerificationResult` has these fields:
Expand Down
36 changes: 36 additions & 0 deletions src/cmcp_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,33 @@ def build_server(ctx: RuntimeContext) -> MCPServer:
)


def _load_tpm_ca_bundle(path: str | None) -> bytes | None:
"""Load and parse-check a caller-pinned TPM CA certificate bundle."""
if path is None:
return None

from cryptography import x509

try:
with open(path, "rb") as bundle_file:
bundle = bundle_file.read()
except OSError as exc:
raise click.ClickException(f"Could not read TPM CA bundle {path!r}: {exc}") from exc

try:
certificates = x509.load_pem_x509_certificates(bundle)
except ValueError as exc:
raise click.ClickException(
"TPM CA bundle must contain PEM-encoded X.509 certificates"
) from exc
if not certificates:
raise click.ClickException(
"TPM CA bundle must contain PEM-encoded X.509 certificates"
)

return bundle


@click.group()
@click.version_option(__version__, prog_name="cmcp")
def main() -> None:
Expand Down Expand Up @@ -166,6 +193,12 @@ def client_bridge(gateway_url: str, token_env: str) -> None:
help="Signed Agent Manifest to cross-check against the Trust Record.")
@click.option("--agent-manifest-trust-anchor", default=None, type=click.Path(exists=True),
help="JSON issuer public key trust anchor for --agent-manifest.")
@click.option(
"--trusted-tpm-ca",
default=None,
type=click.Path(exists=True, dir_okay=False, readable=True),
help="TPM 2.0 claims only: verifier-pinned CA certificate bundle (PEM).",
)
def verify(
claim_file: str,
policy_hash: str | None,
Expand All @@ -175,6 +208,7 @@ def verify(
audit_bundle: str | None,
agent_manifest: str | None,
agent_manifest_trust_anchor: str | None,
trusted_tpm_ca: str | None,
) -> None:
"""Verify a signed TRACE Claim (and optionally its audit bundle).

Expand Down Expand Up @@ -218,13 +252,15 @@ def verify(
)
raise SystemExit(1)

tpm_ca_bundle = _load_tpm_ca_bundle(trusted_tpm_ca)
result = verify_trace_claim(
claim,
approved,
max_attestation_age_seconds=max_age,
trusted_public_key_hex=trusted_key,
agent_manifest=manifest_json,
trusted_agent_manifest_keys=manifest_keys,
trusted_tpm_ca_pem=tpm_ca_bundle,
)

def _line(name: str, ok: bool, note: str = "") -> None:
Expand Down
57 changes: 57 additions & 0 deletions tests/unit/test_verify_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
from click.testing import CliRunner
from starlette.testclient import TestClient

import cmcp_verify
from cmcp_runtime.audit.keys import SigningKey
from cmcp_runtime.cli import build_server, main
from cmcp_runtime.config import AttestationConfig, Config
from cmcp_runtime.policy.bundle import PolicyStore
from cmcp_runtime.startup import RuntimeContext
from cmcp_verify.tpm_roots import AZURE_VTPM_ROOT_2023_PEM


@pytest.fixture
Expand Down Expand Up @@ -135,3 +137,58 @@ def test_verify_fails_on_tampered_audit_bundle(claim_and_bundle, tmp_path):
])
assert result.exit_code == 1
assert "RESULT: FAIL" in result.output


def test_verify_threads_only_the_tpm_ca_bundle(claim_and_bundle, tmp_path, monkeypatch):
claim_file, _, _, _ = claim_and_bundle
ca_path = tmp_path / "tpm-ca.pem"
ca_path.write_bytes(AZURE_VTPM_ROOT_2023_PEM)

captured: dict[str, object] = {}
real_verify = cmcp_verify.verify_trace_claim

def capture_verify(*args, **kwargs):
captured.update(kwargs)
return real_verify(*args, **kwargs)

monkeypatch.setattr(cmcp_verify, "verify_trace_claim", capture_verify)

result = CliRunner().invoke(
main,
["verify", str(claim_file), "--trusted-tpm-ca", str(ca_path)],
)

assert result.exit_code == 1, result.output
assert result.output.endswith("RESULT: FAIL (partially_verified)\n")
assert captured["trusted_tpm_ca_pem"] == AZURE_VTPM_ROOT_2023_PEM
assert "trusted_ark_pem" not in captured
assert "trusted_intel_root_pem" not in captured


def test_verify_rejects_invalid_tpm_ca_bundle(claim_and_bundle, tmp_path, monkeypatch):
claim_file, _, _, _ = claim_and_bundle
ca_path = tmp_path / "not-a-certificate.pem"
ca_path.write_text("this is not a PEM certificate")

def must_not_verify(*args, **kwargs):
pytest.fail("verify_trace_claim must not run with an invalid TPM CA bundle")

monkeypatch.setattr(cmcp_verify, "verify_trace_claim", must_not_verify)

result = CliRunner().invoke(
main,
["verify", str(claim_file), "--trusted-tpm-ca", str(ca_path)],
)

assert result.exit_code == 1, result.output
assert "TPM CA bundle must contain PEM-encoded X.509 certificates" in result.output


def test_verify_help_keeps_the_new_trust_anchor_tpm_only():
result = CliRunner().invoke(main, ["verify", "--help"])

assert result.exit_code == 0, result.output
assert "--trusted-tpm-ca" in result.output
assert "TPM 2.0 claims only" in result.output
assert "--trusted-amd" not in result.output
assert "--trusted-intel" not in result.output