diff --git a/CHANGELOG.md b/CHANGELOG.md index b707fdf..65d0971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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. - Verification now reports malformed embedded Ed25519 public keys as a failed signature check instead of raising a decoding exception. - `verify` and `inspect` now report unreadable or invalid receipt files as 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 4e4cd27..984d52c 100644 --- a/src/answerproof/verifier.py +++ b/src/answerproof/verifier.py @@ -76,7 +76,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 @@ -88,8 +88,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") @@ -151,9 +157,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] = [] @@ -172,7 +179,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 d63858e..0a61ac2 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)