From a50e65e18a885d084c212298bb4c3d1ebf671fec Mon Sep 17 00:00:00 2001 From: eunwoo song Date: Mon, 7 Sep 2026 02:09:57 +0900 Subject: [PATCH] fix: require complete source verification --- CHANGELOG.md | 6 ++++++ README.md | 4 ++-- src/answerproof/verifier.py | 19 +++++++++++++------ tests/test_verifier.py | 27 +++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d366cb..a515f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- Explicitly supplied source contents must now cover every source in the + receipt; empty and partial mappings report the missing source IDs and fail + verification. + ## [0.1.0] - 2026-07-27 ### Added diff --git a/README.md b/README.md index c570838..ab25e37 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Every check is independent and reported separately: | ----------- | -------------------------------------------------------------- | | `signature` | payload is unmodified and signed by the embedded key | | `merkle` | recomputed Merkle root matches the signed root | -| `sources` | supplied source contents hash to the recorded hashes | +| `sources` | every recorded source has supplied content matching its hash | | `grounding` | citations reference real sources; grounding score is honest | | `signer_pin`| (optional) signer public key matches an expected key | @@ -214,7 +214,7 @@ Merkle forgery vectors. answerproof keygen # print a keypair as JSON answerproof keygen --out id.key # write id.key and id.key.pub answerproof verify receipt.json # verify (exit 0 = valid, 1 = invalid) -answerproof verify receipt.json --sources sources.json --json +answerproof verify receipt.json --sources sources.json --json # complete source-id mapping answerproof verify receipt.json --expect-key # pin the signer answerproof inspect receipt.json # human-readable summary ``` diff --git a/src/answerproof/verifier.py b/src/answerproof/verifier.py index 9b85d52..91c0112 100644 --- a/src/answerproof/verifier.py +++ b/src/answerproof/verifier.py @@ -73,7 +73,7 @@ def verify_merkle(receipt: Receipt) -> CheckResult: def verify_sources(receipt: Receipt, contents: dict[str, str]) -> CheckResult: - """Check that supplied source contents match their recorded hashes.""" + """Check that every recorded source has supplied, matching content.""" by_id = {s.id: s for s in receipt.payload.sources} mismatched: list[str] = [] checked = 0 @@ -85,8 +85,14 @@ def verify_sources(receipt: Receipt, contents: dict[str, str]) -> CheckResult: checked += 1 if not verify_content(content, source.content_hash): mismatched.append(sid) + missing = [source.id for source in receipt.payload.sources if source.id not in contents] + problems: list[str] = [] if mismatched: - return CheckResult("sources", False, "content hash mismatch: " + ", ".join(mismatched)) + problems.append("content hash mismatch: " + ", ".join(mismatched)) + if missing: + problems.append("missing source content: " + ", ".join(missing)) + if problems: + return CheckResult("sources", False, "; ".join(problems)) return CheckResult("sources", True, f"{checked} source content(s) matched") @@ -148,9 +154,10 @@ def verify_receipt( ) -> Verdict: """Run all applicable checks and return a :class:`Verdict`. - ``source_contents`` maps source id -> original content; when supplied the - hashes are verified. ``expected_public_key`` pins the signer: if given and - it does not match the receipt's key, verification fails. + ``source_contents`` maps every source id to its original content; when the + mapping is supplied, missing entries and hash mismatches fail verification. + ``expected_public_key`` pins the signer: if given and it does not match the + receipt's key, verification fails. """ checks: list[CheckResult] = [] skipped: list[str] = [] @@ -169,7 +176,7 @@ def verify_receipt( checks.append(verify_merkle(receipt)) checks.append(verify_grounding(receipt)) - if source_contents: + if source_contents is not None: checks.append(verify_sources(receipt, source_contents)) else: skipped.append("sources (no source contents supplied)") diff --git a/tests/test_verifier.py b/tests/test_verifier.py index ab44fb8..58baf1d 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -18,6 +18,33 @@ def test_verify_without_sources_skips_source_check(receipt): assert any("sources" in s for s in verdict.skipped) +def test_explicit_empty_source_contents_fails_with_all_missing_ids(receipt): + verdict = verify_receipt(receipt, source_contents={}) + + assert not verdict.valid + source_check = next(c for c in verdict.checks if c.name == "sources") + assert not source_check.passed + assert source_check.detail == "missing source content: s1, s2, s3" + + +def test_partial_source_contents_fails_with_uncovered_ids(receipt, sources): + verdict = verify_receipt(receipt, source_contents={"s1": sources["s1"]}) + + assert not verdict.valid + source_check = next(c for c in verdict.checks if c.name == "sources") + assert not source_check.passed + assert source_check.detail == "missing source content: s2, s3" + + +def test_wrong_and_partial_source_contents_report_both_failures(receipt): + verdict = verify_receipt(receipt, source_contents={"s1": "wrong"}) + + source_check = next(c for c in verdict.checks if c.name == "sources") + assert source_check.detail == ( + "content hash mismatch: s1; missing source content: s2, s3" + ) + + def test_signer_pinning_success(receipt, sources): pk = receipt.signature.public_key verdict = verify_receipt(receipt, source_contents=sources, expected_public_key=pk)