From d698883f0ee88bb464a2d0fc1666b12088bf4e64 Mon Sep 17 00:00:00 2001 From: Ashay Date: Sat, 8 Aug 2026 18:13:40 +0530 Subject: [PATCH 1/2] Make unpinned verify surface that signer provenance was not proven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When expected_public_key / --expect-key is omitted, signature verification still proves integrity against the key embedded in the receipt — including an attacker key after rewrite-and-re-sign. Record signer_pin as skipped in the JSON verdict, print a CLI warning, and document the distinction in the README. Fixes #3 --- README.md | 10 ++++++++- src/answerproof/cli.py | 11 ++++++++++ src/answerproof/verifier.py | 13 ++++++++++-- tests/test_cli.py | 32 +++++++++++++++++++++++++++++ tests/test_tamper.py | 8 ++++++-- tests/test_verifier.py | 41 +++++++++++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3994827..b5ed7ed 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/answerproof/cli.py b/src/answerproof/cli.py index 6059e65..1426a39 100644 --- a/src/answerproof/cli.py +++ b/src/answerproof/cli.py @@ -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: @@ -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 diff --git a/src/answerproof/verifier.py b/src/answerproof/verifier.py index 9b85d52..afc7574 100644 --- a/src/answerproof/verifier.py +++ b/src/answerproof/verifier.py @@ -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 @@ -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)) diff --git a/tests/test_cli.py b/tests/test_cli.py index bf0305f..747529f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -65,3 +65,35 @@ 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"]) diff --git a/tests/test_tamper.py b/tests/test_tamper.py index a020cc8..f818301 100644 --- a/tests/test_tamper.py +++ b/tests/test_tamper.py @@ -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 diff --git a/tests/test_verifier.py b/tests/test_verifier.py index ab44fb8..788edcb 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -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"]) From 5e4a406ddec49378638a3829152f16c71d4f5c0c Mon Sep 17 00:00:00 2001 From: AshSgDe29071999 Date: Sat, 8 Aug 2026 18:48:18 +0530 Subject: [PATCH 2/2] style: wrap long signer_pin assertion for ruff E501 --- tests/test_cli.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 747529f..d2392ad 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -84,7 +84,11 @@ def test_verify_with_expect_key_no_warning(tmp_path, capsys, receipt): 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 + 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):