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
12 changes: 11 additions & 1 deletion src/cmcp_verify/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions tests/conformance/test_audit_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
Expand Down
52 changes: 50 additions & 2 deletions tests/unit/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
26 changes: 18 additions & 8 deletions tests/unit/test_verify_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down