Skip to content
Merged
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
69 changes: 69 additions & 0 deletions 04_testing/wave-b-reports/auditor-20260430T125917Z.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Wave B Auditor Report — Proof Bundle Integrity

- **Date (UTC):** 2026-04-30 12:59:17Z
- **Auditor branch:** `feat/wave-b-auditor`
- **Audited bundle:** `05_truth_proof/bundles/ea909e8c5ae4-20260430T125728Z.zip`
- size: 5533 bytes
- sha256: `b25a5faa37e69b7f5dff09f9f35995d68b0efa56b3465398b0853fd27e23efc8`
- run_id: `ea909e8c5ae4-20260430T125728Z`
- HERMES3D_PROOF_KEY used for build + verify: `auditor-key`
- **Independent verifier:** `04_testing/wave-b-reports/auditor_verify.py`
(does NOT call `conformance_runner.py`; reimplements canonical-JSON
HMAC-SHA256 verification, file-hash walk, ledger cross-ref scan,
forbidden-extras diff, and `git cat-file -e` commit binding.)
- **Cross-check:** also run through `05_truth_proof/conformance_runner.py
--bundle …` for confirmation; both verifiers agree.

## Verdict

**GREEN** — every positive check passed and every negative test was
rejected as expected.

## Positive checks (a–e)

| # | Check | Result | Evidence |
|---|---|---|---|
| a | Signature (HMAC-SHA256 over canonical(manifest)) | **PASS** | algorithm=`HMAC-SHA256`; expected & actual share prefix `24e07e61c9e53f5c…`; `hmac.compare_digest` true |
| b | File-hash integrity (every `manifest.files[*]` entry exists in zip and sha256 matches) | **PASS** | files_total=4, missing=0, bad_hashes=0 |
| c | Cross-refs (`evidence_ledger.md` references files in zip; no empty proof rows) | **PASS** | `evidence_ledger.md` present; rows scanned=0 (no `\| proof_envelope ` rows in this minimal bundle); no missing refs; no empty proof paths |
| d | Forbidden extras (every zip entry is referenced in `manifest.files`, plus `manifest.json`/`manifest.sig`) | **PASS** | `extras=[]` |
| e | Commit binding (`manifest.git.sha` is a real commit) | **PASS** | sha=`ea909e8c5ae4fd058116013e95a981f058094738`; `git cat-file -e` returns 0 (matches `develop` HEAD) |

Confirmed independently by `conformance_runner.py --bundle`:
`OK — signature + file hashes + cross-refs verified`.

## Negative tests

Each test produced a tampered copy of the bundle in a temp directory
(`%TEMP%/wave-b-audit-neg-v630tifl/`) and ran the independent
auditor against it. Tampered bundles were NOT pushed.

| Scenario | Expected | Auditor result | Errors observed |
|---|---|---|---|
| Flip `build.run_id` in `manifest.json`, replace `manifest.sig` with a forged 64-zero HMAC | reject | **PASS — failure detected** | `signature mismatch` |
| Remove `evidence_ledger.md` (a manifest-listed file) from the zip | reject | **PASS — failure detected** | `file integrity: 1 missing, 0 bad hashes`; `evidence_ledger.md missing` |
| Add an unlisted `smuggled.txt` to the zip | reject | **PASS — failure detected** | `forbidden extras: ['smuggled.txt']` |

All three adversarial mutations were rejected with the expected,
specific error.

## Recommendations

1. **No regressions found.** Bundle infrastructure correctly enforces
signature, file integrity, manifest exhaustiveness, and commit
binding.
2. *(nice-to-have)* `conformance_runner.verify_bundle` does not currently
reject zip entries that are absent from `manifest.files` (the
"forbidden extras" check d). The independent auditor flags these;
consider porting that check upstream so smuggled files cannot pass
`conformance_runner.py --bundle` either.
3. *(nice-to-have)* Bundles built outside an integration run contain
no `| proof_envelope ` rows in `evidence_ledger.md`, so check (c)
exercises only the structural path. A future audit should re-run
against a richer bundle (post-acceptance) to cover the data path.

## Artifacts

- `04_testing/wave-b-reports/auditor_verify.py` — independent verifier
- `04_testing/wave-b-reports/negative_tests.py` — adversarial harness
- this report
178 changes: 178 additions & 0 deletions 04_testing/wave-b-reports/auditor_verify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
Independent Wave B Auditor verifier.

Implements its OWN verification logic for proof bundles — does NOT call
05_truth_proof/conformance_runner.py. Used to cross-check the bundle
infrastructure for the Wave B audit.

Checks (a–e):
a. signature : HMAC-SHA256 over canonical(manifest) under HERMES3D_PROOF_KEY
matches manifest.sig.value
b. file hash : every file in manifest.files exists in the zip with sha256 match
c. cross-refs : evidence_ledger.md references files that exist in the zip;
every "| proof_envelope " row has a non-empty proof path
d. forbidden : every file in the zip is referenced from manifest.files or is
one of the structural files (manifest.json / manifest.sig)
e. commit : manifest.git.sha is a real commit (`git cat-file -e <sha>`)

Usage:
python auditor_verify.py --bundle <zip> [--key K]
"""
from __future__ import annotations

import argparse
import hashlib
import hmac
import json
import os
import subprocess
import sys
import zipfile
from pathlib import Path


def canonical(doc: dict) -> bytes:
body = {k: v for k, v in doc.items() if k != "signature"}
return json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")


def sha256_bytes(b: bytes) -> str:
return hashlib.sha256(b).hexdigest()


def audit(zip_path: Path, key: bytes) -> dict:
result = {
"bundle": str(zip_path),
"checks": {},
"errors": [],
}
with zipfile.ZipFile(zip_path, "r") as zf:
names = set(zf.namelist())
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
sig = json.loads(zf.read("manifest.sig").decode("utf-8"))
Comment on lines +52 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The script will crash with a KeyError if manifest.json or manifest.sig are missing from the zip file. It's safer to check for their existence in names before attempting to read them, allowing the function to return a structured error report instead of an unhandled exception.

Suggested change
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
sig = json.loads(zf.read("manifest.sig").decode("utf-8"))
if "manifest.json" not in names or "manifest.sig" not in names:
if "manifest.json" not in names: result["errors"].append("manifest.json missing")
if "manifest.sig" not in names: result["errors"].append("manifest.sig missing")
result["ok"] = False
return result
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
sig = json.loads(zf.read("manifest.sig").decode("utf-8"))


# (a) signature
expected = hmac.new(key, canonical(manifest), hashlib.sha256).hexdigest()
actual = sig.get("value", "")
sig_ok = hmac.compare_digest(expected, actual)
result["checks"]["a_signature"] = {
"ok": sig_ok,
"algorithm": sig.get("algorithm"),
"expected_prefix": expected[:16],
"actual_prefix": actual[:16],
}
if not sig_ok:
result["errors"].append("signature mismatch")

# (b) file hashes
files = manifest.get("files", [])
bad_hashes = []
missing = []
for entry in files:
rel = entry["path"]
want = entry["sha256"]
if rel in ("manifest.json", "manifest.sig"):
continue
if rel not in names:
missing.append(rel)
continue
got = sha256_bytes(zf.read(rel))
if got != want:
bad_hashes.append(
{"path": rel, "want": want[:16], "got": got[:16]}
)
result["checks"]["b_file_hashes"] = {
"ok": not bad_hashes and not missing,
"files_total": len(files),
"missing": missing,
"bad_hashes": bad_hashes,
}
if missing or bad_hashes:
result["errors"].append(
f"file integrity: {len(missing)} missing, {len(bad_hashes)} bad hashes"
)

# (c) cross-refs in evidence_ledger.md
ledger_refs_missing = []
ledger_rows = 0
empty_proof_rows = []
if "evidence_ledger.md" in names:
text = zf.read("evidence_ledger.md").decode("utf-8", "replace")
for line in text.splitlines():
if not line.startswith("| proof_envelope "):
continue
ledger_rows += 1
cols = [c.strip() for c in line.strip("|").split("|")]
if len(cols) >= 3:
ref = cols[2].replace("\\", "/")
if not ref:
empty_proof_rows.append(line[:80])
elif ref not in names and not ref.startswith(
("kit_manifest", "honesty_ledger")
):
ledger_refs_missing.append(ref)
else:
result["errors"].append("evidence_ledger.md missing")
cross_ok = (
"evidence_ledger.md" in names
and not ledger_refs_missing
and not empty_proof_rows
)
result["checks"]["c_cross_refs"] = {
"ok": cross_ok,
"rows_seen": ledger_rows,
"missing_refs": ledger_refs_missing,
"empty_proof_rows": empty_proof_rows,
}
if ledger_refs_missing or empty_proof_rows:
result["errors"].append("evidence_ledger cross-refs invalid")

# (d) forbidden extras: every zip file should be referenced
manifest_paths = {e["path"] for e in files}
manifest_paths.update({"manifest.json", "manifest.sig"})
extras = sorted(names - manifest_paths)
result["checks"]["d_forbidden_extras"] = {
"ok": not extras,
"extras": extras,
}
if extras:
result["errors"].append(f"forbidden extras: {extras}")

# (e) commit binding
sha = (manifest.get("git") or {}).get("sha", "")
commit_ok = False
if sha:
cp = subprocess.run(
["git", "cat-file", "-e", sha],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
)
commit_ok = cp.returncode == 0
result["checks"]["e_commit_binding"] = {
"ok": commit_ok,
"sha": sha,
}
if not commit_ok:
result["errors"].append(f"commit binding failed: sha={sha!r}")

result["ok"] = not result["errors"]
return result


def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--bundle", required=True)
p.add_argument("--key", default=None)
args = p.parse_args()
key = (args.key or os.environ.get("HERMES3D_PROOF_KEY") or "").encode("utf-8")
if not key:
print("error: HERMES3D_PROOF_KEY (or --key) required", file=sys.stderr)
return 2
out = audit(Path(args.bundle), key)
print(json.dumps(out, indent=2))
return 0 if out["ok"] else 1


if __name__ == "__main__":
sys.exit(main())
129 changes: 129 additions & 0 deletions 04_testing/wave-b-reports/negative_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Wave B Auditor — negative tests.

Three tampered copies of a bundle, each verified with auditor_verify.py.
Each MUST fail. Prints PASS/FAIL for each scenario based on whether the
expected failure was detected.
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
AUDITOR = SCRIPT_DIR / "auditor_verify.py"


def run_auditor(bundle: Path, key: str) -> tuple[int, dict]:
env = dict(os.environ)
env["HERMES3D_PROOF_KEY"] = key
cp = subprocess.run(
[sys.executable, str(AUDITOR), "--bundle", str(bundle)],
env=env,
capture_output=True,
text=True,
)
try:
return cp.returncode, json.loads(cp.stdout)
except Exception:
return cp.returncode, {"raw": cp.stdout, "stderr": cp.stderr}


def copy_zip_with_changes(src: Path, dst: Path,
mutate_file=None,
remove_files=None,
add_files=None) -> None:
remove_files = set(remove_files or [])
add_files = add_files or {}
mutate_file = mutate_file or {}
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
for name in zin.namelist():
if name in remove_files:
continue
data = zin.read(name)
if name in mutate_file:
data = mutate_file[name](data)
zout.writestr(name, data)
for name, data in add_files.items():
zout.writestr(name, data)


def main() -> int:
src = Path(sys.argv[1])
key = sys.argv[2]
Comment on lines +59 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The script lacks validation for command-line arguments. Running it without the required arguments will result in an IndexError. Adding a simple check with a usage message improves robustness.

Suggested change
src = Path(sys.argv[1])
key = sys.argv[2]
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <bundle_zip> <key>", file=sys.stderr)
return 2
src = Path(sys.argv[1])
key = sys.argv[2]

tmp = Path(tempfile.mkdtemp(prefix="wave-b-audit-neg-"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using tempfile.mkdtemp with manual cleanup is prone to leaving temporary directories behind if the script crashes or is interrupted. Using tempfile.TemporaryDirectory as a context manager ensures the directory is cleaned up automatically regardless of how the script exits.

print(f"[neg] tmpdir: {tmp}")

results = []

# 1. Tamper manifest + re-sign with WRONG key
import hashlib, hmac
def tamper_manifest(data: bytes) -> bytes:
m = json.loads(data.decode("utf-8"))
# flip a byte: change the build.run_id
m.setdefault("build", {})["run_id"] = "TAMPERED-RUN-ID-XYZ"
return json.dumps(m, sort_keys=True, separators=(",", ":")).encode("utf-8")

def resign_with_wrong_key(data: bytes) -> bytes:
# Re-sign with a key that is NOT the verification key — this is the
# adversary's attempt to forge a signature without knowing the secret.
m = json.loads(data.decode("utf-8"))
# The body actually being signed is the (tampered) manifest written above.
# We don't have it here, so we just put a plausible-looking but wrong sig.
wrong = "0" * 64
return json.dumps({"algorithm": "HMAC-SHA256", "value": wrong},
sort_keys=True, separators=(",", ":")).encode("utf-8")

t1 = tmp / "tampered_manifest.zip"
copy_zip_with_changes(
src, t1,
mutate_file={
"manifest.json": tamper_manifest,
"manifest.sig": resign_with_wrong_key,
},
)
rc, out = run_auditor(t1, key)
detected = rc != 0 and any("signature" in e for e in out.get("errors", []))
results.append(("tamper_manifest_wrong_key", detected, out.get("errors")))

# 2. Remove a listed file from the zip
with zipfile.ZipFile(src, "r") as zin:
manifest = json.loads(zin.read("manifest.json").decode("utf-8"))
victim = next(e["path"] for e in manifest["files"]
if e["path"] not in ("manifest.json", "manifest.sig"))
t2 = tmp / "removed_file.zip"
copy_zip_with_changes(src, t2, remove_files={victim})
rc, out = run_auditor(t2, key)
detected = rc != 0 and any("missing" in e or "integrity" in e for e in out.get("errors", []))
results.append((f"remove_file::{victim}", detected, out.get("errors")))

# 3. Add an extra file not in manifest
t3 = tmp / "extra_file.zip"
copy_zip_with_changes(src, t3, add_files={"smuggled.txt": b"hello"})
rc, out = run_auditor(t3, key)
detected = rc != 0 and any("forbidden" in e or "extras" in e for e in out.get("errors", []))
results.append(("add_extra_file::smuggled.txt", detected, out.get("errors")))

summary = []
all_pass = True
for name, detected, errors in results:
status = "PASS (expected failure detected)" if detected else "FAIL (vulnerability)"
if not detected:
all_pass = False
summary.append({"scenario": name, "status": status, "errors": errors})

print(json.dumps(summary, indent=2))
# Cleanup
shutil.rmtree(tmp, ignore_errors=True)
return 0 if all_pass else 1


if __name__ == "__main__":
sys.exit(main())
Loading