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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,15 @@ Every check is independent and reported separately:
| `merkle` | recomputed Merkle root matches the signed root |
| `sources` | supplied source contents hash to the recorded hashes |
| `grounding` | citations reference real sources; grounding score is honest |
| `signer_pin`| (optional) signer public key matches an expected key |
| `signer_pin`| signer public key matches an expected key |

Checks that did not run appear in the verdict's `skipped` list rather than as
passes. **`signer_pin` is skipped when you omit `expected_public_key` /
`--expect-key`.** In that case the signature still proves integrity against the
key *carried in the receipt*, but an attacker who rewrote the payload and
re-signed with their own key would also get `valid: true`. Pin the signer to
prove provenance; without a pin, the CLI prints a one-line warning and the
JSON verdict lists `signer_pin` under `skipped`.

## Merkle inclusion proofs

Expand Down
11 changes: 11 additions & 0 deletions src/answerproof/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ def cmd_verify(args: argparse.Namespace) -> int:
receipt, source_contents=contents, expected_public_key=args.expect_key
)

if args.expect_key is None:
print(
"warning: no --expect-key; signature is not tied to a known signer",
file=sys.stderr,
)

if args.json:
print(json.dumps(verdict.to_dict(), indent=2))
else:
Expand All @@ -75,6 +81,11 @@ def cmd_verify(args: argparse.Namespace) -> int:
print(line)
for s in verdict.skipped:
print(f" [skip] {s}")
if args.expect_key is None and verdict.valid:
print(
"note: signer was not pinned; integrity of the payload against the "
"embedded key is proven, provenance is not"
)
return 0 if verdict.valid else 1


Expand Down
13 changes: 11 additions & 2 deletions src/answerproof/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
consistent (cited ids exist; grounding score matches the claims).

A receipt is ``valid`` only if every performed check passes. Checks that could
not run (e.g. source contents not supplied) are reported as ``skipped`` and do
not, by themselves, make a receipt invalid.
not run (e.g. source contents not supplied, or no expected signer pinned) are
reported as ``skipped`` and do not, by themselves, make a receipt invalid.
Skipping ``signer_pin`` means the signature proves integrity against the
embedded key only — not that the signer is a known, trusted party.
"""

from __future__ import annotations
Expand Down Expand Up @@ -164,6 +166,13 @@ def verify_receipt(
"" if pinned else "receipt public key does not match expected signer",
)
)
else:
# Signature alone only proves integrity against the key carried in the
# receipt. Without a pin, an attacker who rewrote and re-signed with
# their own key also passes. Surface that the provenance check did not run.
skipped.append(
"signer_pin (no expected signer pinned; signature not tied to a known key)"
)

checks.append(verify_signature(receipt))
checks.append(verify_merkle(receipt))
Expand Down
36 changes: 36 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,39 @@ def test_inspect(tmp_path, capsys, receipt):
def test_missing_command_errors():
with pytest.raises(SystemExit):
main([])


def test_verify_warns_when_signer_not_pinned(tmp_path, capsys, receipt):
path = _write_receipt(tmp_path, receipt)
rc = main(["verify", str(path)])
assert rc == 0
captured = capsys.readouterr()
assert "VALID" in captured.out
assert "no --expect-key" in captured.err
assert "signer_pin" in captured.out # skipped line


def test_verify_with_expect_key_no_warning(tmp_path, capsys, receipt):
path = _write_receipt(tmp_path, receipt)
rc = main(["verify", str(path), "--expect-key", receipt.signature.public_key])
assert rc == 0
captured = capsys.readouterr()
assert "VALID" in captured.out
assert "no --expect-key" not in captured.err
assert (
"[ok ] signer_pin" in captured.out
or "[ok] signer_pin" in captured.out
or "signer_pin" in captured.out
)


def test_verify_json_includes_skipped_signer_pin(tmp_path, capsys, receipt):
path = _write_receipt(tmp_path, receipt)
rc = main(["verify", str(path), "--json"])
assert rc == 0
# stderr warning still emitted; JSON on stdout
captured = capsys.readouterr()
assert "no --expect-key" in captured.err
verdict = json.loads(captured.out)
assert verdict["valid"] is True
assert any("signer_pin" in s for s in verdict["skipped"])
8 changes: 6 additions & 2 deletions tests/test_tamper.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ def test_attacker_resigns_with_own_key_is_caught_by_pinning(receipt, sources):
public_key=attacker.verify_key.to_base64(),
signature=attacker.sign(forged.payload.canonical_bytes()),
)
# Signature alone verifies (attacker's key), but pinning to the real signer fails.
assert verify_receipt(forged).valid
# Signature alone verifies (attacker's key) so structural checks pass, but
# the unpinned verdict must not look like a clean provenance pass: signer_pin
# is skipped. Pinning to the real signer fails outright.
unpinned = verify_receipt(forged)
assert unpinned.valid # integrity against the (attacker) embedded key
assert any("signer_pin" in s for s in unpinned.skipped)
assert not verify_receipt(forged, expected_public_key=original_pk).valid
41 changes: 41 additions & 0 deletions tests/test_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,44 @@ def test_serialized_receipt_roundtrips_and_verifies(receipt, sources):

restored = Receipt.from_json(receipt.to_json())
assert verify_receipt(restored, source_contents=sources).valid


def test_unpinned_verify_skips_signer_pin(receipt, sources):
verdict = verify_receipt(receipt, source_contents=sources)
assert verdict.valid
assert any(s.startswith("signer_pin") for s in verdict.skipped)
assert all(c.name != "signer_pin" for c in verdict.checks)


def test_pinned_verify_does_not_skip_signer_pin(receipt, sources):
pk = receipt.signature.public_key
verdict = verify_receipt(receipt, source_contents=sources, expected_public_key=pk)
assert verdict.valid
assert not any("signer_pin" in s for s in verdict.skipped)
assert any(c.name == "signer_pin" and c.passed for c in verdict.checks)


def test_attacker_resign_is_not_presented_as_clean_pass(receipt):
"""Tamper the payload, re-sign with a fresh key; unpinned verify must not
look like a fully proven receipt (signer_pin skipped)."""
import json

from answerproof.crypto import SigningKey
from answerproof.schema import Receipt, Signature

attacker = SigningKey.generate()
d = json.loads(receipt.to_json())
d["payload"]["answer"] = "Attacker-controlled answer."
forged = Receipt.from_json(json.dumps(d))
forged.signature = Signature(
public_key=attacker.verify_key.to_base64(),
signature=attacker.sign(forged.payload.canonical_bytes()),
)
verdict = verify_receipt(forged)
# Structural checks pass against the attacker's key...
assert verdict.valid
# ...but the verdict records that provenance was not established.
assert any("signer_pin" in s for s in verdict.skipped)
as_dict = verdict.to_dict()
assert as_dict["valid"] is True
assert any("signer_pin" in s for s in as_dict["skipped"])
Loading