diff --git a/scripts/octavia-pki.sh b/scripts/octavia-pki.sh new file mode 100755 index 0000000..57a1e04 --- /dev/null +++ b/scripts/octavia-pki.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# scripts/octavia-pki.sh verify -- 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 = 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 " + 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 <})" + +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=() + 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/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 9 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() { #