diff --git a/tools/security-bootstrap-console/README.md b/tools/security-bootstrap-console/README.md index 4bd5153..05ee041 100644 --- a/tools/security-bootstrap-console/README.md +++ b/tools/security-bootstrap-console/README.md @@ -84,7 +84,9 @@ The web UI is structured as: privacyIDEA, KeyCape, the custodian age envelope, and Railiance OpenBao. 3. **Integration & Tests** - OIDC and OpenBao preflight checks, with every operator command shown as a copyable console block. -4. **Artefacts & Locations** - final non-secret overview of established +4. **Usecases & Runbooks** - guided routines for key-material compromise, + trial-output exposure, and generating replacement unseal keys. +5. **Artefacts & Locations** - final non-secret overview of established artefacts and where to find their custody references. Role, subsystem, integration, and artefact records use the same fields: @@ -99,6 +101,11 @@ role metadata and Cancel restores the last loaded values. Command cards use available, needs to be run, should be repeated after a state change, or has already succeeded. +The **Key material compromised** runbook is also useful for trial ceremonies: +mark the trial output as exposed, stop treating the generated unseal shares or +root token as production material, then either rotate unseal keys after unseal +or reset the trial environment before any live secrets are migrated. + The UI is a guide and approval surface, not the identity provider. Current lightweight-mode credential placement is: diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index e6655f1..a136311 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -419,10 +419,16 @@ def kit_next_action(kit_gates: list[Gate]) -> str: return "Define king credential kit" -def next_action(gates: list[Gate], kit_gates: list[Gate] | None = None) -> str: +def next_action( + gates: list[Gate], + kit_gates: list[Gate] | None = None, + data: dict[str, Any] | None = None, +) -> str: for gate in gates: if gate.status == "human": if gate.name == "OpenBao init ceremony": + if data and yes(data, "openbao_init_output_produced") and not yes(data, "openbao_initialized"): + return "Run OpenBao unseal prompt" return "Run attended OpenBao init ceremony" return gate.name if gate.status == "blocked": @@ -456,7 +462,7 @@ def print_status(data: dict[str, Any]) -> None: print(derive_stage(merged)) print("") print("Next safe action") - print(next_action(gates, kit_gates)) + print(next_action(gates, kit_gates, merged)) print("") print("Key custody") public_key = extract_age_public_key(merged.get("custodian_age_public_key")) @@ -570,7 +576,11 @@ def merged_approval_metadata( "oidc_login_verified", "password_safe_confirmed", "openbao_preflight_passed", + "openbao_init_output_produced", "openbao_initialized", + "openbao_trial_material_exposed", + "openbao_compromise_response_complete", + "openbao_unseal_keys_rotated", "restore_drill_passed", ): if field in payload: @@ -781,7 +791,11 @@ def metadata_template() -> dict[str, Any]: "metadata_updated_at": "", "progress_scope": "", "openbao_preflight_passed": False, + "openbao_init_output_produced": False, "openbao_initialized": False, + "openbao_trial_material_exposed": False, + "openbao_compromise_response_complete": False, + "openbao_unseal_keys_rotated": False, "root_token_disposition": "", "restore_drill_passed": False, "cleanup_complete": False, @@ -1017,6 +1031,7 @@ def artifact_payloads(data: dict[str, Any]) -> list[dict[str, str]]: public_key = extract_age_public_key(data.get("custodian_age_public_key")) state = bootstrap_secret_state() root_disposition = str(data.get("root_token_disposition") or "") + init_output = yes(data, "openbao_init_output_produced") return [ { "name": "platform-root", @@ -1115,7 +1130,7 @@ def artifact_payloads(data: dict[str, Any]) -> list[dict[str, str]]: "responsibility": "openbao-ceremony-operator", "email": role_email(data, "role_openbao_operator_email"), "location": "created during attended init; not stored here", - "state": state_value(yes(data, "openbao_initialized"), yes(data, "openbao_preflight_passed")), + "state": state_value(yes(data, "openbao_unseal_keys_rotated"), init_output or yes(data, "openbao_initialized")), }, { "name": "initial root token", @@ -1124,7 +1139,7 @@ def artifact_payloads(data: dict[str, Any]) -> list[dict[str, str]]: "responsibility": "openbao-ceremony-operator", "email": role_email(data, "role_openbao_operator_email"), "location": "created during attended init; never pasted here", - "state": state_value(root_disposition in {"revoked", "offline-sealed"}, yes(data, "openbao_initialized")), + "state": state_value(root_disposition in {"revoked", "offline-sealed"}, init_output or yes(data, "openbao_initialized")), }, ] @@ -1132,7 +1147,10 @@ def artifact_payloads(data: dict[str, Any]) -> list[dict[str, str]]: def command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: preflight_done = yes(data, "openbao_preflight_passed") custody_approved = custody_mode_approved(data) + init_output = yes(data, "openbao_init_output_produced") initialized = yes(data, "openbao_initialized") + trial_exposed = yes(data, "openbao_trial_material_exposed") + keys_rotated = yes(data, "openbao_unseal_keys_rotated") root_disposed = data.get("root_token_disposition") in {"revoked", "offline-sealed"} restore_done = yes(data, "restore_drill_passed") @@ -1141,7 +1159,7 @@ def command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: if preflight_done: status_state = "done" status_reason = "Deployment and pre-init status were verified." - if initialized and not root_disposed: + if (init_output or initialized) and not root_disposed: status_state = "redo" status_reason = "OpenBao changed during init/unseal; rerun status before root-token disposition." @@ -1153,21 +1171,33 @@ def command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: preflight_state = "blocked" preflight_reason = "Approve the selected custody strategy first." - ceremony_state = "done" if initialized else "todo" - ceremony_reason = "Init/unseal ceremony has been recorded." - if not initialized: - ceremony_reason = "Run once, attended, after OpenBao preflight." + init_state = "done" if init_output or initialized else "todo" + init_reason = "Init output was produced. Do not paste unseal shares or root token here." + if not (init_output or initialized): + init_reason = "Run once, attended, after OpenBao preflight." if not preflight_done: - ceremony_state = "blocked" - ceremony_reason = "OpenBao preflight must pass first." + init_state = "blocked" + init_reason = "OpenBao preflight must pass first." - config_state = "done" if root_disposed else "todo" - config_reason = "Initial configuration and root-token disposition are recorded." - if not root_disposed: - config_reason = "Configure OpenBao, then revoke or offline-seal the root token." + unseal_state = "done" if initialized else "todo" + unseal_reason = "OpenBao is recorded as initialized and unsealed." if not initialized: + unseal_reason = "Provide threshold shares by prompt, not as command arguments." + if not (init_output or initialized): + unseal_state = "blocked" + unseal_reason = "OpenBao init output must be produced first." + + if trial_exposed and initialized and not keys_rotated: config_state = "blocked" - config_reason = "OpenBao must be initialized and unsealed first." + config_reason = "Trial key material is exposed; rotate unseal keys or reset before configuration." + else: + config_state = "done" if root_disposed else "todo" + config_reason = "Initial configuration and root-token disposition are recorded." + if not root_disposed: + config_reason = "Configure OpenBao, then revoke or offline-seal the root token." + if not initialized: + config_state = "blocked" + config_reason = "OpenBao must be initialized and unsealed first." verify_state = "done" if restore_done else "todo" verify_reason = "Restore proof has been recorded." @@ -1198,15 +1228,15 @@ def command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: { "name": "OpenBao init ceremony", "description": "Creates real unseal shares and the initial root token. Run once, attended.", - "status": ceremony_state, - "status_reason": ceremony_reason, + "status": init_state, + "status_reason": init_reason, "command": "kubectl exec -n openbao openbao-0 -- bao operator init -key-shares=3 -key-threshold=2", }, { "name": "OpenBao unseal prompt", "description": "Enter unseal shares by prompt. Do not place shares on the command line.", - "status": ceremony_state, - "status_reason": ceremony_reason, + "status": unseal_state, + "status_reason": unseal_reason, "command": "kubectl exec -n openbao openbao-0 -- bao operator unseal", }, { @@ -1226,11 +1256,133 @@ def command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: ] +def runbook_payloads(data: dict[str, Any]) -> list[dict[str, str]]: + init_output = yes(data, "openbao_init_output_produced") + initialized = yes(data, "openbao_initialized") + trial_exposed = yes(data, "openbao_trial_material_exposed") + response_complete = yes(data, "openbao_compromise_response_complete") + keys_rotated = yes(data, "openbao_unseal_keys_rotated") + + key_compromise_status = "done" if response_complete else "todo" + key_compromise_location = "Use for trial output exposure, screenshots, chat paste, shell history, or lost custody." + if not trial_exposed: + key_compromise_status = "blocked" if not init_output else "todo" + key_compromise_location = "Mark trial key material exposed before running the response checklist." + + rotate_status = "done" if keys_rotated else "todo" + rotate_location = "Run only after OpenBao is unsealed and existing exposed shares are available for quorum." + if not initialized: + rotate_status = "blocked" + rotate_location = "Unseal OpenBao first; rotate-keys needs a quorum of current unseal shares." + if not trial_exposed and not keys_rotated: + rotate_status = "blocked" + rotate_location = "Record the key-compromise condition or schedule a normal rotation first." + + return [ + { + "name": "Key material compromised", + "description": "Respond when init output, unseal shares, or root-token material escaped the custody boundary.", + "subsystem": "Railiance OpenBao", + "responsibility": "openbao-ceremony-operator", + "email": role_email(data, "role_openbao_operator_email"), + "location": key_compromise_location, + "state": key_compromise_status, + }, + { + "name": "Generate new unseal keys", + "description": "Rotate OpenBao Shamir unseal shares after a trial exposure or planned custody migration.", + "subsystem": "Railiance OpenBao", + "responsibility": "openbao-ceremony-operator", + "email": role_email(data, "role_openbao_operator_email"), + "location": rotate_location, + "state": rotate_status, + }, + ] + + +def runbook_command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: + init_output = yes(data, "openbao_init_output_produced") + initialized = yes(data, "openbao_initialized") + trial_exposed = yes(data, "openbao_trial_material_exposed") + response_complete = yes(data, "openbao_compromise_response_complete") + keys_rotated = yes(data, "openbao_unseal_keys_rotated") + + exposure_status = "done" if trial_exposed else "todo" + exposure_reason = "Trial key-material exposure is recorded in non-secret metadata." if trial_exposed else "Record that the trial init output escaped custody before using affected material." + + response_status = "done" if response_complete else "todo" + response_reason = "Compromise response was recorded." if response_complete else "Stop production use of exposed material, decide rotate-vs-reset, and record non-secret evidence." + if not trial_exposed: + response_status = "blocked" + response_reason = "Record the key-material exposure first." + + unseal_status = "done" if initialized else "todo" + unseal_reason = "OpenBao is unsealed." if initialized else "Unseal by hidden prompt before rotating unseal keys." + if not init_output: + unseal_status = "blocked" + unseal_reason = "OpenBao init output must exist first." + + rotate_status = "done" if keys_rotated else "todo" + rotate_reason = "New unseal keys are recorded as generated." if keys_rotated else "Start rotation, then submit current shares by prompt until quorum completes." + if not initialized: + rotate_status = "blocked" + rotate_reason = "OpenBao must be unsealed before rotate-keys can run." + if not trial_exposed and not keys_rotated: + rotate_status = "blocked" + rotate_reason = "Record exposure or schedule a normal rotation before generating new shares." + + return [ + { + "name": "Record key exposure", + "description": "Non-secret metadata checkbox in this UI; do not paste exposed values.", + "status": exposure_status, + "status_reason": exposure_reason, + "command": "Use the checkbox: Trial key material exposed", + }, + { + "name": "Unseal by prompt", + "description": "Provide threshold shares interactively. Never put shares on the command line.", + "status": unseal_status, + "status_reason": unseal_reason, + "command": "kubectl exec -it -n openbao openbao-0 -- bao operator unseal", + }, + { + "name": "Start unseal-key rotation", + "description": "Generate a new 3-share, threshold-2 Shamir split after compromise or planned migration.", + "status": rotate_status, + "status_reason": rotate_reason, + "command": "kubectl exec -it -n openbao openbao-0 -- bao operator rotate-keys -init -key-shares=3 -key-threshold=2", + }, + { + "name": "Submit current shares for rotation", + "description": "Repeat by prompt until the required threshold completes. Use the nonce from rotation init.", + "status": rotate_status, + "status_reason": rotate_reason, + "command": "kubectl exec -it -n openbao openbao-0 -- bao operator rotate-keys -nonce=", + }, + { + "name": "Cancel key rotation", + "description": "Abort a started rotation if the nonce, share handling, or ceremony context is wrong.", + "status": "todo" if initialized and not keys_rotated else "blocked", + "status_reason": "Available while a rotation is in progress." if initialized and not keys_rotated else "No active rotation expected.", + "command": "kubectl exec -it -n openbao openbao-0 -- bao operator rotate-keys -cancel", + }, + { + "name": "Record compromise response complete", + "description": "Non-secret metadata checkbox after exposed material is rotated or the trial environment is reset.", + "status": response_status, + "status_reason": response_reason, + "command": "Use the checkbox: Compromise response complete", + }, + ] + + def section_gate_payloads(data: dict[str, Any]) -> list[dict[str, str]]: role_rows = role_payloads(data) role_ok = all(row["state"] != "nil" for row in role_rows[:5]) subsystem_rows = subsystem_payloads(data) integration_rows = integration_payloads(data) + runbook_rows = runbook_payloads(data) artifact_rows = artifact_payloads(data) return [ { @@ -1251,6 +1403,12 @@ def section_gate_payloads(data: dict[str, Any]) -> list[dict[str, str]]: "status": "ok" if all(row["state"] == "ok" for row in integration_rows[:4]) else "set", "reason": "Identity and OpenBao preflight checks are done." if all(row["state"] == "ok" for row in integration_rows[:4]) else "Run or confirm the remaining integration checks.", }, + { + "key": "runbooks", + "name": "Usecases & Runbooks", + "status": "ok" if all(row["state"] in {"done", "blocked"} for row in runbook_rows) else "set", + "reason": "Runbook states are recorded." if all(row["state"] in {"done", "blocked"} for row in runbook_rows) else "Review active runbooks and record non-secret outcomes.", + }, { "key": "artifacts", "name": "Artefacts & Locations", @@ -1281,7 +1439,7 @@ def status_payload(data: dict[str, Any], metadata_path: Path) -> dict[str, Any]: return { "metadata_path": str(metadata_path), "stage": derive_stage(merged), - "next_action": next_action(gates, kit_validation(merged)), + "next_action": next_action(gates, kit_validation(merged), merged), "gates": [gate_payload(gate) for gate in gates], "key_custody_gates": [gate_payload(gate) for gate in key_custody_validation(merged)], "kit_gates": [gate_payload(gate) for gate in kit_validation(merged)], @@ -1289,8 +1447,10 @@ def status_payload(data: dict[str, Any], metadata_path: Path) -> dict[str, Any]: "roles": role_payloads(merged), "subsystems": subsystem_payloads(merged), "integrations": integration_payloads(merged), + "runbooks": runbook_payloads(merged), "artifacts": artifact_payloads(merged), "commands": command_payloads(merged), + "runbook_commands": runbook_command_payloads(merged), "bootstrap_secret_state": bootstrap_secret_state(), "metadata": metadata_view, "approval_phrase": APPROVAL_PHRASE, @@ -2039,6 +2199,7 @@ def ui_html() -> str:
+
@@ -2053,8 +2214,21 @@ def ui_html() -> str:
+
+ 4. Usecases & Runbooksnil +
Loading runbook gate.
+

Use these routines when the ceremony path changes, trial secrets are exposed, or custody material must be regenerated. The UI records only non-secret outcomes.

+
+
+ + + +
+
+
+
- 4. Artefacts & Locationsnil + 5. Artefacts & Locationsnil
Loading artefact gate.

This is the final overview of what has been established. Locations are references only; passwords, OTP seeds, age private keys, unseal shares, and root tokens are never recorded here.

@@ -2135,7 +2309,11 @@ def ui_html() -> str: "recovery_confirmed", "custody_packet_prepared", "openbao_preflight_passed", + "openbao_init_output_produced", "openbao_initialized", + "openbao_trial_material_exposed", + "openbao_compromise_response_complete", + "openbao_unseal_keys_rotated", "root_token_disposition", "restore_drill_passed" ]; @@ -2244,8 +2422,8 @@ def ui_html() -> str: } } - function renderCommands(commands) { - const root = document.getElementById("command-list"); + function renderCommands(target, commands) { + const root = document.getElementById(target); root.replaceChildren(); for (const item of commands || []) { const row = document.createElement("div"); @@ -2352,8 +2530,10 @@ def ui_html() -> str: renderRecords("roles-records", data.roles); renderRecords("subsystems-records", data.subsystems); renderRecords("integrations-records", data.integrations); + renderRecords("runbooks-records", data.runbooks); renderRecords("artifacts-records", data.artifacts); - renderCommands(data.commands); + renderCommands("command-list", data.commands); + renderCommands("runbook-command-list", data.runbook_commands); currentMetadata = data.metadata || {}; fillForm(currentMetadata); setResponsibilityDirty(false); @@ -2394,7 +2574,11 @@ def ui_html() -> str: custody_packet_prepared: document.getElementById("custody_packet_prepared").checked, custody_mode: mode ? mode.value : "", openbao_preflight_passed: document.getElementById("openbao_preflight_passed").checked, + openbao_init_output_produced: document.getElementById("openbao_init_output_produced").checked, openbao_initialized: document.getElementById("openbao_initialized").checked, + openbao_trial_material_exposed: document.getElementById("openbao_trial_material_exposed").checked, + openbao_compromise_response_complete: document.getElementById("openbao_compromise_response_complete").checked, + openbao_unseal_keys_rotated: document.getElementById("openbao_unseal_keys_rotated").checked, root_token_disposition: document.getElementById("root_token_disposition").value, restore_drill_passed: document.getElementById("restore_drill_passed").checked, approval_phrase: document.getElementById("approval_phrase").value, @@ -2438,7 +2622,7 @@ def ui_html() -> str: } }); - document.getElementById("command-list").addEventListener("click", async (event) => { + document.addEventListener("click", async (event) => { const button = event.target.closest(".copy-button"); if (!button) return; const command = button.dataset.command || ""; diff --git a/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md b/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md index 16eb611..8a02399 100644 --- a/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md +++ b/workplans/NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md @@ -242,6 +242,12 @@ inside a dirty-state Save/Cancel foldout, future quorum contact uses the same effective-value prefill as the role display, and command cards now derive `blocked`, `todo`, `redo`, or `done` status from bootstrap metadata. +**2026-05-25:** Added a Usecases & Runbooks section for trial-output exposure +and key-material compromise. The UI now records non-secret compromise response +state, separates "init output produced" from "initialized and unsealed", and +adds guided command cards for unseal and OpenBao `rotate-keys` replacement +share generation. + **2026-05-24:** Stepped back from ad hoc secret rollout and added the custodian age-key bootstrap model to the control surface. The UI now records the custodian public age recipient, a derived fingerprint, and a non-secret