Files
net-kingdom/sso-mfa/bootstrap/openbao-init-unseal.sh
2026-07-02 11:01:34 +02:00

196 lines
9.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# openbao-init-unseal.sh — SOPS-held OpenBao init/unseal automation (NET-WP-0020 T2)
#
# Usage:
# bash sso-mfa/bootstrap/openbao-init-unseal.sh [--dry-run] [SECRETS_DIR]
#
# Implements the `sops-held-automation` unseal custody model from
# docs/openbao-unseal-custody-models.md:
# - refuses to run unless the security bootstrap console has selected
# `sops-held-automation` (attended-ceremony/auto-unseal-transit are never
# automated here);
# - if OpenBao is uninitialized: runs `bao operator init` inside the pod and
# writes the init material to SECRETS_DIR/openbao/ (age/SOPS custody via
# encrypt-secrets.sh — never Git plaintext, never stdout);
# - if OpenBao is sealed: replays the SOPS-held unseal shares via stdin until
# the threshold is met;
# - verifies post-unseal state and emits a NON-SECRET JSON evidence line
# (flags only: openbao_initialized, openbao_post_unseal_verified).
#
# Post-unseal platform configuration stays owned by railiance-platform:
# make -C ../railiance-platform openbao-configure-initial
# It is invoked automatically only when OPENBAO_RUN_CONFIGURE_INITIAL=1.
#
# Environment:
# OPENBAO_NAMESPACE k8s namespace (default: openbao)
# OPENBAO_RELEASE helm release / pod prefix (default: openbao)
# OPENBAO_KEY_SHARES init key shares (default: 3)
# OPENBAO_KEY_THRESHOLD init key threshold (default: 2)
# OPENBAO_CONSOLE_METADATA console metadata file (default: <repo>/.local/security-bootstrap.json)
# OPENBAO_RUN_CONFIGURE_INITIAL 1 = run platform configure-initial after unseal
# RAILIANCE_PLATFORM_DIR path to railiance-platform checkout
# (default: sibling of this repo)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
DRY_RUN=false
SECRETS_DIR="$SCRIPT_DIR/secrets"
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
*) SECRETS_DIR="$arg" ;;
esac
done
NAMESPACE="${OPENBAO_NAMESPACE:-openbao}"
RELEASE="${OPENBAO_RELEASE:-openbao}"
POD="${RELEASE}-0"
KEY_SHARES="${OPENBAO_KEY_SHARES:-3}"
KEY_THRESHOLD="${OPENBAO_KEY_THRESHOLD:-2}"
CONSOLE_METADATA="${OPENBAO_CONSOLE_METADATA:-$REPO_ROOT/.local/security-bootstrap.json}"
RAILIANCE_PLATFORM_DIR="${RAILIANCE_PLATFORM_DIR:-$(dirname "$REPO_ROOT")/railiance-platform}"
OPENBAO_SECRETS="$SECRETS_DIR/openbao"
INIT_FILE="$OPENBAO_SECRETS/init.json"
log() { echo " [openbao] $*"; }
die() { echo " [openbao] ERROR: $*" >&2; exit 1; }
evidence() {
# NON-SECRET evidence only: booleans, counts, model, namespace.
python3 - "$@" <<'PY'
import json, sys
keys = [a.split("=", 1) for a in sys.argv[1:]]
def coerce(v):
if v in ("true", "false"):
return v == "true"
try:
return int(v)
except ValueError:
return v
print("EVIDENCE " + json.dumps({k: coerce(v) for k, v in keys}, sort_keys=True))
PY
}
# ── Custody model gate ────────────────────────────────────────────────────────
# Only sops-held-automation may be automated. attended-ceremony and
# auto-unseal-transit must never reach `bao operator init/unseal` from here.
MODEL=""
if [[ -f "$CONSOLE_METADATA" ]]; then
MODEL=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("openbao_unseal_custody_model") or "")' "$CONSOLE_METADATA")
fi
if [[ "$MODEL" != "sops-held-automation" ]]; then
die "unseal custody model is '${MODEL:-unselected}' — automation requires 'sops-held-automation'.
Select it first (see docs/openbao-unseal-custody-models.md):
python3 tools/security-bootstrap-console/security_bootstrap_console.py \\
select-openbao-unseal-custody-model --model sops-held-automation \\
--metadata $CONSOLE_METADATA"
fi
log "custody model gate passed: $MODEL"
command -v kubectl >/dev/null 2>&1 || die "kubectl not found"
command -v python3 >/dev/null 2>&1 || die "python3 not found"
kubectl get namespace "$NAMESPACE" >/dev/null 2>&1 \
|| die "namespace '$NAMESPACE' not found — deploy OpenBao first (railiance-platform: make openbao-deploy)"
kubectl -n "$NAMESPACE" get pod "$POD" >/dev/null 2>&1 \
|| die "pod '$POD' not found in namespace '$NAMESPACE'"
# ── Status probe ──────────────────────────────────────────────────────────────
# `bao status` exits 2 when sealed — capture output regardless of exit code.
bao_status() {
kubectl -n "$NAMESPACE" exec "$POD" -- bao status -format=json 2>/dev/null || true
}
STATUS_JSON="$(bao_status)"
[[ -n "$STATUS_JSON" ]] || die "could not read bao status from $NAMESPACE/$POD"
INITIALIZED=$(python3 -c 'import json,sys; print(str(json.loads(sys.stdin.read()).get("initialized", False)).lower())' <<<"$STATUS_JSON")
SEALED=$(python3 -c 'import json,sys; print(str(json.loads(sys.stdin.read()).get("sealed", True)).lower())' <<<"$STATUS_JSON")
log "status: initialized=$INITIALIZED sealed=$SEALED"
# ── Init (only when uninitialized) ────────────────────────────────────────────
DID_INIT=false
if [[ "$INITIALIZED" != "true" ]]; then
if [[ "$DRY_RUN" == true ]]; then
log "[dry-run] would run: bao operator init -key-shares=$KEY_SHARES -key-threshold=$KEY_THRESHOLD"
else
log "initializing OpenBao ($KEY_SHARES shares, threshold $KEY_THRESHOLD)..."
mkdir -p "$OPENBAO_SECRETS"
chmod 700 "$OPENBAO_SECRETS"
umask 077
kubectl -n "$NAMESPACE" exec "$POD" -- \
bao operator init -key-shares="$KEY_SHARES" -key-threshold="$KEY_THRESHOLD" -format=json \
> "$INIT_FILE"
chmod 600 "$INIT_FILE"
DID_INIT=true
log "init material written to $INIT_FILE (plaintext — encrypt + shred via encrypt-secrets.sh)"
fi
STATUS_JSON="$(bao_status)"
SEALED=$(python3 -c 'import json,sys; print(str(json.loads(sys.stdin.read()).get("sealed", True)).lower())' <<<"$STATUS_JSON")
fi
# ── Unseal (when sealed) ──────────────────────────────────────────────────────
if [[ "$SEALED" == "true" ]]; then
if [[ ! -f "$INIT_FILE" ]]; then
die "OpenBao is sealed but $INIT_FILE is missing.
Decrypt SOPS-held custody first: bash decrypt-secrets.sh $SECRETS_DIR"
fi
if [[ "$DRY_RUN" == true ]]; then
log "[dry-run] would replay $KEY_THRESHOLD unseal share(s) from $INIT_FILE via stdin"
else
SHARE_COUNT=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("unseal_keys_b64") or []))' "$INIT_FILE")
[[ "$SHARE_COUNT" -gt 0 ]] || die "no unseal shares found in $INIT_FILE"
log "replaying unseal shares (have $SHARE_COUNT)..."
for idx in $(seq 0 $((SHARE_COUNT - 1))); do
# Share travels stdin→stdin; never argv, never logs.
python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["unseal_keys_b64"][int(sys.argv[2])])' "$INIT_FILE" "$idx" \
| kubectl -n "$NAMESPACE" exec -i "$POD" -- bao operator unseal - >/dev/null
STATUS_JSON="$(bao_status)"
SEALED=$(python3 -c 'import json,sys; print(str(json.loads(sys.stdin.read()).get("sealed", True)).lower())' <<<"$STATUS_JSON")
log "unseal share $((idx + 1)) applied (sealed=$SEALED)"
[[ "$SEALED" == "false" ]] && break
done
fi
fi
# ── Post-unseal verification ──────────────────────────────────────────────────
VERIFIED=false
if [[ "$DRY_RUN" == true ]]; then
log "[dry-run] would verify post-unseal status"
else
STATUS_JSON="$(bao_status)"
INITIALIZED=$(python3 -c 'import json,sys; print(str(json.loads(sys.stdin.read()).get("initialized", False)).lower())' <<<"$STATUS_JSON")
SEALED=$(python3 -c 'import json,sys; print(str(json.loads(sys.stdin.read()).get("sealed", True)).lower())' <<<"$STATUS_JSON")
if [[ "$INITIALIZED" == "true" && "$SEALED" == "false" ]]; then
VERIFIED=true
log "post-unseal verified: initialized, unsealed"
else
die "post-unseal verification failed: initialized=$INITIALIZED sealed=$SEALED"
fi
fi
evidence \
"custody_model=$MODEL" \
"namespace=$NAMESPACE" \
"pod=$POD" \
"openbao_initialized=$([[ "$INITIALIZED" == "true" || "$DID_INIT" == "true" ]] && echo true || echo false)" \
"openbao_did_init_this_run=$DID_INIT" \
"openbao_post_unseal_verified=$VERIFIED" \
"dry_run=$DRY_RUN"
# ── Platform handoff (railiance-platform owns post-unseal configuration) ─────
if [[ "${OPENBAO_RUN_CONFIGURE_INITIAL:-0}" == "1" && "$DRY_RUN" == false ]]; then
[[ -d "$RAILIANCE_PLATFORM_DIR" ]] || die "railiance-platform not found at $RAILIANCE_PLATFORM_DIR"
log "running railiance-platform post-unseal configuration..."
make -C "$RAILIANCE_PLATFORM_DIR" openbao-configure-initial
else
log "next (railiance-platform): make -C $RAILIANCE_PLATFORM_DIR openbao-configure-initial"
fi
if [[ "$DID_INIT" == true ]]; then
log "REMINDER: encrypt + shred the new init material now:"
log " bash $SCRIPT_DIR/encrypt-secrets.sh $SECRETS_DIR <age-recipient> && commit secrets.enc/"
fi