#!/usr/bin/env bash set -euo pipefail # CASAN H5 — Sign the audit chain head hash via Vault KMS (or local key fallback). # # Called by CI after harness tests rebuild audit.jsonl, so that # verify-audit-chain.sh produces "anchor=signed" (not "anchor=unsigned"). # # Usage: # sign-audit-head.sh [audit-jsonl] # # Writes: # /audit-head.txt — the head hash (plain text) # /audit-head.sig — RSA signature of audit-head.txt # # After this script, verify-audit-chain.sh reports: # AUDIT_CHAIN_VALID anchor=signed # # Environment (KMS path): # VAULT_ADDR — e.g. http://vault:8200 # VAULT_TOKEN — token with transit/sign/casan-audit-key capability SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/casan-paths.sh" PROJECT_ROOT="$CASAN_APP_ROOT" AUDIT_LOG="${1:-$CASAN_STATE_ROOT/logs/audit/audit.jsonl}" AUDIT_DIR="$(dirname "$AUDIT_LOG")" HEAD_FILE="$AUDIT_DIR/audit-head.txt" HEAD_SIG="$AUDIT_DIR/audit-head.sig" AUDIT_PUB="$CASAN_GOVERNANCE_ROOT/audit-public.pem" if [[ ! -f "$AUDIT_LOG" ]]; then echo "SIGN_AUDIT_HEAD_SKIP audit.jsonl not found" >&2 exit 0 fi # ── Compute the current chain head ──────────────────────────────────────── HEAD_HASH="$(python - "$AUDIT_LOG" <<'PY' import hashlib, json, sys path = sys.argv[1] previous = "" with open(path, encoding="utf-8") as f: for line in f: if not line.strip(): continue record = json.loads(line) if int(record.get("schema_version", 1)) >= 2: core = "|".join([ record.get("timestamp",""), record.get("trace_id",""), record.get("action",""), record.get("action_class",""), record.get("actor",""), record.get("risk_level",""), json.dumps(record.get("risk_factors", {}), sort_keys=True, separators=(",", ":")), record.get("evidence_requirement",""), record.get("decision",""), record.get("approval_status",""), record.get("approver",""), record.get("input_hash",""), record.get("output_hash",""), previous, ]) else: core = "|".join([ record.get("timestamp",""), record.get("trace_id",""), record.get("action",""), record.get("actor",""), record.get("risk_level",""), record.get("decision",""), record.get("approval_status",""), record.get("approver",""), record.get("input_hash",""), record.get("output_hash",""), previous, ]) previous = hashlib.sha256(core.encode()).hexdigest() print(previous) PY )" if [[ -z "$HEAD_HASH" ]]; then echo "SIGN_AUDIT_HEAD_SKIP empty chain" >&2 exit 0 fi printf '%s' "$HEAD_HASH" > "$HEAD_FILE" # ── Sign the head file ──────────────────────────────────────────────────── VAULT_KMS="$SCRIPT_DIR/vault-kms.sh" if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then [[ "${VAULT_ADDR:-}" == https://* ]] || { echo "SIGN_AUDIT_HEAD_FAIL reason=vault_https_required_in_prod" >&2; exit 1; } fi if [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]] && \ curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then # KMS path — sign via Vault Transit, export public key bash "$VAULT_KMS" enable-transit bash "$VAULT_KMS" sign "$HEAD_FILE" "$HEAD_SIG" "casan-audit-key" bash "$VAULT_KMS" pubkey "$AUDIT_PUB" "casan-audit-key" echo "SIGN_AUDIT_HEAD_OK head=$HEAD_HASH anchor=vault-kms" # Also re-sign the tool-calls chain head with the same Vault key so that # verify-tool-audit.sh can verify using the same audit-public.pem. TOOL_LOG="$AUDIT_DIR/tool-calls.jsonl" if [[ -f "$TOOL_LOG" ]]; then TOOL_HEAD="$(python - "$TOOL_LOG" <<'PY' import hashlib, json, sys prev = "" with open(sys.argv[1], encoding="utf-8") as f: for line in f: if not line.strip(): continue rec = json.loads(line) stored = rec.pop("record_hash", "") core = json.dumps(rec, sort_keys=True, separators=(",", ":")) if hashlib.sha256((prev + "|" + core).encode()).hexdigest() != stored: raise SystemExit("TOOL_CHAIN_BROKEN") prev = stored sys.stdout.write(prev) PY )" if [[ -n "$TOOL_HEAD" ]]; then printf '%s' "$TOOL_HEAD" > "$AUDIT_DIR/tool-calls-head.txt" bash "$VAULT_KMS" sign "$AUDIT_DIR/tool-calls-head.txt" "$AUDIT_DIR/tool-calls-head.sig" "casan-audit-key" echo "SIGN_TOOL_AUDIT_HEAD_OK head=$TOOL_HEAD anchor=vault-kms" fi fi else # Fallback — local key (dev environment without Vault) if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then echo "SIGN_AUDIT_HEAD_FAIL reason=vault_kms_required_in_prod" >&2 exit 1 fi AUDIT_PRIV="$CASAN_GOVERNANCE_ROOT/audit-private.pem" if [[ ! -f "$AUDIT_PRIV" ]]; then echo "SIGN_AUDIT_HEAD_SKIP no private key and VAULT_ADDR not set — verify will show anchor=unsigned" >&2 exit 0 fi openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$HEAD_SIG" "$HEAD_FILE" echo "SIGN_AUDIT_HEAD_OK head=$HEAD_HASH anchor=local-file" fi # A4/V9: also bind token/cost telemetry to a signed manifest so tampering with # provider-usage.jsonl / metrics.jsonl is detectable. Best-effort — never fails # the audit signing step (verify-telemetry-integrity.sh is the gate). bash "$SCRIPT_DIR/telemetry-integrity.sh" sign >/dev/null 2>&1 || true