Files
Violin 625ca927c0 chore(guard): rename receipt key env var to drop benchmark reference
VIOLIN_BENCHMARK_RECEIPT_KEY -> VIOLIN_RECEIPT_KEY. The all-caps
'BENCHMARK' slipped past the original case-sensitive de-cheat grep in
plugins/violin_guard. No consumers hardcode the literal; all use the
RECEIPT_KEY_ENV constant.
2026-08-13 10:40:39 +01:00

156 lines
4.8 KiB
Python

"""Authenticate execution receipts and their evidence artifacts."""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import shlex
from pathlib import Path
from typing import Any
RECEIPT_KEY_ENV = "VIOLIN_RECEIPT_KEY"
SIGNATURE_FIELD = "receipt_hmac_sha256"
DIGESTS_FIELD = "evidence_sha256"
def _decode_key(value: str | bytes | None) -> bytes | None:
if value is None:
return None
if isinstance(value, bytes):
return value if len(value) >= 32 else None
try:
decoded = bytes.fromhex(value)
except ValueError:
return None
return decoded if len(decoded) >= 32 else None
# The runner injects this only into the Hermes process. Remove it from the
# process environment as the plugin loads so target commands cannot inherit it.
_RUNTIME_KEY = _decode_key(os.environ.pop(RECEIPT_KEY_ENV, None))
def _canonical_receipt(record: dict[str, Any]) -> bytes:
unsigned = {key: value for key, value in record.items() if key != SIGNATURE_FIELD}
return json.dumps(
unsigned,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
def _file_digest(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _declared_output_values(command: str) -> list[str]:
try:
tokens = shlex.split(command, posix=True)
except ValueError:
return []
values: list[str] = []
for index, token in enumerate(tokens):
if token in {"-o", "--output"} and index + 1 < len(tokens):
values.append(tokens[index + 1])
elif token.startswith("--output="):
values.append(token.partition("=")[2])
return values
def _evidence_paths(record: dict[str, Any], engagement: Path) -> list[Path]:
evidence_root = (engagement / "evidence").resolve()
manifest_value = str((record.get("evidence_paths") or {}).get("manifest") or "")
values = [
str(value)
for value in (record.get("evidence_paths") or {}).values()
if value and str(value) != manifest_value
]
values.extend(_declared_output_values(str(record.get("command") or "")))
paths: list[Path] = []
for value in values:
relative = Path(value)
if relative.is_absolute():
continue
candidate = (engagement / relative).resolve()
if (
candidate.is_relative_to(evidence_root)
and candidate.is_file()
and not candidate.is_symlink()
and candidate not in paths
):
paths.append(candidate)
return paths
def seal_execution_receipt(
record: dict[str, Any],
engagement: Path,
*,
key: str | bytes | None = None,
) -> dict[str, Any]:
"""Bind a completed receipt to the exact evidence bytes it produced."""
secret = _decode_key(key) if key is not None else _RUNTIME_KEY
sealed = dict(record)
sealed.pop(SIGNATURE_FIELD, None)
sealed.pop(DIGESTS_FIELD, None)
if secret is None:
return sealed
root = engagement.resolve()
sealed[DIGESTS_FIELD] = {
path.relative_to(root).as_posix(): _file_digest(path)
for path in _evidence_paths(sealed, root)
}
signature = hmac.new(secret, _canonical_receipt(sealed), hashlib.sha256).hexdigest()
sealed[SIGNATURE_FIELD] = f"hmac-sha256:{signature}"
return sealed
def verified_evidence_paths(
record: dict[str, Any],
engagement: Path,
*,
key: str | bytes | None,
) -> tuple[Path, ...] | None:
"""Return authenticated, unchanged evidence paths or fail closed."""
secret = _decode_key(key)
signature = str(record.get(SIGNATURE_FIELD) or "")
if secret is None or not signature.startswith("hmac-sha256:"):
return None
expected = hmac.new(secret, _canonical_receipt(record), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature.removeprefix("hmac-sha256:"), expected):
return None
digests = record.get(DIGESTS_FIELD)
if not isinstance(digests, dict):
return None
evidence_root = (engagement / "evidence").resolve()
verified: list[Path] = []
for value, expected_digest in digests.items():
relative = Path(str(value))
candidate = (engagement / relative).resolve()
if (
relative.is_absolute()
or not candidate.is_relative_to(evidence_root)
or not candidate.is_file()
or candidate.is_symlink()
or not hmac.compare_digest(_file_digest(candidate), str(expected_digest))
):
return None
verified.append(candidate)
return tuple(verified)
__all__ = [
"DIGESTS_FIELD",
"RECEIPT_KEY_ENV",
"SIGNATURE_FIELD",
"seal_execution_receipt",
"verified_evidence_paths",
]