#!/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)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" AUDIT_LOG="${1:-$PROJECT_ROOT/.specify/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="$PROJECT_ROOT/.specify/level5/central-governance/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) 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 [[ -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" else # Fallback — local key (dev environment without Vault) # IMPORTANT: Do NOT generate a new key pair here. audit-public.pem is committed # and shared by both audit.jsonl and tool-calls.jsonl verification. Generating a # new key overwrites audit-public.pem and breaks tool-calls-head.sig verification. AUDIT_PRIV="$PROJECT_ROOT/.specify/level5/central-governance/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