From 9afe30f49f72d82cf70cd6cf566b0e5472b836fd Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 25 May 2026 14:57:53 +0200 Subject: [PATCH] Show compromised OpenBao paths as tainted --- .../security_bootstrap_console.py | 358 +++++++++++------- ...-custody-and-openbao-identity-bootstrap.md | 5 + 2 files changed, 236 insertions(+), 127 deletions(-) diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index e189765..60a7936 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -911,6 +911,28 @@ def state_value(ok: bool, set_value: bool = False, err: bool = False) -> str: return "nil" +def openbao_trial_taint(data: dict[str, Any], relation: str = "downstream") -> dict[str, Any]: + if not yes(data, "openbao_trial_material_exposed") or yes(data, "openbao_compromise_response_complete"): + return {} + relation_text = "Directly marked" if relation == "direct" else "Downstream" + return { + "tainted": True, + "taint_source": "Trial key material exposed", + "taint_reference": "Usecases & Runbooks / Trial key material exposed", + "taint_reason": ( + f"{relation_text} from recorded OpenBao trial key-material exposure. " + "Operator may proceed, but resulting evidence and work should be treated as tainted " + "until rotation, reset, or another compromise response is recorded." + ), + } + + +def add_taint(row: dict[str, Any], taint: dict[str, Any]) -> dict[str, Any]: + if taint: + row.update(taint) + return row + + def subsystem_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() @@ -980,6 +1002,7 @@ def subsystem_payloads(data: dict[str, Any]) -> list[dict[str, str]]: def integration_payloads(data: dict[str, Any]) -> list[dict[str, str]]: + openbao_direct_taint = openbao_trial_taint(data, "direct") return [ { "name": "LLDAP admin group assignment", @@ -1017,15 +1040,18 @@ def integration_payloads(data: dict[str, Any]) -> list[dict[str, str]]: "location": "../railiance-platform", "state": state_value(yes(data, "openbao_preflight_passed"), yes(data, "custody_mode_approved")), }, - { - "name": "OpenBao init/unseal ceremony", - "description": "Attended ceremony creates unseal shares and initial root token outside this UI.", - "subsystem": "Railiance OpenBao", - "responsibility": "openbao-ceremony-operator", - "email": role_email(data, "role_openbao_operator_email"), - "location": "operator shell with custody packet present", - "state": state_value(yes(data, "openbao_initialized"), yes(data, "openbao_preflight_passed")), - }, + add_taint( + { + "name": "OpenBao init/unseal ceremony", + "description": "Attended ceremony creates unseal shares and initial root token outside this UI.", + "subsystem": "Railiance OpenBao", + "responsibility": "openbao-ceremony-operator", + "email": role_email(data, "role_openbao_operator_email"), + "location": "operator shell with custody packet present", + "state": state_value(yes(data, "openbao_initialized"), yes(data, "openbao_preflight_passed")), + }, + openbao_direct_taint, + ), ] @@ -1034,6 +1060,7 @@ def artifact_payloads(data: dict[str, Any]) -> list[dict[str, str]]: state = bootstrap_secret_state() root_disposition = str(data.get("root_token_disposition") or "") init_output = yes(data, "openbao_init_output_produced") + openbao_direct_taint = openbao_trial_taint(data, "direct") return [ { "name": "platform-root", @@ -1125,24 +1152,30 @@ def artifact_payloads(data: dict[str, Any]) -> list[dict[str, str]]: "location": "offline ceremony packet", "state": state_value(yes(data, "custody_packet_prepared")), }, - { - "name": "unseal shares", - "description": "Real OpenBao shares produced by init. They must be routed directly to approved custody locations.", - "subsystem": "Railiance OpenBao", - "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_unseal_keys_rotated"), init_output or yes(data, "openbao_initialized")), - }, - { - "name": "initial root token", - "description": "OpenBao bootstrap token produced by init. Use only for first configuration and disposition.", - "subsystem": "Railiance OpenBao", - "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"}, init_output or yes(data, "openbao_initialized")), - }, + add_taint( + { + "name": "unseal shares", + "description": "Real OpenBao shares produced by init. They must be routed directly to approved custody locations.", + "subsystem": "Railiance OpenBao", + "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_unseal_keys_rotated"), init_output or yes(data, "openbao_initialized")), + }, + openbao_direct_taint, + ), + add_taint( + { + "name": "initial root token", + "description": "OpenBao bootstrap token produced by init. Use only for first configuration and disposition.", + "subsystem": "Railiance OpenBao", + "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"}, init_output or yes(data, "openbao_initialized")), + }, + openbao_direct_taint, + ), ] @@ -1153,8 +1186,11 @@ def command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: initialized = yes(data, "openbao_initialized") post_unseal_verified = yes(data, "openbao_post_unseal_verified") trial_exposed = yes(data, "openbao_trial_material_exposed") + response_complete = yes(data, "openbao_compromise_response_complete") keys_rotated = yes(data, "openbao_unseal_keys_rotated") root_disposed = data.get("root_token_disposition") in {"revoked", "offline-sealed"} + openbao_direct_taint = openbao_trial_taint(data, "direct") + openbao_downstream_taint = openbao_trial_taint(data, "downstream") status_state = "todo" status_reason = "Run any time to inspect the current OpenBao deployment state." @@ -1189,17 +1225,15 @@ def command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: unseal_state = "blocked" unseal_reason = "OpenBao init output must be produced first." - if trial_exposed and initialized and not keys_rotated: + 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 trial_exposed and initialized and not response_complete: + config_reason = "Tainted by trial key-material exposure. Operator may proceed, but record the taint and complete rotation, reset, or another compromise response before production trust." + if not initialized: config_state = "blocked" - 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." + config_reason = "OpenBao must be initialized and unsealed first." verify_state = "done" if post_unseal_verified else "todo" verify_reason = "Post-unseal readiness has been verified." @@ -1227,34 +1261,46 @@ def command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: "openbao-preflight --railiance-path ../railiance-platform --run" ), }, - { - "name": "OpenBao init ceremony", - "description": "Creates real unseal shares and the initial root token. Run once, attended.", - "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 interactive terminal prompt. Do not place shares on the command line.", - "status": unseal_state, - "status_reason": unseal_reason, - "command": "kubectl exec -it -n openbao openbao-0 -- bao operator unseal", - }, - { - "name": "OpenBao initial configuration", - "description": "Apply first audit, auth, mount, and policy configuration after unseal.", - "status": config_state, - "status_reason": config_reason, - "command": "make -C ../railiance-platform openbao-configure-initial", - }, - { - "name": "OpenBao post-unseal verification", - "description": "Verify filesystem and post-unseal readiness before live secrets move in.", - "status": verify_state, - "status_reason": verify_reason, - "command": "make -C ../railiance-platform openbao-verify-post-unseal", - }, + add_taint( + { + "name": "OpenBao init ceremony", + "description": "Creates real unseal shares and the initial root token. Run once, attended.", + "status": init_state, + "status_reason": init_reason, + "command": "kubectl exec -n openbao openbao-0 -- bao operator init -key-shares=3 -key-threshold=2", + }, + openbao_direct_taint if init_output or initialized else {}, + ), + add_taint( + { + "name": "OpenBao unseal prompt", + "description": "Enter unseal shares by interactive terminal prompt. Do not place shares on the command line.", + "status": unseal_state, + "status_reason": unseal_reason, + "command": "kubectl exec -it -n openbao openbao-0 -- bao operator unseal", + }, + openbao_direct_taint if initialized else {}, + ), + add_taint( + { + "name": "OpenBao initial configuration", + "description": "Apply first audit, auth, mount, and policy configuration after unseal.", + "status": config_state, + "status_reason": config_reason, + "command": "make -C ../railiance-platform openbao-configure-initial", + }, + openbao_downstream_taint if initialized else {}, + ), + add_taint( + { + "name": "OpenBao post-unseal verification", + "description": "Verify filesystem and post-unseal readiness before live secrets move in.", + "status": verify_state, + "status_reason": verify_reason, + "command": "make -C ../railiance-platform openbao-verify-post-unseal", + }, + openbao_downstream_taint if initialized else {}, + ), ] @@ -1264,6 +1310,8 @@ def runbook_payloads(data: dict[str, Any]) -> list[dict[str, str]]: trial_exposed = yes(data, "openbao_trial_material_exposed") response_complete = yes(data, "openbao_compromise_response_complete") keys_rotated = yes(data, "openbao_unseal_keys_rotated") + openbao_direct_taint = openbao_trial_taint(data, "direct") + openbao_downstream_taint = openbao_trial_taint(data, "downstream") 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." @@ -1281,24 +1329,30 @@ def runbook_payloads(data: dict[str, Any]) -> list[dict[str, str]]: 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, - }, + add_taint( + { + "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, + }, + openbao_direct_taint if trial_exposed and not response_complete else {}, + ), + add_taint( + { + "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, + }, + openbao_downstream_taint if trial_exposed and not response_complete else {}, + ), ] @@ -1308,6 +1362,8 @@ def runbook_command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: trial_exposed = yes(data, "openbao_trial_material_exposed") response_complete = yes(data, "openbao_compromise_response_complete") keys_rotated = yes(data, "openbao_unseal_keys_rotated") + openbao_direct_taint = openbao_trial_taint(data, "direct") + openbao_downstream_taint = openbao_trial_taint(data, "downstream") 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." @@ -1334,48 +1390,66 @@ def runbook_command_payloads(data: dict[str, Any]) -> list[dict[str, str]]: 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", - }, + add_taint( + { + "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", + }, + openbao_direct_taint if trial_exposed and not response_complete else {}, + ), + add_taint( + { + "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", + }, + openbao_direct_taint if initialized else {}, + ), + add_taint( + { + "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", + }, + openbao_downstream_taint if trial_exposed and not response_complete else {}, + ), + add_taint( + { + "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=", + }, + openbao_downstream_taint if trial_exposed and not response_complete else {}, + ), + add_taint( + { + "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", + }, + openbao_downstream_taint if trial_exposed and not response_complete else {}, + ), + add_taint( + { + "name": "Record compromise response complete", + "description": "Non-secret metadata checkbox after exposed material is rotated or the trial environment was reset.", + "status": response_status, + "status_reason": response_reason, + "command": "Use the checkbox: Compromise response complete", + }, + openbao_downstream_taint if trial_exposed and not response_complete else {}, + ), ] @@ -1641,6 +1715,8 @@ def ui_html() -> str: --warn: #fff2b8; --human: #e6ecf7; --bad: #f4d6d0; + --taint: #fde8e3; + --taint-line: #ba6b61; } * { box-sizing: border-box; } body { @@ -1887,6 +1963,10 @@ def ui_html() -> str: background: #ffffff; padding: 10px; } + .record-row.tainted, .command-row.tainted { + background: var(--taint); + border-color: var(--taint-line); + } .record-name { font-weight: 650; line-height: 1.2; @@ -1898,6 +1978,15 @@ def ui_html() -> str: margin-top: 3px; overflow-wrap: anywhere; } + .taint-note { + border-left: 4px solid var(--taint-line); + color: var(--ink); + font-size: 13px; + line-height: 1.35; + margin-top: 7px; + padding-left: 8px; + overflow-wrap: anywhere; + } .record-context { display: grid; gap: 6px; @@ -2377,12 +2466,23 @@ def ui_html() -> str: return chip; } + function makeTaintNote(item) { + if (!item || !item.tainted) return null; + const note = document.createElement("div"); + note.className = "taint-note"; + const source = item.taint_source || "upstream taint"; + const reference = item.taint_reference ? " (" + item.taint_reference + ")" : ""; + const reason = item.taint_reason ? ": " + item.taint_reason : ""; + note.textContent = "Tainted from " + source + reference + reason; + return note; + } + function renderRecords(target, rows) { const root = document.getElementById(target); root.replaceChildren(); for (const record of rows || []) { const row = document.createElement("div"); - row.className = "record-row"; + row.className = "record-row" + (record.tainted ? " tainted" : ""); row.append(makeStateBadge(record.state)); const identity = document.createElement("div"); @@ -2393,6 +2493,8 @@ def ui_html() -> str: description.className = "record-description"; description.textContent = record.description; identity.append(name, description); + const taintNote = makeTaintNote(record); + if (taintNote) identity.append(taintNote); const context = document.createElement("div"); context.className = "record-context"; @@ -2431,7 +2533,7 @@ def ui_html() -> str: root.replaceChildren(); for (const item of commands || []) { const row = document.createElement("div"); - row.className = "command-row"; + row.className = "command-row" + (item.tainted ? " tainted" : ""); const head = document.createElement("div"); head.className = "command-head"; @@ -2446,6 +2548,8 @@ def ui_html() -> str: statusReason.className = "record-description"; statusReason.textContent = item.status_reason || ""; title.append(name, description, statusReason); + const taintNote = makeTaintNote(item); + if (taintNote) title.append(taintNote); const button = document.createElement("button"); button.className = "copy-button secondary"; 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 8a02399..4d3bafd 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 @@ -248,6 +248,11 @@ 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-25:** Changed compromised/trial-exposed OpenBao material from a hard +block into an explicit taint model. Affected artefacts and downstream command +cards are shown with a light red background and retain the source reference, but +the operator can still proceed deliberately on a tainted workpath. + **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