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

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