diff --git a/ROADMAP.md b/ROADMAP.md index 56c9f44..2e92a9f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -445,12 +445,29 @@ sees the trail. J1–J4 are the open `BACKLOG.md` evidence entries, scheduled; * key out of band — a key shipped alongside the thing it signs proves nothing. Stated in the manifest's own `caveats`, so it travels with the bundle. -- [ ] **J2** `vpcopilot export --verify`. (S, P1) Independent of J1. +- [x] **J2** `vpcopilot export --verify`. (S, P1) — **DONE:** `export.verify_bundle` re-reads a + bundle and checks it against its own manifest; `vpcopilot export --verify [--pubkey ]` + exits non-zero on any problem, so it drops into CI. Verified end to end against a real signed + bundle: clean → OK, tampered member → `MISMATCH audit.log`, smuggled file → `UNLISTED`, no key → + `present-unverified` and still OK. Re-read a bundle, recompute every member digest against the manifest, check the signature when present, print pass or fail per member. - - Acceptance: a tampered member reports by name; a bundle with no signature verifies its - digests and says the signature is absent rather than failing; stdlib only, matching the - existing export. + - Acceptance: a tampered member reports by name ✅; a bundle with no signature verifies its + digests and says the signature is absent rather than failing ✅; stdlib only ✅ (the digest half + is pure stdlib — the same check `docs/AUDIT.md` documents by hand; only the optional signature + check shells out to `minisign`, adding no dependency). + - **Four member verdicts, not two.** `ok` / `mismatch` / `missing` / `unlisted`. A file **added** + to a bundle is exactly as suspicious as one altered, and a check over only the listed members + would wave it through. + - **`present-unverified` is a third signature state and NOT a failure.** Without a public key the + signature genuinely cannot be checked, and one shipped inside the bundle proves nothing. A + reviewer without the key must still be able to check digests; reporting "I cannot check this" + the same way as "this is forged" would destroy the distinction that matters most. + - **Console surface is deliberately narrow.** `GET /api/audit-verify` checks + `/audit-bundle.zip` only — a path derived from server state, never from the request. An + endpoint taking a caller-supplied path would be an arbitrary-file reader, and localhost is not + a reason to ship one; verifying a bundle that has already left the machine is the CLI's job, + because that is where the reviewer is. - **Reconciled:** does **not** depend on J1. Digest verification works on any bundle today — `manifest.json` already SHA-256s every member and `docs/AUDIT.md` ships a runnable verification snippet. Signature checking is added when J1 lands. diff --git a/docs/AUDIT.md b/docs/AUDIT.md index ea5cae6..56b5c09 100644 --- a/docs/AUDIT.md +++ b/docs/AUDIT.md @@ -320,7 +320,14 @@ multi-run bundle, member names are relative to that run's folder. ### Verifying a bundle after it leaves the machine -Recompute every hash from the zip alone — no vpcopilot install needed: +With vpcopilot to hand, one command does all of this and exits non-zero on any problem: + +```sh +vpcopilot export --verify audit-bundle.zip [--pubkey vpcopilot.pub] +``` + +Without it — which is the case that matters, since the bundle is meant to leave the machine — +recompute every hash from the zip alone: ```sh python3 - vpcopilot-audit-out-claude-20260726T190411Z.zip <<'PY' diff --git a/docs/USAGE.md b/docs/USAGE.md index bb0f20a..dfc90fb 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -124,6 +124,27 @@ about that bundle. Signing is optional at every level. No key, no `minisign` on PATH, or a signer that fails all mean an unsigned bundle and a successful export — never a failed one. +**Verifying a bundle.** `--verify` re-reads a bundle and checks it against its own manifest: + +```sh +vpcopilot export --verify audit-bundle.zip # digests only +vpcopilot export --verify audit-bundle.zip --pubkey vpcopilot.pub # digests + signature +``` + +It exits non-zero on any problem, so it drops into CI. Four member verdicts, because a file *added* +to a bundle is as suspicious as one altered: `ok`, `mismatch`, `missing` (listed but absent), and +`unlisted` (present but in no manifest). + +The signature is reported as one of `absent`, `verified`, `failed`, or **`present-unverified`** — +a signature exists but no public key was supplied, so it could not be checked. That is deliberately +**not** a failure: a reviewer without the key must still be able to check the digests, and reporting +"I cannot check this" the same way as "this is forged" would destroy the distinction that matters +most. + +Note the two layers are independent. Tampering with a *member* while leaving the manifest alone +leaves the signature **verified** and fails the digest — it is the chain, not either half, that +catches it. + Every scan also drops a self-contained `out/report.html` (no server, no external assets). In the console it's on **② Review** and **⚙ Setup** — **Open HTML report ↗** for a new tab, **Download** for a timestamped copy. Both rebuild it from the current run dir on every open, so you always get diff --git a/src/vpcopilot/cli.py b/src/vpcopilot/cli.py index 7b1a0a9..4a77556 100644 --- a/src/vpcopilot/cli.py +++ b/src/vpcopilot/cli.py @@ -545,11 +545,39 @@ def export( output: str = typer.Option(None, "--output", help="bundle path (default: /audit-bundle.zip)"), all_runs: bool = typer.Option(False, "--all", help="bundle every run dir on disk, each in its own folder"), root: str = typer.Option(".", help="where to look for run dirs when --all is used"), + verify: str = typer.Option(None, "--verify", help="check an existing bundle instead of writing one"), + pubkey: str = typer.Option(None, "--pubkey", help="minisign public key, to check the signature too"), ): """Export the audit evidence bundle for a run: every change made to a load balancer, the finding that justified it, the exact XC config pushed, and the pre-change snapshot — with a manifest that SHA-256s every member. Same bundle the console's Retire step downloads.""" - from .export import build_audit_events, write_bundle, write_bundle_all + from .export import build_audit_events, verify_bundle, write_bundle, write_bundle_all + + if verify: + r = verify_bundle(verify, pubkey=pubkey, log=lambda m: rprint(f"[dim]{m}[/dim]")) + if r["error"]: + rprint(f"[red]{r['error']}[/red]") + raise typer.Exit(code=1) + t = Table(title=f"verify {verify}") + for c in ["run", "run id", "members", "signature"]: + t.add_column(c) + sig_style = {"verified": "green", "failed": "red", "absent": "dim", + "present-unverified": "yellow"} + for run in r["runs"]: + st = sig_style.get(run["signature"], "") + t.add_row(run["run"], run["run_id"] or "—", str(run["members"]), + f"[{st}]{run['signature']}[/{st}]" if st else run["signature"]) + rprint(t) + for pb in r["problems"]: + rprint(f" [red]{pb['problem'].upper()}[/red] {pb['run']}{pb['member']} — {pb['detail']}") + if r["ok"]: + rprint(f"[green]OK[/green] — {r['members_checked']} member digest(s) match the manifest") + if any(run["signature"] == "present-unverified" for run in r["runs"]): + rprint("[yellow]note:[/yellow] a signature is present but was not checked — " + "pass --pubkey with the signer's key, obtained out of band") + return + rprint(f"[red]FAILED[/red] — {len(r['problems'])} problem(s)") + raise typer.Exit(code=1) if all_runs: path = write_bundle_all(root, output) diff --git a/src/vpcopilot/console/app.py b/src/vpcopilot/console/app.py index ae6b74b..94c7718 100644 --- a/src/vpcopilot/console/app.py +++ b/src/vpcopilot/console/app.py @@ -242,6 +242,23 @@ def runs(): return {"current": str(OUT), "runs": find_runs(".")} +@app.get("/api/audit-verify") +def audit_verify(): + """J2: check the bundle this run last wrote (`/audit-bundle.zip`) against its own manifest. + + The path comes from server state, never from the request. A verify endpoint that took a + caller-supplied path would be an arbitrary-file reader, and localhost is not a reason to ship + one. Verifying a bundle that has already left the machine is the CLI's job — that is where the + reviewer is.""" + from ..export import verify_bundle + p = OUT / "audit-bundle.zip" + if not p.exists(): + return {"bundle": str(p), "exists": False, + "hint": "run `vpcopilot export` (or the Retire step's download) first"} + return {"bundle": str(p), "exists": True, + **verify_bundle(str(p), pubkey=os.environ.get("VPCOPILOT_MINISIGN_PUBKEY"))} + + @app.get("/api/audit-export") def audit_export(scope: str = "run"): """F3: download the evidence bundle — `scope=run` for the run the console is reading, `scope=all` diff --git a/src/vpcopilot/export.py b/src/vpcopilot/export.py index 8eaf38e..1814801 100644 --- a/src/vpcopilot/export.py +++ b/src/vpcopilot/export.py @@ -23,7 +23,7 @@ from typing import Callable from . import __version__, audit, ledger, runmeta -from .sign import sign_bytes +from .sign import sign_bytes, verify_bytes def _quiet(_msg: str) -> None: @@ -287,3 +287,80 @@ def write_bundle_all(root: str = ".", output: str | None = None, *, log: Callabl path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(bundle_all_bytes(root, log=log)) return str(path) + + +# ---------------- J2: verify a bundle after it has left the machine ---------------- +def verify_bundle(path: str, *, pubkey: str | None = None, log: Callable = _quiet) -> dict: + """Re-read a bundle and check it against its own manifest. + + Read-only and stdlib-only for the digest half — the same check `docs/AUDIT.md` documents by + hand, so a reviewer with neither this tool nor a key can still do it. Handles both layouts by + finding every `manifest.json` in the archive, so an `--all` bundle verifies run by run. + + Four member verdicts, not two: a file **added** to a bundle is exactly as suspicious as one + altered, and a check over only the listed members would wave it through.""" + problems: list[dict] = [] + runs: list[dict] = [] + checked = 0 + try: + z = zipfile.ZipFile(path) + except Exception as e: # noqa: BLE001 — a corrupt or missing file is a finding, not a crash + return {"ok": False, "error": f"cannot read {path}: {e}", "problems": [], "runs": [], + "members_checked": 0} + with z: + bad_crc = z.testzip() + if bad_crc: + return {"ok": False, "error": f"corrupt member (CRC): {bad_crc}", "problems": [], + "runs": [], "members_checked": 0} + names = set(z.namelist()) + manifests = sorted(n for n in names if n.endswith("manifest.json")) + if not manifests: + return {"ok": False, "error": "no manifest.json in this archive — not a vpcopilot bundle", + "problems": [], "runs": [], "members_checked": 0} + + accounted = {"index.json"} + for mpath in manifests: + prefix = mpath[: -len("manifest.json")] + accounted.add(mpath) + try: + manifest = json.loads(z.read(mpath)) + except json.JSONDecodeError as e: + problems.append({"run": prefix, "member": mpath, "problem": "unreadable-manifest", + "detail": str(e)}) + continue + members = manifest.get("members") or {} + for name, meta in sorted(members.items()): + full = prefix + name + accounted.add(full) + if full not in names: + problems.append({"run": prefix, "member": name, "problem": "missing", + "detail": "listed in the manifest, absent from the archive"}) + continue + blob = z.read(full) + checked += 1 + got = hashlib.sha256(blob).hexdigest() + if got != meta.get("sha256") or len(blob) != meta.get("bytes"): + problems.append({"run": prefix, "member": name, "problem": "mismatch", + "detail": f"sha256 {got[:16]}… != {str(meta.get('sha256'))[:16]}…"}) + + sigpath = prefix + "manifest.json.minisig" + accounted.add(sigpath) + if sigpath in names: + state = verify_bytes(z.read(mpath), z.read(sigpath), pubkey=pubkey, log=log) + if state == "failed": + problems.append({"run": prefix, "member": "manifest.json.minisig", + "problem": "signature", "detail": "signature did not verify"}) + else: + state = "absent" + runs.append({"run": prefix or "./", "signature": state, + "members": len(members), + "run_id": (manifest.get("run") or {}).get("run_id", "")}) + + for extra in sorted(names - accounted): + if extra.endswith("/"): + continue + problems.append({"run": "", "member": extra, "problem": "unlisted", + "detail": "present in the archive but not listed in any manifest"}) + + return {"ok": not problems, "error": None, "problems": problems, "runs": runs, + "members_checked": checked} diff --git a/src/vpcopilot/sign.py b/src/vpcopilot/sign.py index bc38978..3271068 100644 --- a/src/vpcopilot/sign.py +++ b/src/vpcopilot/sign.py @@ -77,3 +77,46 @@ def sign_bytes(data: bytes, *, log: Callable = print, comment: str = "") -> byte return None log(" signed manifest.json (detached minisign signature)") return sig.read_bytes() + + +PUBKEY_ENV = "VPCOPILOT_MINISIGN_PUBKEY" + + +def verify_bytes(data: bytes, sig: bytes, *, pubkey: str | None = None, + log: Callable = print) -> str: + """Check a detached signature over `data`. Returns one of: + + `verified` the signature checks out against `pubkey` + `failed` it does not — treat the bundle as untrustworthy + `present-unverified` a signature exists but we have no public key, or no minisign to run + + `present-unverified` is deliberately NOT a failure. A reviewer without the key must still be + able to check the member digests, and reporting "I cannot check this" the same way as "this is + forged" would destroy the distinction that matters most.""" + key = (pubkey or os.environ.get(PUBKEY_ENV) or "").strip() + if not key: + log(" signature present, but no public key supplied — digests checked, signature not") + return "present-unverified" + if not Path(key).is_file(): + log(f" signature present, but no public key at {key} — signature not checked") + return "present-unverified" + binary = _binary() + if not binary: + log(" signature present, but `minisign` is not on PATH — signature not checked") + return "present-unverified" + + with tempfile.TemporaryDirectory() as td: + src, sigf = Path(td) / "manifest.json", Path(td) / "manifest.json.minisig" + src.write_bytes(data) + sigf.write_bytes(sig) + try: + r = subprocess.run([binary, "-V", "-p", key, "-m", str(src), "-x", str(sigf)], + capture_output=True, text=True, timeout=30) + except Exception as e: # noqa: BLE001 + log(f" signature present, but minisign could not run: {e} — signature not checked") + return "present-unverified" + if r.returncode == 0: + return "verified" + detail = (r.stderr or r.stdout or "").strip().splitlines() + log(f" ⚠ signature FAILED: {detail[-1] if detail else 'exit ' + str(r.returncode)}") + return "failed" diff --git a/tests/test_console_audit_export.py b/tests/test_console_audit_export.py index 4781e04..54baeee 100644 --- a/tests/test_console_audit_export.py +++ b/tests/test_console_audit_export.py @@ -86,3 +86,47 @@ def test_runs_endpoint_lists_what_can_be_exported(tmp_path, monkeypatch): names = {Path(r).name for r in body["runs"]} assert "out-a" in names and "out-empty" not in names assert body["current"] == str(tmp_path / "out-a") + + +# ---- J2: verifying the bundle this run wrote ---- +def test_verify_endpoint_checks_the_runs_own_bundle(tmp_path, monkeypatch): + from vpcopilot import export + _seed(tmp_path) + export.write_bundle(str(tmp_path)) + monkeypatch.setattr(A, "OUT", tmp_path) + body = _client().get("/api/audit-verify").json() + assert body["exists"] is True and body["ok"] is True + assert body["members_checked"] > 0 and body["runs"][0]["signature"] == "absent" + + +def test_verify_endpoint_reports_a_tampered_member(tmp_path, monkeypatch): + import io + import zipfile + from vpcopilot import export + _seed(tmp_path) + p = export.write_bundle(str(tmp_path)) + src = zipfile.ZipFile(p) + items = [(n, src.read(n)) for n in src.namelist()] + src.close() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + for n, d in items: + z.writestr(n, d + b"x" if n == "audit.log" else d) + open(p, "wb").write(buf.getvalue()) + monkeypatch.setattr(A, "OUT", tmp_path) + body = _client().get("/api/audit-verify").json() + assert body["ok"] is False + assert any(x["member"] == "audit.log" for x in body["problems"]) + + +def test_verify_endpoint_is_a_hint_not_an_error_before_any_export(tmp_path, monkeypatch): + monkeypatch.setattr(A, "OUT", tmp_path / "never") + body = _client().get("/api/audit-verify").json() + assert body["exists"] is False and "hint" in body + + +def test_verify_endpoint_takes_no_path_from_the_caller(): + """A verify endpoint accepting a caller-supplied path would be an arbitrary-file reader; + localhost is not a reason to ship one.""" + import inspect + assert not inspect.signature(A.audit_verify).parameters diff --git a/tests/test_verify.py b/tests/test_verify.py new file mode 100644 index 0000000..ebf072b --- /dev/null +++ b/tests/test_verify.py @@ -0,0 +1,185 @@ +"""J2 — `vpcopilot export --verify`: re-read a bundle and check it against its own manifest. + +The digest half is stdlib-only and works on any bundle, signed or not. The signature half is a +THIRD state, not a boolean: without a public key it genuinely cannot be checked, and a key shipped +inside the bundle proves nothing about that bundle.""" +import io +import json +import os +import stat +import zipfile + +from vpcopilot import audit, export + + +def _bundle(tmp_path, name="out-run"): + out = tmp_path / name + audit.record(str(out), "apply_waf", finding_id="f-1", lb="lab", namespace="ns") + return export.write_bundle(str(out), str(tmp_path / "b.zip")) + + +def _rewrite(path, mutate): + """Rebuild a zip with `mutate(name, data) -> data|None` applied to each member.""" + src = zipfile.ZipFile(path) + items = [(n, src.read(n)) for n in src.namelist()] + src.close() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + for n, d in items: + nd = mutate(n, d) + if nd is not None: + z.writestr(n, nd) + open(path, "wb").write(buf.getvalue()) + return path + + +# ---- the digest half ---- +def test_a_clean_bundle_verifies(tmp_path): + r = export.verify_bundle(_bundle(tmp_path)) + assert r["ok"] is True + assert r["members_checked"] > 0 and not r["problems"] + assert r["runs"][0]["signature"] == "absent" + + +def test_a_tampered_member_is_reported_by_name(tmp_path): + p = _bundle(tmp_path) + _rewrite(p, lambda n, d: d + b"tampered\n" if n == "audit.log" else d) + r = export.verify_bundle(p) + assert r["ok"] is False + bad = [x for x in r["problems"] if x["member"] == "audit.log"] + assert bad and bad[0]["problem"] == "mismatch" + + +def test_a_member_listed_but_absent_is_reported(tmp_path): + p = _bundle(tmp_path) + _rewrite(p, lambda n, d: None if n == "audit.log" else d) + r = export.verify_bundle(p) + assert r["ok"] is False + assert any(x["member"] == "audit.log" and x["problem"] == "missing" for x in r["problems"]) + + +def test_a_member_added_after_export_is_reported(tmp_path): + """A file ADDED to a bundle is exactly as suspicious as one altered — a check over only the + listed members would wave it through.""" + p = _bundle(tmp_path) + z = zipfile.ZipFile(p, "a") + z.writestr("smuggled.txt", "hello") + z.close() + r = export.verify_bundle(p) + assert r["ok"] is False + assert any(x["member"] == "smuggled.txt" and x["problem"] == "unlisted" for x in r["problems"]) + + +def test_the_manifest_and_its_signature_are_not_themselves_unlisted(tmp_path): + """They cannot appear in the manifest that lists members — they must not read as smuggled.""" + r = export.verify_bundle(_bundle(tmp_path)) + assert not [x for x in r["problems"] if x["member"].endswith(("manifest.json", ".minisig"))] + + +def test_a_corrupt_zip_is_reported_not_raised(tmp_path): + p = tmp_path / "broken.zip" + p.write_bytes(b"PK\x03\x04 this is not a zip") + r = export.verify_bundle(str(p)) + assert r["ok"] is False and r["error"] + + +def test_a_missing_file_is_reported_not_raised(tmp_path): + r = export.verify_bundle(str(tmp_path / "nope.zip")) + assert r["ok"] is False and r["error"] + + +def test_a_bundle_with_no_manifest_is_reported(tmp_path): + p = tmp_path / "empty.zip" + with zipfile.ZipFile(p, "w") as z: + z.writestr("readme.txt", "nothing to see") + r = export.verify_bundle(str(p)) + assert r["ok"] is False and "manifest" in (r["error"] or "").lower() + + +def test_every_run_in_a_multi_run_bundle_is_verified(tmp_path): + for n in ("out-a", "out-b"): + audit.record(str(tmp_path / n), "retire", finding_id="f-1", lb="lab") + p = export.write_bundle_all(str(tmp_path), str(tmp_path / "all.zip")) + r = export.verify_bundle(p) + assert r["ok"] is True and len(r["runs"]) == 2 + assert {run["run"] for run in r["runs"]} == {"out-a/", "out-b/"} + + +def test_one_bad_run_fails_the_bundle_and_names_which(tmp_path): + for n in ("out-a", "out-b"): + audit.record(str(tmp_path / n), "retire", finding_id="f-1", lb="lab") + p = export.write_bundle_all(str(tmp_path), str(tmp_path / "all.zip")) + _rewrite(p, lambda n, d: d + b"x" if n == "out-b/audit.log" else d) + r = export.verify_bundle(p) + assert r["ok"] is False + assert any(x["run"] == "out-b/" for x in r["problems"]) + assert not [x for x in r["problems"] if x["run"] == "out-a/"] + + +# ---- the signature half: four states, not a boolean ---- +def _stub_verifier(tmp_path, code=0): + p = tmp_path / "bin" / "minisign" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(f"#!/bin/sh\nexit {code}\n") + p.chmod(p.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return str(p.parent) + + +def _signed(tmp_path, monkeypatch): + sp = tmp_path / "bin" / "minisign" + sp.parent.mkdir(parents=True, exist_ok=True) + sp.write_text("#!/bin/sh\n" + "while [ $# -gt 0 ]; do case $1 in -x) shift; OUT=$1;; esac; shift; done\n" + "printf 'RWSIG\\n' > \"$OUT\"\n") + sp.chmod(0o755) + (tmp_path / "key").write_text("k") + monkeypatch.setenv("VPCOPILOT_MINISIGN_KEY", str(tmp_path / "key")) + monkeypatch.setenv("PATH", str(sp.parent) + os.pathsep + os.environ["PATH"]) + return _bundle(tmp_path) + + +def test_a_signature_with_no_public_key_is_unverified_but_not_a_failure(tmp_path, monkeypatch): + """A reviewer without the key must still be able to check the digests. Reporting this as a + failure would make the honest 'I cannot check this' indistinguishable from 'this is forged'.""" + p = _signed(tmp_path, monkeypatch) + monkeypatch.delenv("VPCOPILOT_MINISIGN_KEY", raising=False) + r = export.verify_bundle(p) + assert r["ok"] is True + assert r["runs"][0]["signature"] == "present-unverified" + + +def test_a_signature_that_checks_out_is_reported_verified(tmp_path, monkeypatch): + p = _signed(tmp_path, monkeypatch) + (tmp_path / "pub").write_text("RWQpublickey\n") + monkeypatch.setenv("PATH", _stub_verifier(tmp_path, 0) + os.pathsep + os.environ["PATH"]) + r = export.verify_bundle(p, pubkey=str(tmp_path / "pub")) + assert r["runs"][0]["signature"] == "verified" and r["ok"] is True + + +def test_a_signature_that_fails_fails_the_bundle(tmp_path, monkeypatch): + p = _signed(tmp_path, monkeypatch) + (tmp_path / "pub").write_text("RWQpublickey\n") + monkeypatch.setenv("PATH", _stub_verifier(tmp_path, 1) + os.pathsep + os.environ["PATH"]) + r = export.verify_bundle(p, pubkey=str(tmp_path / "pub")) + assert r["runs"][0]["signature"] == "failed" and r["ok"] is False + + +def test_asking_to_verify_an_unsigned_bundle_says_absent_not_failed(tmp_path): + r = export.verify_bundle(_bundle(tmp_path), pubkey="/some/key.pub") + assert r["runs"][0]["signature"] == "absent" and r["ok"] is True + + +def test_verification_is_read_only(tmp_path): + """Evidence gathering never mutates what it records — and neither does checking it.""" + p = _bundle(tmp_path) + before = open(p, "rb").read() + export.verify_bundle(p) + assert open(p, "rb").read() == before + assert json.loads(zipfile.ZipFile(p).read("manifest.json"))["kind"] == "vpcopilot-audit-bundle" + + +def test_a_pubkey_path_that_does_not_exist_is_unverified_not_failed(tmp_path, monkeypatch): + """Pointing at a key that isn't there is an operator mistake, not evidence of forgery.""" + p = _signed(tmp_path, monkeypatch) + r = export.verify_bundle(p, pubkey=str(tmp_path / "no-such.pub")) + assert r["runs"][0]["signature"] == "present-unverified" and r["ok"] is True