Files
net-kingdom/tools/security-bootstrap-console/security_bootstrap_console.py

1260 lines
45 KiB
Python
Executable File

#!/usr/bin/env python3
"""Non-secret security bootstrap console.
This tool is intentionally conservative. It prints status, gates, checklists,
and templates. It does not collect or store secret values, and it refuses to run
live OpenBao initialization.
"""
from __future__ import annotations
import argparse
import html
import json
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
DEFAULT_STAGE = "S1 - Low-trust assembly"
DEFAULT_METADATA_PATH = Path("/tmp/net-kingdom-security-bootstrap.json")
APPROVAL_PHRASE = "approve custody mode"
VALID_STORAGE_CLASSES = {"password-safe", "offline-packet", "hardware-token"}
VALID_MFA_CLASSES = {"totp", "webauthn", "hardware-token"}
VALID_MFA_ENROLLMENT_SOURCES = {
"identity-provider",
"external-verifier",
"hardware-registration",
"deferred",
}
VALID_CUSTODY_MODES = {"temporary-single-king", "two-of-three-planned", "two-of-three-ready"}
CUSTODY_APPROVAL_MODES = {"temporary-single-king", "two-of-three-ready"}
@dataclass(frozen=True)
class Gate:
name: str
status: str
reason: str
def load_metadata(path: Path | None) -> dict[str, Any]:
if path is None or not path.exists():
return {}
try:
data = json.loads(path.read_text())
except json.JSONDecodeError as exc:
raise SystemExit(f"metadata is not valid JSON: {path}: {exc}") from exc
if not isinstance(data, dict):
raise SystemExit(f"metadata root must be an object: {path}")
return data
def write_metadata(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f".{path.name}.tmp")
tmp_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
tmp_path.replace(path)
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def normalize_storage_classes(value: Any) -> list[str]:
if isinstance(value, str):
raw_values = [item.strip() for item in value.split(",")]
elif isinstance(value, list):
raw_values = [str(item).strip() for item in value]
else:
raw_values = []
values = [item for item in raw_values if item]
return sorted(set(values))
def yes(data: dict[str, Any], key: str) -> bool:
return data.get(key) is True
def second_factor_ready(data: dict[str, Any]) -> bool:
return (
data.get("mfa_class") in VALID_MFA_CLASSES
and yes(data, "mfa_enrolled_confirmed")
and data.get("mfa_enrollment_source") in VALID_MFA_ENROLLMENT_SOURCES - {"deferred"}
)
def second_factor_reason(data: dict[str, Any]) -> str:
if data.get("mfa_class") not in VALID_MFA_CLASSES:
return "Select TOTP, WebAuthn, or hardware-token."
if data.get("mfa_enrollment_source") == "deferred":
return "Deferred factor enrollment blocks live OpenBao custody."
if not yes(data, "mfa_enrolled_confirmed"):
return "Confirm the factor was enrolled with the authority that will verify it; record no seed."
if data.get("mfa_enrollment_source") not in VALID_MFA_ENROLLMENT_SOURCES:
return "Record the non-secret enrollment source."
return "Second factor enrollment is confirmed without recording seed material."
def kit_validation(data: dict[str, Any]) -> list[Gate]:
storage_classes = data.get("storage_classes", [])
if not isinstance(storage_classes, list):
storage_classes = []
storage_values = {str(item) for item in storage_classes}
custody_mode = data.get("custody_mode", "")
return [
Gate(
"Credential label",
"done" if data.get("credential_label") else "blocked",
"Use a dedicated label such as platform-root.",
),
Gate(
"Setup operator/contact",
"done" if data.get("setup_operator") and data.get("notification_contact") else "blocked",
"Record non-secret setup operator and notification contact.",
),
Gate(
"Storage class",
"done" if storage_values & VALID_STORAGE_CLASSES else "blocked",
"Select password-safe, offline-packet, hardware-token, or a combination.",
),
Gate(
"Second factor",
"done" if second_factor_ready(data) else "blocked",
second_factor_reason(data),
),
Gate(
"Recovery material",
"done" if yes(data, "recovery_confirmed") else "blocked",
"Confirm recovery material exists without recording values.",
),
Gate(
"Custody packet",
"done" if yes(data, "custody_packet_prepared") else "blocked",
"Prepare the offline custody packet.",
),
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.",
),
Gate(
"Custody mode",
"done" if custody_mode in VALID_CUSTODY_MODES else "blocked",
"Approve temporary-single-king, two-of-three-planned, or two-of-three-ready.",
),
]
def king_kit_ready(data: dict[str, Any]) -> bool:
gates = kit_validation(data)
required = [gate for gate in gates if gate.name != "Custody mode"]
return all(gate.status == "done" for gate in required)
def custody_mode_approved(data: dict[str, Any]) -> bool:
return data.get("custody_mode") in CUSTODY_APPROVAL_MODES and yes(data, "custody_mode_approved")
def custody_mode_reason(data: dict[str, Any]) -> str:
mode = data.get("custody_mode")
if mode in CUSTODY_APPROVAL_MODES and yes(data, "custody_mode_approved"):
return "Approved for the next gate under the selected custody mode."
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 "Choose temporary-single-king or two-of-three-ready for live OpenBao custody."
def derive_stage(data: dict[str, Any]) -> str:
if yes(data, "platform_reopened"):
return "S5 - Reopen under custody"
if yes(data, "cleanup_complete"):
return "S4 - Cleanup and hardening"
if yes(data, "openbao_initialized"):
return "S3 - OpenBao bootstrap"
if yes(data, "king_credential_ready") or king_kit_ready(data):
return "S2 - King credential preparation"
return DEFAULT_STAGE
def build_gates(data: dict[str, Any]) -> list[Gate]:
return [
Gate(
"King credential kit",
"done" if yes(data, "king_credential_ready") or king_kit_ready(data) else "blocked",
"Dedicated king credential, second factor, and recovery storage.",
),
Gate(
"Custody mode",
"done" if custody_mode_approved(data) else "blocked",
custody_mode_reason(data),
),
Gate(
"OpenBao preflight",
"done" if yes(data, "openbao_preflight_passed") else "blocked",
"Run safe Railiance OpenBao status and verification checks.",
),
Gate(
"OpenBao init ceremony",
"human" if not yes(data, "openbao_initialized") else "done",
"Human-attended ceremony only. This console will not run init.",
),
Gate(
"Root-token disposition",
"done" if data.get("root_token_disposition") in {"revoked", "offline-sealed"} else "blocked",
"Root token is revoked or sealed offline without recording value.",
),
Gate(
"Restore drill",
"done" if yes(data, "restore_drill_passed") else "blocked",
"Snapshot and isolated restore proof before live secrets.",
),
Gate(
"Cleanup and rotation",
"done" if yes(data, "cleanup_complete") else "blocked",
"Bootstrap-era credentials, databases, and access paths reviewed.",
),
]
def next_action(gates: list[Gate]) -> str:
for gate in gates:
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 == "OpenBao preflight":
return "Run OpenBao preflight"
if gate.name == "Root-token disposition":
return "Record root-token disposition"
if gate.name == "Restore drill":
return "Run restore drill"
if gate.name == "Cleanup and rotation":
return "Complete handover cleanup"
return "Review related workplans"
def print_status(data: dict[str, Any]) -> None:
gates = build_gates(data)
print("SECURITY BOOTSTRAP")
print("")
print("Stage")
print(derive_stage(data))
print("")
print("Next safe action")
print(next_action(gates))
print("")
print("Gates")
for gate in gates:
print(f"- {gate.status}: {gate.name} - {gate.reason}")
print("")
print("Available actions")
print("1. king-kit")
print("2. custody-packet")
print("3. openbao-preflight")
print("4. handover-checklist")
print("5. metadata-template")
print("6. approve-custody-mode")
print("7. web-ui")
print("")
print("Refusal boundary")
print("This console will not run bao operator init or collect secret values.")
def print_king_kit() -> None:
print("KING CREDENTIAL KIT")
print("")
rows = [
"Name the credential, for example platform-root.",
"Choose storage: password safe, offline packet, hardware-backed, or a combination.",
"Add a second factor: TOTP, WebAuthn, or hardware token.",
"Prepare recovery material without recording values in software.",
"Select custody mode: temporary-single-king, two-of-three-planned, or two-of-three-ready.",
"Print or prepare the offline custody packet.",
"Record only non-secret metadata.",
]
for index, row in enumerate(rows, start=1):
print(f"{index}. {row}")
def print_validate_king_kit(data: dict[str, Any]) -> int:
print("KING CREDENTIAL KIT VALIDATION")
print("")
if not data:
print("No metadata loaded. Use --metadata with a non-secret JSON file.")
print("Run metadata-template for the expected shape.")
return 2
gates = kit_validation(data)
for gate in gates:
print(f"- {gate.status}: {gate.name} - {gate.reason}")
print("")
if king_kit_ready(data) and custody_mode_approved(data):
print("Kit definition and custody-mode approval are complete.")
print("Live OpenBao init remains a separate human-attended ceremony.")
return 0
if king_kit_ready(data):
print("Kit definition is complete except custody-mode approval.")
print("Live OpenBao init is still blocked until T03 approves custody mode.")
return 0
print("Kit definition is incomplete.")
return 1
def merged_approval_metadata(
existing: dict[str, Any],
payload: dict[str, Any],
) -> dict[str, Any]:
data = metadata_template()
data.update(existing)
text_fields = (
"credential_label",
"setup_operator",
"notification_contact",
"mfa_class",
"mfa_enrollment_source",
"mfa_enrollment_reference",
"custody_mode",
"notes",
)
for field in text_fields:
if field in payload and payload[field] is not None:
data[field] = str(payload[field]).strip()
if "storage_classes" in payload:
data["storage_classes"] = normalize_storage_classes(payload["storage_classes"])
for field in (
"recovery_confirmed",
"custody_packet_prepared",
"no_secret_capture_confirmed",
"mfa_enrolled_confirmed",
):
if field in payload:
data[field] = payload[field] is True
return data
def validate_custody_approval(
data: dict[str, Any],
approval_phrase: str,
) -> list[str]:
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.')
if mode not in VALID_CUSTODY_MODES:
errors.append("Select a custody mode.")
elif mode not in CUSTODY_APPROVAL_MODES:
errors.append(
"two-of-three-planned is a target state, not live-init 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":
continue
if gate.status != "done":
errors.append(f"{gate.name}: {gate.reason}")
return errors
def approve_custody_metadata(
existing: dict[str, Any],
payload: dict[str, Any],
approval_phrase: str,
approver: str,
) -> tuple[dict[str, Any], list[str]]:
data = merged_approval_metadata(existing, payload)
errors = validate_custody_approval(data, approval_phrase)
if errors:
return data, errors
data["king_credential_ready"] = True
data["custody_mode_approved"] = True
data["custody_approved_at"] = utc_now()
data["custody_approved_by"] = approver or data.get("setup_operator", "")
data["approval_scope"] = "Non-secret local custody-mode approval only. Does not run OpenBao init."
return data, []
def print_approve_custody_mode(args: argparse.Namespace, data: dict[str, Any]) -> int:
if args.metadata is None:
print("ERROR: approve-custody-mode requires --metadata /path/to/non-secret.json", file=sys.stderr)
return 2
approval_phrase = args.approval_phrase or ""
if args.yes:
approval_phrase = APPROVAL_PHRASE
elif not approval_phrase:
print("This writes non-secret custody approval metadata only.")
print("It will not run OpenBao init and will not store secret values.")
try:
approval_phrase = input(f'Type "{APPROVAL_PHRASE}" to continue: ')
except EOFError:
approval_phrase = ""
payload: dict[str, Any] = {
"custody_mode": args.mode,
}
for key in (
"credential_label",
"setup_operator",
"notification_contact",
"mfa_class",
"mfa_enrollment_source",
"mfa_enrollment_reference",
"notes",
):
value = getattr(args, key)
if value is not None:
payload[key] = value
if args.storage_class:
payload["storage_classes"] = args.storage_class
for field in (
"recovery_confirmed",
"custody_packet_prepared",
"no_secret_capture_confirmed",
"mfa_enrolled_confirmed",
):
if getattr(args, field):
payload[field] = True
approved, errors = approve_custody_metadata(
data,
payload,
approval_phrase,
args.approved_by or "",
)
if errors:
print("CUSTODY MODE NOT APPROVED")
print("")
for error in errors:
print(f"- {error}")
return 1
write_metadata(args.metadata, approved)
print("CUSTODY MODE APPROVED")
print("")
print(f"Metadata: {args.metadata}")
print(f"Mode: {approved['custody_mode']}")
print(f"Approved by: {approved.get('custody_approved_by', '')}")
print(f"Approved at: {approved.get('custody_approved_at', '')}")
print("")
print("OpenBao init remains a separate human-attended ceremony.")
return 0
def print_custody_packet() -> None:
print("CUSTODY PACKET TEMPLATE")
print("")
print("Credential label:")
print("Date:")
print("Setup operator/contact:")
print("Custody mode:")
print("Notification contact:")
print("")
print("Storage location description:")
print("Second-factor location description:")
print("Recovery material location description:")
print("")
print("OpenBao share assignment rows:")
print("- Share A:")
print("- Share B:")
print("- Share C:")
print("")
print("Root-token disposition:")
print("Signature/date:")
print("")
print("Do not write this packet into Git, State Hub, chat, tickets, or email.")
def print_handover_checklist() -> None:
print("HANDOVER CHECKLIST")
print("")
rows = [
"King credential kit complete.",
"OpenBao initialized and unsealed under approved custody mode.",
"Root token revoked or sealed offline.",
"Non-root platform admin path verified.",
"Bootstrap-era database credentials rotated.",
"Temporary admin accounts reviewed and removed or scoped.",
"Kubernetes service accounts and privileged bindings reviewed.",
"SOPS/age recipients and emergency bundle reviewed.",
"Backup snapshot exists.",
"Restore drill passed.",
"Audit handling known.",
"Remaining risk exceptions recorded with owner and date.",
]
for row in rows:
print(f"- {row}")
def metadata_template() -> dict[str, Any]:
return {
"credential_label": "platform-root",
"setup_operator": "tegwick",
"notification_contact": "bernd.worsch@gmail.com",
"storage_classes": ["password-safe", "offline-packet"],
"mfa_class": "totp",
"mfa_enrolled_confirmed": False,
"mfa_enrollment_source": "deferred",
"mfa_enrollment_reference": "",
"recovery_confirmed": False,
"custody_packet_prepared": False,
"no_secret_capture_confirmed": False,
"king_credential_ready": False,
"custody_mode": "",
"custody_mode_approved": False,
"custody_approved_at": "",
"custody_approved_by": "",
"approval_scope": "",
"openbao_preflight_passed": False,
"openbao_initialized": False,
"root_token_disposition": "",
"restore_drill_passed": False,
"cleanup_complete": False,
"platform_reopened": False,
"review_date": "",
"notes": "Non-secret metadata only.",
}
def print_openbao_preflight(args: argparse.Namespace) -> int:
print("OPENBAO PREFLIGHT")
print("")
print("Safe commands:")
print(f"make -C {args.railiance_path} openbao-status")
print(f"make -C {args.railiance_path} openbao-verify")
print("")
if not args.run:
print("Dry run only. Pass --run to execute safe preflight commands.")
return 0
railiance_path = Path(args.railiance_path).expanduser().resolve()
if not railiance_path.is_dir():
print(f"ERROR: Railiance path not found: {railiance_path}", file=sys.stderr)
return 2
for target in ("openbao-status", "openbao-verify"):
result = subprocess.run(
["make", "-C", str(railiance_path), target],
check=False,
)
if result.returncode != 0:
return result.returncode
return 0
def gate_payload(gate: Gate) -> dict[str, str]:
return {
"name": gate.name,
"status": gate.status,
"reason": gate.reason,
}
def status_payload(data: dict[str, Any], metadata_path: Path) -> dict[str, Any]:
gates = build_gates(data)
return {
"metadata_path": str(metadata_path),
"stage": derive_stage(data),
"next_action": next_action(gates),
"gates": [gate_payload(gate) for gate in gates],
"kit_gates": [gate_payload(gate) for gate in kit_validation(data)],
"metadata": data,
"approval_phrase": APPROVAL_PHRASE,
"custody_approval_modes": sorted(CUSTODY_APPROVAL_MODES),
}
def ui_html() -> str:
return """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NetKingdom Security Bootstrap</title>
<style>
:root {
--ink: #111111;
--muted: #555555;
--paper: #fffdf7;
--field: #ffffff;
--line: #1d1d1d;
--soft-line: #d8d3c7;
--background: #f5f2e9;
--hi: #ffe14a;
--ok: #dceee5;
--warn: #fff2b8;
--human: #e6ecf7;
--bad: #f4d6d0;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--background);
color: var(--ink);
font-family: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
letter-spacing: 0;
}
header {
border-bottom: 1px solid var(--line);
background: var(--paper);
padding: 22px 28px;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12px;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
margin: 8px 0 0;
font-size: 28px;
line-height: 1.15;
font-weight: 650;
}
main {
width: min(1180px, 100%);
margin: 0 auto;
padding: 24px;
}
.topline {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border: 1px solid var(--line);
background: var(--paper);
border-radius: 6px;
}
.metric {
min-width: 0;
padding: 16px;
border-right: 1px solid var(--soft-line);
}
.metric:last-child { border-right: 0; }
.label {
display: block;
color: var(--muted);
font-size: 13px;
margin-bottom: 6px;
}
.value {
display: block;
font-size: 17px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.72fr);
gap: 18px;
margin-top: 18px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--paper);
padding: 18px;
}
.panel + .panel { margin-top: 18px; }
h2 {
margin: 0 0 14px;
font-size: 18px;
line-height: 1.2;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.field { min-width: 0; }
input[type="text"], select {
width: 100%;
min-height: 42px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--field);
color: var(--ink);
font: inherit;
padding: 9px 10px;
}
input[type="checkbox"], input[type="radio"] {
accent-color: var(--ink);
width: 16px;
height: 16px;
margin: 0;
}
.choice-list {
display: grid;
gap: 8px;
}
.choice {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 10px;
align-items: start;
border: 1px solid var(--soft-line);
border-radius: 4px;
background: #ffffff;
padding: 10px;
}
.choice strong {
display: block;
font-weight: 650;
line-height: 1.2;
}
.choice span {
color: var(--muted);
display: block;
font-size: 13px;
line-height: 1.3;
margin-top: 3px;
}
.notice {
border-left: 6px solid var(--hi);
background: #fff9d8;
padding: 12px 14px;
line-height: 1.35;
margin: 0 0 16px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-top: 16px;
}
button {
min-height: 42px;
border: 1px solid var(--ink);
border-radius: 4px;
background: var(--ink);
color: #ffffff;
font: inherit;
font-weight: 650;
padding: 10px 14px;
cursor: pointer;
}
button.secondary {
background: var(--paper);
color: var(--ink);
}
button:disabled {
cursor: wait;
opacity: 0.65;
}
.gates {
border: 1px solid var(--soft-line);
border-radius: 4px;
overflow: hidden;
}
.gate {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 12px;
padding: 11px 12px;
border-top: 1px solid var(--soft-line);
background: #ffffff;
}
.gate:first-child { border-top: 0; }
.pill {
align-self: start;
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px 8px;
text-align: center;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12px;
line-height: 1.2;
text-transform: uppercase;
background: var(--warn);
}
.done .pill { background: var(--ok); }
.human .pill { background: var(--human); }
.blocked .pill { background: var(--bad); }
.gate-title {
font-weight: 650;
line-height: 1.2;
}
.gate-reason {
color: var(--muted);
font-size: 13px;
line-height: 1.35;
margin-top: 3px;
}
.message {
margin-top: 12px;
border: 1px solid var(--line);
border-radius: 4px;
background: #ffffff;
padding: 12px;
min-height: 46px;
white-space: pre-line;
}
.message.ok { background: var(--ok); }
.message.error { background: var(--bad); }
code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 13px;
}
@media (max-width: 820px) {
header { padding: 18px; }
main { padding: 16px; }
.topline, .layout, .grid { grid-template-columns: 1fr; }
.metric {
border-right: 0;
border-bottom: 1px solid var(--soft-line);
}
.metric:last-child { border-bottom: 0; }
}
</style>
</head>
<body>
<header>
<div class="eyebrow">NetKingdom control surface</div>
<h1>Security bootstrap custody approval</h1>
</header>
<main>
<section class="topline" aria-label="Bootstrap status">
<div class="metric">
<span class="label">Stage</span>
<span class="value" id="stage">Loading</span>
</div>
<div class="metric">
<span class="label">Next safe action</span>
<span class="value" id="next-action">Loading</span>
</div>
<div class="metric">
<span class="label">Metadata</span>
<span class="value"><code id="metadata-path">Loading</code></span>
</div>
</section>
<div class="layout">
<form id="approval-form">
<section class="panel">
<h2>King credential</h2>
<p class="notice">Local non-secret metadata only. OpenBao initialization stays manual.</p>
<div class="grid">
<label class="field">
<span class="label">Credential label</span>
<input id="credential_label" type="text" autocomplete="off">
</label>
<label class="field">
<span class="label">Setup operator</span>
<input id="setup_operator" type="text" autocomplete="off">
</label>
<label class="field">
<span class="label">Notification contact</span>
<input id="notification_contact" type="text" autocomplete="off">
</label>
<label class="field">
<span class="label">Second factor</span>
<select id="mfa_class">
<option value="">Select</option>
<option value="totp">TOTP</option>
<option value="webauthn">WebAuthn</option>
<option value="hardware-token">Hardware token</option>
</select>
</label>
</div>
</section>
<section class="panel">
<h2>Second factor enrollment</h2>
<p class="notice">The QR code or setup key belongs to the authority that verifies login. This UI records confirmation only.</p>
<div class="grid">
<label class="field">
<span class="label">Enrollment source</span>
<select id="mfa_enrollment_source">
<option value="deferred">Deferred</option>
<option value="identity-provider">Identity provider</option>
<option value="external-verifier">External verifier</option>
<option value="hardware-registration">Hardware registration</option>
</select>
</label>
<label class="field">
<span class="label">Reference</span>
<input id="mfa_enrollment_reference" type="text" autocomplete="off" placeholder="provider or vault entry label">
</label>
</div>
<div class="choice-list" style="margin-top: 14px;">
<label class="choice"><input id="mfa_enrolled_confirmed" type="checkbox"><span><strong>Factor enrolled</strong><span>The real verifier produced or accepted the factor. No seed is recorded here.</span></span></label>
</div>
</section>
<section class="panel">
<h2>Storage and recovery</h2>
<div class="choice-list">
<label class="choice"><input name="storage_classes" value="password-safe" type="checkbox"><span><strong>Password safe</strong><span>Credential held in a dedicated vault entry.</span></span></label>
<label class="choice"><input name="storage_classes" value="offline-packet" type="checkbox"><span><strong>Offline packet</strong><span>Recovery material exists outside live systems.</span></span></label>
<label class="choice"><input name="storage_classes" value="hardware-token" type="checkbox"><span><strong>Hardware token</strong><span>Custody includes hardware-backed access.</span></span></label>
<label class="choice"><input id="recovery_confirmed" type="checkbox"><span><strong>Recovery material confirmed</strong><span>No values recorded here.</span></span></label>
<label class="choice"><input id="custody_packet_prepared" type="checkbox"><span><strong>Custody packet prepared</strong><span>Offline packet is ready for the ceremony.</span></span></label>
<label class="choice"><input id="no_secret_capture_confirmed" type="checkbox"><span><strong>No secret capture</strong><span>No secrets in Git, State Hub, chat, tickets, email, or screenshots.</span></span></label>
</div>
</section>
<section class="panel">
<h2>Custody mode</h2>
<div class="choice-list">
<label class="choice"><input name="custody_mode" value="temporary-single-king" type="radio"><span><strong>Temporary single king</strong><span>Recommended while Railiance is still pre-production.</span></span></label>
<label class="choice"><input name="custody_mode" value="two-of-three-ready" type="radio"><span><strong>Two of three ready</strong><span>Use when independent shares already exist.</span></span></label>
<label class="choice"><input name="custody_mode" value="two-of-three-planned" type="radio"><span><strong>Two of three planned</strong><span>Records intent but does not approve live init.</span></span></label>
</div>
<div class="field" style="margin-top: 14px;">
<span class="label">Approval phrase</span>
<input id="approval_phrase" type="text" autocomplete="off" placeholder="approve custody mode">
</div>
<div class="actions">
<button id="approve-button" type="submit">Approve custody mode</button>
<button class="secondary" id="refresh-button" type="button">Refresh</button>
</div>
<div id="message" class="message" role="status">Waiting for local approval.</div>
</section>
</form>
<aside>
<section class="panel">
<h2>Bootstrap gates</h2>
<div id="gates" class="gates"></div>
</section>
<section class="panel">
<h2>Kit gates</h2>
<div id="kit-gates" class="gates"></div>
</section>
</aside>
</div>
</main>
<script>
const fields = [
"credential_label",
"setup_operator",
"notification_contact",
"mfa_class",
"mfa_enrollment_source",
"mfa_enrollment_reference",
"mfa_enrolled_confirmed",
"recovery_confirmed",
"custody_packet_prepared",
"no_secret_capture_confirmed"
];
function setMessage(text, kind) {
const element = document.getElementById("message");
element.textContent = text;
element.className = "message" + (kind ? " " + kind : "");
}
function renderGates(target, gates) {
const root = document.getElementById(target);
root.replaceChildren();
for (const gate of gates) {
const row = document.createElement("div");
row.className = "gate " + gate.status;
const status = document.createElement("div");
status.className = "pill";
status.textContent = gate.status;
const body = document.createElement("div");
const title = document.createElement("div");
title.className = "gate-title";
title.textContent = gate.name;
const reason = document.createElement("div");
reason.className = "gate-reason";
reason.textContent = gate.reason;
body.append(title, reason);
row.append(status, body);
root.append(row);
}
}
function fillForm(metadata) {
for (const id of fields) {
const element = document.getElementById(id);
if (!element) continue;
if (element.type === "checkbox") {
element.checked = metadata[id] === true;
} else {
element.value = metadata[id] || "";
}
}
const storage = metadata.storage_classes || [];
document.querySelectorAll("[name='storage_classes']").forEach((input) => {
input.checked = storage.includes(input.value);
});
const mode = metadata.custody_mode || "temporary-single-king";
const selected = document.querySelector(`[name='custody_mode'][value='${mode}']`);
if (selected) selected.checked = true;
}
async function loadStatus() {
const response = await fetch("/api/status");
const data = await response.json();
document.getElementById("stage").textContent = data.stage;
document.getElementById("next-action").textContent = data.next_action;
document.getElementById("metadata-path").textContent = data.metadata_path;
renderGates("gates", data.gates);
renderGates("kit-gates", data.kit_gates);
fillForm(data.metadata || {});
}
function approvalPayload() {
const storage = Array.from(document.querySelectorAll("[name='storage_classes']:checked"))
.map((input) => input.value);
const mode = document.querySelector("[name='custody_mode']:checked");
return {
credential_label: document.getElementById("credential_label").value.trim(),
setup_operator: document.getElementById("setup_operator").value.trim(),
notification_contact: document.getElementById("notification_contact").value.trim(),
mfa_class: document.getElementById("mfa_class").value,
mfa_enrollment_source: document.getElementById("mfa_enrollment_source").value,
mfa_enrollment_reference: document.getElementById("mfa_enrollment_reference").value.trim(),
mfa_enrolled_confirmed: document.getElementById("mfa_enrolled_confirmed").checked,
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("approval-form").addEventListener("submit", async (event) => {
event.preventDefault();
const button = document.getElementById("approve-button");
button.disabled = true;
try {
const response = await fetch("/api/approve-custody", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(approvalPayload())
});
const result = await response.json();
if (!response.ok) {
setMessage("Not approved:\\n" + (result.errors || []).map((item) => "- " + item).join("\\n"), "error");
renderGates("kit-gates", result.kit_gates || []);
return;
}
document.getElementById("approval_phrase").value = "";
await loadStatus();
setMessage("Custody mode approved. OpenBao init remains a separate human ceremony.", "ok");
} catch (error) {
setMessage("Request failed: " + error.message, "error");
} finally {
button.disabled = false;
}
});
document.getElementById("refresh-button").addEventListener("click", () => {
loadStatus().then(() => setMessage("Status refreshed.", ""));
});
loadStatus().catch((error) => setMessage("Load failed: " + error.message, "error"));
</script>
</body>
</html>
"""
def make_ui_handler(metadata_path: Path) -> type[BaseHTTPRequestHandler]:
class SecurityBootstrapUIHandler(BaseHTTPRequestHandler):
server_version = "SecurityBootstrapUI/1.0"
def log_message(self, format: str, *args: Any) -> None:
print(f"{self.address_string()} - {format % args}")
def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
body = json.dumps(payload, indent=2).encode("utf-8")
self.send_response(status.value)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
if self.path == "/" or self.path == "/index.html":
body = ui_html().encode("utf-8")
self.send_response(HTTPStatus.OK.value)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if self.path == "/api/status":
data = load_metadata(metadata_path)
if not data:
data = metadata_template()
self.send_json(HTTPStatus.OK, status_payload(data, metadata_path))
return
self.send_error(HTTPStatus.NOT_FOUND.value)
def do_POST(self) -> None:
if self.path != "/api/approve-custody":
self.send_error(HTTPStatus.NOT_FOUND.value)
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self.send_json(HTTPStatus.BAD_REQUEST, {"errors": ["Invalid content length."]})
return
if length > 65536:
self.send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"errors": ["Request too large."]})
return
try:
payload = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError as exc:
self.send_json(HTTPStatus.BAD_REQUEST, {"errors": [f"Invalid JSON: {exc}"]})
return
if not isinstance(payload, dict):
self.send_json(HTTPStatus.BAD_REQUEST, {"errors": ["JSON body must be an object."]})
return
existing = load_metadata(metadata_path)
approval_phrase = str(payload.get("approval_phrase", ""))
approved_by = str(payload.get("approved_by", ""))
approved, errors = approve_custody_metadata(existing, payload, approval_phrase, approved_by)
if errors:
response = status_payload(approved, metadata_path)
response["errors"] = errors
self.send_json(HTTPStatus.BAD_REQUEST, response)
return
write_metadata(metadata_path, approved)
response = status_payload(approved, metadata_path)
response["message"] = "Custody mode approved."
self.send_json(HTTPStatus.OK, response)
return SecurityBootstrapUIHandler
def serve_web_ui(args: argparse.Namespace) -> int:
metadata_path = args.metadata or DEFAULT_METADATA_PATH
handler = make_ui_handler(metadata_path)
httpd = ThreadingHTTPServer((args.host, args.port), handler)
host = html.escape(args.host)
print("SECURITY BOOTSTRAP UI")
print("")
print(f"URL: http://{host}:{args.port}")
print(f"Metadata: {metadata_path}")
print("")
print("Local non-secret custody approval only. Press Ctrl+C to stop.")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("")
print("Stopped.")
finally:
httpd.server_close()
return 0
def refuse_live_init() -> int:
print("REFUSED")
print("")
print("This console does not run bao operator init.")
print("Use the human-attended Railiance OpenBao ceremony after all gates pass.")
return 2
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Non-secret NetKingdom security bootstrap console.",
)
parser.add_argument(
"--metadata",
type=Path,
help="Optional non-secret metadata JSON file.",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("status", help="Show trust stage, gates, and next safe action.")
sub.add_parser("king-kit", help="Print king credential kit checklist.")
sub.add_parser("validate-king-kit", help="Validate non-secret king credential metadata.")
approve = sub.add_parser("approve-custody-mode", help="Approve a live-init-ready custody mode.")
approve.add_argument(
"--mode",
choices=sorted(VALID_CUSTODY_MODES),
default="temporary-single-king",
help="Custody mode to record. two-of-three-planned cannot approve live init.",
)
approve.add_argument("--credential-label", dest="credential_label")
approve.add_argument("--setup-operator", dest="setup_operator")
approve.add_argument("--notification-contact", dest="notification_contact")
approve.add_argument("--storage-class", dest="storage_class", action="append")
approve.add_argument("--mfa-class", dest="mfa_class", choices=sorted(VALID_MFA_CLASSES))
approve.add_argument("--mfa-enrolled-confirmed", action="store_true")
approve.add_argument(
"--mfa-enrollment-source",
choices=sorted(VALID_MFA_ENROLLMENT_SOURCES),
default=None,
)
approve.add_argument("--mfa-enrollment-reference")
approve.add_argument("--recovery-confirmed", action="store_true")
approve.add_argument("--custody-packet-prepared", action="store_true")
approve.add_argument("--no-secret-capture-confirmed", action="store_true")
approve.add_argument("--approval-phrase", default="")
approve.add_argument("--approved-by", default="")
approve.add_argument("--notes")
approve.add_argument(
"--yes",
action="store_true",
help=f'Use the required approval phrase "{APPROVAL_PHRASE}" non-interactively.',
)
sub.add_parser("custody-packet", help="Print blank offline custody packet template.")
sub.add_parser("handover-checklist", help="Print handover and cleanup checklist.")
sub.add_parser("metadata-template", help="Print non-secret metadata JSON template.")
sub.add_parser("refuse-live-init", help="Explain why live OpenBao init is refused.")
web = sub.add_parser("web-ui", help="Serve a local custody approval UI.")
web.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to localhost.")
web.add_argument("--port", type=int, default=8765, help="Bind port. Defaults to 8765.")
preflight = sub.add_parser("openbao-preflight", help="Show or run safe OpenBao preflight.")
preflight.add_argument(
"--railiance-path",
default="../railiance-platform",
help="Path to railiance-platform repo.",
)
preflight.add_argument(
"--run",
action="store_true",
help="Run safe preflight targets. Does not run OpenBao init.",
)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
data = load_metadata(args.metadata)
if args.command == "status":
print_status(data)
return 0
if args.command == "king-kit":
print_king_kit()
return 0
if args.command == "validate-king-kit":
return print_validate_king_kit(data)
if args.command == "approve-custody-mode":
return print_approve_custody_mode(args, data)
if args.command == "custody-packet":
print_custody_packet()
return 0
if args.command == "handover-checklist":
print_handover_checklist()
return 0
if args.command == "metadata-template":
print(json.dumps(metadata_template(), indent=2))
return 0
if args.command == "openbao-preflight":
return print_openbao_preflight(args)
if args.command == "web-ui":
return serve_web_ui(args)
if args.command == "refuse-live-init":
return refuse_live_init()
parser.error(f"unknown command: {args.command}")
return 2
if __name__ == "__main__":
raise SystemExit(main())