#!/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: /.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="" PROFILE="" 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") PROFILE=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("deployment_profile") or "")' "$CONSOLE_METADATA") fi if [[ "$PROFILE" == "production" ]]; then die "deployment profile is 'production' — sops-held automation is blocked. Use the attended ceremony (docs/openbao-attended-ceremony-runbook.md) or auto-unseal-transit, or reselect: select-deployment-profile --profile lab" 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. # `bao operator unseal -` does not read stdin (needs a TTY or the # share in argv), so use the sys/unseal API with key=- instead. 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 write sys/unseal key=- >/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 && commit secrets.enc/" fi