diff --git a/Makefile b/Makefile index 4a46618..fa8d300 100644 --- a/Makefile +++ b/Makefile @@ -106,7 +106,7 @@ check-secrets: ## Fail if any file under secrets/ is not SOPS-encrypted fi; \ case "$$f" in *.age|*.gpg) continue ;; esac; \ echo " UNENCRYPTED: $$f"; bad=1; \ - done < <(git ls-files --others --cached 'secrets' 2>/dev/null | grep -v '/$'); \ + done < <(git ls-files --others --cached 'secrets' 2>/dev/null | grep -v '/$$'); \ if [[ "$$bad" -ne 0 ]]; then \ echo ""; \ echo "ERROR: Unencrypted secret(s) detected. Encrypt with: sops --encrypt --in-place "; \ @@ -311,6 +311,20 @@ security-bootstrap-select-openbao-unseal-custody-model: security-bootstrap-metad select-openbao-unseal-custody-model \ --model "$(if $(MODEL),$(MODEL),sops-held-automation)" +security-bootstrap-select-deployment-profile: security-bootstrap-metadata-init ## Select deployment profile (production blocks sops-held-automation): make ... PROFILE=production + @[[ -n "$(PROFILE)" ]] || (echo "Usage: make security-bootstrap-select-deployment-profile PROFILE=lab|production"; exit 1) + python3 tools/security-bootstrap-console/security_bootstrap_console.py \ + --metadata "$(SECURITY_BOOTSTRAP_METADATA)" \ + select-deployment-profile --profile "$(PROFILE)" + +security-bootstrap-openbao-ceremony-record-template: ## Print non-secret attended OpenBao ceremony record template + python3 tools/security-bootstrap-console/security_bootstrap_console.py openbao-ceremony-record-template + +security-bootstrap-validate-openbao-ceremony-record: ## Validate non-secret attended ceremony record: make ... [EVIDENCE=.local/openbao-ceremony-record.json] + python3 tools/security-bootstrap-console/security_bootstrap_console.py \ + validate-openbao-ceremony-record \ + --evidence "$(if $(EVIDENCE),$(EVIDENCE),.local/openbao-ceremony-record.json)" + security-bootstrap-metadata-init: ## Create durable local non-secret bootstrap metadata if missing @mkdir -p "$$(dirname "$(SECURITY_BOOTSTRAP_METADATA)")" @if [[ -f "$(SECURITY_BOOTSTRAP_METADATA)" ]]; then \ @@ -355,6 +369,11 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a security-bootstrap-sign-custody-roster \ security-bootstrap-approve-custody \ security-bootstrap-custody-packet security-bootstrap-openbao-preflight \ + security-bootstrap-openbao-unseal-custody-models \ + security-bootstrap-select-openbao-unseal-custody-model \ + security-bootstrap-select-deployment-profile \ + security-bootstrap-openbao-ceremony-record-template \ + security-bootstrap-validate-openbao-ceremony-record \ security-bootstrap-metadata-init security-bootstrap-ui \ security-bootstrap-console-test security-bootstrap-scripts-syntax \ security-bootstrap-validate-keycape-client diff --git a/SCOPE.md b/SCOPE.md index d8b50f9..73cf9e8 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -22,13 +22,21 @@ NetKingdom is a self-optimizing security platform for Kubernetes-based IT infras - NetKingdom IAM Profile specification (versioned OIDC/PKCE contract; canonical spec: `canon/standards/iam-profile_v0.2.md`) -- SSO/MFA Platform: Keycloak with LDAP/Entra federation, enterprise identity (NK-WP-0001) -- Local Identity: file-based user store + minimal OIDC server for bootstrap phase (NK-WP-0002) +- SSO/MFA Platform: Keycloak with LDAP/Entra federation, enterprise identity (NK-WP-0001, finished) +- Local Identity: file-based user store + minimal OIDC server for bootstrap phase (NK-WP-0002, finished) - User Engine Boundary Contract: source-of-truth, membership, application-onboarding, projection, authorization, and audit contracts for `user-engine` integration (`canon/standards/user-engine-boundary-contract_v0.1.md`) - Security bootstrapping: credential management, SOPS/age integration, platform-root custody, OpenBao runtime secret authority +- OpenBao init/unseal custody models (NET-WP-0020): `sops-held-automation` + (lab, unattended greenfield rebuilds via `creds-bootstrap-agent` Phase 7b), + `attended-ceremony` (production, runbook + non-secret evidence records), and + `auto-unseal-transit` (production HA; seal stanza lives in + railiance-platform) — all gated by the security bootstrap console and a + lab/production deployment profile +- Security bootstrap console (`tools/security-bootstrap-console/`): custody + gates, roster, evidence validators, refuse-live-init boundary - Architectural decisions (DECISIONS.md): identity source, secrets, GitOps, bootstrap user store --- @@ -62,9 +70,17 @@ NetKingdom is a self-optimizing security platform for Kubernetes-based IT infras ## Current State -- Status: active (design phase complete, implementation ongoing) -- Implementation: emerging — NK-WP-0001 (SSO/MFA) and NK-WP-0002 (local identity) both in active development -- Stability: evolving +- Status: active — core identity and bootstrap phases delivered; follow-on work proposed +- Implementation: NK-WP-0001 (SSO/MFA), NK-WP-0002 (local identity), the + security bootstrap arc (NET-WP-0015–0017, 0019), the IAM Profile spec + (NK-WP-0012), user-engine boundary contracts (NK-WP-0014), and OpenBao + unseal custody + SSH automation (NET-WP-0020) are all finished — see + `workplans/archived/` +- Open: NK-WP-0009 (security pattern tutorials) and NK-WP-0011 (enterprise + federation / SAML) are proposed, not yet started +- Stability: stabilizing — bootstrap/custody tooling is live-proven (greenfield + OpenBao init/unseal proof 2026-07-02); production custody models are gated + by evidence - Usage: foundational authentication layer for all NetKingdom deployments --- @@ -108,6 +124,13 @@ description: Enterprise-grade Keycloak-based SSO with LDAP/Entra federation, MFA keywords: [sso, mfa, keycloak, ldap, entra, federation, oidc, enterprise] ``` +```capability +type: security +title: OpenBao unseal custody models and bootstrap automation +description: Three gated init/unseal custody models — SOPS-held automation for unattended lab rebuilds (greenfield-proven), attended ceremony with non-secret evidence records for production, and transit/KMS auto-unseal for production HA — enforced by the security bootstrap console and a lab/production deployment profile. +keywords: [openbao, unseal, custody, bootstrap, sops, age, ceremony, transit, auto-unseal, console] +``` + ```capability type: security title: Bootstrap local identity service @@ -121,9 +144,12 @@ keywords: [bootstrap, local-identity, oidc, minimal, dev, sandbox] - Start with: `wiki/` (specifications and decisions), `DECISIONS.md` (key architectural choices D1–D5) - Key files / directories: `docs/platform-root-custody.md`, `sso-mfa/` - (NK-WP-0001 active workplan), `local-identity/` (NK-WP-0002), - `workplans/` -- Entry points: `workplans/NK-WP-0001-sso-mfa-platform.md` and `NK-WP-0002-local-identity.md` for current work + (SSO/MFA platform + bootstrap scripts), `local-identity/`, + `tools/security-bootstrap-console/`, `workplans/` (finished plans in + `workplans/archived/`) +- Entry points: `workplans/NK-WP-0009-netkingdom-security-pattern-tutorials.md` + and `workplans/NK-WP-0011-enterprise-federation-saml.md` (proposed next + work); finished context in `workplans/archived/` - User-domain boundary contract: `canon/standards/user-engine-boundary-contract_v0.1.md` - User-engine integration assessment (intent/scope fit, gaps, and recommendations): @@ -131,5 +157,8 @@ keywords: [bootstrap, local-identity, oidc, minimal, dev, sandbox] - Bootstrap/custody entry points: `docs/platform-root-custody.md`, `docs/security-bootstrap-use-cases.md`, - `workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md`, - and `workplans/NET-WP-0016-guided-security-bootstrap-experience.md` + `docs/openbao-unseal-custody-models.md` (three custody models + deployment + profile), and `docs/openbao-attended-ceremony-runbook.md` (production + ceremony); history of the custody/bootstrap arc in `workplans/archived/` + (NET-WP-0015–0017, 0019) and + `workplans/NET-WP-0020-openbao-unseal-custody-and-ssh-automation.md` diff --git a/docs/openbao-attended-ceremony-runbook.md b/docs/openbao-attended-ceremony-runbook.md new file mode 100644 index 0000000..21a5321 --- /dev/null +++ b/docs/openbao-attended-ceremony-runbook.md @@ -0,0 +1,82 @@ +# OpenBao Attended Ceremony Runbook + +Date: 2026-07-02 +Status: active — production custody model (`attended-ceremony`, NET-WP-0020 T3) + +Human-attended OpenBao init/unseal for production trust posture. The security +bootstrap console **never runs `bao operator init`** (refuse-live-init +boundary) — this runbook is executed by the `openbao-ceremony-operator` role +and evidenced by a **non-secret** ceremony record. + +Contrast with `sops-held-automation` (lab posture): there, root token and +unseal shares live together in one SOPS bundle for unattended rebuild loops. +The attended ceremony keeps unseal shares **out of band** and retires the root +token, so no single artifact can open the platform. + +--- + +## Preconditions + +1. Console gates green up to the init ceremony: + `make security-bootstrap-console` — custody strategy approved, preflight + passed. +2. Select profile and model (production blocks the lab default): + + ```bash + python3 tools/security-bootstrap-console/security_bootstrap_console.py \ + --metadata .local/security-bootstrap.json \ + select-deployment-profile --profile production + + python3 tools/security-bootstrap-console/security_bootstrap_console.py \ + --metadata .local/security-bootstrap.json \ + select-openbao-unseal-custody-model --model attended-ceremony + ``` + +3. OpenBao deployed and reachable: + `make -C ../railiance-platform openbao-status` (expect uninitialized/sealed + on greenfield). +4. Two people present (operator + witness) where the custody roster requires + it; unseal-share escrow destinations agreed per + `docs/platform-root-custody.md` (signed custody roster). + +## Ceremony + +Follow `railiance-platform/docs/openbao.md` for the exact commands. Outline: + +1. **Init** — operator runs `bao operator init` (3 shares, threshold 2) + directly against the pod from a terminal. Output goes only to the + operator's screen — never into the console, chat, State Hub, or files + inside a Git checkout. +2. **Escrow** — each unseal share is transcribed to its escrow destination + (offline packet / password safe per roster). No two shares in the same + custody location. +3. **Unseal** — replay threshold shares; verify + `initialized=true sealed=false`. +4. **Root retirement** — use the root token only for the initial + configuration handoff (`make -C ../railiance-platform + openbao-configure-initial`), then revoke it (`bao token revoke -self`) or + escrow it per roster; record the disposition. + +## Evidence + +Record the ceremony in a non-secret JSON record and validate it: + +```bash +python3 tools/security-bootstrap-console/security_bootstrap_console.py \ + openbao-ceremony-record-template > .local/openbao-ceremony-record.json +# edit: dispositions, dates, roles — NEVER shares, tokens, or key material + +make security-bootstrap-validate-openbao-ceremony-record +``` + +The validator refuses records containing secret-looking markers (tokens, key +blocks, otpauth URIs) or leftover template placeholders. After a valid +record, set the console flags (`openbao_initialized`, +`openbao_post_unseal_verified`) in the metadata so the gates advance. + +## Related + +- `docs/openbao-unseal-custody-models.md` — model framework +- `docs/platform-root-custody.md` — custody roster and share holders +- `docs/security-bootstrap-openbao-ceremony-ux.md` — operator UX notes +- `railiance-platform/docs/openbao.md` — deploy + command-level ceremony diff --git a/docs/openbao-unseal-custody-models.md b/docs/openbao-unseal-custody-models.md index 80a8833..771cf6d 100644 --- a/docs/openbao-unseal-custody-models.md +++ b/docs/openbao-unseal-custody-models.md @@ -1,7 +1,8 @@ # OpenBao Unseal Custody Models -Date: 2026-06-17 -Status: framework — automation path active; production paths planned +Date: 2026-06-17 (updated 2026-07-02) +Status: all three models implemented — automation path proven greenfield; +production models gated by deployment profile and evidence NetKingdom bootstrap must support **three** OpenBao init/unseal custody models. Development starts with **maximum automation** for fast test cycles, then adds @@ -18,9 +19,9 @@ during bootstrap and rebuild. | Model ID | Label | Custody strength | Automation | Status | | --- | --- | --- | --- | --- | -| `sops-held-automation` | SOPS-held unseal | Lab / fast iteration | High | **Implemented** (console + creds agent path) | -| `attended-ceremony` | Attended ceremony | Production | Low | Planned | -| `auto-unseal-transit` | Auto-unseal (transit/KMS) | Production HA | High | Planned | +| `sops-held-automation` | SOPS-held unseal | Lab / fast iteration | High | **Implemented** (console + creds agent path; greenfield-proven 2026-07-02) | +| `attended-ceremony` | Attended ceremony | Production | Low | **Implemented** (runbook + ceremony-record validator) | +| `auto-unseal-transit` | Auto-unseal (transit/KMS) | Production HA | High | **Implemented** (Helm seal stanza prepared; gate blocked until seal configured + verified) | ### `sops-held-automation` (default for greenfield dev) @@ -37,32 +38,42 @@ during bootstrap and rebuild. - **Not** production trust posture — use to prove S1→S3→SSH engine automation, then graduate to stronger models. -### `attended-ceremony` (production target) +### `attended-ceremony` (production) - Human-attended `bao operator init`, out-of-band unseal share escrow, root token - retirement — per `railiance-platform/docs/openbao.md`. + retirement — runbook: `docs/openbao-attended-ceremony-runbook.md` + (command detail in `railiance-platform/docs/openbao.md`). - Matches first successful NetKingdom bootstrap (NET-WP-0015–0017). -- Console keeps **refuse-live-init** boundary; ceremony runbooks only. +- Console keeps the **refuse-live-init** boundary; the ceremony is evidenced by + a non-secret record validated with `validate-openbao-ceremony-record` + (`make security-bootstrap-validate-openbao-ceremony-record`). -### `auto-unseal-transit` (production HA target) +### `auto-unseal-transit` (production HA) - OpenBao seal configuration uses **transit** or cloud KMS auto-unseal. - Pod restart without manual unseal threshold ceremony. -- Requires `railiance-platform` Helm seal stanza + KMS provisioning. +- Seal stanza prepared (disabled by default) in + `railiance-platform/helm/openbao-values.yaml`; enable + migrate per + `railiance-platform/docs/openbao.md` "Auto-Unseal via Transit Seal". +- Console init gate stays **blocked** until `openbao_transit_seal_configured` + and `openbao_auto_unseal_verified` are set in the non-secret metadata. --- -## Development strategy +## Deployment profile -```text -1. Implement automation path (sops-held-automation) - → SSH engine, warden sign, host CA trust, 3-node rebuild loops -2. Add attended-ceremony gates (block automation defaults in production profile) -3. Add auto-unseal-transit for HA ThreePhoenix rebuilds +The console metadata carries a `deployment_profile` (`lab` default, +`production`). The **production profile blocks `sops-held-automation`** — its +SOPS bundle holds root token and unseal shares together, which is lab posture +only. Selection, status gates, and `openbao-init-unseal.sh` all enforce the +block: + +```bash +make security-bootstrap-select-deployment-profile PROFILE=production ``` -Each model is selectable in the **security bootstrap console**. Unimplemented -models are **blocked** with a hint pointing to the active automation path. +Each model is selectable in the **security bootstrap console**; gates express +what evidence is still missing for the selected model. --- diff --git a/history/2026-07-02-openbao-greenfield-init-unseal-proof.md b/history/2026-07-02-openbao-greenfield-init-unseal-proof.md new file mode 100644 index 0000000..bdcace7 --- /dev/null +++ b/history/2026-07-02-openbao-greenfield-init-unseal-proof.md @@ -0,0 +1,40 @@ +# OpenBao greenfield init/unseal proof (NET-WP-0020 T2) + +Date: 2026-07-02 +Scope: `sso-mfa/bootstrap/openbao-init-unseal.sh` proven live against a +genuinely uninitialized OpenBao 2.5.5 instance. + +## Method + +A fresh local `bao server` (file storage, throwaway scratchpad dir) was started +uninitialized and sealed. A `kubectl` shim forwarded the script's +`kubectl -n openbao exec openbao-0 -- bao …` calls to the local instance, so +the script ran byte-for-byte unmodified logic against real OpenBao. Console +metadata lived in a scratch file with `sops-held-automation` selected. + +This substitutes for the "rebuild slate" run: it exercises the exact +init/unseal/verify code paths on a greenfield instance. The first 3-node +rebuild will re-run the same script through `creds-bootstrap-agent.sh` +Phase 7b. + +## Results + +1. **Custody gate refusal** — with no model selected, the script refused + (`unseal custody model is 'unselected'`). Matches earlier negative tests. +2. **Bug found and fixed** — `bao operator unseal -` does **not** read the + share from stdin (OpenBao treats `-` as the literal key: *"'key' must be a + valid hex or base64 string"*; without an argument it demands a TTY). The + live cluster never hit this because it was already unsealed. Fixed to + `bao write sys/unseal key=-`, which reads the value from stdin — shares + still never touch argv or logs. +3. **Greenfield init path** — uninitialized → `operator init` (3 shares, + threshold 2) → init.json written 0600 into the age-custody secrets dir → + share replay stopped at threshold (share 2 of 3) → post-unseal verified. + Evidence: `openbao_did_init_this_run=true`, `openbao_initialized=true`, + `openbao_post_unseal_verified=true`. +4. **Restart/reseal path** — server restarted (initialized + sealed) → + script skipped init, replayed SOPS-held shares to threshold → verified. + Evidence: `openbao_did_init_this_run=false`, both flags true. + +Test server, storage, and init material were destroyed after the proof +(init.json shredded). diff --git a/sso-mfa/bootstrap/openbao-init-unseal.sh b/sso-mfa/bootstrap/openbao-init-unseal.sh index c574049..c245e44 100755 --- a/sso-mfa/bootstrap/openbao-init-unseal.sh +++ b/sso-mfa/bootstrap/openbao-init-unseal.sh @@ -78,8 +78,15 @@ PY # 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'. @@ -145,8 +152,10 @@ if [[ "$SEALED" == "true" ]]; then 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 operator unseal - >/dev/null + | 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)" diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index 318f23d..2115683 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -64,30 +64,28 @@ OPENBAO_UNSEAL_CUSTODY_MODEL_SPECS: dict[str, dict[str, str]] = { }, "attended-ceremony": { "label": "Attended ceremony (production custody)", - "implementation": "planned", + "implementation": "implemented", "summary": ( "Human-attended init, out-of-band unseal escrow, root retirement — " - "railiance-platform/docs/openbao.md ceremony." - ), - "blocked_hint": ( - "Not yet implemented in the automation path. Use sops-held-automation for " - "fast bootstrap test cycles; attended ceremony will gate production trust." + "railiance-platform/docs/openbao.md ceremony. Console never runs init; " + "runbook + evidence validation only." ), + "runbook_entry": "docs/openbao-attended-ceremony-runbook.md", "custody_strength": "production", }, "auto-unseal-transit": { "label": "Auto-unseal (transit/KMS seal)", - "implementation": "planned", + "implementation": "implemented", "summary": ( - "Seal config uses transit or cloud KMS; pod restart without manual unseal." - ), - "blocked_hint": ( - "Not yet implemented. Requires railiance-platform Helm seal stanza and " - "KMS/transit provisioning. Use sops-held-automation until available." + "Seal config uses transit or cloud KMS; pod restart without manual unseal. " + "Gate stays blocked until the seal stanza is applied and auto-unseal is verified." ), + "config_entry": "railiance-platform/helm/openbao-values.yaml (seal stanza)", "custody_strength": "production-ha", }, } +VALID_DEPLOYMENT_PROFILES = ("lab", "production") +DEFAULT_DEPLOYMENT_PROFILE = "lab" VALID_OPENBAO_UNSEAL_CUSTODY_MODELS = frozenset(OPENBAO_UNSEAL_CUSTODY_MODEL_SPECS) DEFAULT_OPENBAO_UNSEAL_CUSTODY_MODEL = "sops-held-automation" IMPLEMENTED_OPENBAO_UNSEAL_CUSTODY_MODELS = frozenset( @@ -461,14 +459,38 @@ def openbao_unseal_custody_model_implemented(model: str) -> bool: return model in IMPLEMENTED_OPENBAO_UNSEAL_CUSTODY_MODELS +def resolve_deployment_profile(data: dict[str, Any]) -> str: + profile = str(data.get("deployment_profile") or "").strip() + if profile in VALID_DEPLOYMENT_PROFILES: + return profile + return DEFAULT_DEPLOYMENT_PROFILE + + +def openbao_unseal_custody_model_entry(spec: dict[str, str]) -> str: + for key in ("automation_entry", "runbook_entry", "config_entry"): + if spec.get(key): + return spec[key] + return "n/a" + + def openbao_unseal_custody_model_gate(data: dict[str, Any]) -> Gate: model = resolve_openbao_unseal_custody_model(data) spec = OPENBAO_UNSEAL_CUSTODY_MODEL_SPECS[model] + if resolve_deployment_profile(data) == "production" and model == "sops-held-automation": + return Gate( + "OpenBao unseal custody model", + "blocked", + ( + "Production profile blocks sops-held-automation (root token and unseal " + "shares in one SOPS bundle is lab posture). Select attended-ceremony or " + "auto-unseal-transit." + ), + ) if openbao_unseal_custody_model_implemented(model): return Gate( "OpenBao unseal custody model", "done", - f"{spec['label']} selected — automation entry: {spec.get('automation_entry', 'n/a')}", + f"{spec['label']} selected — entry: {openbao_unseal_custody_model_entry(spec)}", ) return Gate( "OpenBao unseal custody model", @@ -489,6 +511,12 @@ def openbao_init_ceremony_gate(data: dict[str, Any]) -> Gate: spec.get("blocked_hint", "Selected unseal custody model is not implemented."), ) if model == "sops-held-automation": + if resolve_deployment_profile(data) == "production": + return Gate( + "OpenBao init ceremony", + "blocked", + "Production profile blocks sops-held-automation. Select a production model.", + ) entry = OPENBAO_UNSEAL_CUSTODY_MODEL_SPECS[model].get("automation_entry", "") return Gate( "OpenBao init ceremony", @@ -498,10 +526,44 @@ def openbao_init_ceremony_gate(data: dict[str, Any]) -> Gate: f"({entry}). Console will not run init." ), ) + if model == "auto-unseal-transit": + if not yes(data, "openbao_transit_seal_configured"): + return Gate( + "OpenBao init ceremony", + "blocked", + ( + "Transit/KMS seal not configured yet — enable the seal stanza in " + "railiance-platform/helm/openbao-values.yaml and provision the " + "transit/KMS backend, then set openbao_transit_seal_configured." + ), + ) + if not yes(data, "openbao_auto_unseal_verified"): + return Gate( + "OpenBao init ceremony", + "blocked", + ( + "Auto-unseal not verified — restart the OpenBao pod, confirm it " + "unseals without shares, then set openbao_auto_unseal_verified." + ), + ) + return Gate( + "OpenBao init ceremony", + "human", + ( + "Transit seal active. Attended `bao operator init` still required once " + "(recovery keys, root retirement) — follow " + "docs/openbao-attended-ceremony-runbook.md. Console will not run init." + ), + ) + runbook = OPENBAO_UNSEAL_CUSTODY_MODEL_SPECS[model].get("runbook_entry", "") return Gate( "OpenBao init ceremony", "human", - "Human-attended ceremony only. This console will not run init.", + ( + "Human-attended ceremony only. This console will not run init. " + f"Follow {runbook} and validate the non-secret record with " + "validate-openbao-ceremony-record." + ), ) @@ -740,12 +802,14 @@ def next_action( if gate.name == "OpenBao preflight": return "Run OpenBao preflight" if gate.name == "OpenBao unseal custody model": - return ( - "Select openbao-unseal-custody-model sops-held-automation " - "(other models not yet implemented)" - ) + if data and resolve_deployment_profile(data) == "production": + return ( + "Select a production unseal custody model " + "(attended-ceremony or auto-unseal-transit)" + ) + return "Select an implemented openbao-unseal-custody-model" if gate.name == "OpenBao init ceremony": - return "Select an implemented unseal custody model first" + return "Resolve the OpenBao init ceremony gate (see gate reason)" if gate.name == "KeyCape OpenBao client definition": return "Ship KeyCape OpenBao client definition" if gate.name == "KeyCape OpenBao client deployed": @@ -821,6 +885,9 @@ def print_status(data: dict[str, Any]) -> None: print("17. select-openbao-unseal-custody-model") print("18. web-ui") print("19. validate-keycape-client (T08: example of validator-driven gate in UI state model)") + print("20. select-deployment-profile") + print("21. openbao-ceremony-record-template") + print("22. validate-openbao-ceremony-record") print("") print("Refusal boundary") print("This console will not run bao operator init or collect secret values.") @@ -1934,6 +2001,10 @@ def metadata_template() -> dict[str, Any]: "progress_scope": "", "openbao_unseal_custody_model": DEFAULT_OPENBAO_UNSEAL_CUSTODY_MODEL, "openbao_unseal_custody_model_selected_at": "", + "deployment_profile": DEFAULT_DEPLOYMENT_PROFILE, + "deployment_profile_selected_at": "", + "openbao_transit_seal_configured": False, + "openbao_auto_unseal_verified": False, "openbao_preflight_passed": False, "openbao_init_output_produced": False, "openbao_initialized": False, @@ -2002,7 +2073,7 @@ def print_openbao_unseal_custody_models() -> int: if status != "implemented": print(f" blocked_hint: {spec.get('blocked_hint', '')}") else: - print(f" automation_entry: {spec.get('automation_entry', '')}") + print(f" entry: {openbao_unseal_custody_model_entry(spec)}") print("") return 0 @@ -2031,6 +2102,18 @@ def print_select_openbao_unseal_custody_model(args: argparse.Namespace, data: di file=sys.stderr, ) return 2 + if model == "sops-held-automation" and resolve_deployment_profile(data) == "production": + print("OPENBAO UNSEAL CUSTODY MODEL NOT SELECTABLE") + print("") + print(f"Model: {model} ({spec.get('label', '')})") + print("Deployment profile: production") + print("") + print( + "Production profile blocks sops-held-automation — root token and unseal " + "shares in one SOPS bundle is lab posture. Select attended-ceremony or " + "auto-unseal-transit, or switch back with: select-deployment-profile --profile lab" + ) + return 1 merged = metadata_template() merged.update(data) merged["openbao_unseal_custody_model"] = model @@ -2042,10 +2125,100 @@ def print_select_openbao_unseal_custody_model(args: argparse.Namespace, data: di print(f"Metadata: {args.metadata}") print(f"Model: {model}") print(f"Label: {spec.get('label', '')}") - print(f"Automation entry: {spec.get('automation_entry', '')}") + print(f"Entry: {openbao_unseal_custody_model_entry(spec)}") return 0 +def print_select_deployment_profile(args: argparse.Namespace, data: dict[str, Any]) -> int: + profile = args.profile + if args.metadata is None: + print( + "ERROR: select-deployment-profile requires --metadata /path/to/non-secret.json", + file=sys.stderr, + ) + return 2 + merged = metadata_template() + merged.update(data) + merged["deployment_profile"] = profile + merged["deployment_profile_selected_at"] = utc_now() + merged["metadata_updated_at"] = utc_now() + write_metadata(args.metadata, merged) + print("DEPLOYMENT PROFILE SELECTED") + print("") + print(f"Metadata: {args.metadata}") + print(f"Profile: {profile}") + if profile == "production": + model = resolve_openbao_unseal_custody_model(merged) + print("") + print("Production profile blocks sops-held-automation for OpenBao init/unseal.") + if model == "sops-held-automation": + print( + f"WARNING: current unseal custody model is {model!r} — now blocked. " + "Select attended-ceremony or auto-unseal-transit." + ) + return 0 + + +def openbao_ceremony_record_template() -> dict[str, Any]: + return { + "record_version": "v1", + "evidence_date": "YYYY-MM-DD", + "operator": "openbao-ceremony-operator", + "runbook_reference": "docs/openbao-attended-ceremony-runbook.md", + "ceremony_scope": "Attended OpenBao operator init on , unseal share escrow, root token retirement.", + "key_shares": 3, + "key_threshold": 2, + "unseal_share_escrow_disposition": "Describe where each share went (holder role + storage class) — never the shares themselves.", + "root_token_disposition": "revoked-or-escrowed: describe.", + "witness": "role or name of second attendee, or 'none' with justification", + "attended_init_completed": False, + "unseal_shares_escrowed_out_of_band": False, + "root_token_retired_or_escrowed": False, + "post_unseal_verified": False, + "no_secret_material_recorded": False, + } + + +def print_openbao_ceremony_record_template() -> None: + print(json.dumps(openbao_ceremony_record_template(), indent=2)) + + +def print_validate_openbao_ceremony_record(args: argparse.Namespace) -> int: + evidence_path = resolve_cli_path(args.evidence) + evidence, errors = load_evidence_json(evidence_path, "openbao-ceremony") + if evidence is not None: + errors.extend( + require_evidence_fields( + evidence, + required_strings=( + "evidence_date", + "operator", + "runbook_reference", + "ceremony_scope", + "unseal_share_escrow_disposition", + "root_token_disposition", + "witness", + ), + required_true=( + "attended_init_completed", + "unseal_shares_escrowed_out_of_band", + "root_token_retired_or_escrowed", + "post_unseal_verified", + "no_secret_material_recorded", + ), + ) + ) + return print_validation_result( + "OPENBAO ATTENDED CEREMONY RECORD VALIDATION", + errors, + [ + f"ceremony record valid: {evidence_path}", + "record is non-secret (no secret-looking markers found)", + "next: set openbao_initialized / openbao_post_unseal_verified in console metadata", + ], + ) + + def gate_payload(gate: Gate) -> dict[str, str]: return { "name": gate.name, @@ -4974,6 +5147,29 @@ def build_parser() -> argparse.ArgumentParser: default=DEFAULT_OPENBAO_UNSEAL_CUSTODY_MODEL, help="Unseal custody model id.", ) + select_profile = sub.add_parser( + "select-deployment-profile", + help="Select deployment profile (production blocks sops-held-automation).", + ) + select_profile.add_argument( + "--profile", + choices=list(VALID_DEPLOYMENT_PROFILES), + required=True, + help="Deployment profile id.", + ) + sub.add_parser( + "openbao-ceremony-record-template", + help="Print non-secret attended OpenBao ceremony record JSON template.", + ) + validate_ceremony = sub.add_parser( + "validate-openbao-ceremony-record", + help="Validate a non-secret attended OpenBao ceremony record.", + ) + validate_ceremony.add_argument( + "--evidence", + default=".local/openbao-ceremony-record.json", + help="Path to the non-secret ceremony record JSON.", + ) sub.add_parser("refuse-live-init", help="Explain why live OpenBao init is refused.") web = sub.add_parser("web-ui", help="Serve a local custody approval UI.") web.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to localhost.") @@ -5002,6 +5198,7 @@ def main(argv: list[str] | None = None) -> int: "validate-cleanup", "approve-custody-mode", "select-openbao-unseal-custody-model", + "select-deployment-profile", "web-ui", } if args.command in metadata_commands and args.metadata is None: @@ -5099,6 +5296,13 @@ def main(argv: list[str] | None = None) -> int: return print_openbao_preflight(args) if args.command == "openbao-unseal-custody-models": return print_openbao_unseal_custody_models() + if args.command == "select-deployment-profile": + return print_select_deployment_profile(args, data) + if args.command == "openbao-ceremony-record-template": + print_openbao_ceremony_record_template() + return 0 + if args.command == "validate-openbao-ceremony-record": + return print_validate_openbao_ceremony_record(args) if args.command == "select-openbao-unseal-custody-model": return print_select_openbao_unseal_custody_model(args, data) if args.command == "web-ui": diff --git a/tools/security-bootstrap-console/tests/test_security_bootstrap_console.py b/tools/security-bootstrap-console/tests/test_security_bootstrap_console.py index d1eaa4c..64d507d 100644 --- a/tools/security-bootstrap-console/tests/test_security_bootstrap_console.py +++ b/tools/security-bootstrap-console/tests/test_security_bootstrap_console.py @@ -36,15 +36,82 @@ def test_openbao_unseal_custody_model_gate_automation_default(): assert init_gate.status == "automation" -def test_openbao_unseal_custody_planned_models_blocked(): +def test_openbao_unseal_custody_production_models_selectable(): for model in ("attended-ceremony", "auto-unseal-transit"): data = console.metadata_template() data["openbao_unseal_custody_model"] = model gate = console.openbao_unseal_custody_model_gate(data) - assert gate.status == "blocked" - assert "not yet implemented" in gate.reason.lower() - init_gate = console.openbao_init_ceremony_gate(data) - assert init_gate.status == "blocked" + assert gate.status == "done" + + +def test_attended_ceremony_init_gate_is_human_with_runbook(): + data = console.metadata_template() + data["openbao_unseal_custody_model"] = "attended-ceremony" + init_gate = console.openbao_init_ceremony_gate(data) + assert init_gate.status == "human" + assert "openbao-attended-ceremony-runbook" in init_gate.reason + + +def test_auto_unseal_transit_init_gate_requires_evidence(): + data = console.metadata_template() + data["openbao_unseal_custody_model"] = "auto-unseal-transit" + gate = console.openbao_init_ceremony_gate(data) + assert gate.status == "blocked" + assert "openbao_transit_seal_configured" in gate.reason + data["openbao_transit_seal_configured"] = True + gate = console.openbao_init_ceremony_gate(data) + assert gate.status == "blocked" + assert "openbao_auto_unseal_verified" in gate.reason + data["openbao_auto_unseal_verified"] = True + gate = console.openbao_init_ceremony_gate(data) + assert gate.status == "human" + + +def test_production_profile_blocks_sops_held_automation(): + data = console.metadata_template() + data["deployment_profile"] = "production" + gate = console.openbao_unseal_custody_model_gate(data) + assert gate.status == "blocked" + assert "production profile" in gate.reason.lower() + init_gate = console.openbao_init_ceremony_gate(data) + assert init_gate.status == "blocked" + data["openbao_unseal_custody_model"] = "attended-ceremony" + assert console.openbao_unseal_custody_model_gate(data).status == "done" + + +def test_openbao_ceremony_record_template_and_validation(tmp_path): + tmpl = console.openbao_ceremony_record_template() + for key in ( + "attended_init_completed", + "unseal_shares_escrowed_out_of_band", + "root_token_retired_or_escrowed", + "post_unseal_verified", + "no_secret_material_recorded", + ): + assert key in tmpl + # a filled-in, non-secret record validates cleanly + record = dict(tmpl) + record.update( + evidence_date="2026-07-02", + ceremony_scope="Attended init on Railiance ThreePhoenix.", + unseal_share_escrow_disposition="Shares to roster holders, offline packets.", + root_token_disposition="revoked after configure-initial", + witness="recovery-custodian", + attended_init_completed=True, + unseal_shares_escrowed_out_of_band=True, + root_token_retired_or_escrowed=True, + post_unseal_verified=True, + no_secret_material_recorded=True, + ) + path = tmp_path / "record.json" + path.write_text(json.dumps(record)) + _, errors = console.load_evidence_json(path, "openbao-ceremony") + assert errors == [] + # a record leaking a token marker is refused + record["root_token_disposition"] = "hvs.deadbeef" + path.write_text(json.dumps(record)) + _, errors = console.load_evidence_json(path, "openbao-ceremony") + assert any("secret-looking" in e for e in errors) def test_onboarding_dry_run_template_has_required_fields(): tmpl = console.onboarding_dry_run_template() diff --git a/workplans/ADHOC-2026-07-02.md b/workplans/ADHOC-2026-07-02.md index 3578187..fedc1bf 100644 --- a/workplans/ADHOC-2026-07-02.md +++ b/workplans/ADHOC-2026-07-02.md @@ -33,3 +33,18 @@ recipient and a clear notice instead of dying; live runs without a key still fail hard. Verified: full `--dry-run` now traverses Phase 0 through Phase 10 including the new Phase 7b OpenBao hook (NET-WP-0020-T02) on a machine with no age key. + +## Fix broken check-secrets Make target (unescaped `$`) + +```task +id: ADHOC-2026-07-02-T02 +status: done +priority: medium +``` + +`make check-secrets` failed with a bash parse error ("unexpected EOF while +looking for matching `'`"): the trailing `grep -v '/$'` used a single `$`, +which make expanded before bash saw it. Escaped to `$$`. Verified: +`make check-secrets` passes again ("All secrets/ files appear SOPS-encrypted"). +Pre-existing bug, unrelated to NET-WP-0020; found while running the final +checks for that workplan. 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 8e1208c..ffe7a7e 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 @@ -4,7 +4,7 @@ type: workplan title: "OpenBao Unseal Custody Models and SSH Automation Path" domain: infotech repo: net-kingdom -status: active +status: finished owner: codex topic_slug: net-kingdom created: "2026-06-17" @@ -44,7 +44,7 @@ state_hub_task_id: "7040f347-d54a-42ba-a14f-5b0a7e691786" ```task id: NET-WP-0020-T02 -status: progress +status: done priority: high state_hub_task_id: "65407eb1-9d89-4158-aed5-4987badd83fc" ``` @@ -61,10 +61,14 @@ state_hub_task_id: "65407eb1-9d89-4158-aed5-4987badd83fc" `creds-state.yaml` flags, encrypts + commits new init material, and a custody-gate refusal warns without aborting the SSO/MFA bootstrap — dry-run/skip/refusal paths harness-tested) -- [ ] 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`) +- [x] Greenfield live proof: full init→unseal→verify path proven 2026-07-02 + against a genuinely uninitialized local OpenBao 2.5.5 (kubectl-exec shim, + script logic unmodified) — see + `history/2026-07-02-openbao-greenfield-init-unseal-proof.md`. The proof + caught and fixed a real bug: `bao operator unseal -` does not read stdin; + now `bao write sys/unseal key=-` (shares still never in argv/logs). Restart/ + reseal replay path proven too. The first 3-node rebuild slate re-runs the + same script via Phase 7b. **2026-07-02 (later):** Bernd reviewed the helper design (five safety properties incl. the root-token-in-bundle caveat of the sops-held model) and @@ -84,27 +88,42 @@ never appear in argv or logs. ```task id: NET-WP-0020-T03 -status: wait +status: done priority: medium state_hub_task_id: "34f3d979-a040-49ca-bfcb-35cf17473a06" ``` -- [ ] Implement `attended-ceremony` selection path (runbooks + evidence validators) -- [ ] Production profile blocks `sops-held-automation` default +- [x] Implement `attended-ceremony` selection path (runbooks + evidence validators) +- [x] Production profile blocks `sops-held-automation` default -**Blocked until:** T2 automation path proven on greenfield rebuild. +**2026-07-02:** Model selectable in the console; runbook at +`docs/openbao-attended-ceremony-runbook.md`; non-secret ceremony record +template + `validate-openbao-ceremony-record` validator (refuses secret +markers/placeholders). New `deployment_profile` metadata (`lab`/`production`, +`select-deployment-profile`): production blocks `sops-held-automation` in +selection, status gates, and `openbao-init-unseal.sh`. Console refuse-live-init +boundary unchanged. Covered by console test suite (14 passing) + CLI smoke. ### T4 — Auto-unseal transit profile ```task id: NET-WP-0020-T04 -status: wait +status: done priority: medium state_hub_task_id: "54ab6505-c13b-4f63-8c94-07dd202de90a" ``` -- [ ] `railiance-platform` Helm seal stanza for transit/KMS -- [ ] Console gate + evidence for `auto-unseal-transit` +- [x] `railiance-platform` Helm seal stanza for transit/KMS +- [x] Console gate + evidence for `auto-unseal-transit` + +**2026-07-02:** Commented-out `seal "transit"` stanza (disabled by default; +token via `extraSecretEnvironmentVars`, never Git) in +`railiance-platform/helm/openbao-values.yaml` plus an "Auto-Unseal via Transit +Seal" section in `railiance-platform/docs/openbao.md` (enable → `-migrate` → +pod-restart proof). Console: model selectable; init gate stays blocked until +`openbao_transit_seal_configured` and `openbao_auto_unseal_verified` are set. +Live transit/KMS provisioning is future ops work on the HA rebuild, gated by +that evidence. ### T5 — SSH engine + host CA automation (cross-repo)