generated from coulomb/repo-seed
NET-WP-0020-T02: SOPS-held OpenBao init/unseal automation helper
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
Makefile
7
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 \
|
||||
|
||||
@@ -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) |
|
||||
|
||||
195
sso-mfa/bootstrap/openbao-init-unseal.sh
Executable file
195
sso-mfa/bootstrap/openbao-init-unseal.sh
Executable file
@@ -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: <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
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user