diff --git a/tools/security-bootstrap-console/README.md b/tools/security-bootstrap-console/README.md index 498b2e2..fd1722e 100644 --- a/tools/security-bootstrap-console/README.md +++ b/tools/security-bootstrap-console/README.md @@ -37,8 +37,7 @@ python3 tools/security-bootstrap-console/security_bootstrap_console.py \ --mfa-enrolled-confirmed \ --mfa-enrollment-source identity-provider \ --recovery-confirmed \ - --custody-packet-prepared \ - --no-secret-capture-confirmed + --custody-packet-prepared ``` The command asks for the phrase `approve custody mode` unless `--yes` is passed. @@ -49,6 +48,21 @@ For TOTP, use the QR code or setup key from the identity provider or other authority that will verify the login. This tool records only the non-secret enrollment confirmation and source. +Recovery material means the operator can regain control of the platform-root +credential and encrypted bootstrap bundle without this UI storing any values: +the platform-root password-safe entry, MFA recovery or re-enrollment path, +custodian age private-key location, encrypted bootstrap bundle location, and +notification/setup contact are all known. + +The custody packet is separate. It is the offline OpenBao ceremony envelope: +selected custody strategy, recovery-material references, init checklist, +unseal-share assignment slots, root-token disposition plan, and signature/date. + +Secret capture is an architecture gate, not a user checkbox. The control +surface must not request or store passwords, OTP seeds, recovery codes, private +keys, OpenBao root tokens, or unseal shares. The UI reports this automatically +from local metadata and plaintext bootstrap-secret presence. + Serve the local approval UI: ```bash diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index 001a0ee..0c37155 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -141,6 +141,61 @@ def identity_login_ready(data: dict[str, Any]) -> bool: return yes(data, "oidc_login_verified") +def recovery_material_ready(data: dict[str, Any]) -> bool: + return yes(data, "recovery_confirmed") + + +def recovery_material_reason(data: dict[str, Any]) -> str: + if recovery_material_ready(data): + return "Recovery references are prepared outside this UI." + return ( + "Prepare password recovery, MFA recovery/re-enrollment, custodian age-key " + "recovery, and encrypted bootstrap bundle recovery references." + ) + + +def custody_packet_ready(data: dict[str, Any]) -> bool: + return yes(data, "custody_packet_prepared") + + +def custody_packet_reason(data: dict[str, Any]) -> str: + if custody_packet_ready(data): + return "The offline ceremony packet is ready without recording secret values here." + return ( + "Prepare the OpenBao ceremony packet: selected custody mode, recovery references, " + "share assignment slots, root-token disposition plan, and signature/date." + ) + + +def metadata_secret_boundary_issue(data: dict[str, Any]) -> str: + state = bootstrap_secret_state() + if state["plaintext_secrets_present"]: + return "Plaintext bootstrap secrets directory is present; remove it before custody approval." + encoded = json.dumps(data, sort_keys=True) + secret_markers = ( + AGE_PRIVATE_MARKER, + "-----BEGIN PRIVATE KEY-----", + "-----BEGIN OPENSSH PRIVATE KEY-----", + "OPENBAO_ROOT_TOKEN", + "VAULT_TOKEN", + ) + for marker in secret_markers: + if marker in encoded: + return f"Metadata contains a secret-looking marker: {marker}." + return "" + + +def secret_boundary_ready(data: dict[str, Any]) -> bool: + return metadata_secret_boundary_issue(data) == "" + + +def secret_boundary_reason(data: dict[str, Any]) -> str: + issue = metadata_secret_boundary_issue(data) + if issue: + return issue + return "The control surface stores only non-secret references; no user attestation is required." + + def extract_age_public_key(value: Any) -> str: if value is None: return "" @@ -232,7 +287,7 @@ def kit_validation(data: dict[str, Any]) -> list[Gate]: Gate( "Storage class", "done" if storage_values & VALID_STORAGE_CLASSES else "blocked", - "Select password-safe, offline-packet, hardware-token, or a combination.", + "Select where the credential is held; hardware is optional policy, not a default requirement.", ), Gate( "Password safe storage", @@ -251,30 +306,30 @@ def kit_validation(data: dict[str, Any]) -> list[Gate]: ), Gate( "Recovery material", - "done" if yes(data, "recovery_confirmed") else "blocked", - "Confirm recovery material exists without recording values.", + "done" if recovery_material_ready(data) else "blocked", + recovery_material_reason(data), ), Gate( "Custody packet", - "done" if yes(data, "custody_packet_prepared") else "blocked", - "Prepare the offline custody packet.", + "done" if custody_packet_ready(data) else "blocked", + custody_packet_reason(data), ), Gate( - "No secret capture", - "done" if yes(data, "no_secret_capture_confirmed") else "blocked", - "Confirm no secret values were stored in metadata, Git, State Hub, chat, tickets, or email.", + "Control-surface secret boundary", + "done" if secret_boundary_ready(data) else "blocked", + secret_boundary_reason(data), ), Gate( - "Custody mode", + "Custody strategy selected", "done" if custody_mode in VALID_CUSTODY_MODES else "blocked", - "Approve temporary-single-king, two-of-three-planned, or two-of-three-ready.", + "Choose how the OpenBao init ceremony will be controlled.", ), ] def king_kit_ready(data: dict[str, Any]) -> bool: gates = kit_validation(data) - required = [gate for gate in gates if gate.name != "Custody mode"] + required = [gate for gate in gates if gate.name != "Custody strategy selected"] return all(gate.status == "done" for gate in required) @@ -289,7 +344,7 @@ def custody_mode_reason(data: dict[str, Any]) -> str: if mode == "two-of-three-planned": return "Two-of-three is recorded as the target, but live init stays blocked until it is ready." if mode in CUSTODY_APPROVAL_MODES and not yes(data, "custody_mode_approved"): - return "Mode is selected but not yet explicitly approved." + return "Strategy is selected; explicit approval is still pending." return "Choose temporary-single-king or two-of-three-ready for live OpenBao custody." @@ -313,7 +368,7 @@ def build_gates(data: dict[str, Any]) -> list[Gate]: "Dedicated king credential, second factor, and recovery storage.", ), Gate( - "Custody mode", + "Custody strategy approval", "done" if custody_mode_approved(data) else "blocked", custody_mode_reason(data), ), @@ -350,8 +405,8 @@ def next_action(gates: list[Gate]) -> str: if gate.status == "blocked": if gate.name == "King credential kit": return "Define king credential kit" - if gate.name == "Custody mode": - return "Choose custody mode" + if gate.name == "Custody strategy approval": + return "Approve custody strategy" if gate.name == "OpenBao preflight": return "Run OpenBao preflight" if gate.name == "Root-token disposition": @@ -501,7 +556,7 @@ def validate_custody_approval( errors: list[str] = [] mode = data.get("custody_mode") if approval_phrase.strip().lower() != APPROVAL_PHRASE: - errors.append(f'Type "{APPROVAL_PHRASE}" to approve custody mode.') + errors.append(f'Type "{APPROVAL_PHRASE}" to approve the selected custody strategy.') if mode not in VALID_CUSTODY_MODES: errors.append("Select a custody mode.") elif mode not in CUSTODY_APPROVAL_MODES: @@ -510,7 +565,7 @@ def validate_custody_approval( "Use temporary-single-king now or two-of-three-ready when shares exist." ) for gate in kit_validation(data): - if gate.name == "Custody mode": + if gate.name == "Custody strategy selected": continue if gate.status != "done": errors.append(f"{gate.name}: {gate.reason}") @@ -1071,6 +1126,23 @@ def ui_html() -> str: line-height: 1.35; margin: 0 0 16px; } + .spec-list { + margin: 0 0 16px; + padding-left: 20px; + color: var(--muted); + font-size: 13px; + line-height: 1.45; + } + .spec-list li { margin: 5px 0; } + .system-note { + border: 1px solid var(--soft-line); + background: #ffffff; + padding: 12px 14px; + line-height: 1.35; + margin: 0 0 16px; + } + .conditional { display: none; } + .conditional.visible { display: grid; } .actions { display: flex; flex-wrap: wrap; @@ -1194,7 +1266,7 @@ def ui_html() -> str: