Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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 <base64> # pin the signer
answerproof inspect receipt.json # human-readable summary
```
Expand Down
19 changes: 13 additions & 6 deletions src/answerproof/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")


Expand Down Expand Up @@ -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] = []
Expand All @@ -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)")
Expand Down
27 changes: 27 additions & 0 deletions tests/test_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading