From 4d406b3ba2cd31b38b8673a76cf042bd9dfd389f Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Tue, 30 Jun 2026 12:19:01 -0700 Subject: [PATCH] fix(verify): fail closed on software-only and misbound external receipts Two fail-closed fixes in cmcp_verify.verify: 1. Software-only claims are no longer reported as VERIFIED. Previously a self-consistent software-only claim (no hardware-backed attestation) ended with failure=None and the status decision returned VERIFIED, contradicting LIMITATIONS.md, which states such claims are partially_verified. The status decision now forces PARTIALLY_VERIFIED whenever hardware_attestation is unverified and there is otherwise no failure. A real failure still takes precedence and is not downgraded. 2. External-execution-evidence misbinding now short-circuits. When external_evidence_keys is supplied and a receipt's linked_call_id does not match the entry call_id, verification recorded the mismatch but fell through and still ran the signature check, so a receipt bound to a different call could be reported signature-valid. A `continue` now stops processing that entry after the binding mismatch. Tests: software-only claim returns PARTIALLY_VERIFIED (not VERIFIED); hardware-backed happy path still returns VERIFIED; a real failure keeps its failure status; a linked_call_id mismatch is rejected and the receipt is not also reported signature-valid. Updated the CLI verify tests, which used a software-only fixture and previously asserted a full pass. Tracked Wave 2 follow-ups (not addressed here, require hardware-validated work): binding the audit-chain root into the attested report; making the cnf.jwk<->report_data binding fatal for SEV-SNP/TDX and fixing the TDX report_data offset; verifying the attestation report signatures (VCEK/DCAP/EK). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cmcp_verify/verify.py | 12 ++++- tests/conformance/test_audit_conformance.py | 8 ++++ tests/unit/test_verify.py | 52 ++++++++++++++++++++- tests/unit/test_verify_command.py | 26 +++++++---- 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/cmcp_verify/verify.py b/src/cmcp_verify/verify.py index 3048ea1..1f52ed0 100644 --- a/src/cmcp_verify/verify.py +++ b/src/cmcp_verify/verify.py @@ -347,6 +347,9 @@ def verify_audit_bundle( "the entry call_id", ) ) + # Fail closed: a receipt bound to a different call_id must never + # also be reported as signature-valid. Stop processing this entry. + continue key_id = ev.get("issuer_key_id", "") if not isinstance(key_id, str) or not _ISSUER_KEY_ID_RE.match(key_id): failures.append( @@ -747,7 +750,14 @@ def verify_trace_claim( # Determine overall status if failure is None: - status = VerificationStatus.VERIFIED + # Fail closed: a claim with no hardware-backed attestation (software-only + # or any non-hardware-backed path) is never fully VERIFIED, even when it is + # otherwise self-consistent. See LIMITATIONS.md. A real failure below still + # takes precedence and is not downgraded to partial. + if "hardware_attestation" in unverified: + status = VerificationStatus.PARTIALLY_VERIFIED + else: + status = VerificationStatus.VERIFIED elif verified: status = VerificationStatus.PARTIALLY_VERIFIED else: diff --git a/tests/conformance/test_audit_conformance.py b/tests/conformance/test_audit_conformance.py index 9618f81..6de1be0 100644 --- a/tests/conformance/test_audit_conformance.py +++ b/tests/conformance/test_audit_conformance.py @@ -441,6 +441,10 @@ def test_tampered_receipt_fails(self): assert any("signature is invalid" in f for f in result.failures) def test_linked_call_id_mismatch_fails(self): + # A receipt that is internally self-consistent (correctly signed over its + # own fields) but bound to a different call_id must be rejected, and the + # verifier must short-circuit so the misbound receipt is never also + # reported as signature-valid. priv, pub, key_id = _ed25519_keypair() chain = AuditChain("sess-301-d") chain.append( @@ -457,6 +461,10 @@ def test_linked_call_id_mismatch_fails(self): ) assert not result.verified assert any("linked_call_id" in f for f in result.failures) + # The signature check must not run after the binding mismatch: there must + # be no signature-validity outcome recorded for the misbound receipt. + assert not any("signature is invalid" in f for f in result.failures) + assert not any("could not be verified" in f for f in result.failures) def test_unknown_issuer_key_fails(self): priv, _, key_id = _ed25519_keypair() diff --git a/tests/unit/test_verify.py b/tests/unit/test_verify.py index c322192..2ff7bae 100644 --- a/tests/unit/test_verify.py +++ b/tests/unit/test_verify.py @@ -291,11 +291,59 @@ def test_missing_audit_chain_root_fails(): def test_software_only_provider_is_partially_verified(): - """software-only attestation is never fully VERIFIED.""" + """software-only attestation is never fully VERIFIED, even when otherwise self-consistent. + + Without hardware-backed attestation the claim must fail closed to + PARTIALLY_VERIFIED (see LIMITATIONS.md), never VERIFIED. + """ claim_dict, _ = _make_signed_claim() result = verify_trace_claim(claim_dict, _approved()) - assert result.status in (VerificationStatus.PARTIALLY_VERIFIED, VerificationStatus.VERIFIED) assert "hardware_attestation" in result.unverified_fields + assert result.status == VerificationStatus.PARTIALLY_VERIFIED + + +def test_hardware_backed_happy_path_is_verified(monkeypatch): + """A hardware-backed claim whose attestation verifies is fully VERIFIED. + + The software-only fail-closed rule must not downgrade a genuine + hardware-backed claim that has no failures. + """ + import cmcp_verify.tdx as tdx_mod + from cmcp_verify.tdx import TDXVerificationResult + + def _passing_tdx(*args, **kwargs): + return TDXVerificationResult( + verified=True, + verified_fields=["measurement", "report_data"], + ) + + monkeypatch.setattr(tdx_mod, "verify_tdx_measurement", _passing_tdx) + + claim_dict, key = _make_signed_claim(provider="tdx") + result = verify_trace_claim( + claim_dict, _approved(), trusted_public_key_hex=key.public_key_hex + ) + assert result.failure_reason is None, result.details + assert "hardware_attestation" in result.verified_fields + assert "hardware_attestation" not in result.unverified_fields + assert result.status == VerificationStatus.VERIFIED + + +def test_real_failure_is_not_downgraded_to_partial(): + """A genuine failure keeps its failure status; the software-only rule never + flips a real failure (here, a mismatched policy hash) to PARTIALLY_VERIFIED + in a way that hides it.""" + claim_dict, _ = _make_signed_claim() + approved = ApprovedHashes( + policy_bundle_hash="sha256:" + "f" * 64, tool_catalog_hash=CATALOG_HASH + ) + result = verify_trace_claim(claim_dict, approved) + assert result.failure_reason is not None + assert "policy_bundle.hash" in result.unverified_fields + assert result.status in ( + VerificationStatus.PARTIALLY_VERIFIED, + VerificationStatus.UNVERIFIED, + ) def test_all_software_only_verified_fields_are_present(): diff --git a/tests/unit/test_verify_command.py b/tests/unit/test_verify_command.py index f714583..74ae127 100644 --- a/tests/unit/test_verify_command.py +++ b/tests/unit/test_verify_command.py @@ -65,23 +65,30 @@ def claim_and_bundle(tmp_path): return claim_file, bundle_file, claim, bundle -def test_verify_passes_on_genuine_claim(claim_and_bundle): +def test_verify_software_only_is_partially_verified(claim_and_bundle): + # The fixture is a software-only (dev mode) claim: every cryptographic check + # passes, but with no hardware-backed attestation the verifier fails closed + # to partially_verified, so the CLI reports FAIL and exits non-zero. claim_file, _, _, _ = claim_and_bundle result = CliRunner().invoke(main, ["verify", str(claim_file)]) - assert result.exit_code == 0, result.output - assert "RESULT: PASS" in result.output - assert "signature" in result.output + assert result.exit_code == 1, result.output + assert "RESULT: FAIL (partially_verified)" in result.output + assert "signature PASS" in result.output + assert "hardware_attestation FAIL" in result.output assert "not pinned" in result.output # hashes unpinned by default -def test_verify_passes_with_pinned_hashes(claim_and_bundle): +def test_verify_pinned_hashes_still_partial_without_hardware(claim_and_bundle): + # Pinning the hashes does not grant a software-only claim a full pass; it is + # still partially_verified because hardware attestation is absent. claim_file, _, claim, _ = claim_and_bundle result = CliRunner().invoke(main, [ "verify", str(claim_file), "--policy-hash", claim["trace"]["policy"]["bundle_hash"], "--catalog-hash", claim["gateway"]["catalog"]["hash"], ]) - assert result.exit_code == 0, result.output + assert result.exit_code == 1, result.output + assert "RESULT: FAIL (partially_verified)" in result.output def test_verify_fails_with_wrong_pinned_hash(claim_and_bundle): @@ -106,12 +113,15 @@ def test_verify_fails_on_tampered_claim(claim_and_bundle, tmp_path): def test_verify_audit_bundle_passes(claim_and_bundle): + # The audit bundle itself verifies (PASS), but the software-only claim is + # only partially_verified, so the overall CLI result is still FAIL. claim_file, bundle_file, _, _ = claim_and_bundle result = CliRunner().invoke(main, [ "verify", str(claim_file), "--audit-bundle", str(bundle_file), ]) - assert result.exit_code == 0, result.output - assert "audit_bundle" in result.output + assert result.exit_code == 1, result.output + assert "audit_bundle PASS" in result.output + assert "RESULT: FAIL (partially_verified)" in result.output def test_verify_fails_on_tampered_audit_bundle(claim_and_bundle, tmp_path):