diff --git a/.gitignore b/.gitignore index 1a24f55..aee5053 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ __pycache__/ .lake/ *.olean *.ilean +dogfood/state/ +dogfood/pacta-verified-verify/target/ +dogfood/pacta-verified-verify/Cargo.toml +dogfood/pacta-verified-verify/Cargo.lock diff --git a/dogfood/pacta-verified-verify/Cargo.toml.template b/dogfood/pacta-verified-verify/Cargo.toml.template new file mode 100644 index 0000000..d13bd6e --- /dev/null +++ b/dogfood/pacta-verified-verify/Cargo.toml.template @@ -0,0 +1,14 @@ +# Rendered by `pacta dogfood-build` - the {{SOURCE}} placeholder is replaced +# with the local checkout of the PINNED proven source workspace +# (saymrwulf/curve25519-dalek-source). Committed as a template so the repo +# never hardcodes a machine-specific path. +[package] +name = "pacta-verified-verify" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +ed25519-dalek = { path = "{{SOURCE}}/ed25519-dalek", default-features = false, features = ["fast", "zeroize"] } + +[workspace] diff --git a/dogfood/pacta-verified-verify/src/main.rs b/dogfood/pacta-verified-verify/src/main.rs new file mode 100644 index 0000000..d491af9 --- /dev/null +++ b/dogfood/pacta-verified-verify/src/main.rs @@ -0,0 +1,77 @@ +//! pacta's dogfood Ed25519 verifier. +//! +//! This binary is built against the PINNED, PROVEN source workspace +//! (saymrwulf/curve25519-dalek-source at the commit named in the build +//! provenance) with the serial backend pinned - the exact code path whose +//! correctness certificates pacta consumes. When pacta checks a provider +//! signature or a signed tree head through this binary, the agent is +//! eating its own dogfood: the arithmetic under the verification is +//! certificate-covered (field, group law, scalars, decompression, and the +//! four-tier signature apex), and the residual trusted base is exactly the +//! theorems' documented boundary (SHA-512 as an oracle, the wire glue). +//! +//! Usage: pacta-verified-verify +//! Exit 0 = signature valid; exit 1 = invalid; exit 2 = usage/format error. + +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use std::process::ExitCode; + +fn hex_decode(s: &str) -> Result, String> { + if s.len() % 2 != 0 { + return Err("odd-length hex".into()); + } + (0..s.len() / 2) + .map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).map_err(|e| e.to_string())) + .collect() +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + if args.len() != 4 { + eprintln!("usage: pacta-verified-verify "); + return ExitCode::from(2); + } + let pk_bytes = match hex_decode(&args[1]) { + Ok(b) if b.len() == 32 => b, + _ => { + eprintln!("error: public key must be 32 bytes of hex"); + return ExitCode::from(2); + } + }; + let sig_bytes = match hex_decode(&args[2]) { + Ok(b) if b.len() == 64 => b, + _ => { + eprintln!("error: signature must be 64 bytes of hex"); + return ExitCode::from(2); + } + }; + let payload = match std::fs::read(&args[3]) { + Ok(p) => p, + Err(e) => { + eprintln!("error: cannot read payload: {e}"); + return ExitCode::from(2); + } + }; + let mut pk_array = [0u8; 32]; + pk_array.copy_from_slice(&pk_bytes); + let verifying_key = match VerifyingKey::from_bytes(&pk_array) { + Ok(k) => k, + Err(e) => { + eprintln!("error: invalid public key: {e}"); + return ExitCode::from(2); + } + }; + let mut sig_array = [0u8; 64]; + sig_array.copy_from_slice(&sig_bytes); + let signature = Signature::from_bytes(&sig_array); + match verifying_key.verify(&payload, &signature) { + Ok(()) => { + println!("OK"); + ExitCode::SUCCESS + } + Err(_) => { + println!("INVALID"); + ExitCode::from(1) + } + } +} diff --git a/src/pacta/attestation.py b/src/pacta/attestation.py index e8b8a0b..ccf1e7f 100644 --- a/src/pacta/attestation.py +++ b/src/pacta/attestation.py @@ -6,7 +6,7 @@ from typing import Any from .config import RepoConfig from .profiles import get_profile -from .signing import verify_attestation_signature +from .signing import verify_attestation_signature_detailed from .sthstore import check_sth_against_store, check_sth_freshness from .transparency import load_receipt, verify_receipt from .yamlio import load_data @@ -45,6 +45,7 @@ def validate_attestation( sth_store_path: str | Path | None = None, consistency_proof_path: str | Path | None = None, max_sth_age_seconds: int | None = None, + require_verified_verifier: bool = False, ) -> AttestationResult: provider = raw.get("provider") subject = raw.get("subject") or {} @@ -82,12 +83,18 @@ def validate_attestation( signature = raw.get("signature") or {} environment = raw.get("environment") or {} signature_status = signature.get("status", "not_checked") + signature_backend = "none" if public_key_path: - ok, error = verify_attestation_signature(raw, public_key_path) + ok, error, signature_backend = verify_attestation_signature_detailed(raw, public_key_path) if ok: signature_status = "verified" else: diagnostics.append(f"Attestation signature verification failed: {error}") + if require_verified_verifier and signature_backend != "verified-dalek-serial": + diagnostics.append( + "Policy requires the dogfood (certificate-covered) Ed25519 verifier, but verification ran on " + f"backend '{signature_backend}'. Build it with: pacta dogfood-build --source ." + ) elif signature_status == "signed": diagnostics.append("Signed attestation requires --attestation-public-key.") elif signature_status == "not_implemented": @@ -142,6 +149,7 @@ def validate_attestation( "attestation_provider": provider, "attestation_path": str(path) if path else None, "attestation_signature_status": signature_status, + "attestation_signature_backend": signature_backend, "attestation_log_url": raw.get("log_url") or signature.get("log_url"), "attestation_issued_at": raw.get("issued_at"), "check_log_path": (raw.get("replay") or {}).get("check_log_path"), diff --git a/src/pacta/cli.py b/src/pacta/cli.py index 00626a6..3754c5b 100644 --- a/src/pacta/cli.py +++ b/src/pacta/cli.py @@ -139,8 +139,17 @@ def build_parser() -> argparse.ArgumentParser: 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).") + receipt_verify.add_argument("--require-verified-verifier", action="store_true", help="Fail closed unless Ed25519 verification ran on the dogfood (certificate-covered) verifier.") receipt_verify.set_defaults(func=cmd_receipt_verify) + dogfood_build = sub.add_parser("dogfood-build", help="Build the dogfood Ed25519 verifier from the pinned proven source workspace.") + dogfood_build.add_argument("--source", required=True, help="Local checkout of saymrwulf/curve25519-dalek-source (the pinned proven workspace).") + dogfood_build.add_argument("--timeout", type=int, default=900) + dogfood_build.set_defaults(func=cmd_dogfood_build) + + dogfood_status = sub.add_parser("dogfood-status", help="Show which Ed25519 verification backend pacta will use, with provenance.") + dogfood_status.set_defaults(func=cmd_dogfood_status) + agent = sub.add_parser("agent", help="Apply a policy-gated consequence to verification evidence.") agent.add_argument("--claims", help="Existing claim card to act on.") agent.add_argument("--config", help="Repository config used to generate a claim card.") @@ -169,6 +178,7 @@ def build_parser() -> argparse.ArgumentParser: agent.add_argument("--sth-store", help="Path to the local STH pin store (split-view/rollback defense).") agent.add_argument("--consistency-proof", help="File with a hex consistency proof from the pinned tree size.") agent.add_argument("--max-sth-age-seconds", type=int, help="Reject signed tree heads older than this.") + agent.add_argument("--require-verified-verifier", action="store_true", help="Fail closed unless Ed25519 verification ran on the dogfood (certificate-covered) verifier.") agent.set_defaults(func=cmd_agent) return parser @@ -385,6 +395,11 @@ def cmd_receipt_verify(args: argparse.Namespace) -> int: consistency_proof_path=args.consistency_proof, max_sth_age_seconds=args.max_sth_age_seconds, ) + if args.require_verified_verifier and result.signatures.get("ed25519_backend") != "verified-dalek-serial": + accountability_diagnostics.append( + "Policy requires the dogfood (certificate-covered) Ed25519 verifier, but verification ran on backend " + f"'{result.signatures.get('ed25519_backend', 'none')}'." + ) if accountability_diagnostics: result.accepted = False result.diagnostics.extend(accountability_diagnostics) @@ -402,6 +417,38 @@ def cmd_receipt_verify(args: argparse.Namespace) -> int: return 0 if result.accepted else 1 +def cmd_dogfood_build(args: argparse.Namespace) -> int: + from .dogfood import build_verifier + + result = build_verifier(args.source, timeout=args.timeout) + for diagnostic in result.diagnostics: + print(diagnostic) + if not result.built: + print("dogfood verifier NOT built; Ed25519 verification falls back to OpenSSL (recorded as a downgrade).") + return 1 + print(f"binary: {result.binary_path}") + for key in ("source_commit", "backend_cfg", "rustc_version"): + print(f"{key}: {result.provenance.get(key)}") + print("coverage: " + str(result.provenance.get("coverage_note"))) + return 0 + + +def cmd_dogfood_status(args: argparse.Namespace) -> int: + from .dogfood import BACKEND_OPENSSL, BACKEND_VERIFIED, load_provenance, locate_verifier + + binary = locate_verifier() + if binary is None: + print(f"backend: {BACKEND_OPENSSL} (fallback)") + print("dogfood verifier not built. Build with: pacta dogfood-build --source ") + return 1 + print(f"backend: {BACKEND_VERIFIED}") + print(f"binary: {binary}") + provenance = load_provenance(binary) + for key in ("source_workspace", "source_commit", "backend_cfg", "rustc_version"): + print(f"{key}: {provenance.get(key)}") + return 0 + + def _log_accountability_checks( receipt: dict, sth_store: str | None, @@ -553,4 +600,5 @@ def _attestation_for_args(args: argparse.Namespace, repo: RepoConfig): sth_store_path=getattr(args, "sth_store", None), consistency_proof_path=getattr(args, "consistency_proof", None), max_sth_age_seconds=getattr(args, "max_sth_age_seconds", None), + require_verified_verifier=bool(getattr(args, "require_verified_verifier", False)), ) diff --git a/src/pacta/dogfood.py b/src/pacta/dogfood.py new file mode 100644 index 0000000..429703d --- /dev/null +++ b/src/pacta/dogfood.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import base64 +import binascii +import json +import os +import shutil +import subprocess +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +DOGFOOD_ENV = "PACTA_DOGFOOD_VERIFIER" +DEFAULT_STATE_DIR = Path("dogfood") / "state" +BACKEND_VERIFIED = "verified-dalek-serial" +BACKEND_OPENSSL = "openssl" + +# Ed25519 SubjectPublicKeyInfo (RFC 8410): a fixed 12-byte DER prefix then +# the raw 32-byte key. Parsing by prefix is exact for this OID, not a +# heuristic. +_ED25519_SPKI_PREFIX = bytes.fromhex("302a300506032b6570032100") + + +@dataclass(slots=True) +class DogfoodBuildResult: + built: bool + binary_path: Path | None + provenance: dict[str, Any] = field(default_factory=dict) + diagnostics: list[str] = field(default_factory=list) + + +def pem_public_key_to_raw(public_key_path: str | Path) -> bytes: + """Extract the raw 32-byte Ed25519 key from an OpenSSL PEM SPKI file.""" + text = Path(public_key_path).read_text(encoding="utf-8") + body = "".join( + line.strip() + for line in text.splitlines() + if line.strip() and not line.startswith("-----") + ) + try: + der = base64.b64decode(body, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError(f"Not a PEM public key: {public_key_path}: {exc}") from exc + if not der.startswith(_ED25519_SPKI_PREFIX) or len(der) != len(_ED25519_SPKI_PREFIX) + 32: + raise ValueError( + f"Not an Ed25519 SubjectPublicKeyInfo: {public_key_path} " + f"(got {len(der)} DER bytes)" + ) + return der[len(_ED25519_SPKI_PREFIX):] + + +def default_binary_path(state_dir: str | Path | None = None) -> Path: + return Path(state_dir or DEFAULT_STATE_DIR) / "pacta-verified-verify" + + +def locate_verifier(state_dir: str | Path | None = None) -> Path | None: + """Find the dogfood verifier binary: explicit env var first, then the + default build location. Returns None when unavailable (callers fall + back to OpenSSL and must record the downgrade).""" + env = os.environ.get(DOGFOOD_ENV) + if env: + path = Path(env) + return path if path.exists() else None + path = default_binary_path(state_dir) + return path if path.exists() else None + + +def load_provenance(binary_path: str | Path) -> dict[str, Any]: + sidecar = Path(binary_path).with_suffix(".provenance.json") + if sidecar.exists(): + return json.loads(sidecar.read_text(encoding="utf-8")) + return {} + + +def build_verifier( + source_workspace: str | Path, + crate_dir: str | Path = Path("dogfood") / "pacta-verified-verify", + state_dir: str | Path | None = None, + timeout: int = 900, +) -> DogfoodBuildResult: + """Build the dogfood verifier against the pinned proven source workspace. + + The serial backend is pinned via RUSTFLAGS exactly as the verified + extraction pins it; provenance (source path, source commit, rustc, + backend cfg) is recorded next to the binary and surfaces in evidence. + """ + source = Path(source_workspace).expanduser().resolve() + crate = Path(crate_dir).resolve() + diagnostics: list[str] = [] + cargo = shutil.which("cargo") + if not cargo: + return DogfoodBuildResult(False, None, {}, ["cargo is not available on PATH; cannot build the dogfood verifier."]) + if not (source / "ed25519-dalek" / "Cargo.toml").exists(): + return DogfoodBuildResult( + False, + None, + {}, + [f"{source} does not look like the pinned curve25519-dalek source workspace (no ed25519-dalek/Cargo.toml)."], + ) + template = (crate / "Cargo.toml.template").read_text(encoding="utf-8") + (crate / "Cargo.toml").write_text(template.replace("{{SOURCE}}", str(source)), encoding="utf-8") + env = dict(os.environ) + backend_cfg = 'curve25519_dalek_backend="serial"' + env["RUSTFLAGS"] = (env.get("RUSTFLAGS", "") + f" --cfg {backend_cfg}").strip() + completed = subprocess.run( + [cargo, "build", "--release", "--quiet"], + cwd=str(crate), + env=env, + capture_output=True, + text=True, + timeout=timeout, + ) + if completed.returncode != 0: + tail = (completed.stderr or completed.stdout or "").strip().splitlines()[-12:] + return DogfoodBuildResult(False, None, {}, ["cargo build failed:"] + tail) + built = crate / "target" / "release" / "pacta-verified-verify" + if not built.exists(): + return DogfoodBuildResult(False, None, {}, [f"cargo reported success but {built} does not exist."]) + out = default_binary_path(state_dir) + out.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(built, out) + provenance = { + "type": "pacta.dogfood.verifier_provenance.v1", + "source_workspace": str(source), + "source_commit": _git_commit(source), + "backend_cfg": backend_cfg, + "rustc_version": _tool_version("rustc"), + "cargo_version": _tool_version("cargo"), + "entry_point": "ed25519_dalek::VerifyingKey::verify (pinned workspace)", + "coverage_note": ( + "The certificates cover verify_sha512, the extraction-refactored image of this verify path " + "(same internals; the delta is the documented hash-wrapper refactor in the pinned source). " + "Field, group law, scalars, encoding/decompression, and the four apex tiers are certificate-covered; " + "SHA-512 and the ~15 lines of wire glue are the theorems' documented trusted base." + ), + } + out.with_suffix(".provenance.json").write_text(json.dumps(provenance, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return DogfoodBuildResult(True, out, provenance, diagnostics) + + +def verify_payload_dogfood( + payload: bytes, + signature: bytes, + public_key_path: str | Path, + binary: str | Path, + timeout: int = 30, +) -> tuple[bool, str | None]: + try: + raw_key = pem_public_key_to_raw(public_key_path) + except ValueError as exc: + return False, str(exc) + with tempfile.TemporaryDirectory(prefix="pacta-dogfood-") as tmp: + payload_path = Path(tmp) / "payload.bin" + payload_path.write_bytes(payload) + completed = subprocess.run( + [str(binary), raw_key.hex(), signature.hex(), str(payload_path)], + capture_output=True, + text=True, + timeout=timeout, + ) + if completed.returncode == 0: + return True, None + if completed.returncode == 1: + return False, "signature invalid (verified-path verifier)" + return False, (completed.stderr or completed.stdout or "dogfood verifier error").strip() + + +def _git_commit(path: Path) -> str | None: + git = shutil.which("git") + if not git: + return None + completed = subprocess.run( + [git, "rev-parse", "HEAD"], cwd=str(path), capture_output=True, text=True, timeout=15 + ) + return completed.stdout.strip() if completed.returncode == 0 else None + + +def _tool_version(tool: str) -> str | None: + path = shutil.which(tool) + if not path: + return None + completed = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=15) + return completed.stdout.strip() if completed.returncode == 0 else None diff --git a/src/pacta/signing.py b/src/pacta/signing.py index eacea8d..7fb8bb2 100644 --- a/src/pacta/signing.py +++ b/src/pacta/signing.py @@ -58,22 +58,27 @@ def sign_attestation(attestation: dict[str, Any], private_key_path: str | Path, def verify_attestation_signature(attestation: dict[str, Any], public_key_path: str | Path) -> tuple[bool, str | None]: + ok, error, _backend = verify_attestation_signature_detailed(attestation, public_key_path) + return ok, error + + +def verify_attestation_signature_detailed(attestation: dict[str, Any], public_key_path: str | Path) -> tuple[bool, str | None, str]: signature = attestation.get("signature") or {} if signature.get("scheme") != "openssl-ed25519": - return False, f"Unsupported attestation signature scheme: {signature.get('scheme')}" + return False, f"Unsupported attestation signature scheme: {signature.get('scheme')}", "none" encoded = signature.get("signature_base64") if not encoded: - return False, "Attestation signature is missing signature_base64." + return False, "Attestation signature is missing signature_base64.", "none" expected_digest = signature.get("payload_digest_sha256") actual_digest = payload_digest(attestation) if expected_digest and expected_digest != actual_digest: - return False, "Attestation payload digest does not match signature metadata." + return False, "Attestation payload digest does not match signature metadata.", "none" expected_fingerprint = signature.get("public_key_fingerprint_sha256") if expected_fingerprint: actual_fingerprint = public_key_fingerprint(public_key_path) if expected_fingerprint != actual_fingerprint: - return False, "Attestation public key fingerprint does not match signature metadata." - return verify_payload_ed25519(canonical_attestation_payload(attestation), encoded, public_key_path) + return False, "Attestation public key fingerprint does not match signature metadata.", "none" + return verify_payload_ed25519_detailed(canonical_attestation_payload(attestation), encoded, public_key_path) def sign_payload_ed25519(payload: bytes, private_key_path: str | Path) -> str: @@ -96,11 +101,37 @@ def sign_payload_ed25519(payload: bytes, private_key_path: str | Path) -> str: def verify_payload_ed25519(payload: bytes, signature_base64: str, public_key_path: str | Path) -> tuple[bool, str | None]: - openssl = _openssl() + ok, error, _backend = verify_payload_ed25519_detailed(payload, signature_base64, public_key_path) + return ok, error + + +def verify_payload_ed25519_detailed( + payload: bytes, + signature_base64: str, + public_key_path: str | Path, +) -> tuple[bool, str | None, str]: + """Verify an Ed25519 signature, preferring pacta's DOGFOOD verifier - + a binary built from the pinned, certificate-covered dalek source with + the serial backend pinned - and falling back to OpenSSL when the + dogfood binary is unavailable. The third element names the backend that + actually ran so callers can record (and policies can require) the + verified path.""" + from .dogfood import BACKEND_OPENSSL, BACKEND_VERIFIED, locate_verifier, verify_payload_dogfood + try: signature_bytes = base64.b64decode(signature_base64, validate=True) except ValueError as exc: - return False, f"Invalid base64 signature: {exc}" + return False, f"Invalid base64 signature: {exc}", "none" + binary = locate_verifier() + if binary is not None: + ok, error = verify_payload_dogfood(payload, signature_bytes, public_key_path, binary) + return ok, error, BACKEND_VERIFIED + ok, error = _verify_payload_openssl(payload, signature_bytes, public_key_path) + return ok, error, BACKEND_OPENSSL + + +def _verify_payload_openssl(payload: bytes, signature_bytes: bytes, public_key_path: str | Path) -> tuple[bool, str | None]: + openssl = _openssl() with tempfile.TemporaryDirectory(prefix="pacta-verify-") as tmp: payload_path = Path(tmp) / "payload.bin" signature_path = Path(tmp) / "payload.sig" diff --git a/src/pacta/transparency.py b/src/pacta/transparency.py index 219607b..f729bba 100644 --- a/src/pacta/transparency.py +++ b/src/pacta/transparency.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any from .postquantum import detect_ml_dsa -from .signing import canonical_json, public_key_fingerprint, sign_payload_ed25519, verify_payload_ed25519 +from .signing import canonical_json, public_key_fingerprint, sign_payload_ed25519, verify_payload_ed25519_detailed from .yamlio import load_data HASH_ALGORITHM = "RFC9162_SHA256" @@ -206,8 +206,9 @@ def verify_signed_tree_head( diagnostics.append("Signed tree head Ed25519 public-key fingerprint mismatch.") statuses["ed25519"] = "key_mismatch" else: - ok, error = verify_payload_ed25519(payload, str(ed25519.get("signature_base64") or ""), public_key_path) + ok, error, backend = verify_payload_ed25519_detailed(payload, str(ed25519.get("signature_base64") or ""), public_key_path) statuses["ed25519"] = "verified" if ok else "failed" + statuses["ed25519_backend"] = backend if not ok: diagnostics.append(f"Signed tree head Ed25519 verification failed: {error}") diff --git a/tests/test_dogfood.py b/tests/test_dogfood.py new file mode 100644 index 0000000..08ce493 --- /dev/null +++ b/tests/test_dogfood.py @@ -0,0 +1,66 @@ +import os +import stat +import subprocess + +from pacta.dogfood import BACKEND_VERIFIED, locate_verifier, pem_public_key_to_raw +from pacta.signing import generate_ed25519_keypair, sign_payload_ed25519, verify_payload_ed25519_detailed + + +def test_pem_spki_to_raw_roundtrip(tmp_path): + private_key = tmp_path / "k.key" + public_key = tmp_path / "k.pub" + generate_ed25519_keypair(private_key, public_key) + raw = pem_public_key_to_raw(public_key) + assert len(raw) == 32 + # cross-check against openssl's own raw dump + dumped = subprocess.run( + ["openssl", "pkey", "-pubin", "-in", str(public_key), "-outform", "DER"], + capture_output=True, check=True, + ).stdout + assert dumped.endswith(raw) + + +def test_pem_rejects_non_ed25519(tmp_path): + bogus = tmp_path / "bogus.pub" + bogus.write_text("-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n") + import pytest + + with pytest.raises(ValueError): + pem_public_key_to_raw(bogus) + + +def test_dispatch_prefers_dogfood_binary_and_records_backend(tmp_path, monkeypatch): + # a fake verifier that accepts everything: proves dispatch + backend label + fake = tmp_path / "fake-verify" + fake.write_text("#!/bin/sh\necho OK\nexit 0\n") + fake.chmod(fake.stat().st_mode | stat.S_IEXEC) + private_key = tmp_path / "k.key" + public_key = tmp_path / "k.pub" + generate_ed25519_keypair(private_key, public_key) + payload = b"dogfood dispatch test" + signature = sign_payload_ed25519(payload, private_key) + monkeypatch.setenv("PACTA_DOGFOOD_VERIFIER", str(fake)) + ok, error, backend = verify_payload_ed25519_detailed(payload, signature, public_key) + assert ok and backend == BACKEND_VERIFIED + monkeypatch.setenv("PACTA_DOGFOOD_VERIFIER", str(tmp_path / "missing")) + assert locate_verifier() is None + ok, error, backend = verify_payload_ed25519_detailed(payload, signature, public_key) + assert ok and backend == "openssl" + + +def test_real_dogfood_binary_if_built(tmp_path): + import pytest + + binary = locate_verifier() + if binary is None or "fake" in str(binary): + pytest.skip("dogfood verifier not built on this host") + private_key = tmp_path / "k.key" + public_key = tmp_path / "k.pub" + generate_ed25519_keypair(private_key, public_key) + payload = b"the proven path verifies this" + signature = sign_payload_ed25519(payload, private_key) + ok, error, backend = verify_payload_ed25519_detailed(payload, signature, public_key) + assert ok and backend == BACKEND_VERIFIED + # flip one payload byte: the proven path must reject + ok, error, backend = verify_payload_ed25519_detailed(payload + b"x", signature, public_key) + assert not ok and backend == BACKEND_VERIFIED