From 60142241a303f1bfd7220b6f66d1a3a5986ec567 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 2 Jul 2026 11:01:34 +0200 Subject: [PATCH] NET-WP-0020-T02: SOPS-held OpenBao init/unseal automation helper Co-Authored-By: Claude Fable 5 --- Makefile | 7 + docs/openbao-unseal-custody-models.md | 12 +- sso-mfa/bootstrap/openbao-init-unseal.sh | 195 ++++++++++++++++++ ...enbao-unseal-custody-and-ssh-automation.md | 29 ++- 4 files changed, 235 insertions(+), 8 deletions(-) create mode 100755 sso-mfa/bootstrap/openbao-init-unseal.sh diff --git a/Makefile b/Makefile index 9c8454f..4a46618 100644 --- a/Makefile +++ b/Makefile @@ -179,6 +179,12 @@ creds-agent-status: ## Show current v2 bootstrap state (agent mode) creds-emergency-reprint: ## Re-deliver emergency bundle (if lost/stolen — reprints, rotates nothing) @bash sso-mfa/bootstrap/emergency-bundle.sh --reprint +openbao-init-unseal: ## SOPS-held OpenBao init/unseal (NET-WP-0020 T2; requires sops-held-automation model selected) + @bash sso-mfa/bootstrap/openbao-init-unseal.sh + +openbao-init-unseal-dry-run: ## Dry-run the SOPS-held OpenBao init/unseal path + @bash sso-mfa/bootstrap/openbao-init-unseal.sh --dry-run + iam-profile-conformance-test: ## Run IAM Profile v0.2 conformance fixture tests python3 -m pytest tools/iam-profile-conformance/tests @@ -329,6 +335,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a check-secrets creds-init creds-generate creds-bundle creds-apply creds-verify \ creds-status creds-rotate \ creds-agent-init creds-agent-status creds-emergency-reprint \ + openbao-init-unseal openbao-init-unseal-dry-run \ iam-profile-conformance-test playbook-contract-test \ security-bootstrap-console-test security-bootstrap-scripts-syntax \ security-bootstrap-console security-bootstrap-king-kit \ diff --git a/docs/openbao-unseal-custody-models.md b/docs/openbao-unseal-custody-models.md index 867d6c9..80a8833 100644 --- a/docs/openbao-unseal-custody-models.md +++ b/docs/openbao-unseal-custody-models.md @@ -25,8 +25,14 @@ during bootstrap and rebuild. ### `sops-held-automation` (default for greenfield dev) - Init/unseal material lives in **SOPS/age** custody bundle (not Git plaintext). -- Applied by `sso-mfa/bootstrap/creds-bootstrap-agent.sh` and related `creds-apply` - tooling after cluster + OpenBao pod exist. +- Applied by `sso-mfa/bootstrap/openbao-init-unseal.sh` (`make + openbao-init-unseal`, NET-WP-0020 T2) after cluster + OpenBao pod exist. The + helper enforces the console custody-model gate, initializes only when + uninitialized (init JSON written straight into the age-custody secrets dir), + replays unseal shares stdin-to-stdin, verifies post-unseal state, and emits + non-secret `openbao_initialized` / `openbao_post_unseal_verified` evidence. + Set `OPENBAO_RUN_CONFIGURE_INITIAL=1` to chain `railiance-platform: make + openbao-configure-initial`. - Enables **unattended rebuild test cycles** on a 3-node slate. - **Not** production trust posture — use to prove S1→S3→SSH engine automation, then graduate to stronger models. @@ -88,7 +94,7 @@ Metadata field: `openbao_unseal_custody_model` | S1 OS baseline | railiance-infra | 3 nodes | | S2 k3s HA | railiance-cluster | ThreePhoenix | | S3 OpenBao deploy | railiance-platform | `make openbao-deploy` | -| Init/unseal apply | net-kingdom | `creds-bootstrap-agent.sh` (sops-held) | +| Init/unseal apply | net-kingdom | `make openbao-init-unseal` (sops-held) | | Platform config | railiance-platform | `openbao-configure-initial` | | SSH engine | railiance-platform | `openbao-configure-ssh` (planned) | | Host CA trust | railiance-infra | `bootstrap-ssh-ca` (planned) | diff --git a/sso-mfa/bootstrap/openbao-init-unseal.sh b/sso-mfa/bootstrap/openbao-init-unseal.sh new file mode 100755 index 0000000..c574049 --- /dev/null +++ b/sso-mfa/bootstrap/openbao-init-unseal.sh @@ -0,0 +1,195 @@ +#!/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="" +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 && commit secrets.enc/" +fi diff --git a/workplans/NET-WP-0020-openbao-unseal-custody-and-ssh-automation.md b/workplans/NET-WP-0020-openbao-unseal-custody-and-ssh-automation.md index 5c23090..f5c04a8 100644 --- a/workplans/NET-WP-0020-openbao-unseal-custody-and-ssh-automation.md +++ b/workplans/NET-WP-0020-openbao-unseal-custody-and-ssh-automation.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: net-kingdom created: "2026-06-17" -updated: "2026-06-18" +updated: "2026-07-02" state_hub_workstream_id: "d6338ac9-797d-4009-8203-4b8dd39010af" --- @@ -44,14 +44,33 @@ state_hub_task_id: "7040f347-d54a-42ba-a14f-5b0a7e691786" ```task id: NET-WP-0020-T02 -status: todo +status: progress priority: high state_hub_task_id: "65407eb1-9d89-4158-aed5-4987badd83fc" ``` -- [ ] Extend `creds-bootstrap-agent.sh` for OpenBao init/unseal when sealed -- [ ] Non-secret evidence flags: `openbao_initialized`, `openbao_post_unseal_verified` -- [ ] Integrate with `make openbao-configure-initial` post-unseal +- [x] SOPS-held init/unseal automation: `sso-mfa/bootstrap/openbao-init-unseal.sh` + (`make openbao-init-unseal` / `openbao-init-unseal-dry-run`) +- [x] Non-secret evidence flags: `openbao_initialized`, `openbao_post_unseal_verified` + (emitted on the script's `EVIDENCE` JSON line) +- [x] Integrate with `make openbao-configure-initial` post-unseal + (`OPENBAO_RUN_CONFIGURE_INITIAL=1` chains it; default prints the handoff hint) +- [ ] Wire the helper as an optional phase inside `creds-bootstrap-agent.sh` + (agent-policy blocked automated edits to the credential bootstrap script on + 2026-07-02 — operator should add a phase that calls the helper, sets the two + state flags in `creds-state.yaml`, and re-runs `encrypt-secrets.sh` + commit + when `secrets/openbao/` was created) +- [ ] Greenfield live proof: run against a sealed/uninitialized OpenBao on a + rebuild slate (current cluster is already initialized+unsealed, so only the + status/verify path was live-smoked on 2026-07-02; custody-gate refusal was + proven for `unselected` and `attended-ceremony`) + +**2026-07-02:** Helper implemented and smoke-tested: dry-run against the live +cluster passed the custody gate (`sops-held-automation` selected) and read +`initialized=true sealed=false`; negative tests proved refusal for unselected +and attended-ceremony models. Init material is written only into +`secrets/openbao/` for age custody; unseal shares travel stdin-to-stdin and +never appear in argv or logs. ### T3 — Attended ceremony automation profile