From bcbf92904573948d9e0afc4888f2677f775027f9 Mon Sep 17 00:00:00 2001 From: mrwulf Date: Mon, 17 Aug 2026 10:29:42 +0200 Subject: [PATCH] =?UTF-8?q?receipt-verify=20checks=20the=20post-quantum=20?= =?UTF-8?q?co-signature=20=E2=80=94=20the=20tool=20now=20does=20what=20run?= =?UTF-8?q?g=202=20promises?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-caught: rung 2 demanded both public keys while the shown command consumed only the Ed25519 one — and the gap was real: pacta had NO SLH-DSA head-signature check (only the mirror's verify.py had one; '--require-signatures both' refers to the empty ML-DSA slot). New: verify_receipt(slhdsa_public_key_path=...) verifies the additive co-signature fail-closed (absent on pre-14 heads reports absent, not failed; unavailable OpenSSL fails closed), CLI grows --slhdsa-public-key, rung 2's command carries the flag and its muted text explains both checks. Proven against the LIVE log: accepted:true, slh_dsa:verified, ed25519 on the dogfood backend. New tamper test flips a signature byte and must be rejected. Suite 156 green. --- provider/src/pacta_provider/webdocs.py | 5 ++-- src/pacta/cli.py | 4 ++- src/pacta/transparency.py | 24 ++++++++++++++++ tests/test_transparency.py | 39 ++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/provider/src/pacta_provider/webdocs.py b/provider/src/pacta_provider/webdocs.py index d90ebc4..ffc29dd 100644 --- a/provider/src/pacta_provider/webdocs.py +++ b/provider/src/pacta_provider/webdocs.py @@ -255,8 +255,9 @@ two holders compare. You still trust: that the operator’s recorded observation is honest.
You need: four small files from the tables below — the two public keys, plus one library’s claim file (“attestation”) and its proof of inclusion (“receipt”). -
pacta receipt-verify --attestation … --receipt … --log-public-key provider.ed25519.pub
-Your machine checks one Ed25519 signature and +
pacta receipt-verify --attestation … --receipt … --log-public-key provider.ed25519.pub --slhdsa-public-key provider.slhdsa.pub
+Your machine checks the required Ed25519 signature, the additive +post-quantum co-signature (needs OpenSSL ≥ 3.5; drop the second flag to skip it), and ~{max(1,(latest.get('tree_size') or 1).bit_length())} hashes — no proof assistant involved. The pacta tool ships in the pacta repository diff --git a/src/pacta/cli.py b/src/pacta/cli.py index 4cd8803..3c7ac98 100644 --- a/src/pacta/cli.py +++ b/src/pacta/cli.py @@ -137,6 +137,7 @@ def build_parser() -> argparse.ArgumentParser: receipt_verify.add_argument("--receipt", required=True) receipt_verify.add_argument("--log-public-key", required=True) receipt_verify.add_argument("--require-signatures", choices=["ed25519", "both"], default="ed25519") + receipt_verify.add_argument("--slhdsa-public-key", help="Also verify the additive SLH-DSA head co-signature against this public key (OpenSSL >= 3.5; heads before tree size 14 report absent, not failed).") receipt_verify.add_argument("--sth-store", help="Path to the local STH pin store (split-view/rollback defense).") receipt_verify.add_argument("--consistency-proof", help="File with a hex consistency proof from the pinned tree size (provider: log-consistency).") receipt_verify.add_argument("--max-sth-age-seconds", type=int, help="Reject signed tree heads older than this (freshness policy).") @@ -476,7 +477,8 @@ def cmd_score(args: argparse.Namespace) -> int: def cmd_receipt_verify(args: argparse.Namespace) -> int: attestation = load_attestation(args.attestation) receipt = load_receipt(args.receipt) - result = verify_receipt(attestation, receipt, args.log_public_key, require_signatures=args.require_signatures) + result = verify_receipt(attestation, receipt, args.log_public_key, require_signatures=args.require_signatures, + slhdsa_public_key_path=args.slhdsa_public_key) accountability_diagnostics = _log_accountability_checks( receipt, sth_store=args.sth_store, diff --git a/src/pacta/transparency.py b/src/pacta/transparency.py index fa90699..e9a51ed 100644 --- a/src/pacta/transparency.py +++ b/src/pacta/transparency.py @@ -256,6 +256,7 @@ def verify_receipt( receipt: dict[str, Any], log_public_key_path: str | Path, require_signatures: str = "ed25519", + slhdsa_public_key_path: str | Path | None = None, ) -> ReceiptVerificationResult: diagnostics: list[str] = [] if receipt.get("type") != RECEIPT_TYPE: @@ -266,6 +267,29 @@ def verify_receipt( sth = receipt.get("sth") or {} sth_ok, sth_diagnostics, statuses = verify_signed_tree_head(sth, log_public_key_path, require_signatures=require_signatures) diagnostics.extend(sth_diagnostics) + if slhdsa_public_key_path is not None: + # The additive post-quantum co-signature (heads from tree size 14 + # on). Absent on older heads is NOT a failure - an append-only log + # keeps its history; a present-but-bad signature fails closed. + from .slhdsa import verify_payload_slhdsa + + slh = (sth.get("signatures") or {}).get("slh_dsa") or {} + if str(slh.get("status") or "absent") == "signed": + try: + slh_ok, slh_error = verify_payload_slhdsa( + signed_tree_head_payload(sth), + str(slh.get("signature_base64") or ""), + slhdsa_public_key_path, + ) + except Exception as exc: # old OpenSSL, unreadable key: fail closed + slh_ok, slh_error = False, f"SLH-DSA verification unavailable: {exc}" + if slh_ok: + statuses["slh_dsa"] = "verified" + else: + statuses["slh_dsa"] = "failed" + diagnostics.append(f"SLH-DSA head co-signature did not verify: {slh_error}") + else: + statuses["slh_dsa"] = "absent" try: tree_size = int(receipt.get("tree_size")) leaf_index = int(receipt.get("leaf_index")) diff --git a/tests/test_transparency.py b/tests/test_transparency.py index 63dd61c..0b1ce4f 100644 --- a/tests/test_transparency.py +++ b/tests/test_transparency.py @@ -145,3 +145,42 @@ def test_requiring_both_signatures_fails_without_ml_dsa_backend(tmp_path): assert not result.accepted assert result.signatures["ed25519"] == "verified" assert result.signatures["ml_dsa"] != "verified" + + +def test_receipt_verify_checks_slhdsa_cosignature(tmp_path): + # Rung 2 of the site promises both head signatures are checkable; + # this binds the promise to the tool (operator-caught 2026-08-16). + import pytest + + from pacta import slhdsa + + try: + slhdsa.generate_slhdsa_keypair(tmp_path / "slh.key", tmp_path / "slh.pub") + except Exception: + pytest.skip("OpenSSL without SLH-DSA support on this host") + attestation, private_key, public_key = _signed_attestation(tmp_path) + from pacta.yamlio import dump_data + + dump_data(attestation, tmp_path / "attestation.yaml") + log = TransparencyLog(tmp_path / "log") + log.init("local-test-provider", public_key) + receipt = log.append_attestation( + tmp_path / "attestation.yaml", private_key, public_key, + receipt_out=tmp_path / "receipt.yaml", + slhdsa_private_key_path=tmp_path / "slh.key", + slhdsa_public_key_path=tmp_path / "slh.pub", + ) + result = verify_receipt(attestation, receipt, public_key, + slhdsa_public_key_path=tmp_path / "slh.pub") + assert result.accepted, result.diagnostics + assert result.signatures["slh_dsa"] == "verified" + + tampered = __import__("copy").deepcopy(receipt) + sig = tampered["sth"]["signatures"]["slh_dsa"]["signature_base64"] + import base64 as _b64 + raw = bytearray(_b64.b64decode(sig)); raw[0] ^= 0xFF + tampered["sth"]["signatures"]["slh_dsa"]["signature_base64"] = _b64.b64encode(bytes(raw)).decode() + bad = verify_receipt(attestation, tampered, public_key, + slhdsa_public_key_path=tmp_path / "slh.pub") + assert not bad.accepted + assert bad.signatures["slh_dsa"] == "failed"