Add OpenBao compromise runbooks to bootstrap UI

This commit is contained in:
2026-05-25 13:38:03 +02:00
parent 976f399342
commit 7a060a0ee6
3 changed files with 224 additions and 27 deletions

View File

@@ -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:

View File

@@ -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=<nonce-from-rotation-init>",
},
{
"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:
<div class="choice-list" style="margin-top: 14px;">
<label class="choice"><input id="oidc_login_verified" type="checkbox"><span><strong>OIDC login verified</strong><span>The account can complete the NetKingdom login path through KeyCape after MFA enrollment.</span></span></label>
<label class="choice"><input id="openbao_preflight_passed" type="checkbox"><span><strong>OpenBao preflight passed</strong><span>Status and verification checks completed after custody approval.</span></span></label>
<label class="choice"><input id="openbao_init_output_produced" type="checkbox"><span><strong>Init output produced</strong><span>OpenBao generated unseal shares and the initial root token outside this UI. Do not paste those values here.</span></span></label>
<label class="choice"><input id="openbao_initialized" type="checkbox"><span><strong>Initialized and unsealed</strong><span>The human ceremony completed outside this UI under the approved strategy.</span></span></label>
<label class="choice"><input id="restore_drill_passed" type="checkbox"><span><strong>Restore drill passed</strong><span>Snapshot and isolated restore proof completed before live secrets are migrated.</span></span></label>
</div>
@@ -2053,8 +2214,21 @@ def ui_html() -> str:
<div id="command-list" class="command-list"></div>
</details>
<details class="panel workflow-section" data-section="runbooks" open>
<summary><span class="summary-title">4. Usecases & Runbooks</span><span class="state nil" data-section-state="runbooks">nil</span></summary>
<div class="section-gate" data-section-gate="runbooks">Loading runbook gate.</div>
<p class="notice">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.</p>
<div id="runbooks-records" class="record-list"></div>
<div class="choice-list">
<label class="choice"><input id="openbao_trial_material_exposed" type="checkbox"><span><strong>Trial key material exposed</strong><span>Init output, unseal shares, or root-token material escaped the custody boundary during a trial.</span></span></label>
<label class="choice"><input id="openbao_compromise_response_complete" type="checkbox"><span><strong>Compromise response complete</strong><span>Exposed material was rotated or the trial environment was reset. No secret values are recorded here.</span></span></label>
<label class="choice"><input id="openbao_unseal_keys_rotated" type="checkbox"><span><strong>New unseal keys generated</strong><span>OpenBao generated replacement unseal shares under the current runbook.</span></span></label>
</div>
<div id="runbook-command-list" class="command-list"></div>
</details>
<details class="panel workflow-section" data-section="artifacts" open>
<summary><span class="summary-title">4. Artefacts & Locations</span><span class="state nil" data-section-state="artifacts">nil</span></summary>
<summary><span class="summary-title">5. Artefacts & Locations</span><span class="state nil" data-section-state="artifacts">nil</span></summary>
<div class="section-gate" data-section-gate="artifacts">Loading artefact gate.</div>
<p class="notice">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.</p>
<div id="artifacts-records" class="record-list"></div>
@@ -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 || "";

View File

@@ -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