bootstrapping guidance ui and missing stuff

This commit is contained in:
2026-05-24 17:04:15 +02:00
parent 1d0b0e7330
commit d555a33695
10 changed files with 913 additions and 36 deletions

View File

@@ -9,10 +9,15 @@ live OpenBao initialization.
from __future__ import annotations
import argparse
import base64
import hashlib
import html
import json
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from http import HTTPStatus
@@ -34,6 +39,12 @@ VALID_MFA_ENROLLMENT_SOURCES = {
}
VALID_CUSTODY_MODES = {"temporary-single-king", "two-of-three-planned", "two-of-three-ready"}
CUSTODY_APPROVAL_MODES = {"temporary-single-king", "two-of-three-ready"}
KEYCAPE_ISSUER = "https://kc.coulomb.social"
OIDC_CLIENT_ID = "netkingdom-bootstrap-console"
OIDC_SCOPE = "openid profile email groups"
OIDC_CODE_VERIFIER = "netkingdom-bootstrap-local-oidc-verifier-2026-v1"
AGE_PUBLIC_PREFIX = "age1"
AGE_PRIVATE_MARKER = "AGE-SECRET-KEY-1"
@dataclass(frozen=True)
@@ -101,6 +112,101 @@ def second_factor_reason(data: dict[str, Any]) -> str:
return "Second factor enrollment is confirmed without recording seed material."
def identity_account_ready(data: dict[str, Any]) -> bool:
return (
yes(data, "identity_account_created")
and bool(data.get("identity_account_reference"))
and yes(data, "identity_group_confirmed")
and bool(data.get("identity_group_reference"))
)
def identity_account_reason(data: dict[str, Any]) -> str:
if not yes(data, "identity_account_created"):
return "Create the dedicated platform-root account in LLDAP first."
if not data.get("identity_account_reference"):
return "Record a non-secret account reference such as platform-root@lldap."
if not yes(data, "identity_group_confirmed"):
return "Confirm the account is assigned to the required LLDAP admin group."
if not data.get("identity_group_reference"):
return "Record the non-secret group reference such as net-kingdom-admins."
return "Dedicated identity account is recorded without storing its password."
def password_safe_ready(data: dict[str, Any]) -> bool:
return yes(data, "password_safe_confirmed")
def identity_login_ready(data: dict[str, Any]) -> bool:
return yes(data, "oidc_login_verified")
def extract_age_public_key(value: Any) -> str:
if value is None:
return ""
text = str(value).strip()
if AGE_PRIVATE_MARKER in text:
return ""
for token in text.replace(",", " ").split():
clean = token.strip().strip('"').strip("'")
if clean.startswith(AGE_PUBLIC_PREFIX):
return clean
return ""
def age_public_key_fingerprint(public_key: str) -> str:
if not public_key:
return ""
digest = hashlib.sha256(public_key.encode("utf-8")).hexdigest()
return f"sha256:{digest[:16]}"
def bootstrap_secret_state() -> dict[str, Any]:
root = Path.cwd()
bootstrap_dir = root / "sso-mfa" / "bootstrap"
encrypted_dir = bootstrap_dir / "secrets.enc"
plaintext_dir = bootstrap_dir / "secrets"
encrypted_files = sorted(encrypted_dir.glob("*/*.age")) if encrypted_dir.exists() else []
plaintext_files = sorted(path for path in plaintext_dir.glob("*/*") if path.is_file()) if plaintext_dir.exists() else []
return {
"bootstrap_dir": str(bootstrap_dir),
"encrypted_bundle_path": str(encrypted_dir),
"encrypted_bundle_exists": encrypted_dir.exists(),
"encrypted_file_count": len(encrypted_files),
"plaintext_secrets_path": str(plaintext_dir),
"plaintext_secrets_present": plaintext_dir.exists(),
"plaintext_file_count": len(plaintext_files),
}
def key_custody_validation(data: dict[str, Any]) -> list[Gate]:
public_key = extract_age_public_key(data.get("custodian_age_public_key"))
state = bootstrap_secret_state()
plaintext_present = bool(state["plaintext_secrets_present"])
return [
Gate(
"Custodian public key",
"done" if public_key and yes(data, "custodian_age_public_key_confirmed") else "blocked",
"Register the custodian age public key used to encrypt bootstrap bundles.",
),
Gate(
"Private key custody",
"done" if data.get("custodian_age_private_key_reference") and yes(data, "custodian_age_private_key_confirmed") else "blocked",
"Record only where the private key is held; never paste it into this UI.",
),
Gate(
"Encrypted bundle",
"done" if state["encrypted_bundle_exists"] and state["encrypted_file_count"] else "blocked",
"Encrypted bootstrap secrets should live under sso-mfa/bootstrap/secrets.enc/.",
),
Gate(
"Plaintext exposure",
"blocked" if plaintext_present else "done",
"Plaintext sso-mfa/bootstrap/secrets/ is present; shred it after any apply ceremony." if plaintext_present else "No plaintext bootstrap secrets directory is present.",
),
]
def kit_validation(data: dict[str, Any]) -> list[Gate]:
storage_classes = data.get("storage_classes", [])
if not isinstance(storage_classes, list):
@@ -113,6 +219,11 @@ def kit_validation(data: dict[str, Any]) -> list[Gate]:
"done" if data.get("credential_label") else "blocked",
"Use a dedicated label such as platform-root.",
),
Gate(
"Identity account",
"done" if identity_account_ready(data) else "blocked",
identity_account_reason(data),
),
Gate(
"Setup operator/contact",
"done" if data.get("setup_operator") and data.get("notification_contact") else "blocked",
@@ -123,11 +234,21 @@ def kit_validation(data: dict[str, Any]) -> list[Gate]:
"done" if storage_values & VALID_STORAGE_CLASSES else "blocked",
"Select password-safe, offline-packet, hardware-token, or a combination.",
),
Gate(
"Password safe storage",
"done" if password_safe_ready(data) else "blocked",
"Confirm the credential password is stored in the password safe without recording it here.",
),
Gate(
"Second factor",
"done" if second_factor_ready(data) else "blocked",
second_factor_reason(data),
),
Gate(
"Identity login path",
"done" if identity_login_ready(data) else "blocked",
"Verify the dedicated account can complete the NetKingdom login path.",
),
Gate(
"Recovery material",
"done" if yes(data, "recovery_confirmed") else "blocked",
@@ -243,15 +364,27 @@ def next_action(gates: list[Gate]) -> str:
def print_status(data: dict[str, Any]) -> None:
gates = build_gates(data)
merged = metadata_template()
merged.update(data)
gates = build_gates(merged)
key_gates = key_custody_validation(merged)
state = bootstrap_secret_state()
print("SECURITY BOOTSTRAP")
print("")
print("Stage")
print(derive_stage(data))
print(derive_stage(merged))
print("")
print("Next safe action")
print(next_action(gates))
print("")
print("Key custody")
public_key = extract_age_public_key(merged.get("custodian_age_public_key"))
print(f"- fingerprint: {age_public_key_fingerprint(public_key) or 'not registered'}")
print(f"- encrypted bundle files: {state['encrypted_file_count']} at {state['encrypted_bundle_path']}")
print(f"- plaintext secrets present: {state['plaintext_secrets_present']}")
for gate in key_gates:
print(f"- {gate.status}: {gate.name} - {gate.reason}")
print("")
print("Gates")
for gate in gates:
print(f"- {gate.status}: {gate.name} - {gate.reason}")
@@ -316,6 +449,11 @@ def merged_approval_metadata(
data.update(existing)
text_fields = (
"credential_label",
"bootstrap_mode",
"identity_account_home",
"identity_account_reference",
"identity_group_reference",
"custodian_age_private_key_reference",
"setup_operator",
"notification_contact",
"mfa_class",
@@ -326,20 +464,36 @@ def merged_approval_metadata(
)
for field in text_fields:
if field in payload and payload[field] is not None:
data[field] = str(payload[field]).strip()
value = str(payload[field]).strip()
data[field] = "" if AGE_PRIVATE_MARKER in value else value
if "custodian_age_public_key" in payload:
data["custodian_age_public_key"] = extract_age_public_key(payload["custodian_age_public_key"])
if "storage_classes" in payload:
data["storage_classes"] = normalize_storage_classes(payload["storage_classes"])
for field in (
"custodian_age_public_key_confirmed",
"custodian_age_private_key_confirmed",
"recovery_confirmed",
"custody_packet_prepared",
"no_secret_capture_confirmed",
"mfa_enrolled_confirmed",
"identity_account_created",
"identity_group_confirmed",
"oidc_login_verified",
"password_safe_confirmed",
):
if field in payload:
data[field] = payload[field] is True
return data
def save_progress_metadata(existing: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
data = merged_approval_metadata(existing, payload)
data["metadata_updated_at"] = utc_now()
data["progress_scope"] = "Non-secret local bootstrap progress only."
return data
def validate_custody_approval(
data: dict[str, Any],
approval_phrase: str,
@@ -400,6 +554,9 @@ def print_approve_custody_mode(args: argparse.Namespace, data: dict[str, Any]) -
}
for key in (
"credential_label",
"identity_account_home",
"identity_account_reference",
"identity_group_reference",
"setup_operator",
"notification_contact",
"mfa_class",
@@ -417,6 +574,10 @@ def print_approve_custody_mode(args: argparse.Namespace, data: dict[str, Any]) -
"custody_packet_prepared",
"no_secret_capture_confirmed",
"mfa_enrolled_confirmed",
"identity_account_created",
"identity_group_confirmed",
"oidc_login_verified",
"password_safe_confirmed",
):
if getattr(args, field):
payload[field] = True
@@ -491,10 +652,21 @@ def print_handover_checklist() -> None:
def metadata_template() -> dict[str, Any]:
return {
"bootstrap_mode": "custody",
"custodian_age_public_key": "",
"custodian_age_public_key_confirmed": False,
"custodian_age_private_key_reference": "",
"custodian_age_private_key_confirmed": False,
"credential_label": "platform-root",
"identity_account_home": "lldap",
"identity_account_reference": "",
"identity_account_created": False,
"identity_group_reference": "net-kingdom-admins",
"identity_group_confirmed": False,
"setup_operator": "tegwick",
"notification_contact": "bernd.worsch@gmail.com",
"storage_classes": ["password-safe", "offline-packet"],
"password_safe_confirmed": False,
"mfa_class": "totp",
"mfa_enrolled_confirmed": False,
"mfa_enrollment_source": "deferred",
@@ -508,6 +680,9 @@ def metadata_template() -> dict[str, Any]:
"custody_approved_at": "",
"custody_approved_by": "",
"approval_scope": "",
"oidc_login_verified": False,
"metadata_updated_at": "",
"progress_scope": "",
"openbao_preflight_passed": False,
"openbao_initialized": False,
"root_token_disposition": "",
@@ -553,19 +728,187 @@ def gate_payload(gate: Gate) -> dict[str, str]:
def status_payload(data: dict[str, Any], metadata_path: Path) -> dict[str, Any]:
gates = build_gates(data)
merged = metadata_template()
merged.update(data)
gates = build_gates(merged)
metadata_view = dict(merged)
public_key = extract_age_public_key(metadata_view.get("custodian_age_public_key"))
metadata_view["custodian_age_public_key"] = public_key
metadata_view["custodian_age_public_key_fingerprint"] = age_public_key_fingerprint(public_key)
return {
"metadata_path": str(metadata_path),
"stage": derive_stage(data),
"stage": derive_stage(merged),
"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,
"key_custody_gates": [gate_payload(gate) for gate in key_custody_validation(merged)],
"kit_gates": [gate_payload(gate) for gate in kit_validation(merged)],
"bootstrap_secret_state": bootstrap_secret_state(),
"metadata": metadata_view,
"approval_phrase": APPROVAL_PHRASE,
"custody_approval_modes": sorted(CUSTODY_APPROVAL_MODES),
}
def oidc_code_challenge() -> str:
digest = hashlib.sha256(OIDC_CODE_VERIFIER.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def local_oidc_redirect_uri(host: str) -> str:
clean_host = host.strip() or "127.0.0.1:8876"
return f"http://{clean_host}/oidc/callback"
def local_oidc_start_url(host: str) -> str:
params = {
"response_type": "code",
"client_id": OIDC_CLIENT_ID,
"redirect_uri": local_oidc_redirect_uri(host),
"scope": OIDC_SCOPE,
"state": "netkingdom-bootstrap-login-check",
"code_challenge": oidc_code_challenge(),
"code_challenge_method": "S256",
}
return f"{KEYCAPE_ISSUER}/authorize?{urllib.parse.urlencode(params)}"
def decode_jwt_payload(token: str) -> dict[str, Any]:
parts = token.split(".")
if len(parts) < 2:
return {}
payload = parts[1]
payload += "=" * (-len(payload) % 4)
try:
decoded = base64.urlsafe_b64decode(payload.encode("ascii"))
claims = json.loads(decoded)
except (ValueError, json.JSONDecodeError):
return {}
return claims if isinstance(claims, dict) else {}
def exchange_oidc_code(code: str, host: str) -> dict[str, Any]:
form = urllib.parse.urlencode(
{
"grant_type": "authorization_code",
"client_id": OIDC_CLIENT_ID,
"code": code,
"code_verifier": OIDC_CODE_VERIFIER,
"redirect_uri": local_oidc_redirect_uri(host),
}
).encode("utf-8")
request = urllib.request.Request(
f"{KEYCAPE_ISSUER}/token",
data=form,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(request, timeout=10) as response:
payload = json.loads(response.read().decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("token endpoint returned a non-object JSON payload")
return payload
def oidc_result_html(query: str, host: str) -> str:
params = urllib.parse.parse_qs(query)
error = params.get("error", [""])[0]
description = params.get("error_description", [""])[0]
code = params.get("code", [""])[0]
state = params.get("state", [""])[0]
title = "OIDC Login Check"
status = "Waiting for callback result."
rows: list[tuple[str, str]] = []
note = (
"No tokens or OTP values are stored by this local page. If token exchange "
"succeeds, only non-secret claims are shown."
)
if error:
status = "Login did not complete."
rows.append(("Error", error))
if description:
rows.append(("Description", description))
elif not code:
status = "No authorization code was returned."
note = (
"Start the check from the bootstrap console. If the browser never "
"returns here, KeyCape may still need its public Authelia redirect "
"configuration or a browser OTP prompt."
)
else:
try:
token_payload = exchange_oidc_code(code, host)
claims = decode_jwt_payload(str(token_payload.get("access_token", "")))
status = "OIDC login path completed."
rows.extend(
[
("State", state or "(none)"),
("Issuer", str(claims.get("iss", ""))),
("Audience", str(claims.get("aud", ""))),
("Subject", str(claims.get("sub", ""))),
("Username", str(claims.get("preferred_username", ""))),
("Email", str(claims.get("email", ""))),
("Groups", json.dumps(claims.get("groups", []))),
]
)
note = (
"Return to the bootstrap console, check OIDC login verified for "
"the same account, and save progress."
)
except urllib.error.HTTPError as exc:
body = exc.read(1000).decode("utf-8", "replace")
status = "Authorization returned, but token exchange failed."
rows.extend(
[
("HTTP status", str(exc.code)),
("Endpoint", f"{KEYCAPE_ISSUER}/token"),
("Response", body),
]
)
note = (
"This usually means the live KeyCape config has not yet registered "
"this local callback URI, the code expired, or the OTP browser "
"prompt path is still incomplete."
)
except Exception as exc: # noqa: BLE001 - local diagnostic page
status = "Authorization returned, but token exchange could not run."
rows.append(("Error", str(exc)))
table_rows = "\n".join(
f"<tr><th>{html.escape(label)}</th><td>{html.escape(value)}</td></tr>"
for label, value in rows
)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<style>
body {{ margin: 0; background: #f5f2e9; color: #111; font-family: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif; }}
main {{ width: min(760px, 100%); margin: 0 auto; padding: 28px; }}
section {{ border: 1px solid #111; border-radius: 6px; background: #fffdf7; padding: 20px; }}
h1 {{ margin: 0 0 12px; font-size: 24px; }}
p {{ line-height: 1.4; }}
table {{ width: 100%; border-collapse: collapse; margin: 16px 0; background: #fff; }}
th, td {{ border: 1px solid #d8d3c7; padding: 9px; text-align: left; vertical-align: top; overflow-wrap: anywhere; }}
th {{ width: 150px; }}
a {{ display: inline-flex; min-height: 38px; align-items: center; justify-content: center; border: 1px solid #111; border-radius: 4px; background: #111; color: #fff; padding: 8px 12px; text-decoration: none; font-weight: 650; }}
</style>
</head>
<body>
<main>
<section>
<h1>{html.escape(status)}</h1>
<p>{html.escape(note)}</p>
<table>{table_rows}</table>
<a href="/" title="Return to the local NetKingdom bootstrap console.">Return to bootstrap console</a>
</section>
</main>
</body>
</html>"""
def ui_html() -> str:
return """<!doctype html>
<html lang="en">
@@ -703,6 +1046,17 @@ def ui_html() -> str:
font-weight: 650;
line-height: 1.2;
}
.step-number {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border: 1px solid var(--line);
border-radius: 999px;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12px;
background: var(--hi);
}
.choice span {
color: var(--muted);
display: block;
@@ -724,6 +1078,12 @@ def ui_html() -> str:
align-items: center;
margin-top: 16px;
}
.inline-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
button {
min-height: 42px;
border: 1px solid var(--ink);
@@ -739,6 +1099,23 @@ def ui_html() -> str:
background: var(--paper);
color: var(--ink);
}
.button-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
border: 1px solid var(--ink);
border-radius: 4px;
background: var(--ink);
color: #ffffff;
font-weight: 650;
padding: 7px 11px;
text-decoration: none;
}
.button-link.secondary {
background: var(--paper);
color: var(--ink);
}
button:disabled {
cursor: wait;
opacity: 0.65;
@@ -797,6 +1174,11 @@ def ui_html() -> str:
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 13px;
}
a {
color: var(--ink);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
@media (max-width: 820px) {
header { padding: 18px; }
main { padding: 16px; }
@@ -832,6 +1214,73 @@ def ui_html() -> str:
<div class="layout">
<form id="approval-form">
<section class="panel">
<h2>Bootstrap key custody</h2>
<p class="notice">The custodian age public key encrypts bootstrap bundles. The private key is ceremony material and must not be pasted into this UI.</p>
<div class="choice-list">
<div class="choice"><span class="step-number">1</span><span><strong>Register public recipient</strong><span>Paste only the custodian public age recipient, for example <code>age1...</code>. This value is safe to store and lets tools encrypt new bootstrap bundles.</span></span></div>
<div class="choice"><span class="step-number">2</span><span><strong>Record private-key custody</strong><span>Record a non-secret reference such as <code>KeePassXC: custodian/age/private</code> or <code>offline USB label</code>. The actual private key is provided only during an unlock/apply ceremony.</span></span></div>
<div class="choice"><span class="step-number">3</span><span><strong>Use trial before custody</strong><span>Trial mode may use throwaway values to document the process. Custody mode encrypts real generated secrets immediately and shreds plaintext after apply.</span></span></div>
</div>
<div class="grid" style="margin-top: 14px;">
<label class="field">
<span class="label">Mode</span>
<select id="bootstrap_mode">
<option value="trial">Trial</option>
<option value="custody">Custody</option>
</select>
</label>
<label class="field">
<span class="label">Public key fingerprint</span>
<input id="custodian_age_public_key_fingerprint" type="text" readonly title="Derived from the public age recipient; useful for comparing with your password safe entry.">
</label>
<label class="field">
<span class="label">Custodian public age key</span>
<input id="custodian_age_public_key" type="text" autocomplete="off" placeholder="age1..." title="Public recipient only. Never paste AGE-SECRET-KEY material here.">
</label>
<label class="field">
<span class="label">Private key custody reference</span>
<input id="custodian_age_private_key_reference" type="text" autocomplete="off" placeholder="KeePassXC: custodian/age/private" title="Non-secret pointer to where the private key is held; not the key itself.">
</label>
</div>
<div class="choice-list" style="margin-top: 14px;">
<label class="choice"><input id="custodian_age_public_key_confirmed" type="checkbox"><span><strong>Public key confirmed</strong><span>The public recipient matches the custodian key material you intend to use.</span></span></label>
<label class="choice"><input id="custodian_age_private_key_confirmed" type="checkbox"><span><strong>Private key location confirmed</strong><span>You know where the private key is stored and can unlock it intentionally later.</span></span></label>
</div>
</section>
<section class="panel">
<h2>Credential home</h2>
<p class="notice">Guide mode. OpenBao stores and audits secrets after the ceremony; it does not create the king account.</p>
<div class="choice-list">
<div class="choice"><span class="step-number">1</span><span><strong>Open LLDAP as bootstrap admin</strong><span>LLDAP has no public registration. Log in as <code>admin</code> using <code>LLDAP_LDAP_USER_PASS</code> from your password safe entry <code>net-kingdom/LLDAP/admin</code>. That value was generated during installation and injected into the <code>lldap-secrets</code> Kubernetes Secret.</span><span class="inline-actions"><a class="button-link" href="https://lldap.coulomb.social" target="_blank" rel="noreferrer" title="Open the LLDAP admin UI. This path uses password auth only and must be restricted before production.">Open LLDAP</a></span></span></div>
<div class="choice"><span class="step-number">2</span><span><strong>Create dedicated account</strong><span>Create a dedicated <code>platform-root</code> or <code>king</code> user. Suggested values: username <code>platform-root</code>, notification contact <code>bernd.worsch@gmail.com</code>. Add it to <code>net-kingdom-admins</code> for the current lightweight path. Do not use <code>tegwick</code> as this account.</span></span></div>
<div class="choice"><span class="step-number">3</span><span><strong>Enroll MFA</strong><span>Use <code>pi-admin</code> only to confirm the LLDAP resolver, realm, and self-enrollment policy. Then log in to privacyIDEA self-service as the account recorded above, usually <code>platform-root</code>, for the QR code or setup key. If self-service is not ready, use admin-assisted token assignment as a fallback and record that as the enrollment source.</span><span class="inline-actions"><a class="button-link" href="https://pink-account.coulomb.social" target="_blank" rel="noreferrer" title="Open privacyIDEA self-service to enroll or test the platform-root OTP factor.">Open self-service</a><a class="button-link secondary" href="https://pink.coulomb.social" target="_blank" rel="noreferrer" title="Open privacyIDEA admin. Use pi-admin here only to check resolver, realm, policy, or fallback token assignment.">Open admin</a></span></span></div>
<div class="choice"><span class="step-number">4</span><span><strong>Confirm identity path</strong><span>KeyCape is an OIDC issuer, not a dashboard; its root path returning 404 is expected. The login check starts the dedicated bootstrap-console OIDC client and should return to this console. If it never reaches the callback page, KeyCape may still need the public Authelia redirect config, this callback URI registration, or a browser OTP prompt. Mark OIDC verified only after the browser flow works for the same account.</span><span class="inline-actions"><a class="button-link" href="/oidc/start" target="_blank" rel="noreferrer" title="Start the bootstrap-console OIDC authorization flow through KeyCape. It should return to this local console callback without storing tokens.">Start OIDC login check</a><a class="button-link secondary" href="https://kc.coulomb.social/.well-known/openid-configuration" target="_blank" rel="noreferrer" title="Open KeyCape OIDC discovery JSON. This proves the issuer metadata is published.">Open discovery</a><a class="button-link secondary" href="https://kc.coulomb.social/healthz" target="_blank" rel="noreferrer" title="Open the KeyCape health endpoint. This proves the service process responds.">Open health</a></span></span></div>
<div class="choice"><span class="step-number">5</span><span><strong>Then OpenBao</strong><span>After custody approval, the OpenBao ceremony creates unseal shares, root-token disposition, policies, and temporary admin access.</span></span></div>
</div>
<div class="grid" style="margin-top: 14px;">
<label class="field">
<span class="label">Account home</span>
<input id="identity_account_home" type="text" autocomplete="off" value="lldap">
</label>
<label class="field">
<span class="label">Account reference</span>
<input id="identity_account_reference" type="text" autocomplete="off" placeholder="platform-root@lldap">
</label>
<label class="field">
<span class="label">Admin group</span>
<input id="identity_group_reference" type="text" autocomplete="off" value="net-kingdom-admins">
</label>
</div>
<div class="choice-list" style="margin-top: 14px;">
<label class="choice"><input id="identity_account_created" type="checkbox"><span><strong>Account created</strong><span>The dedicated identity account exists in LLDAP. No password is stored here.</span></span></label>
<label class="choice"><input id="identity_group_confirmed" type="checkbox"><span><strong>Admin group assigned</strong><span>The account is a member of <code>net-kingdom-admins</code> in LLDAP.</span></span></label>
<label class="choice"><input id="password_safe_confirmed" type="checkbox"><span><strong>Password stored</strong><span>The account password is stored in your password safe or offline custody packet. No value is stored here.</span></span></label>
<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>
</div>
</section>
<section class="panel">
<h2>King credential</h2>
<p class="notice">Local non-secret metadata only. OpenBao initialization stays manual.</p>
@@ -907,8 +1356,9 @@ def ui_html() -> str:
<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>
<button class="secondary" id="save-button" type="button" title="Save the visible non-secret progress fields to local metadata.">Save progress</button>
<button id="approve-button" type="submit" title="Approve the selected custody mode only after all kit gates are satisfied.">Approve custody mode</button>
<button class="secondary" id="refresh-button" type="button" title="Reload the local metadata and gate status from disk.">Refresh</button>
</div>
<div id="message" class="message" role="status">Waiting for local approval.</div>
</section>
@@ -919,6 +1369,10 @@ def ui_html() -> str:
<h2>Bootstrap gates</h2>
<div id="gates" class="gates"></div>
</section>
<section class="panel">
<h2>Key custody</h2>
<div id="key-gates" class="gates"></div>
</section>
<section class="panel">
<h2>Kit gates</h2>
<div id="kit-gates" class="gates"></div>
@@ -928,7 +1382,20 @@ def ui_html() -> str:
</main>
<script>
const fields = [
"bootstrap_mode",
"custodian_age_public_key",
"custodian_age_public_key_fingerprint",
"custodian_age_public_key_confirmed",
"custodian_age_private_key_reference",
"custodian_age_private_key_confirmed",
"credential_label",
"identity_account_home",
"identity_account_reference",
"identity_account_created",
"identity_group_reference",
"identity_group_confirmed",
"password_safe_confirmed",
"oidc_login_verified",
"setup_operator",
"notification_contact",
"mfa_class",
@@ -994,6 +1461,7 @@ def ui_html() -> str:
document.getElementById("next-action").textContent = data.next_action;
document.getElementById("metadata-path").textContent = data.metadata_path;
renderGates("gates", data.gates);
renderGates("key-gates", data.key_custody_gates);
renderGates("kit-gates", data.kit_gates);
fillForm(data.metadata || {});
}
@@ -1003,7 +1471,19 @@ def ui_html() -> str:
.map((input) => input.value);
const mode = document.querySelector("[name='custody_mode']:checked");
return {
bootstrap_mode: document.getElementById("bootstrap_mode").value,
custodian_age_public_key: document.getElementById("custodian_age_public_key").value.trim(),
custodian_age_public_key_confirmed: document.getElementById("custodian_age_public_key_confirmed").checked,
custodian_age_private_key_reference: document.getElementById("custodian_age_private_key_reference").value.trim(),
custodian_age_private_key_confirmed: document.getElementById("custodian_age_private_key_confirmed").checked,
credential_label: document.getElementById("credential_label").value.trim(),
identity_account_home: document.getElementById("identity_account_home").value.trim(),
identity_account_reference: document.getElementById("identity_account_reference").value.trim(),
identity_account_created: document.getElementById("identity_account_created").checked,
identity_group_reference: document.getElementById("identity_group_reference").value.trim(),
identity_group_confirmed: document.getElementById("identity_group_confirmed").checked,
password_safe_confirmed: document.getElementById("password_safe_confirmed").checked,
oidc_login_verified: document.getElementById("oidc_login_verified").checked,
setup_operator: document.getElementById("setup_operator").value.trim(),
notification_contact: document.getElementById("notification_contact").value.trim(),
mfa_class: document.getElementById("mfa_class").value,
@@ -1046,6 +1526,29 @@ def ui_html() -> str:
}
});
document.getElementById("save-button").addEventListener("click", async () => {
const button = document.getElementById("save-button");
button.disabled = true;
try {
const response = await fetch("/api/save-progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(approvalPayload())
});
const result = await response.json();
if (!response.ok) {
setMessage("Save failed:\\n" + (result.errors || []).map((item) => "- " + item).join("\\n"), "error");
return;
}
await loadStatus();
setMessage("Progress saved. No secrets were recorded.", "ok");
} catch (error) {
setMessage("Save failed: " + error.message, "error");
} finally {
button.disabled = false;
}
});
document.getElementById("refresh-button").addEventListener("click", () => {
loadStatus().then(() => setMessage("Status refreshed.", ""));
});
@@ -1073,26 +1576,41 @@ def make_ui_handler(metadata_path: Path) -> type[BaseHTTPRequestHandler]:
self.end_headers()
self.wfile.write(body)
def send_html(self, status: HTTPStatus, body: str) -> None:
encoded = body.encode("utf-8")
self.send_response(status.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(encoded)))
self.end_headers()
self.wfile.write(encoded)
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)
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/" or parsed.path == "/index.html":
self.send_html(HTTPStatus.OK, ui_html())
return
if self.path == "/api/status":
if parsed.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
if parsed.path == "/oidc/start":
host = self.headers.get("Host", "127.0.0.1:8876")
self.send_response(HTTPStatus.FOUND.value)
self.send_header("Location", local_oidc_start_url(host))
self.send_header("Cache-Control", "no-store")
self.end_headers()
return
if parsed.path == "/oidc/callback":
host = self.headers.get("Host", "127.0.0.1:8876")
self.send_html(HTTPStatus.OK, oidc_result_html(parsed.query, host))
return
self.send_error(HTTPStatus.NOT_FOUND.value)
def do_POST(self) -> None:
if self.path != "/api/approve-custody":
if self.path not in {"/api/approve-custody", "/api/save-progress"}:
self.send_error(HTTPStatus.NOT_FOUND.value)
return
try:
@@ -1112,6 +1630,13 @@ def make_ui_handler(metadata_path: Path) -> type[BaseHTTPRequestHandler]:
self.send_json(HTTPStatus.BAD_REQUEST, {"errors": ["JSON body must be an object."]})
return
existing = load_metadata(metadata_path)
if self.path == "/api/save-progress":
saved = save_progress_metadata(existing, payload)
write_metadata(metadata_path, saved)
response = status_payload(saved, metadata_path)
response["message"] = "Progress saved."
self.send_json(HTTPStatus.OK, response)
return
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)