diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e46f618..c097beae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,22 @@ All notable changes to vouch are documented here. Format follows artifact the caller could not already retrieve, and it touches no write path. ### Fixed +- **security: empty-quote receipts no longer clear the auto-approve gate** + (#513 reopened, root-caused): `verify_receipt` and `verify_evidence` both + guarded only on `quote is None`, not an empty string. An `Evidence` with + `quote=""` and a zero-length span (`byte_start == byte_end`) decodes to + `""`, trivially string-equals the empty quote, and returned `VERIFIED` — + so a claim citing only a forged, content-free receipt cleared + `evaluate_claim_receipts` and, with `review.auto_approve_on_receipt` + (the starter-config default), landed as a durable, approved claim with + zero real evidentiary backing. `Evidence.quote` carries no min-length + constraint at the model layer and `bundle`/`sync` intake write incoming + Evidence straight to disk after schema validation only, so this was + reachable from a hand-crafted bundle or a malicious federation peer, not + just the normal propose path (which already routes through + `locate_span`'s existing empty-quote guard on the *mint* side). Both + guards now reject an empty quote the same way `locate_span` already + does, closing the gap on the *verify* side. - **`notify sweep`'s backlog alert re-arms after dropping below threshold** (#652): the `queue.backlogged` idempotence marker was a one-way latch, cleared only when the pending queue reached exactly zero — not when it diff --git a/src/vouch/receipts.py b/src/vouch/receipts.py index 00ac10a1..12416ff3 100644 --- a/src/vouch/receipts.py +++ b/src/vouch/receipts.py @@ -58,7 +58,7 @@ def verify_receipt(evidence: Evidence, source_bytes: bytes) -> ReceiptResult: when the decoded span equals the quote exactly. """ start, end, quote = evidence.byte_start, evidence.byte_end, evidence.quote - if start is None or end is None or quote is None: + if start is None or end is None or not quote: return ReceiptResult(ReceiptStatus.NO_RECEIPT, "no byte-offset span") if start > end or end > len(source_bytes): return ReceiptResult( @@ -184,7 +184,7 @@ def verify_evidence(store: KBStore, evidence: Evidence) -> ReceiptResult: """ from .storage import ArtifactNotFoundError - if evidence.byte_start is None or evidence.byte_end is None or evidence.quote is None: + if evidence.byte_start is None or evidence.byte_end is None or not evidence.quote: return ReceiptResult(ReceiptStatus.NO_RECEIPT, "no byte-offset span") try: source_bytes = store.read_source_content(evidence.source_id) diff --git a/tests/test_receipts.py b/tests/test_receipts.py index 23c30eb0..573251ef 100644 --- a/tests/test_receipts.py +++ b/tests/test_receipts.py @@ -98,6 +98,16 @@ def test_no_receipt_when_quote_absent() -> None: assert result.status is ReceiptStatus.NO_RECEIPT +def test_no_receipt_when_quote_is_empty_string() -> None: + # an empty quote with a zero-length span decodes to "" and trivially + # equals the quote — the gate must treat this as nothing to compare, + # never as a verified receipt for an attacker-chosen claim. + ev = _ev(quote="", byte_start=0, byte_end=0) + result = verify_receipt(ev, SOURCE) + assert result.status is ReceiptStatus.NO_RECEIPT + assert result.verified is False + + def test_receipt_uses_byte_offsets_not_char_offsets() -> None: # "café — au lait": 'é' is 2 bytes (0xc3 0xa9), '—' is 3 bytes (em dash). # "au lait" starts at char index 7 but byte index 10. A char-offset @@ -160,6 +170,30 @@ def test_verify_evidence_not_verified_when_source_missing(store: KBStore) -> Non assert result.verified is False +def test_verify_evidence_no_receipt_for_empty_quote(store: KBStore) -> None: + src = store.put_source(b"the quick brown fox", title="t") + ev = Evidence( + id="e4", source_id=src.id, locator="b0-0", + quote="", byte_start=0, byte_end=0, + ) + assert verify_evidence(store, ev).status is ReceiptStatus.NO_RECEIPT + + +def test_claim_gate_rejects_forged_empty_quote_receipt(store: KBStore) -> None: + # end-to-end: a claim citing only an empty-quote, zero-length-span + # Evidence must never clear the mechanical auto-approve gate, even + # though `source_bytes[0:0] == ""` trivially string-equals the quote. + src = store.put_source(b"the quick brown fox", title="t") + ev = store.put_evidence( + Evidence( + id="forged-empty", source_id=src.id, locator="b0-0", + quote="", byte_start=0, byte_end=0, + ) + ) + verdict = evaluate_claim_receipts(store, [ev.id]) + assert verdict.approve is False + + # ---- the quote step: locate a span, or drop what cannot be quoted ----