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:
NetKingdom control surface
-

Security bootstrap custody approval

+

Guided security bootstrap

@@ -1215,7 +1287,7 @@ def ui_html() -> str:
-

Bootstrap key custody

+

1. Bootstrap key envelope

The custodian age public key encrypts bootstrap bundles. The private key is ceremony material and must not be pasted into this UI.

1Register public recipientPaste only the custodian public age recipient, for example age1.... This value is safe to store and lets tools encrypt new bootstrap bundles.
@@ -1250,7 +1322,7 @@ def ui_html() -> str:
-

Credential home

+

2. Platform-root identity

Guide mode. OpenBao stores and audits secrets after the ceremony; it does not create the king account.

1Open LLDAP as bootstrap adminLLDAP has no public registration. Log in as admin using LLDAP_LDAP_USER_PASS from your password safe entry net-kingdom/LLDAP/admin. That value was generated during installation and injected into the lldap-secrets Kubernetes Secret.Open LLDAP
@@ -1282,7 +1354,7 @@ def ui_html() -> str:
-

King credential

+

3. Credential record

Local non-secret metadata only. OpenBao initialization stays manual.

-

Second factor enrollment

+

4. MFA and login proof

The QR code or setup key belongs to the authority that verifies login. This UI records confirmation only.

-

Storage and recovery

+

5. Recovery material

+

Recovery material is the ability to regain control of the platform-root credential and encrypted bootstrap bundle. It is not the OpenBao ceremony packet, and this UI stores only references.

+
    +
  • Platform-root password entry exists in the password safe and its label is known.
  • +
  • MFA recovery or re-enrollment path is known, such as privacyIDEA admin repair or a stored recovery-code location if that authority issues codes.
  • +
  • Custodian age private-key location is known and separate from the public recipient stored here.
  • +
  • Encrypted bootstrap bundle location is known; plaintext bootstrap secrets are absent before custody approval.
  • +
  • Notification contact and setup operator are recorded for lockout handling.
  • +
- - - - + +
-

Custody mode

+

6. Custody packet and approval

+

The custody packet is the offline ceremony envelope for OpenBao init. Recovery material proves access can be restored; the custody packet governs how the first real secrets are created, split, sealed, and signed off.

+
    +
  • Credential label, setup operator, notification contact, and selected custody strategy.
  • +
  • References to recovery material, not the recovery values themselves.
  • +
  • OpenBao init checklist, unseal-share assignment slots, and quorum plan.
  • +
  • Root-token disposition plan: revoke immediately or seal offline after scoped admin access works.
  • +
  • Signature/date line for the attended ceremony.
  • +
+
Secret capture is enforced by architecture: the control surface does not request secrets, and the gate checks local metadata plus plaintext bootstrap-secret presence. There is no user checkbox for this contract.
+
+ +
+

Selecting a strategy records intent. Approval is a separate operator action that unlocks the next gate; it still does not run OpenBao init.

- Approval phrase + Approval phrase for selected strategy
- +
Waiting for local approval.
@@ -1403,8 +1494,7 @@ def ui_html() -> str: "mfa_enrollment_reference", "mfa_enrolled_confirmed", "recovery_confirmed", - "custody_packet_prepared", - "no_secret_capture_confirmed" + "custody_packet_prepared" ]; function setMessage(text, kind) { @@ -1452,6 +1542,16 @@ def ui_html() -> str: const mode = metadata.custody_mode || "temporary-single-king"; const selected = document.querySelector(`[name='custody_mode'][value='${mode}']`); if (selected) selected.checked = true; + syncConditionalHardware(); + } + + function syncConditionalHardware() { + const source = document.getElementById("mfa_class"); + const row = document.getElementById("hardware-storage-choice"); + if (!source || !row) return; + const input = row.querySelector("input"); + const visible = source.value === "hardware-token" || (input && input.checked); + row.classList.toggle("visible", visible); } async function loadStatus() { @@ -1493,13 +1593,14 @@ def ui_html() -> str: storage_classes: storage, recovery_confirmed: document.getElementById("recovery_confirmed").checked, custody_packet_prepared: document.getElementById("custody_packet_prepared").checked, - no_secret_capture_confirmed: document.getElementById("no_secret_capture_confirmed").checked, custody_mode: mode ? mode.value : "", approval_phrase: document.getElementById("approval_phrase").value, approved_by: document.getElementById("setup_operator").value.trim() }; } + document.getElementById("mfa_class").addEventListener("change", syncConditionalHardware); + document.getElementById("approval-form").addEventListener("submit", async (event) => { event.preventDefault(); const button = document.getElementById("approve-button"); @@ -1518,7 +1619,7 @@ def ui_html() -> str: } document.getElementById("approval_phrase").value = ""; await loadStatus(); - setMessage("Custody mode approved. OpenBao init remains a separate human ceremony.", "ok"); + setMessage("Selected custody strategy approved. OpenBao init remains a separate human ceremony.", "ok"); } catch (error) { setMessage("Request failed: " + error.message, "error"); } finally { @@ -1647,7 +1748,7 @@ def make_ui_handler(metadata_path: Path) -> type[BaseHTTPRequestHandler]: return write_metadata(metadata_path, approved) response = status_payload(approved, metadata_path) - response["message"] = "Custody mode approved." + response["message"] = "Selected custody strategy approved." self.send_json(HTTPStatus.OK, response) return SecurityBootstrapUIHandler 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 8fb95de..cc567c4 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 @@ -216,6 +216,13 @@ showed issuer `https://kc.coulomb.social`, audience bootstrap progress now records both MFA enrollment confirmation and OIDC login verification. +**2026-05-25:** Reworked the bootstrap-console flow after operator review. The +UI now follows the use case top to bottom, hides hardware-token storage unless +the selected policy uses hardware tokens, specifies the exact recovery material +contents, distinguishes recovery material from the OpenBao custody packet, and +turns "no secret capture" into an automatic control-surface boundary gate +rather than a user checkbox. + **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