Skip to content
Open
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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions examples/tamper.py
Original file line number Diff line number Diff line change
@@ -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()
54 changes: 54 additions & 0 deletions tests/test_tamper_example.py
Original file line number Diff line number Diff line change
@@ -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
Loading