generated from coulomb/repo-seed
bootstrapping guidance ui and missing stuff
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user