diff --git a/CHANGELOG.md b/CHANGELOG.md index d327af12..7577ee89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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. diff --git a/README.md b/README.md index 870f0a90..5380962d 100644 --- a/README.md +++ b/README.md @@ -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:` (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) | --- diff --git a/docs/quickstart.md b/docs/quickstart.md index c5e58fa1..014015b9 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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`). diff --git a/docs/tutorials/verifying-a-trace-claim.md b/docs/tutorials/verifying-a-trace-claim.md index daa33b4a..b38306f8 100644 --- a/docs/tutorials/verifying-a-trace-claim.md +++ b/docs/tutorials/verifying-a-trace-claim.md @@ -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: diff --git a/src/cmcp_runtime/cli.py b/src/cmcp_runtime/cli.py index f0e2ab28..ab1f1d87 100644 --- a/src/cmcp_runtime/cli.py +++ b/src/cmcp_runtime/cli.py @@ -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: @@ -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, @@ -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). @@ -218,6 +252,7 @@ def verify( ) raise SystemExit(1) + tpm_ca_bundle = _load_tpm_ca_bundle(trusted_tpm_ca) result = verify_trace_claim( claim, approved, @@ -225,6 +260,7 @@ def verify( 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: diff --git a/tests/unit/test_verify_command.py b/tests/unit/test_verify_command.py index 74ae1274..62f18204 100644 --- a/tests/unit/test_verify_command.py +++ b/tests/unit/test_verify_command.py @@ -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 @@ -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