generated from coulomb/repo-seed
1953 lines
82 KiB
Python
Executable File
1953 lines
82 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 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
|
|
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"}
|
|
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)
|
|
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 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 recovery_material_ready(data: dict[str, Any]) -> bool:
|
|
return yes(data, "recovery_confirmed")
|
|
|
|
|
|
def recovery_material_reason(data: dict[str, Any]) -> str:
|
|
if recovery_material_ready(data):
|
|
return "Recovery references are prepared outside this UI."
|
|
return (
|
|
"Prepare password recovery, MFA recovery/re-enrollment, custodian age-key "
|
|
"recovery, and encrypted bootstrap bundle recovery references."
|
|
)
|
|
|
|
|
|
def custody_packet_ready(data: dict[str, Any]) -> bool:
|
|
return yes(data, "custody_packet_prepared")
|
|
|
|
|
|
def custody_packet_reason(data: dict[str, Any]) -> str:
|
|
if custody_packet_ready(data):
|
|
return "The offline ceremony packet is ready without recording secret values here."
|
|
return (
|
|
"Prepare the OpenBao ceremony packet: selected custody mode, recovery references, "
|
|
"share assignment slots, root-token disposition plan, and signature/date."
|
|
)
|
|
|
|
|
|
def metadata_secret_boundary_issue(data: dict[str, Any]) -> str:
|
|
state = bootstrap_secret_state()
|
|
if state["plaintext_secrets_present"]:
|
|
return "Plaintext bootstrap secrets directory is present; remove it before custody approval."
|
|
encoded = json.dumps(data, sort_keys=True)
|
|
secret_markers = (
|
|
AGE_PRIVATE_MARKER,
|
|
"-----BEGIN PRIVATE KEY-----",
|
|
"-----BEGIN OPENSSH PRIVATE KEY-----",
|
|
"OPENBAO_ROOT_TOKEN",
|
|
"VAULT_TOKEN",
|
|
)
|
|
for marker in secret_markers:
|
|
if marker in encoded:
|
|
return f"Metadata contains a secret-looking marker: {marker}."
|
|
return ""
|
|
|
|
|
|
def secret_boundary_ready(data: dict[str, Any]) -> bool:
|
|
return metadata_secret_boundary_issue(data) == ""
|
|
|
|
|
|
def secret_boundary_reason(data: dict[str, Any]) -> str:
|
|
issue = metadata_secret_boundary_issue(data)
|
|
if issue:
|
|
return issue
|
|
return "The control surface stores only non-secret references; no user attestation is required."
|
|
|
|
|
|
def extract_age_public_key(value: Any) -> str:
|
|
if value is None:
|
|
return ""
|
|
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):
|
|
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(
|
|
"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",
|
|
"Record non-secret setup operator and notification contact.",
|
|
),
|
|
Gate(
|
|
"Storage class",
|
|
"done" if storage_values & VALID_STORAGE_CLASSES else "blocked",
|
|
"Select where the credential is held; hardware is optional policy, not a default requirement.",
|
|
),
|
|
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(
|
|
"Custody strategy selected",
|
|
"done" if custody_mode in VALID_CUSTODY_MODES else "blocked",
|
|
"Choose how the OpenBao init ceremony will be controlled.",
|
|
),
|
|
Gate(
|
|
"Recovery material",
|
|
"done" if recovery_material_ready(data) else "blocked",
|
|
recovery_material_reason(data),
|
|
),
|
|
Gate(
|
|
"Custody packet",
|
|
"done" if custody_packet_ready(data) else "blocked",
|
|
custody_packet_reason(data),
|
|
),
|
|
Gate(
|
|
"Control-surface secret boundary",
|
|
"done" if secret_boundary_ready(data) else "blocked",
|
|
secret_boundary_reason(data),
|
|
),
|
|
]
|
|
|
|
|
|
def king_kit_ready(data: dict[str, Any]) -> bool:
|
|
gates = kit_validation(data)
|
|
return all(gate.status == "done" for gate in gates)
|
|
|
|
|
|
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 "Strategy is selected; explicit approval is still pending."
|
|
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 strategy approval",
|
|
"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 kit_next_action(kit_gates: list[Gate]) -> str:
|
|
labels = {
|
|
"Credential label": "Set credential label",
|
|
"Identity account": "Create platform-root account",
|
|
"Setup operator/contact": "Record setup contact",
|
|
"Storage class": "Select recovery storage",
|
|
"Password safe storage": "Confirm password safe entry",
|
|
"Second factor": "Enroll MFA factor",
|
|
"Identity login path": "Verify OIDC login",
|
|
"Custody strategy selected": "Select custody strategy",
|
|
"Recovery material": "Prepare recovery material",
|
|
"Custody packet": "Prepare custody packet",
|
|
"Control-surface secret boundary": "Confirm secret boundary",
|
|
}
|
|
for gate in kit_gates:
|
|
if gate.status != "done":
|
|
return labels.get(gate.name, "Define king credential kit")
|
|
return "Define king credential kit"
|
|
|
|
|
|
def next_action(gates: list[Gate], kit_gates: list[Gate] | None = None) -> str:
|
|
for gate in gates:
|
|
if gate.status == "blocked":
|
|
if gate.name == "King credential kit":
|
|
if kit_gates is not None:
|
|
return kit_next_action(kit_gates)
|
|
return "Define king credential kit"
|
|
if gate.name == "Custody strategy approval":
|
|
return "Approve custody strategy"
|
|
if gate.name == "OpenBao preflight":
|
|
return "Run OpenBao preflight"
|
|
if gate.name == "Root-token disposition":
|
|
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:
|
|
merged = metadata_template()
|
|
merged.update(data)
|
|
gates = build_gates(merged)
|
|
key_gates = key_custody_validation(merged)
|
|
kit_gates = kit_validation(merged)
|
|
state = bootstrap_secret_state()
|
|
print("SECURITY BOOTSTRAP")
|
|
print("")
|
|
print("Stage")
|
|
print(derive_stage(merged))
|
|
print("")
|
|
print("Next safe action")
|
|
print(next_action(gates, kit_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}")
|
|
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",
|
|
"bootstrap_mode",
|
|
"identity_account_home",
|
|
"identity_account_reference",
|
|
"identity_group_reference",
|
|
"custodian_age_private_key_reference",
|
|
"setup_operator",
|
|
"notification_contact",
|
|
"mfa_class",
|
|
"mfa_enrollment_source",
|
|
"mfa_enrollment_reference",
|
|
"custody_mode",
|
|
"root_token_disposition",
|
|
"notes",
|
|
)
|
|
for field in text_fields:
|
|
if field in payload and payload[field] is not None:
|
|
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",
|
|
"openbao_preflight_passed",
|
|
"openbao_initialized",
|
|
"restore_drill_passed",
|
|
):
|
|
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,
|
|
) -> 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 the selected custody strategy.')
|
|
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 strategy selected":
|
|
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",
|
|
"identity_account_home",
|
|
"identity_account_reference",
|
|
"identity_group_reference",
|
|
"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",
|
|
"identity_account_created",
|
|
"identity_group_confirmed",
|
|
"oidc_login_verified",
|
|
"password_safe_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 {
|
|
"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",
|
|
"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": "",
|
|
"oidc_login_verified": False,
|
|
"metadata_updated_at": "",
|
|
"progress_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]:
|
|
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(merged),
|
|
"next_action": next_action(gates, kit_validation(merged)),
|
|
"gates": [gate_payload(gate) for gate in gates],
|
|
"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">
|
|
<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;
|
|
}
|
|
.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;
|
|
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;
|
|
}
|
|
.spec-list {
|
|
margin: 0 0 16px;
|
|
padding-left: 20px;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
line-height: 1.45;
|
|
}
|
|
.spec-list li { margin: 5px 0; }
|
|
.system-note {
|
|
border: 1px solid var(--soft-line);
|
|
background: #ffffff;
|
|
padding: 12px 14px;
|
|
line-height: 1.35;
|
|
margin: 0 0 16px;
|
|
}
|
|
.conditional { display: none; }
|
|
.conditional.visible { display: grid; }
|
|
.actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
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);
|
|
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-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;
|
|
}
|
|
.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;
|
|
}
|
|
a {
|
|
color: var(--ink);
|
|
text-decoration-thickness: 1px;
|
|
text-underline-offset: 3px;
|
|
}
|
|
@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>Guided security bootstrap</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>1. Bootstrap key envelope</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>2. Platform-root identity</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>3. Credential record</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 (policy only)</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>4. MFA and login proof</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>5. Custody strategy</h2>
|
|
<p class="notice">Select the control strategy before preparing the custody packet. Approval comes later, after recovery material and packet contents match this strategy.</p>
|
|
<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. One dedicated platform-root custodian controls the first ceremony, with migration to quorum custody planned.</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 only when independent share holders already exist and can attend the first OpenBao init ceremony.</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 the target model but does not approve live init yet.</span></span></label>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>6. Recovery material</h2>
|
|
<p class="notice">Recovery material is the ability to regain control of the platform-root credential and encrypted bootstrap bundle. It is not the OpenBao ceremony packet, and this UI stores only references.</p>
|
|
<ul class="spec-list">
|
|
<li>Platform-root password entry exists in the password safe and its label is known.</li>
|
|
<li>MFA recovery or re-enrollment path is known, such as privacyIDEA admin repair or a stored recovery-code location if that authority issues codes.</li>
|
|
<li>Custodian age private-key location is known and separate from the public recipient stored here.</li>
|
|
<li>Encrypted bootstrap bundle location is known; plaintext bootstrap secrets are absent before custody approval.</li>
|
|
<li>Notification contact and setup operator are recorded for lockout handling.</li>
|
|
</ul>
|
|
<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 id="hardware-storage-choice" class="choice conditional"><input name="storage_classes" value="hardware-token" type="checkbox"><span><strong>Hardware token storage</strong><span>Shown only when the selected credential policy uses a hardware token.</span></span></label>
|
|
<label class="choice"><input id="recovery_confirmed" type="checkbox"><span><strong>Recovery material prepared</strong><span>The items above exist outside this UI. Do not paste passwords, OTP seeds, recovery codes, or private keys here.</span></span></label>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>7. Custody packet and approval</h2>
|
|
<p class="notice">The custody packet is the offline ceremony envelope for the selected OpenBao strategy. It is prepared before approval, but it does not initialize OpenBao and does not contain secret values in this UI.</p>
|
|
<ul class="spec-list">
|
|
<li>Credential label, setup operator, notification contact, and selected custody strategy.</li>
|
|
<li>References to recovery material, not the recovery values themselves.</li>
|
|
<li>OpenBao init checklist, unseal-share assignment slots, and quorum plan.</li>
|
|
<li>Root-token disposition plan: revoke immediately or seal offline after scoped admin access works.</li>
|
|
<li>Signature/date line for the attended ceremony.</li>
|
|
</ul>
|
|
<div class="system-note">Secret capture is enforced by architecture: the control surface does not request secrets, and the gate checks local metadata plus plaintext bootstrap-secret presence. There is no user checkbox for this contract.</div>
|
|
<div class="choice-list" style="margin-bottom: 14px;">
|
|
<label class="choice"><input id="custody_packet_prepared" type="checkbox"><span><strong>Custody packet prepared</strong><span>The offline ceremony packet above is ready for the selected strategy. No OpenBao root token or unseal share is recorded here.</span></span></label>
|
|
</div>
|
|
<p class="notice">Approval is the explicit handoff from preparation into OpenBao preflight. It still does not run OpenBao init.</p>
|
|
<div class="field" style="margin-top: 14px;">
|
|
<span class="label">Approval phrase for selected strategy</span>
|
|
<input id="approval_phrase" type="text" autocomplete="off" placeholder="approve custody mode" title="Type the approval phrase only after the selected strategy, recovery material, and custody packet are ready.">
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>8. OpenBao setup path</h2>
|
|
<p class="notice">OpenBao comes after custody approval. Public ingress is disabled in Railiance, so the attended ceremony uses Railiance commands, kubectl exec, or operator port-forwarding rather than this browser collecting secrets.</p>
|
|
<ul class="spec-list">
|
|
<li>Before approval, limit work to deployment/status checks: <code>make -C ../railiance-platform openbao-dry-run</code>, <code>make -C ../railiance-platform openbao-deploy</code>, and <code>make -C ../railiance-platform openbao-status</code>.</li>
|
|
<li>After approval, run safe preflight from this repo: <code>python3 tools/security-bootstrap-console/security_bootstrap_console.py openbao-preflight --railiance-path ../railiance-platform --run</code>.</li>
|
|
<li>Human init ceremony on Railiance: <code>kubectl exec -n openbao openbao-0 -- bao operator init -key-shares=3 -key-threshold=2</code>, then distribute unseal shares according to the approved packet.</li>
|
|
<li>Unseal and configure without pasting values here: <code>kubectl exec -n openbao openbao-0 -- bao operator unseal</code>, then <code>make -C ../railiance-platform openbao-configure-initial</code>.</li>
|
|
<li>Verify and prove recovery: <code>make -C ../railiance-platform openbao-verify-post-unseal</code>, snapshot, and run an isolated restore drill before live secrets move in.</li>
|
|
</ul>
|
|
<div class="choice-list">
|
|
<label class="choice"><input id="openbao_preflight_passed" type="checkbox"><span><strong>OpenBao preflight passed</strong><span>Status and verification checks completed after custody approval.</span></span></label>
|
|
<label class="choice"><input id="openbao_initialized" type="checkbox"><span><strong>Initialized and unsealed</strong><span>The human ceremony completed outside this UI under the approved strategy.</span></span></label>
|
|
<label class="choice"><input id="restore_drill_passed" type="checkbox"><span><strong>Restore drill passed</strong><span>Snapshot and isolated restore proof completed before live secrets are migrated.</span></span></label>
|
|
</div>
|
|
<label class="field" style="margin-top: 14px;">
|
|
<span class="label">Root-token disposition</span>
|
|
<select id="root_token_disposition" title="Record only what happened to the root token; never record the token value.">
|
|
<option value="">Not recorded</option>
|
|
<option value="revoked">Revoked after scoped admin works</option>
|
|
<option value="offline-sealed">Sealed offline</option>
|
|
</select>
|
|
</label>
|
|
<div class="actions">
|
|
<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 strategy only after all kit gates are satisfied.">Approve selected strategy</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>
|
|
</form>
|
|
|
|
<aside>
|
|
<section class="panel">
|
|
<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>
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
</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",
|
|
"mfa_enrollment_source",
|
|
"mfa_enrollment_reference",
|
|
"mfa_enrolled_confirmed",
|
|
"recovery_confirmed",
|
|
"custody_packet_prepared",
|
|
"openbao_preflight_passed",
|
|
"openbao_initialized",
|
|
"root_token_disposition",
|
|
"restore_drill_passed"
|
|
];
|
|
|
|
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);
|
|
});
|
|
document.querySelectorAll("[name='custody_mode']").forEach((input) => {
|
|
input.checked = false;
|
|
});
|
|
const mode = metadata.custody_mode || "";
|
|
const selected = document.querySelector(`[name='custody_mode'][value='${mode}']`);
|
|
if (selected) selected.checked = true;
|
|
syncConditionalHardware();
|
|
}
|
|
|
|
function syncConditionalHardware() {
|
|
const source = document.getElementById("mfa_class");
|
|
const row = document.getElementById("hardware-storage-choice");
|
|
if (!source || !row) return;
|
|
const input = row.querySelector("input");
|
|
const visible = source.value === "hardware-token" || (input && input.checked);
|
|
row.classList.toggle("visible", visible);
|
|
}
|
|
|
|
async function loadStatus() {
|
|
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("key-gates", data.key_custody_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 {
|
|
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,
|
|
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,
|
|
custody_mode: mode ? mode.value : "",
|
|
openbao_preflight_passed: document.getElementById("openbao_preflight_passed").checked,
|
|
openbao_initialized: document.getElementById("openbao_initialized").checked,
|
|
root_token_disposition: document.getElementById("root_token_disposition").value,
|
|
restore_drill_passed: document.getElementById("restore_drill_passed").checked,
|
|
approval_phrase: document.getElementById("approval_phrase").value,
|
|
approved_by: document.getElementById("setup_operator").value.trim()
|
|
};
|
|
}
|
|
|
|
document.getElementById("mfa_class").addEventListener("change", syncConditionalHardware);
|
|
|
|
document.getElementById("approval-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const button = document.getElementById("approve-button");
|
|
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("Selected custody strategy approved. OpenBao init remains a separate human ceremony.", "ok");
|
|
} catch (error) {
|
|
setMessage("Request failed: " + error.message, "error");
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
|
|
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.", ""));
|
|
});
|
|
|
|
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 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:
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
if parsed.path == "/" or parsed.path == "/index.html":
|
|
self.send_html(HTTPStatus.OK, ui_html())
|
|
return
|
|
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 not in {"/api/approve-custody", "/api/save-progress"}:
|
|
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)
|
|
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)
|
|
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"] = "Selected custody strategy 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())
|