#!/usr/bin/env bash
# scripts/octavia-pki.sh verify <site> -- assert a DC's Octavia amphora control-plane PKI
#
# WHY THIS EXISTS. The PKI is minted by runbooks/phase-01-bundle-deploy.md Step 1.0-GEN,
# a copy-paste block chain. On 2026-07-29 that chain was executed for both DCs and TWO
# defects in it surfaced only because a human happened to notice:
#   - F8: the step's heredoc is a PASTE HAZARD. An indented terminator yields a config
#     with no `[alt_names]` section -- producing a controller certificate with NO SANs AT
#     ALL -- while every command in the chain still prints OK. **NOTHING in this repo
#     asserted the SAN set**, so that would have shipped silently.
#   - E2: the CA certificates landed mode 664. Group-WRITABLE trust anchors: the exposure
#     is INTEGRITY (a group member can substitute a CA), not confidentiality. The session
#     that generated them initially dismissed this as harmless "for public certs".
# Prose cannot be tested. This script can, and it has a harness, which is the whole point:
# the assertions below are the executable form of what the runbook only hoped for.
#
# SCOPE, DELIBERATELY NARROW (operator direction 2026-07-29: "Build the verify mode
# first, then we'll rule the guard fork"). This script VERIFIES ONLY. It mints nothing,
# writes nothing, and moves nothing. A `generate` mode is NOT built here: minting is
# operator-executed under the PreToolUse guard, and whether a sanctioned script may mint
# is D-137 open fork 1 -- an UNRULED decision (option (c) would have the guard refuse
# secret-writing shapes OUTSIDE a sanctioned minter). Building `generate` before that
# ruling would convert the guard from "blocks secret minting" into "blocks secret minting
# except through any script" as a SIDE EFFECT. That is a posture change and it belongs to
# the operator, not to this script.
#
# IT CANNOT LEAK KEY MATERIAL, BY CONSTRUCTION. Every openssl call uses `-noout`; the
# only file contents ever read are counted or pattern-matched, never printed. There is no
# `cat`, no `base64`, and no unredirected read of any key, passphrase or overlay value.
# That property is what makes it safe to run from an agent session; keep it true.
#
# RELATION TO THE REGISTER. D-137's `creds-matrix.py` checks that declared artifacts
# EXIST and are not world-readable. It cannot check that a certificate is the RIGHT
# certificate -- correct subject, correct SANs, correct issuer. This closes that gap. The
# two are complementary and neither replaces the other.
#
# FIRST CONSUMER OF THE creds-mint PATTERN, NOT A RIVAL TO IT. D-137 proposal 2(a) wants
# exactly ONE sanctioned minting path (`scripts/creds-mint.sh`, unbuilt, queued by R13).
# This script is scoped to Octavia so that pattern can be proven against a live consumer
# rather than designed in the abstract; if `creds-mint.sh` is later built, it ABSORBS the
# generate half and this file keeps only `verify`.
#
# USAGE
#   bash scripts/octavia-pki.sh verify <site>      # site = vr1-dc0 | vr1-dc1 (region-qualified)
#
# RUNS ON the host the PKI lives on, which is RULED: the D-128 Plane-2 headend
# (D-109 ruling note (b), 2026-07-29 -- "Generate on voffice1"). The binding is READ from
# creds-manifests/host-identity rather than hardcoded, so there is one source of truth and
# this script cannot drift from the register. On any other host it REFUSES rather than
# reporting a filesystem it should not be measuring -- the F6 lesson.
#
# EXIT: 0 all assertions pass | 1 an assertion FAILED | 2 bad args | 3 REFUSED (could not
# evaluate). Three outcomes, never two: "could not look" is never "nothing there".

set -uo pipefail          # deliberately NOT -e: a failing assertion must be COUNTED, not abort the run

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# REPO_ROOT and the workspace root are OVERRIDABLE so the harness can point them at
# fixtures. This is not a convenience: creds-matrix.py's V2 check shipped with ZERO harness
# cases precisely because it had no fixture path, and that is recorded in this repo as the
# reason it went untested. A check that cannot be pointed at a constructed defect cannot be
# proven capable of failing. Defaults are unchanged for every real caller.
REPO_ROOT="${OCTAVIA_PKI_REPO:-$(cd -- "$SCRIPT_DIR/.." && pwd)}"
PKI_HOME="${OCTAVIA_PKI_HOME:-$HOME}"

NFAIL=0; NREFUSE=0; NOK=0
ok()      { echo "  ok      $1"; NOK=$((NOK+1)); }
nfail()   { echo "  FAIL    $1"; NFAIL=$((NFAIL+1)); }
nrefuse() { echo "  REFUSE  $1"; NREFUSE=1; }

usage() {
  echo "usage: bash scripts/octavia-pki.sh verify <site>"
  echo "  site: region-qualified, e.g. vr1-dc0 or vr1-dc1 (bare 'dc0' is REJECTED)"
  exit 2
}

ACTION="${1:-}"; SITE="${2:-}"
[ -n "$ACTION" ] || usage
[ "$ACTION" = "verify" ] || { echo "unknown action '$ACTION' (only 'verify' is implemented; see the header on why 'generate' is not)"; usage; }
[ -n "$SITE" ] || usage
# Region-qualified only. A bare `dc0` has been a live defect class in this repo.
case "$SITE" in
  vr1-dc0|vr1-dc1) : ;;
  dc0|dc1|dc2) echo "REJECT: site '$SITE' is not region-qualified -- use vr1-dc0 / vr1-dc1"; exit 2 ;;
  *) echo "REJECT: unknown site '$SITE'"; exit 2 ;;
esac

echo "=== octavia-pki verify: $SITE ==="

# ---------------------------------------------------------------- preconditions (REFUSE)
command -v openssl >/dev/null 2>&1 || nrefuse "openssl is not on PATH -- cannot evaluate any certificate (this is a MISSING TOOL, not a bad PKI)"
command -v python3 >/dev/null 2>&1 || nrefuse "python3 is not on PATH -- needed to normalise IPv6 literals and parse the VIP overlay"

# HOST BINDING, read from the register rather than hardcoded (one source of truth; F6).
HOST_ID_FILE="$REPO_ROOT/creds-manifests/host-identity"
THIS_HOST="${OCTAVIA_PKI_THIS_HOST:-$(hostname)}"
EXPECT_HOST=""
if [ -r "$HOST_ID_FILE" ]; then
  # `headend` is the role the D-109 ruling note (b) binds the Octavia PKI to.
  EXPECT_HOST="$(awk '!/^[[:space:]]*#/ && NF==2 && $1=="headend" {print $2; exit}' "$HOST_ID_FILE")"
fi
if [ -z "$EXPECT_HOST" ]; then
  nrefuse "no 'headend' binding in creds-manifests/host-identity -- cannot tell whether this host should hold the PKI, and will not assume it does"
elif [ "${EXPECT_HOST%%.*}" != "${THIS_HOST%%.*}" ]; then
  nrefuse "this run is on '$THIS_HOST' but the PKI lives on '$EXPECT_HOST' (D-109 ruling note (b)); measuring this host's filesystem would describe the wrong machine"
else
  ok "host: '$THIS_HOST' is the declared headend, so its filesystem is the right one to measure"
fi

if [ "$NREFUSE" -ne 0 ]; then
  echo "octavia-pki verify ($SITE): REFUSE -- preconditions unmet; this is NOT a pass"; exit 3
fi

# ---------------------------------------------------------------- expected values, DERIVED
# NEVER a hardcoded VIP: the ruled value lives in the per-DC overlay, and R11/ruling-3
# moved these once already. Deriving means a future re-ruling updates this check for free.
VIP_OVERLAY="$REPO_ROOT/overlays/${SITE}-vips.yaml"
if [ ! -r "$VIP_OVERLAY" ]; then
  nrefuse "VIP overlay not readable at $VIP_OVERLAY -- the expected SANs are derived from it and will not be guessed"
  echo "octavia-pki verify ($SITE): REFUSE"; exit 3
fi

read -r EXP_V4 EXP_V6 <<EOF
$(python3 - "$VIP_OVERLAY" <<'PY'
import sys, ipaddress, yaml
try:
    v = yaml.safe_load(open(sys.argv[1]))["applications"]["octavia"]["options"]["vip"].split()
except Exception:
    print(""); sys.exit(0)
v4 = next((a for a in v if ipaddress.ip_address(a).version == 4), "")
v6 = next((a for a in v if ipaddress.ip_address(a).version == 6), "")
# compressed/exploded IPv6 forms differ between openssl and yaml; normalise ours now
print(v4, ipaddress.ip_address(v6).compressed if v6 else "")
PY
)
EOF
EXP_V4="${EXP_V4:-}"; EXP_V6="${EXP_V6:-}"
if [ -z "$EXP_V4" ]; then
  nrefuse "could not derive octavia's provider v4 VIP from $VIP_OVERLAY -- refusing rather than asserting against an empty expectation"
  echo "octavia-pki verify ($SITE): REFUSE"; exit 3
fi
# DC_LABEL is the CA-subject token the generator bakes. Derived the same way the runbook
# derives it, so a mismatch here means the generator ran with an unset/wrong DC_LABEL --
# which is F8's sibling defect (the runbook guards ${DC:?} but NOT ${DC_LABEL}).
DC_LABEL="$(printf '%s' "$SITE" | tr 'a-z-' 'A-Z ')"
echo "  (expect CA label '$DC_LABEL'; provider VIP v4=$EXP_V4 v6=${EXP_V6:-<none>})"

W="$PKI_HOME/octavia-pki/$SITE"
ISS_CERT="$W/issuing-ca/issuing-ca.cert.pem"
CON_CACERT="$W/controller-ca/controller-ca.cert.pem"
CON_CERT="$W/controller/controller.cert.pem"
OVERLAY="$REPO_ROOT/overlays/${SITE}-octavia-pki.yaml"

# ---------------------------------------------------------------- A1 workspace + file set
if [ ! -d "$W" ]; then
  nfail "A1 workspace $W does not exist -- this DC's PKI has not been generated (run Step 1.0-GEN for $SITE)"
else
  ok "A1 workspace present: ~/octavia-pki/$SITE"
  # The nine artifacts the generator is documented to produce. Absence is CONCLUSIVE.
  MISSING=()
  # `controller-ca.cert.srl` is asserted because it is CA issuance STATE, not residue: if it
  # is missing, the next `-CAcreateserial` starts a fresh sequence and the same CA can issue a
  # DUPLICATE serial. The controller cert is 2-year and D-106's FQDN work will want new SANs
  # (F9), so reissuance is scheduled, not hypothetical. Declared in creds-matrix.tsv alongside
  # this assertion -- the register and the gate must agree on what should exist.
  for rel in issuing-ca/passphrase.txt issuing-ca/issuing-ca.key.enc issuing-ca/issuing-ca.cert.pem \
             controller-ca/passphrase.txt controller-ca/controller-ca.key.enc controller-ca/controller-ca.cert.pem \
             controller-ca/controller-ca.cert.srl \
             controller/controller.key controller/controller.cert.pem controller/controller.bundle.pem; do
    [ -f "$W/$rel" ] || MISSING+=("$rel")
  done
  if [ "${#MISSING[@]}" -eq 0 ]; then ok "A2 all 10 expected artifacts present"
  else nfail "A2 missing ${#MISSING[@]} expected artifact(s): ${MISSING[*]}"; fi
fi

# ---------------------------------------------------------------- A3 modes
# Private material must be 0600. CERTS are public, so confidentiality is not the point --
# but a cert that is GROUP- or WORLD-WRITABLE is a substitutable trust anchor, which is
# the E2 finding of 2026-07-29 and the reason this assertion exists at all.
mode_of() { stat -c '%a' "$1" 2>/dev/null; }
for rel in issuing-ca/passphrase.txt issuing-ca/issuing-ca.key.enc \
           controller-ca/passphrase.txt controller-ca/controller-ca.key.enc \
           controller/controller.key controller/controller.bundle.pem; do
  f="$W/$rel"; [ -f "$f" ] || continue
  m="$(mode_of "$f")"
  if [ "$m" = "600" ]; then ok "A3 private $rel is 0600"
  else nfail "A3 private $rel is $m -- private material must be 0600"; fi
done
for rel in issuing-ca/issuing-ca.cert.pem controller-ca/controller-ca.cert.pem controller/controller.cert.pem; do
  f="$W/$rel"; [ -f "$f" ] || continue
  m="$(mode_of "$f")"
  # group-writable = 2nd digit has the 2 bit; world-writable = 3rd digit has the 2 bit
  gw=$(( ${m:1:1} & 2 )); ww=$(( ${m:2:1} & 2 ))
  if [ "$gw" -eq 0 ] && [ "$ww" -eq 0 ]; then ok "A3 cert $rel is $m -- not group/world writable"
  else nfail "A3 cert $rel is $m -- WRITABLE by group/other: a substitutable trust anchor (E2)"; fi
done

# ---------------------------------------------------------------- A4/A5 CA identities
subject_of() { openssl x509 -in "$1" -noout -subject 2>/dev/null; }
check_subject() { # <file> <expected-substring> <label>
  local f="$1" want="$2" lbl="$3" got
  [ -f "$f" ] || { nfail "$lbl certificate absent"; return; }
  got="$(subject_of "$f")"
  if [ -z "$got" ]; then nrefuse "$lbl subject unreadable -- no conclusion drawn"; return; fi
  case "$got" in
    *"$want"*) ok "$lbl subject names this DC: contains '$want'" ;;
    *) nfail "$lbl subject does NOT contain '$want' -- got: ${got#subject=}" ;;
  esac
}
check_subject "$ISS_CERT"   "$DC_LABEL Omega Cloud Octavia Issuing CA"    "A4 issuing CA"
check_subject "$CON_CACERT" "$DC_LABEL Omega Cloud Octavia Controller CA" "A5 controller CA"

# ---------------------------------------------------------------- A6/A7 self-signatures
for pair in "A6 issuing CA:$ISS_CERT" "A7 controller CA:$CON_CACERT"; do
  lbl="${pair%%:*}"; f="${pair#*:}"
  [ -f "$f" ] || { nfail "$lbl absent"; continue; }
  if openssl verify -CAfile "$f" "$f" >/dev/null 2>&1; then ok "$lbl self-signature verifies"
  else nfail "$lbl does NOT verify against itself"; fi
done

# ---------------------------------------------------------------- A8 chain, both directions
# POSITIVE: the controller cert must verify against the CONTROLLER CA.
# NEGATIVE: it must NOT verify against the ISSUING CA. Both matter -- the two CAs are
# distinct trust domains, and a chain accidentally built against the wrong one would still
# produce a working-looking `verify OK` if only the positive case were checked.
if [ -f "$CON_CERT" ] && [ -f "$CON_CACERT" ] && [ -f "$ISS_CERT" ]; then
  if openssl verify -CAfile "$CON_CACERT" "$CON_CERT" >/dev/null 2>&1; then
    ok "A8 controller cert verifies against the CONTROLLER CA"
  else
    nfail "A8 controller cert does NOT verify against the controller CA -- chain is wrong"
  fi
  if openssl verify -CAfile "$ISS_CERT" "$CON_CERT" >/dev/null 2>&1; then
    nfail "A8 controller cert ALSO verifies against the ISSUING CA -- the two CAs are not distinct trust domains"
  else
    ok "A8 controller cert correctly does NOT verify against the issuing CA"
  fi
else
  nfail "A8 cannot evaluate the chain -- one or more certificates absent"
fi

# ---------------------------------------------------------------- A9 the SAN set (F8's gap)
# THIS IS THE ASSERTION WHOSE ABSENCE F8 RECORDS. A controller certificate with an EMPTY
# `[alt_names]` -- the exact output of the runbook's heredoc paste hazard -- passed every
# other check in the chain. openssl prints IPv6 EXPANDED AND UPPERCASE
# (`2602:F3E2:F02:11:0:0:0:57`), so a literal string compare against the overlay's
# compressed form would FAIL on a CORRECT cert. Both sides are normalised.
if [ ! -f "$CON_CERT" ]; then
  nfail "A9 controller cert absent -- SAN set not evaluated"
else
  SAN_RAW="$(openssl x509 -in "$CON_CERT" -noout -ext subjectAltName 2>/dev/null)"
  if [ -z "$SAN_RAW" ]; then
    nfail "A9 controller cert has NO subjectAltName extension at all -- this is F8's failure mode (empty [alt_names]); amphora TLS would have no valid name to match"
  else
    NDNS="$(printf '%s' "$SAN_RAW" | grep -oE 'DNS:[^,[:space:]]+' | wc -l | tr -d ' ')"
    [ "$NDNS" -eq 2 ] && ok "A9 SAN carries 2 DNS names" \
                      || nfail "A9 SAN carries $NDNS DNS name(s), expected 2"
    GOT_IPS="$(printf '%s' "$SAN_RAW" | grep -oE 'IP Address:[0-9A-Fa-f:.]+' | sed 's/^IP Address://')"
    NORM="$(printf '%s\n' "$GOT_IPS" | python3 -c '
import sys, ipaddress
for line in sys.stdin:
    s = line.strip()
    if not s: continue
    try: print(ipaddress.ip_address(s).compressed)
    except ValueError: print("UNPARSEABLE:"+s)
')"
    printf '%s\n' "$NORM" | grep -qx "$EXP_V4" \
      && ok "A9 SAN carries this DC's provider v4 VIP ($EXP_V4)" \
      || nfail "A9 SAN is MISSING this DC's provider v4 VIP ($EXP_V4) -- got: $(printf '%s' "$NORM" | tr '\n' ' ')"
    if [ -n "$EXP_V6" ]; then
      printf '%s\n' "$NORM" | grep -qx "$EXP_V6" \
        && ok "A9 SAN carries this DC's provider v6 VIP ($EXP_V6)" \
        || nfail "A9 SAN is MISSING this DC's provider v6 VIP ($EXP_V6) -- D-109 ruling note 2026-07-29 requires it"
    else
      ok "A9 overlay declares no v6 provider leg, so no v6 SAN is expected"
    fi
  fi
fi

# ---------------------------------------------------------------- A10 the deploy overlay
if [ ! -f "$OVERLAY" ]; then
  nfail "A10 deploy overlay absent at overlays/${SITE}-octavia-pki.yaml -- phase-01 hard-ABORTs without it"
else
  m="$(mode_of "$OVERLAY")"
  [ "$m" = "600" ] && ok "A10 overlay is 0600" || nfail "A10 overlay is $m -- it carries CA key blobs plus a plaintext passphrase; must be 0600"
  K="$(grep -c 'lb-mgmt-' "$OVERLAY" 2>/dev/null || true)"
  [ "$K" = "5" ] && ok "A10 overlay declares 5 lb-mgmt-* keys" || nfail "A10 overlay declares $K lb-mgmt-* key(s), expected 5"
  if LC_ALL=C grep -qP '[^\x00-\x7F]' "$OVERLAY" 2>/dev/null; then
    nfail "A10 overlay contains non-ASCII bytes"
  else ok "A10 overlay is ASCII clean"; fi
  # The F4 gate, re-asserted at verify time: this file is the only thing between a CA
  # private key and publication in a repo SEC-004 records as PUBLIC.
  if git -C "$REPO_ROOT" check-ignore -q "$OVERLAY" 2>/dev/null; then
    ok "A10 overlay is gitignored (F4)"
  else
    nfail "A10 overlay is NOT gitignored -- a CA private key is committable RIGHT NOW (F4)"
  fi
fi

# ---------------------------------------------------------------- A11 cross-DC independence
# The point of the D-109 amendment (R7): no cross-DC amphora root-of-trust. Before F1 the
# generator's paths were shared, so a second DC's generation OVERWROTE the first and these
# would have been byte-identical. Only asserted when the other DC is actually present.
OTHER=""
case "$SITE" in vr1-dc0) OTHER=vr1-dc1 ;; vr1-dc1) OTHER=vr1-dc0 ;; esac
OW="$PKI_HOME/octavia-pki/$OTHER"
if [ -n "$OTHER" ] && [ -d "$OW" ]; then
  SAME=0; CMP=0
  for rel in issuing-ca/issuing-ca.key.enc controller-ca/controller-ca.key.enc controller/controller.cert.pem; do
    [ -f "$W/$rel" ] && [ -f "$OW/$rel" ] || continue
    CMP=$((CMP+1))
    cmp -s "$W/$rel" "$OW/$rel" && { SAME=$((SAME+1)); nfail "A11 $rel is IDENTICAL to $OTHER's -- the two DCs share amphora trust material, which R7 refused"; }
  done
  [ "$CMP" -gt 0 ] && [ "$SAME" -eq 0 ] && ok "A11 all $CMP compared artifacts differ from $OTHER -- per-DC independence holds"
  [ "$CMP" -eq 0 ] && ok "A11 no comparable artifact pairs present -- independence not evaluated"
else
  ok "A11 $OTHER not present on this host -- cross-DC independence not evaluated (not a pass for $OTHER)"
fi

# ---------------------------------------------------------------- verdict: three outcomes
echo
if [ "$NREFUSE" -ne 0 ]; then
  echo "octavia-pki verify ($SITE): REFUSE -- could not evaluate; this is NOT a pass"; exit 3
fi
if [ "$NFAIL" -ne 0 ]; then
  echo "octavia-pki verify ($SITE): FAIL -- $NFAIL assertion(s) failed, $NOK passed"; exit 1
fi
echo "octavia-pki verify ($SITE): PASS -- $NOK assertion(s), 0 failed"
exit 0
