diff --git a/README.md b/README.md index 3994827..c570838 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,50 @@ Every check is independent and reported separately: | `grounding` | citations reference real sources; grounding score is honest | | `signer_pin`| (optional) signer public key matches an expected key | +### What failure looks like + +Run the tamper walkthrough with no external services: + +```bash +python examples/tamper.py +``` + +It edits one part of a genuine receipt at a time and prints the independent +checks that reject each change: + +```text +=== Genuine receipt === +valid: True + +=== Source document edited after signing === +valid: False + [FAIL] sources - content hash mismatch: doc-eiffel + +=== Citation changed to an unknown source === +valid: False + [FAIL] signature - Ed25519 signature does not match payload + [FAIL] grounding - citation references unknown source doc-unknown + +=== Merkle root edited by hand === +valid: False + [FAIL] signature - Ed25519 signature does not match payload + [FAIL] merkle - recomputed Merkle root does not match signed root + +=== Payload edited and re-signed with another key (unpinned) === +valid: True + [PASS] signature + +=== Same re-signed receipt with the original signer pinned === +valid: False + [FAIL] signer_pin - receipt public key does not match expected signer + [PASS] signature +``` + +The last two verdicts are the key-trust boundary in concrete form. A valid +signature proves that the payload was signed by the public key embedded in the +receipt. To prove that a known issuer signed it, pass that issuer's public key +as `expected_public_key`. + ## Merkle inclusion proofs Prove one source was in the retrieval set without revealing the rest: @@ -218,6 +262,7 @@ cd answerproof pip install -e ".[dev]" pytest -q # full test suite python examples/demo_rag.py # produce + verify a receipt, then tamper and re-verify +python examples/tamper.py # inspect four tamper paths and signer pinning ``` ## Contributing diff --git a/examples/tamper.py b/examples/tamper.py new file mode 100644 index 0000000..47c7b97 --- /dev/null +++ b/examples/tamper.py @@ -0,0 +1,116 @@ +"""Show how each independent receipt check reports tampering. + +Run with no external services: + + python examples/tamper.py +""" + +from __future__ import annotations + +from answerproof import ReceiptBuilder, SigningKey, verify_receipt +from answerproof.schema import Receipt, Signature +from answerproof.verifier import Verdict + +SOURCES = { + "doc-eiffel": "The Eiffel Tower is in Paris, France.", + "doc-height": "The Eiffel Tower is 330 metres tall.", +} + + +def clone(receipt: Receipt) -> Receipt: + """Round-trip through the public JSON format before hostile edits.""" + return Receipt.from_json(receipt.to_json()) + + +def format_verdict(title: str, verdict: Verdict, *, show_passes: tuple[str, ...] = ()) -> str: + lines = [f"=== {title} ===", f"valid: {verdict.valid}"] + for check in verdict.failures(): + lines.append(f" [FAIL] {check.name} - {check.detail}") + by_name = {check.name: check for check in verdict.checks} + for name in show_passes: + check = by_name[name] + if not check.passed: + raise RuntimeError(f"expected {name} to pass") + lines.append(f" [PASS] {name}") + return "\n".join(lines) + + +def build_receipt(signing_key: SigningKey) -> Receipt: + builder = ReceiptBuilder(signing_key) + builder.set_query("Where is the Eiffel Tower, and how tall is it?") + builder.set_answer("The Eiffel Tower is in Paris, France. The Eiffel Tower is 330 metres tall.") + builder.set_principal("auditor-7", permissions=["kb:paris"], tenant="demo") + builder.set_model("demo-model", provider="local", params={"temperature": 0.0}) + for source_id, content in SOURCES.items(): + builder.add_source(source_id, content=content) + return builder.finalize(receipt_id="tamper-demo") + + +def main() -> None: + original_key = SigningKey.generate() + receipt = build_receipt(original_key) + sections = [ + format_verdict( + "Genuine receipt", + verify_receipt(receipt, source_contents=SOURCES), + ) + ] + + edited_sources = dict(SOURCES) + edited_sources["doc-eiffel"] = "The Eiffel Tower is in Berlin, Germany." + sections.append( + format_verdict( + "Source document edited after signing", + verify_receipt(receipt, source_contents=edited_sources), + ) + ) + + citation_tamper = clone(receipt) + citation_tamper.payload.citations[0].source_id = "doc-unknown" + sections.append( + format_verdict( + "Citation changed to an unknown source", + verify_receipt(citation_tamper, source_contents=SOURCES), + ) + ) + + merkle_tamper = clone(receipt) + merkle_tamper.payload.merkle_root = "00" * 32 + sections.append( + format_verdict( + "Merkle root edited by hand", + verify_receipt(merkle_tamper, source_contents=SOURCES), + ) + ) + + attacker_key = SigningKey.generate() + resigned = clone(receipt) + resigned.payload.query = "A substituted query" + resigned.signature = Signature( + public_key=attacker_key.verify_key.to_base64(), + signature=attacker_key.sign(resigned.payload.canonical_bytes()), + ) + sections.append( + format_verdict( + "Payload edited and re-signed with another key (unpinned)", + verify_receipt(resigned, source_contents=SOURCES), + show_passes=("signature",), + ) + ) + sections.append( + format_verdict( + "Same re-signed receipt with the original signer pinned", + verify_receipt( + resigned, + source_contents=SOURCES, + expected_public_key=original_key.verify_key.to_base64(), + ), + show_passes=("signature",), + ) + ) + + print("\n\n".join(sections)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_tamper_example.py b/tests/test_tamper_example.py new file mode 100644 index 0000000..d7cf000 --- /dev/null +++ b/tests/test_tamper_example.py @@ -0,0 +1,54 @@ +"""The documented tamper walkthrough stays runnable and deterministic.""" + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_tamper_example_prints_each_verification_boundary(): + completed = subprocess.run( + [sys.executable, "examples/tamper.py"], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr + assert completed.stderr == "" + assert ( + completed.stdout + == """=== Genuine receipt === +valid: True + +=== Source document edited after signing === +valid: False + [FAIL] sources - content hash mismatch: doc-eiffel + +=== Citation changed to an unknown source === +valid: False + [FAIL] signature - Ed25519 signature does not match payload + [FAIL] grounding - citation references unknown source doc-unknown + +=== Merkle root edited by hand === +valid: False + [FAIL] signature - Ed25519 signature does not match payload + [FAIL] merkle - recomputed Merkle root does not match signed root + +=== Payload edited and re-signed with another key (unpinned) === +valid: True + [PASS] signature + +=== Same re-signed receipt with the original signer pinned === +valid: False + [FAIL] signer_pin - receipt public key does not match expected signer + [PASS] signature +""" + ) + + readme = (ROOT / "README.md").read_text() + section = readme.split("### What failure looks like", 1)[1] + documented = section.split("```text\n", 1)[1].split("\n```", 1)[0] + "\n" + assert documented == completed.stdout