generated from coulomb/repo-seed
3061 lines
130 KiB
Python
Executable File
3061 lines
130 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(
|
|
"OpenBao initial configuration",
|
|
(
|
|
"done"
|
|
if yes(data, "openbao_initial_config_applied")
|
|
else "human"
|
|
if yes(data, "openbao_initialized")
|
|
else "blocked"
|
|
),
|
|
"Apply first auth, mount, and policy configuration; audit may be a declarative follow-up.",
|
|
),
|
|
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,
|
|
data: dict[str, Any] | None = None,
|
|
) -> str:
|
|
for gate in gates:
|
|
if gate.status == "human":
|
|
if gate.name == "OpenBao init ceremony":
|
|
if data and yes(data, "openbao_init_output_produced") and not yes(data, "openbao_initialized"):
|
|
return "Run OpenBao unseal prompt"
|
|
return "Run attended OpenBao init ceremony"
|
|
return gate.name
|
|
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, merged))
|
|
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",
|
|
"role_setup_operator_email",
|
|
"role_platform_custodian_email",
|
|
"role_identity_admin_email",
|
|
"role_openbao_operator_email",
|
|
"role_recovery_custodian_email",
|
|
"role_future_quorum_email",
|
|
"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_init_output_produced",
|
|
"openbao_initialized",
|
|
"openbao_post_unseal_verified",
|
|
"openbao_initial_config_applied",
|
|
"openbao_trial_material_exposed",
|
|
"openbao_compromise_response_complete",
|
|
"openbao_unseal_keys_rotated",
|
|
"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",
|
|
"role_setup_operator_email": "bernd.worsch@gmail.com",
|
|
"role_platform_custodian_email": "bernd.worsch@gmail.com",
|
|
"role_identity_admin_email": "bernd.worsch@gmail.com",
|
|
"role_openbao_operator_email": "bernd.worsch@gmail.com",
|
|
"role_recovery_custodian_email": "bernd.worsch@gmail.com",
|
|
"role_future_quorum_email": "",
|
|
"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_init_output_produced": False,
|
|
"openbao_initialized": False,
|
|
"openbao_post_unseal_verified": False,
|
|
"openbao_initial_config_applied": False,
|
|
"openbao_trial_material_exposed": False,
|
|
"openbao_compromise_response_complete": False,
|
|
"openbao_unseal_keys_rotated": 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 role_email(data: dict[str, Any], role_key: str) -> str:
|
|
fallback = str(data.get("notification_contact") or "").strip()
|
|
return str(data.get(role_key) or fallback).strip()
|
|
|
|
|
|
def role_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
|
|
rows = [
|
|
(
|
|
"setup-operator",
|
|
"Runs bootstrap commands and records non-secret evidence.",
|
|
"NetKingdom bootstrap",
|
|
"role_setup_operator_email",
|
|
),
|
|
(
|
|
"platform-root-custodian",
|
|
"Holds the dedicated platform-root credential and approves custody gates.",
|
|
"NetKingdom identity",
|
|
"role_platform_custodian_email",
|
|
),
|
|
(
|
|
"identity-admin",
|
|
"Administers LLDAP, privacyIDEA, and login repair during bootstrap.",
|
|
"Identity stack",
|
|
"role_identity_admin_email",
|
|
),
|
|
(
|
|
"openbao-ceremony-operator",
|
|
"Runs the attended OpenBao ceremony without copying secret output into the control surface.",
|
|
"Railiance OpenBao",
|
|
"role_openbao_operator_email",
|
|
),
|
|
(
|
|
"recovery-custodian",
|
|
"Can recover the platform-root credential and encrypted bootstrap bundle outside this UI.",
|
|
"Custody packet",
|
|
"role_recovery_custodian_email",
|
|
),
|
|
(
|
|
"future-quorum-custodian",
|
|
"Reserved for later two-of-three custody migration.",
|
|
"Custody strategy",
|
|
"role_future_quorum_email",
|
|
),
|
|
]
|
|
payloads: list[dict[str, str]] = []
|
|
for name, description, subsystem, key in rows:
|
|
email = role_email(data, key)
|
|
payloads.append(
|
|
{
|
|
"name": name,
|
|
"description": description,
|
|
"subsystem": subsystem,
|
|
"responsibility": name,
|
|
"email": email,
|
|
"location": "Role assignment in local bootstrap metadata." if email else "Not assigned yet.",
|
|
"state": "set" if email else "nil",
|
|
}
|
|
)
|
|
return payloads
|
|
|
|
|
|
def state_value(ok: bool, set_value: bool = False, err: bool = False) -> str:
|
|
if err:
|
|
return "err"
|
|
if ok:
|
|
return "ok"
|
|
if set_value:
|
|
return "set"
|
|
return "nil"
|
|
|
|
|
|
def openbao_trial_taint(data: dict[str, Any], relation: str = "downstream") -> dict[str, Any]:
|
|
if not yes(data, "openbao_trial_material_exposed") or yes(data, "openbao_compromise_response_complete"):
|
|
return {}
|
|
relation_text = "Directly marked" if relation == "direct" else "Downstream"
|
|
return {
|
|
"tainted": True,
|
|
"taint_source": "Trial key material exposed",
|
|
"taint_reference": "Usecases & Runbooks / Trial key material exposed",
|
|
"taint_reason": (
|
|
f"{relation_text} from recorded OpenBao trial key-material exposure. "
|
|
"Operator may proceed, but resulting evidence and work should be treated as tainted "
|
|
"until rotation, reset, or another compromise response is recorded."
|
|
),
|
|
}
|
|
|
|
|
|
def add_taint(row: dict[str, Any], taint: dict[str, Any]) -> dict[str, Any]:
|
|
if taint:
|
|
row.update(taint)
|
|
return row
|
|
|
|
|
|
def subsystem_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
|
|
public_key = extract_age_public_key(data.get("custodian_age_public_key"))
|
|
state = bootstrap_secret_state()
|
|
return [
|
|
{
|
|
"name": "age recipient",
|
|
"description": "Public age recipient used to encrypt bootstrap bundles.",
|
|
"subsystem": "custodian age envelope",
|
|
"responsibility": "recovery-custodian",
|
|
"email": role_email(data, "role_recovery_custodian_email"),
|
|
"location": age_public_key_fingerprint(public_key) or "No public recipient registered.",
|
|
"state": state_value(
|
|
bool(public_key) and yes(data, "custodian_age_public_key_confirmed"),
|
|
bool(public_key),
|
|
),
|
|
},
|
|
{
|
|
"name": "platform-root user",
|
|
"description": "Dedicated LLDAP account used as the current king credential.",
|
|
"subsystem": "LLDAP",
|
|
"responsibility": "identity-admin",
|
|
"email": role_email(data, "role_identity_admin_email"),
|
|
"location": str(data.get("identity_account_reference") or "Not recorded."),
|
|
"state": state_value(identity_account_ready(data), bool(data.get("identity_account_reference"))),
|
|
},
|
|
{
|
|
"name": "platform-root MFA token",
|
|
"description": "Second factor enrolled with the authority that verifies login.",
|
|
"subsystem": "privacyIDEA",
|
|
"responsibility": "identity-admin",
|
|
"email": role_email(data, "role_identity_admin_email"),
|
|
"location": str(data.get("mfa_enrollment_reference") or "Not recorded."),
|
|
"state": state_value(second_factor_ready(data), yes(data, "mfa_enrolled_confirmed")),
|
|
},
|
|
{
|
|
"name": "bootstrap OIDC client",
|
|
"description": "KeyCape login path used to verify platform-root can authenticate with MFA.",
|
|
"subsystem": "KeyCape",
|
|
"responsibility": "identity-admin",
|
|
"email": role_email(data, "role_identity_admin_email"),
|
|
"location": KEYCAPE_ISSUER,
|
|
"state": state_value(identity_login_ready(data), bool(data.get("identity_account_reference"))),
|
|
},
|
|
{
|
|
"name": "openbao-0",
|
|
"description": "Railiance OpenBao pod, services, PVCs, and sealed pre-init state.",
|
|
"subsystem": "Railiance OpenBao",
|
|
"responsibility": "openbao-ceremony-operator",
|
|
"email": role_email(data, "role_openbao_operator_email"),
|
|
"location": "namespace=openbao, pod=openbao-0",
|
|
"state": state_value(yes(data, "openbao_preflight_passed"), yes(data, "custody_mode_approved")),
|
|
},
|
|
{
|
|
"name": "encrypted bootstrap bundle",
|
|
"description": "Encrypted bootstrap secret bundle; plaintext directory must be absent.",
|
|
"subsystem": "sso-mfa/bootstrap",
|
|
"responsibility": "recovery-custodian",
|
|
"email": role_email(data, "role_recovery_custodian_email"),
|
|
"location": str(state["encrypted_bundle_path"]),
|
|
"state": state_value(
|
|
bool(state["encrypted_bundle_exists"]) and not bool(state["plaintext_secrets_present"]),
|
|
bool(state["encrypted_bundle_exists"]),
|
|
bool(state["plaintext_secrets_present"]),
|
|
),
|
|
},
|
|
]
|
|
|
|
|
|
def integration_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
|
|
openbao_direct_taint = openbao_trial_taint(data, "direct")
|
|
return [
|
|
{
|
|
"name": "LLDAP admin group assignment",
|
|
"description": "platform-root is assigned to the current NetKingdom admin group.",
|
|
"subsystem": "LLDAP",
|
|
"responsibility": "identity-admin",
|
|
"email": role_email(data, "role_identity_admin_email"),
|
|
"location": str(data.get("identity_group_reference") or "Not recorded."),
|
|
"state": state_value(yes(data, "identity_group_confirmed"), bool(data.get("identity_group_reference"))),
|
|
},
|
|
{
|
|
"name": "privacyIDEA MFA verification",
|
|
"description": "The same platform-root account has an enrolled second factor.",
|
|
"subsystem": "privacyIDEA",
|
|
"responsibility": "identity-admin",
|
|
"email": role_email(data, "role_identity_admin_email"),
|
|
"location": str(data.get("mfa_enrollment_reference") or "Not recorded."),
|
|
"state": state_value(second_factor_ready(data), yes(data, "mfa_enrolled_confirmed")),
|
|
},
|
|
{
|
|
"name": "KeyCape OIDC login",
|
|
"description": "platform-root completed the OIDC login check through KeyCape.",
|
|
"subsystem": "KeyCape",
|
|
"responsibility": "identity-admin",
|
|
"email": role_email(data, "role_identity_admin_email"),
|
|
"location": "local bootstrap callback",
|
|
"state": state_value(yes(data, "oidc_login_verified")),
|
|
},
|
|
{
|
|
"name": "OpenBao preflight",
|
|
"description": "Railiance status and verify targets passed in the approved pre-init state.",
|
|
"subsystem": "Railiance OpenBao",
|
|
"responsibility": "openbao-ceremony-operator",
|
|
"email": role_email(data, "role_openbao_operator_email"),
|
|
"location": "../railiance-platform",
|
|
"state": state_value(yes(data, "openbao_preflight_passed"), yes(data, "custody_mode_approved")),
|
|
},
|
|
add_taint(
|
|
{
|
|
"name": "OpenBao init/unseal ceremony",
|
|
"description": "Attended ceremony creates unseal shares and initial root token outside this UI.",
|
|
"subsystem": "Railiance OpenBao",
|
|
"responsibility": "openbao-ceremony-operator",
|
|
"email": role_email(data, "role_openbao_operator_email"),
|
|
"location": "operator shell with custody packet present",
|
|
"state": state_value(yes(data, "openbao_initialized"), yes(data, "openbao_preflight_passed")),
|
|
},
|
|
openbao_direct_taint,
|
|
),
|
|
add_taint(
|
|
{
|
|
"name": "OpenBao initial configuration",
|
|
"description": "First auth, mount, and policy configuration after unseal.",
|
|
"subsystem": "Railiance OpenBao",
|
|
"responsibility": "openbao-ceremony-operator",
|
|
"email": role_email(data, "role_openbao_operator_email"),
|
|
"location": "../railiance-platform openbao-configure-initial",
|
|
"state": state_value(yes(data, "openbao_initial_config_applied"), yes(data, "openbao_initialized")),
|
|
},
|
|
openbao_trial_taint(data, "downstream") if yes(data, "openbao_initialized") else {},
|
|
),
|
|
]
|
|
|
|
|
|
def artifact_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
|
|
public_key = extract_age_public_key(data.get("custodian_age_public_key"))
|
|
state = bootstrap_secret_state()
|
|
root_disposition = str(data.get("root_token_disposition") or "")
|
|
init_output = yes(data, "openbao_init_output_produced")
|
|
openbao_direct_taint = openbao_trial_taint(data, "direct")
|
|
return [
|
|
{
|
|
"name": "platform-root",
|
|
"description": "Dedicated LLDAP user for the king credential.",
|
|
"subsystem": "LLDAP",
|
|
"responsibility": "platform-root-custodian",
|
|
"email": role_email(data, "role_platform_custodian_email"),
|
|
"location": str(data.get("identity_account_reference") or "Not recorded."),
|
|
"state": state_value(identity_account_ready(data), bool(data.get("identity_account_reference"))),
|
|
},
|
|
{
|
|
"name": "net-kingdom-admins",
|
|
"description": "Current lightweight admin group for the platform-root identity.",
|
|
"subsystem": "LLDAP",
|
|
"responsibility": "identity-admin",
|
|
"email": role_email(data, "role_identity_admin_email"),
|
|
"location": str(data.get("identity_group_reference") or "Not recorded."),
|
|
"state": state_value(yes(data, "identity_group_confirmed"), bool(data.get("identity_group_reference"))),
|
|
},
|
|
{
|
|
"name": "platform-root password entry",
|
|
"description": "Password-safe entry for the dedicated identity password. Value is never stored here.",
|
|
"subsystem": "password safe",
|
|
"responsibility": "platform-root-custodian",
|
|
"email": role_email(data, "role_platform_custodian_email"),
|
|
"location": "operator password safe / offline packet",
|
|
"state": state_value(yes(data, "password_safe_confirmed")),
|
|
},
|
|
{
|
|
"name": "TOTP token",
|
|
"description": "privacyIDEA token for the platform-root login path.",
|
|
"subsystem": "privacyIDEA",
|
|
"responsibility": "platform-root-custodian",
|
|
"email": role_email(data, "role_platform_custodian_email"),
|
|
"location": str(data.get("mfa_enrollment_reference") or "Not recorded."),
|
|
"state": state_value(second_factor_ready(data), yes(data, "mfa_enrolled_confirmed")),
|
|
},
|
|
{
|
|
"name": "age recipient",
|
|
"description": "Public recipient used for encrypted bootstrap bundles.",
|
|
"subsystem": "custodian age envelope",
|
|
"responsibility": "recovery-custodian",
|
|
"email": role_email(data, "role_recovery_custodian_email"),
|
|
"location": age_public_key_fingerprint(public_key) or "Not recorded.",
|
|
"state": state_value(bool(public_key) and yes(data, "custodian_age_public_key_confirmed"), bool(public_key)),
|
|
},
|
|
{
|
|
"name": "age private key reference",
|
|
"description": "Non-secret pointer to the private age key location.",
|
|
"subsystem": "custodian age envelope",
|
|
"responsibility": "recovery-custodian",
|
|
"email": role_email(data, "role_recovery_custodian_email"),
|
|
"location": str(data.get("custodian_age_private_key_reference") or "Not recorded."),
|
|
"state": state_value(yes(data, "custodian_age_private_key_confirmed"), bool(data.get("custodian_age_private_key_reference"))),
|
|
},
|
|
{
|
|
"name": "secrets.enc",
|
|
"description": "Encrypted bootstrap bundle.",
|
|
"subsystem": "sso-mfa/bootstrap",
|
|
"responsibility": "recovery-custodian",
|
|
"email": role_email(data, "role_recovery_custodian_email"),
|
|
"location": str(state["encrypted_bundle_path"]),
|
|
"state": state_value(bool(state["encrypted_bundle_exists"]), False, bool(state["plaintext_secrets_present"])),
|
|
},
|
|
{
|
|
"name": "custody strategy",
|
|
"description": "Selected OpenBao ceremony control model.",
|
|
"subsystem": "custody model",
|
|
"responsibility": "platform-root-custodian",
|
|
"email": role_email(data, "role_platform_custodian_email"),
|
|
"location": str(data.get("custody_mode") or "Not selected."),
|
|
"state": state_value(yes(data, "custody_mode_approved"), data.get("custody_mode") in VALID_CUSTODY_MODES),
|
|
},
|
|
{
|
|
"name": "recovery material",
|
|
"description": "Recovery references for identity, MFA, age key, and encrypted bootstrap bundle.",
|
|
"subsystem": "custody packet",
|
|
"responsibility": "recovery-custodian",
|
|
"email": role_email(data, "role_recovery_custodian_email"),
|
|
"location": "offline packet / password safe references",
|
|
"state": state_value(yes(data, "recovery_confirmed")),
|
|
},
|
|
{
|
|
"name": "OpenBao custody packet",
|
|
"description": "Ceremony envelope with share assignment slots and root-token disposition plan.",
|
|
"subsystem": "Railiance OpenBao",
|
|
"responsibility": "openbao-ceremony-operator",
|
|
"email": role_email(data, "role_openbao_operator_email"),
|
|
"location": "offline ceremony packet",
|
|
"state": state_value(yes(data, "custody_packet_prepared")),
|
|
},
|
|
add_taint(
|
|
{
|
|
"name": "unseal shares",
|
|
"description": "Real OpenBao shares produced by init. They must be routed directly to approved custody locations.",
|
|
"subsystem": "Railiance OpenBao",
|
|
"responsibility": "openbao-ceremony-operator",
|
|
"email": role_email(data, "role_openbao_operator_email"),
|
|
"location": "created during attended init; not stored here",
|
|
"state": state_value(yes(data, "openbao_unseal_keys_rotated"), init_output or yes(data, "openbao_initialized")),
|
|
},
|
|
openbao_direct_taint,
|
|
),
|
|
add_taint(
|
|
{
|
|
"name": "initial root token",
|
|
"description": "OpenBao bootstrap token produced by init. Use only for first configuration and disposition.",
|
|
"subsystem": "Railiance OpenBao",
|
|
"responsibility": "openbao-ceremony-operator",
|
|
"email": role_email(data, "role_openbao_operator_email"),
|
|
"location": "created during attended init; never pasted here",
|
|
"state": state_value(root_disposition in {"revoked", "offline-sealed"}, init_output or yes(data, "openbao_initialized")),
|
|
},
|
|
openbao_direct_taint,
|
|
),
|
|
]
|
|
|
|
|
|
def command_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
|
|
preflight_done = yes(data, "openbao_preflight_passed")
|
|
custody_approved = custody_mode_approved(data)
|
|
init_output = yes(data, "openbao_init_output_produced")
|
|
initialized = yes(data, "openbao_initialized")
|
|
post_unseal_verified = yes(data, "openbao_post_unseal_verified")
|
|
initial_config_applied = yes(data, "openbao_initial_config_applied")
|
|
trial_exposed = yes(data, "openbao_trial_material_exposed")
|
|
response_complete = yes(data, "openbao_compromise_response_complete")
|
|
keys_rotated = yes(data, "openbao_unseal_keys_rotated")
|
|
root_disposed = data.get("root_token_disposition") in {"revoked", "offline-sealed"}
|
|
openbao_direct_taint = openbao_trial_taint(data, "direct")
|
|
openbao_downstream_taint = openbao_trial_taint(data, "downstream")
|
|
|
|
status_state = "todo"
|
|
status_reason = "Run any time to inspect the current OpenBao deployment state."
|
|
if preflight_done:
|
|
status_state = "done"
|
|
status_reason = "Deployment and pre-init status were verified."
|
|
if (init_output or initialized) and not root_disposed:
|
|
status_state = "redo"
|
|
status_reason = "OpenBao changed during init/unseal; rerun status before root-token disposition."
|
|
|
|
preflight_state = "done" if preflight_done else "todo"
|
|
preflight_reason = "Safe preflight passed."
|
|
if not preflight_done:
|
|
preflight_reason = "Run after custody approval and before init."
|
|
if not custody_approved:
|
|
preflight_state = "blocked"
|
|
preflight_reason = "Approve the selected custody strategy first."
|
|
|
|
init_state = "done" if init_output or initialized else "todo"
|
|
init_reason = "Init output was produced. Do not paste unseal shares or root token here."
|
|
if not (init_output or initialized):
|
|
init_reason = "Run once, attended, after OpenBao preflight."
|
|
if not preflight_done:
|
|
init_state = "blocked"
|
|
init_reason = "OpenBao preflight must pass first."
|
|
|
|
unseal_state = "done" if initialized else "todo"
|
|
unseal_reason = "OpenBao is recorded as initialized and unsealed."
|
|
if not initialized:
|
|
unseal_reason = "Provide threshold shares by prompt, not as command arguments."
|
|
if not (init_output or initialized):
|
|
unseal_state = "blocked"
|
|
unseal_reason = "OpenBao init output must be produced first."
|
|
|
|
config_state = "done" if initial_config_applied else "todo"
|
|
config_reason = "Initial configuration is recorded. Root-token disposition remains a separate gate."
|
|
if not initial_config_applied:
|
|
config_reason = "Configure OpenBao, then record this non-secret completion flag."
|
|
if trial_exposed and initialized and not response_complete:
|
|
if initial_config_applied:
|
|
config_reason = "Initial configuration is recorded on a tainted workpath. Complete root-token disposition and compromise response before production trust."
|
|
else:
|
|
config_reason = "Tainted by trial key-material exposure. Operator may proceed, but record the taint and complete rotation, reset, or another compromise response before production trust."
|
|
if not initialized:
|
|
config_state = "blocked"
|
|
config_reason = "OpenBao must be initialized and unsealed first."
|
|
|
|
verify_state = "done" if post_unseal_verified else "todo"
|
|
verify_reason = "Post-unseal readiness has been verified."
|
|
if not post_unseal_verified:
|
|
verify_reason = "Verify filesystem and post-unseal readiness before live secrets move in."
|
|
if not initialized:
|
|
verify_state = "blocked"
|
|
verify_reason = "OpenBao must be initialized and unsealed first."
|
|
|
|
return [
|
|
{
|
|
"name": "OpenBao status",
|
|
"description": "Show pod, service, PVC, and seal/init status.",
|
|
"status": status_state,
|
|
"status_reason": status_reason,
|
|
"command": "make -C ../railiance-platform openbao-status",
|
|
},
|
|
{
|
|
"name": "OpenBao preflight",
|
|
"description": "Run safe status and verification checks. Does not initialize OpenBao.",
|
|
"status": preflight_state,
|
|
"status_reason": preflight_reason,
|
|
"command": (
|
|
"python3 tools/security-bootstrap-console/security_bootstrap_console.py "
|
|
"openbao-preflight --railiance-path ../railiance-platform --run"
|
|
),
|
|
},
|
|
add_taint(
|
|
{
|
|
"name": "OpenBao init ceremony",
|
|
"description": "Creates real unseal shares and the initial root token. Run once, attended.",
|
|
"status": init_state,
|
|
"status_reason": init_reason,
|
|
"command": "kubectl exec -n openbao openbao-0 -- bao operator init -key-shares=3 -key-threshold=2",
|
|
},
|
|
openbao_direct_taint if init_output or initialized else {},
|
|
),
|
|
add_taint(
|
|
{
|
|
"name": "OpenBao unseal prompt",
|
|
"description": "Enter unseal shares by interactive terminal prompt. Do not place shares on the command line.",
|
|
"status": unseal_state,
|
|
"status_reason": unseal_reason,
|
|
"command": "kubectl exec -it -n openbao openbao-0 -- bao operator unseal",
|
|
},
|
|
openbao_direct_taint if initialized else {},
|
|
),
|
|
add_taint(
|
|
{
|
|
"name": "OpenBao initial configuration",
|
|
"description": "Apply first audit, auth, mount, and policy configuration after unseal.",
|
|
"status": config_state,
|
|
"status_reason": config_reason,
|
|
"command": "make -C ../railiance-platform openbao-configure-initial",
|
|
},
|
|
openbao_downstream_taint if initialized else {},
|
|
),
|
|
add_taint(
|
|
{
|
|
"name": "OpenBao post-unseal verification",
|
|
"description": "Verify filesystem and post-unseal readiness before live secrets move in.",
|
|
"status": verify_state,
|
|
"status_reason": verify_reason,
|
|
"command": "make -C ../railiance-platform openbao-verify-post-unseal",
|
|
},
|
|
openbao_downstream_taint if initialized else {},
|
|
),
|
|
]
|
|
|
|
|
|
def runbook_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
|
|
init_output = yes(data, "openbao_init_output_produced")
|
|
initialized = yes(data, "openbao_initialized")
|
|
trial_exposed = yes(data, "openbao_trial_material_exposed")
|
|
response_complete = yes(data, "openbao_compromise_response_complete")
|
|
keys_rotated = yes(data, "openbao_unseal_keys_rotated")
|
|
openbao_direct_taint = openbao_trial_taint(data, "direct")
|
|
openbao_downstream_taint = openbao_trial_taint(data, "downstream")
|
|
|
|
key_compromise_status = "done" if response_complete else "todo"
|
|
key_compromise_location = "Use for trial output exposure, screenshots, chat paste, shell history, or lost custody."
|
|
if not trial_exposed:
|
|
key_compromise_status = "blocked" if not init_output else "todo"
|
|
key_compromise_location = "Mark trial key material exposed before running the response checklist."
|
|
|
|
rotate_status = "done" if keys_rotated else "todo"
|
|
rotate_location = "Run only after OpenBao is unsealed and existing exposed shares are available for quorum."
|
|
if not initialized:
|
|
rotate_status = "blocked"
|
|
rotate_location = "Unseal OpenBao first; rotate-keys needs a quorum of current unseal shares."
|
|
if not trial_exposed and not keys_rotated:
|
|
rotate_status = "blocked"
|
|
rotate_location = "Record the key-compromise condition or schedule a normal rotation first."
|
|
|
|
return [
|
|
add_taint(
|
|
{
|
|
"name": "Key material compromised",
|
|
"description": "Respond when init output, unseal shares, or root-token material escaped the custody boundary.",
|
|
"subsystem": "Railiance OpenBao",
|
|
"responsibility": "openbao-ceremony-operator",
|
|
"email": role_email(data, "role_openbao_operator_email"),
|
|
"location": key_compromise_location,
|
|
"state": key_compromise_status,
|
|
},
|
|
openbao_direct_taint if trial_exposed and not response_complete else {},
|
|
),
|
|
add_taint(
|
|
{
|
|
"name": "Generate new unseal keys",
|
|
"description": "Rotate OpenBao Shamir unseal shares after a trial exposure or planned custody migration.",
|
|
"subsystem": "Railiance OpenBao",
|
|
"responsibility": "openbao-ceremony-operator",
|
|
"email": role_email(data, "role_openbao_operator_email"),
|
|
"location": rotate_location,
|
|
"state": rotate_status,
|
|
},
|
|
openbao_downstream_taint if trial_exposed and not response_complete else {},
|
|
),
|
|
]
|
|
|
|
|
|
def runbook_command_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
|
|
init_output = yes(data, "openbao_init_output_produced")
|
|
initialized = yes(data, "openbao_initialized")
|
|
trial_exposed = yes(data, "openbao_trial_material_exposed")
|
|
response_complete = yes(data, "openbao_compromise_response_complete")
|
|
keys_rotated = yes(data, "openbao_unseal_keys_rotated")
|
|
openbao_direct_taint = openbao_trial_taint(data, "direct")
|
|
openbao_downstream_taint = openbao_trial_taint(data, "downstream")
|
|
|
|
exposure_status = "done" if trial_exposed else "todo"
|
|
exposure_reason = "Trial key-material exposure is recorded in non-secret metadata." if trial_exposed else "Record that the trial init output escaped custody before using affected material."
|
|
|
|
response_status = "done" if response_complete else "todo"
|
|
response_reason = "Compromise response was recorded." if response_complete else "Stop production use of exposed material, decide rotate-vs-reset, and record non-secret evidence."
|
|
if not trial_exposed:
|
|
response_status = "blocked"
|
|
response_reason = "Record the key-material exposure first."
|
|
|
|
unseal_status = "done" if initialized else "todo"
|
|
unseal_reason = "OpenBao is unsealed." if initialized else "Unseal by hidden prompt before rotating unseal keys."
|
|
if not init_output:
|
|
unseal_status = "blocked"
|
|
unseal_reason = "OpenBao init output must exist first."
|
|
|
|
rotate_status = "done" if keys_rotated else "todo"
|
|
rotate_reason = "New unseal keys are recorded as generated." if keys_rotated else "Start rotation, then submit current shares by prompt until quorum completes."
|
|
if not initialized:
|
|
rotate_status = "blocked"
|
|
rotate_reason = "OpenBao must be unsealed before rotate-keys can run."
|
|
if not trial_exposed and not keys_rotated:
|
|
rotate_status = "blocked"
|
|
rotate_reason = "Record exposure or schedule a normal rotation before generating new shares."
|
|
|
|
return [
|
|
add_taint(
|
|
{
|
|
"name": "Record key exposure",
|
|
"description": "Non-secret metadata checkbox in this UI; do not paste exposed values.",
|
|
"status": exposure_status,
|
|
"status_reason": exposure_reason,
|
|
"command": "Use the checkbox: Trial key material exposed",
|
|
},
|
|
openbao_direct_taint if trial_exposed and not response_complete else {},
|
|
),
|
|
add_taint(
|
|
{
|
|
"name": "Unseal by prompt",
|
|
"description": "Provide threshold shares interactively. Never put shares on the command line.",
|
|
"status": unseal_status,
|
|
"status_reason": unseal_reason,
|
|
"command": "kubectl exec -it -n openbao openbao-0 -- bao operator unseal",
|
|
},
|
|
openbao_direct_taint if initialized else {},
|
|
),
|
|
add_taint(
|
|
{
|
|
"name": "Start unseal-key rotation",
|
|
"description": "Generate a new 3-share, threshold-2 Shamir split after compromise or planned migration.",
|
|
"status": rotate_status,
|
|
"status_reason": rotate_reason,
|
|
"command": "kubectl exec -it -n openbao openbao-0 -- bao operator rotate-keys -init -key-shares=3 -key-threshold=2",
|
|
},
|
|
openbao_downstream_taint if trial_exposed and not response_complete else {},
|
|
),
|
|
add_taint(
|
|
{
|
|
"name": "Submit current shares for rotation",
|
|
"description": "Repeat by prompt until the required threshold completes. Use the nonce from rotation init.",
|
|
"status": rotate_status,
|
|
"status_reason": rotate_reason,
|
|
"command": "kubectl exec -it -n openbao openbao-0 -- bao operator rotate-keys -nonce=<nonce-from-rotation-init>",
|
|
},
|
|
openbao_downstream_taint if trial_exposed and not response_complete else {},
|
|
),
|
|
add_taint(
|
|
{
|
|
"name": "Cancel key rotation",
|
|
"description": "Abort a started rotation if the nonce, share handling, or ceremony context is wrong.",
|
|
"status": "todo" if initialized and not keys_rotated else "blocked",
|
|
"status_reason": "Available while a rotation is in progress." if initialized and not keys_rotated else "No active rotation expected.",
|
|
"command": "kubectl exec -it -n openbao openbao-0 -- bao operator rotate-keys -cancel",
|
|
},
|
|
openbao_downstream_taint if trial_exposed and not response_complete else {},
|
|
),
|
|
add_taint(
|
|
{
|
|
"name": "Record compromise response complete",
|
|
"description": "Non-secret metadata checkbox after exposed material is rotated or the trial environment was reset.",
|
|
"status": response_status,
|
|
"status_reason": response_reason,
|
|
"command": "Use the checkbox: Compromise response complete",
|
|
},
|
|
openbao_downstream_taint if trial_exposed and not response_complete else {},
|
|
),
|
|
]
|
|
|
|
|
|
def section_gate_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
|
|
role_rows = role_payloads(data)
|
|
role_ok = all(row["state"] != "nil" for row in role_rows[:5])
|
|
subsystem_rows = subsystem_payloads(data)
|
|
integration_rows = integration_payloads(data)
|
|
runbook_rows = runbook_payloads(data)
|
|
artifact_rows = artifact_payloads(data)
|
|
return [
|
|
{
|
|
"key": "roles",
|
|
"name": "Roles & Responsibilities",
|
|
"status": "ok" if role_ok else "set",
|
|
"reason": "All active bootstrap roles have a designated email." if role_ok else "Assign an email to each active bootstrap role.",
|
|
},
|
|
{
|
|
"key": "subsystems",
|
|
"name": "Subsystems & Scope",
|
|
"status": "ok" if all(row["state"] in {"ok", "set"} for row in subsystem_rows) else "err",
|
|
"reason": "Subsystems have install/access evidence." if all(row["state"] != "nil" for row in subsystem_rows) else "Complete subsystem setup fields and confirmations.",
|
|
},
|
|
{
|
|
"key": "integrations",
|
|
"name": "Integration & Tests",
|
|
"status": "ok" if all(row["state"] == "ok" for row in integration_rows[:4]) else "set",
|
|
"reason": "Identity and OpenBao preflight checks are done." if all(row["state"] == "ok" for row in integration_rows[:4]) else "Run or confirm the remaining integration checks.",
|
|
},
|
|
{
|
|
"key": "runbooks",
|
|
"name": "Usecases & Runbooks",
|
|
"status": "ok" if all(row["state"] in {"done", "blocked"} for row in runbook_rows) else "set",
|
|
"reason": "Runbook states are recorded." if all(row["state"] in {"done", "blocked"} for row in runbook_rows) else "Review active runbooks and record non-secret outcomes.",
|
|
},
|
|
{
|
|
"key": "artifacts",
|
|
"name": "Artefacts & Locations",
|
|
"status": "ok" if all(row["state"] != "nil" for row in artifact_rows[:10]) else "set",
|
|
"reason": "Core custody artefacts have locations or confirmations." if all(row["state"] != "nil" for row in artifact_rows[:10]) else "Record missing artefact locations and confirmations.",
|
|
},
|
|
]
|
|
|
|
|
|
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)
|
|
for role_key in (
|
|
"role_setup_operator_email",
|
|
"role_platform_custodian_email",
|
|
"role_identity_admin_email",
|
|
"role_openbao_operator_email",
|
|
"role_recovery_custodian_email",
|
|
"role_future_quorum_email",
|
|
):
|
|
if not metadata_view.get(role_key):
|
|
metadata_view[role_key] = role_email(merged, role_key)
|
|
return {
|
|
"metadata_path": str(metadata_path),
|
|
"stage": derive_stage(merged),
|
|
"next_action": next_action(gates, kit_validation(merged), 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)],
|
|
"section_gates": section_gate_payloads(merged),
|
|
"roles": role_payloads(merged),
|
|
"subsystems": subsystem_payloads(merged),
|
|
"integrations": integration_payloads(merged),
|
|
"runbooks": runbook_payloads(merged),
|
|
"artifacts": artifact_payloads(merged),
|
|
"commands": command_payloads(merged),
|
|
"runbook_commands": runbook_command_payloads(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;
|
|
--taint: #fde8e3;
|
|
--taint-line: #ba6b61;
|
|
}
|
|
* { 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;
|
|
}
|
|
summary {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
list-style: none;
|
|
}
|
|
summary::-webkit-details-marker { display: none; }
|
|
.summary-title {
|
|
font-size: 18px;
|
|
line-height: 1.2;
|
|
font-weight: 650;
|
|
}
|
|
.section-gate {
|
|
border: 1px solid var(--soft-line);
|
|
border-radius: 4px;
|
|
background: #ffffff;
|
|
padding: 10px 12px;
|
|
margin: 14px 0;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
line-height: 1.35;
|
|
}
|
|
.record-list, .command-list {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin: 14px 0;
|
|
}
|
|
.record-row {
|
|
display: grid;
|
|
grid-template-columns: 58px minmax(170px, 1.25fr) minmax(130px, 0.72fr) minmax(150px, 1fr);
|
|
gap: 10px;
|
|
align-items: start;
|
|
border: 1px solid var(--soft-line);
|
|
border-radius: 4px;
|
|
background: #ffffff;
|
|
padding: 10px;
|
|
}
|
|
.record-row.tainted, .command-row.tainted {
|
|
background: var(--taint);
|
|
border-color: var(--taint-line);
|
|
}
|
|
.record-name {
|
|
font-weight: 650;
|
|
line-height: 1.2;
|
|
}
|
|
.record-description, .record-meta {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
line-height: 1.35;
|
|
margin-top: 3px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.taint-note {
|
|
border-left: 4px solid var(--taint-line);
|
|
color: var(--ink);
|
|
font-size: 13px;
|
|
line-height: 1.35;
|
|
margin-top: 7px;
|
|
padding-left: 8px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.record-context {
|
|
display: grid;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
}
|
|
.state {
|
|
display: inline-flex;
|
|
justify-content: center;
|
|
min-width: 42px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
padding: 3px 8px;
|
|
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
font-size: 12px;
|
|
line-height: 1.2;
|
|
text-transform: uppercase;
|
|
background: var(--warn);
|
|
}
|
|
.state.ok { background: var(--ok); }
|
|
.state.done { background: var(--ok); }
|
|
.state.set { background: var(--human); }
|
|
.state.redo { background: var(--human); }
|
|
.state.nil { background: #ffffff; }
|
|
.state.todo { background: var(--warn); }
|
|
.state.err { background: var(--bad); }
|
|
.state.blocked { background: var(--bad); }
|
|
.role-chip {
|
|
display: inline-flex;
|
|
max-width: 100%;
|
|
border: 1px solid var(--soft-line);
|
|
border-radius: 999px;
|
|
padding: 3px 8px;
|
|
background: #ffffff;
|
|
color: var(--ink);
|
|
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
font-size: 12px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.command-row {
|
|
border: 1px solid var(--soft-line);
|
|
border-radius: 4px;
|
|
background: #ffffff;
|
|
padding: 10px;
|
|
}
|
|
.command-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: start;
|
|
}
|
|
.command-code {
|
|
display: block;
|
|
width: 100%;
|
|
margin-top: 8px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 4px;
|
|
background: #181818;
|
|
color: #ffffff;
|
|
padding: 10px 12px;
|
|
overflow-x: auto;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.copy-button {
|
|
min-height: 32px;
|
|
padding: 6px 10px;
|
|
font-size: 13px;
|
|
}
|
|
.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; }
|
|
.record-row { 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">
|
|
<details class="panel workflow-section" data-section="roles" open>
|
|
<summary><span class="summary-title">1. Roles & Responsibilities</span><span class="state nil" data-section-state="roles">nil</span></summary>
|
|
<div class="section-gate" data-section-gate="roles">Loading role gate.</div>
|
|
<p class="notice">Define who is accountable for each bootstrap role before touching subsystem-specific controls. Role chips in every record show the role name; hover them to see the designated email.</p>
|
|
<div id="roles-records" class="record-list"></div>
|
|
<details id="responsibility-editor" class="system-note">
|
|
<summary><span class="record-name">Change responsibilities</span><span class="state set" id="responsibility-edit-state">set</span></summary>
|
|
<div class="grid" style="margin-top: 14px;">
|
|
<label class="field">
|
|
<span class="label">Setup operator</span>
|
|
<input id="setup_operator" type="text" autocomplete="off" title="Local operator handle used for non-secret progress records.">
|
|
</label>
|
|
<label class="field">
|
|
<span class="label">Notification contact</span>
|
|
<input id="notification_contact" type="text" autocomplete="off" title="Email for bootstrap notifications and lockout handling.">
|
|
</label>
|
|
<label class="field">
|
|
<span class="label">Setup operator email</span>
|
|
<input id="role_setup_operator_email" type="text" autocomplete="off" title="Designated user for the setup-operator role.">
|
|
</label>
|
|
<label class="field">
|
|
<span class="label">Platform-root custodian email</span>
|
|
<input id="role_platform_custodian_email" type="text" autocomplete="off" title="Designated user for the platform-root-custodian role.">
|
|
</label>
|
|
<label class="field">
|
|
<span class="label">Identity admin email</span>
|
|
<input id="role_identity_admin_email" type="text" autocomplete="off" title="Designated user for identity-admin during bootstrap.">
|
|
</label>
|
|
<label class="field">
|
|
<span class="label">OpenBao operator email</span>
|
|
<input id="role_openbao_operator_email" type="text" autocomplete="off" title="Designated user for the attended OpenBao ceremony.">
|
|
</label>
|
|
<label class="field">
|
|
<span class="label">Recovery custodian email</span>
|
|
<input id="role_recovery_custodian_email" type="text" autocomplete="off" title="Designated user for recovery material and bootstrap bundle custody.">
|
|
</label>
|
|
<label class="field">
|
|
<span class="label">Future quorum email</span>
|
|
<input id="role_future_quorum_email" type="text" autocomplete="off" title="Optional placeholder for later two-of-three custody migration.">
|
|
</label>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="secondary" id="responsibility-cancel-button" type="button" disabled title="Discard responsibility edits and close this foldout.">Cancel</button>
|
|
<button id="responsibility-save-button" type="button" disabled title="Save responsibility edits as non-secret local metadata and close this foldout.">Save</button>
|
|
</div>
|
|
</details>
|
|
</details>
|
|
|
|
<details class="panel workflow-section" data-section="subsystems" open>
|
|
<summary><span class="summary-title">2. Subsystems & Scope</span><span class="state nil" data-section-state="subsystems">nil</span></summary>
|
|
<div class="section-gate" data-section-gate="subsystems">Loading subsystem gate.</div>
|
|
<p class="notice">This section is about installing each subsystem and establishing initial user access. Integration checks come later.</p>
|
|
<div id="subsystems-records" class="record-list"></div>
|
|
<div class="grid">
|
|
<label class="field">
|
|
<span class="label">Bootstrap mode</span>
|
|
<select id="bootstrap_mode" title="Trial mode documents the process; custody mode is for real handover material.">
|
|
<option value="trial">Trial</option>
|
|
<option value="custody">Custody</option>
|
|
</select>
|
|
</label>
|
|
<label class="field">
|
|
<span class="label">Credential label</span>
|
|
<input id="credential_label" type="text" autocomplete="off" title="Dedicated credential label, usually platform-root.">
|
|
</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">Public key fingerprint</span>
|
|
<input id="custodian_age_public_key_fingerprint" type="text" readonly title="Derived from the public age recipient.">
|
|
</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>
|
|
<label class="field">
|
|
<span class="label">Account home</span>
|
|
<input id="identity_account_home" type="text" autocomplete="off" value="lldap" title="Identity subsystem where the platform-root account lives.">
|
|
</label>
|
|
<label class="field">
|
|
<span class="label">Account reference</span>
|
|
<input id="identity_account_reference" type="text" autocomplete="off" placeholder="platform-root@lldap" title="Non-secret account reference.">
|
|
</label>
|
|
<label class="field">
|
|
<span class="label">Admin group</span>
|
|
<input id="identity_group_reference" type="text" autocomplete="off" value="net-kingdom-admins" title="LLDAP group used for current bootstrap admin access.">
|
|
</label>
|
|
<label class="field">
|
|
<span class="label">Second factor</span>
|
|
<select id="mfa_class" title="Hardware token is optional policy, not a default requirement.">
|
|
<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>
|
|
<label class="field">
|
|
<span class="label">MFA enrollment source</span>
|
|
<select id="mfa_enrollment_source" title="The real verifier owns the QR code or setup key.">
|
|
<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">MFA reference</span>
|
|
<input id="mfa_enrollment_reference" type="text" autocomplete="off" placeholder="provider or vault entry label" title="Non-secret reference to the enrollment authority or vault label.">
|
|
</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>
|
|
<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.</span></span></label>
|
|
<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>
|
|
<div 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>
|
|
<a class="button-link secondary" 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 for resolver, realm, policy, or fallback token assignment.">Open admin</a>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="panel workflow-section" data-section="integrations" open>
|
|
<summary><span class="summary-title">3. Integration & Tests</span><span class="state nil" data-section-state="integrations">nil</span></summary>
|
|
<div class="section-gate" data-section-gate="integrations">Loading integration gate.</div>
|
|
<p class="notice">This section connects the subsystems and shows every console command as a copyable block. Commands still run outside this browser so secret output never enters the control surface.</p>
|
|
<div id="integrations-records" class="record-list"></div>
|
|
<div class="inline-actions">
|
|
<a class="button-link" href="/oidc/start" target="_blank" rel="noreferrer" title="Start the bootstrap-console OIDC authorization flow through KeyCape.">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.">Open discovery</a>
|
|
<a class="button-link secondary" href="https://kc.coulomb.social/healthz" target="_blank" rel="noreferrer" title="Open the KeyCape health endpoint.">Open health</a>
|
|
</div>
|
|
<div class="choice-list" style="margin-top: 14px;">
|
|
<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>
|
|
<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_init_output_produced" type="checkbox"><span><strong>Init output produced</strong><span>OpenBao generated unseal shares and the initial root token outside this UI. Do not paste those values here.</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="openbao_post_unseal_verified" type="checkbox"><span><strong>Post-unseal verification passed</strong><span>Filesystem and post-unseal readiness checks completed without recording secret material.</span></span></label>
|
|
<label class="choice"><input id="openbao_initial_config_applied" type="checkbox"><span><strong>Initial configuration applied</strong><span>OpenBao auth, mounts, and policies were applied; audit may remain a declarative follow-up.</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 id="command-list" class="command-list"></div>
|
|
</details>
|
|
|
|
<details class="panel workflow-section" data-section="runbooks" open>
|
|
<summary><span class="summary-title">4. Usecases & Runbooks</span><span class="state nil" data-section-state="runbooks">nil</span></summary>
|
|
<div class="section-gate" data-section-gate="runbooks">Loading runbook gate.</div>
|
|
<p class="notice">Use these routines when the ceremony path changes, trial secrets are exposed, or custody material must be regenerated. The UI records only non-secret outcomes.</p>
|
|
<div id="runbooks-records" class="record-list"></div>
|
|
<div class="choice-list">
|
|
<label class="choice"><input id="openbao_trial_material_exposed" type="checkbox"><span><strong>Trial key material exposed</strong><span>Init output, unseal shares, or root-token material escaped the custody boundary during a trial.</span></span></label>
|
|
<label class="choice"><input id="openbao_compromise_response_complete" type="checkbox"><span><strong>Compromise response complete</strong><span>Exposed material was rotated or the trial environment was reset. No secret values are recorded here.</span></span></label>
|
|
<label class="choice"><input id="openbao_unseal_keys_rotated" type="checkbox"><span><strong>New unseal keys generated</strong><span>OpenBao generated replacement unseal shares under the current runbook.</span></span></label>
|
|
</div>
|
|
<div id="runbook-command-list" class="command-list"></div>
|
|
</details>
|
|
|
|
<details class="panel workflow-section" data-section="artifacts" open>
|
|
<summary><span class="summary-title">5. Artefacts & Locations</span><span class="state nil" data-section-state="artifacts">nil</span></summary>
|
|
<div class="section-gate" data-section-gate="artifacts">Loading artefact gate.</div>
|
|
<p class="notice">This is the final overview of what has been established. Locations are references only; passwords, OTP seeds, age private keys, unseal shares, and root tokens are never recorded here.</p>
|
|
<div id="artifacts-records" class="record-list"></div>
|
|
<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. Migration to quorum custody remains 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>
|
|
<ul class="spec-list" style="margin-top: 14px;">
|
|
<li>Recovery material: password recovery, MFA recovery/re-enrollment, custodian age-key recovery, encrypted bundle recovery, and notification contact references.</li>
|
|
<li>Custody packet: selected strategy, recovery references, OpenBao init checklist, unseal-share assignment slots, quorum plan, root-token disposition plan, and signature/date line.</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 secret material here.</span></span></label>
|
|
<label class="choice"><input id="custody_packet_prepared" type="checkbox"><span><strong>Custody packet prepared</strong><span>The offline ceremony packet is ready for the selected strategy. No OpenBao root token or unseal share is recorded here.</span></span></label>
|
|
</div>
|
|
<div class="system-note" style="margin-top: 14px;">Secret capture is enforced by architecture: the control surface does not request secrets, and the gate checks local metadata plus plaintext bootstrap-secret presence.</div>
|
|
<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>
|
|
<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>
|
|
</details>
|
|
</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",
|
|
"role_setup_operator_email",
|
|
"role_platform_custodian_email",
|
|
"role_identity_admin_email",
|
|
"role_openbao_operator_email",
|
|
"role_recovery_custodian_email",
|
|
"role_future_quorum_email",
|
|
"mfa_class",
|
|
"mfa_enrollment_source",
|
|
"mfa_enrollment_reference",
|
|
"mfa_enrolled_confirmed",
|
|
"recovery_confirmed",
|
|
"custody_packet_prepared",
|
|
"openbao_preflight_passed",
|
|
"openbao_init_output_produced",
|
|
"openbao_initialized",
|
|
"openbao_post_unseal_verified",
|
|
"openbao_initial_config_applied",
|
|
"openbao_trial_material_exposed",
|
|
"openbao_compromise_response_complete",
|
|
"openbao_unseal_keys_rotated",
|
|
"root_token_disposition",
|
|
"restore_drill_passed"
|
|
];
|
|
const responsibilityFields = [
|
|
"setup_operator",
|
|
"notification_contact",
|
|
"role_setup_operator_email",
|
|
"role_platform_custodian_email",
|
|
"role_identity_admin_email",
|
|
"role_openbao_operator_email",
|
|
"role_recovery_custodian_email",
|
|
"role_future_quorum_email"
|
|
];
|
|
let currentMetadata = {};
|
|
|
|
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 makeStateBadge(state) {
|
|
const badge = document.createElement("span");
|
|
const value = state || "nil";
|
|
badge.className = "state " + value;
|
|
badge.textContent = value;
|
|
return badge;
|
|
}
|
|
|
|
function makeRoleChip(role, email) {
|
|
const chip = document.createElement("span");
|
|
chip.className = "role-chip";
|
|
chip.textContent = role || "unassigned";
|
|
chip.title = email ? role + " -> " + email : role + " has no designated email";
|
|
return chip;
|
|
}
|
|
|
|
function makeTaintNote(item) {
|
|
if (!item || !item.tainted) return null;
|
|
const note = document.createElement("div");
|
|
note.className = "taint-note";
|
|
const source = item.taint_source || "upstream taint";
|
|
const reference = item.taint_reference ? " (" + item.taint_reference + ")" : "";
|
|
const reason = item.taint_reason ? ": " + item.taint_reason : "";
|
|
note.textContent = "Tainted from " + source + reference + reason;
|
|
return note;
|
|
}
|
|
|
|
function renderRecords(target, rows) {
|
|
const root = document.getElementById(target);
|
|
root.replaceChildren();
|
|
for (const record of rows || []) {
|
|
const row = document.createElement("div");
|
|
row.className = "record-row" + (record.tainted ? " tainted" : "");
|
|
row.append(makeStateBadge(record.state));
|
|
|
|
const identity = document.createElement("div");
|
|
const name = document.createElement("div");
|
|
name.className = "record-name";
|
|
name.textContent = record.name;
|
|
const description = document.createElement("div");
|
|
description.className = "record-description";
|
|
description.textContent = record.description;
|
|
identity.append(name, description);
|
|
const taintNote = makeTaintNote(record);
|
|
if (taintNote) identity.append(taintNote);
|
|
|
|
const context = document.createElement("div");
|
|
context.className = "record-context";
|
|
const subsystem = document.createElement("div");
|
|
subsystem.className = "record-meta";
|
|
subsystem.textContent = record.subsystem;
|
|
const responsibility = document.createElement("div");
|
|
responsibility.append(makeRoleChip(record.responsibility, record.email));
|
|
context.append(subsystem, responsibility);
|
|
|
|
const location = document.createElement("div");
|
|
location.className = "record-meta";
|
|
location.textContent = record.location;
|
|
|
|
row.append(identity, context, location);
|
|
root.append(row);
|
|
}
|
|
}
|
|
|
|
function renderSectionGates(gates) {
|
|
for (const gate of gates || []) {
|
|
const badge = document.querySelector(`[data-section-state='${gate.key}']`);
|
|
if (badge) {
|
|
badge.className = "state " + gate.status;
|
|
badge.textContent = gate.status;
|
|
}
|
|
const message = document.querySelector(`[data-section-gate='${gate.key}']`);
|
|
if (message) {
|
|
message.textContent = gate.reason;
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderCommands(target, commands) {
|
|
const root = document.getElementById(target);
|
|
root.replaceChildren();
|
|
for (const item of commands || []) {
|
|
const row = document.createElement("div");
|
|
row.className = "command-row" + (item.tainted ? " tainted" : "");
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "command-head";
|
|
const title = document.createElement("div");
|
|
const name = document.createElement("div");
|
|
name.className = "record-name";
|
|
name.textContent = item.name;
|
|
const description = document.createElement("div");
|
|
description.className = "record-description";
|
|
description.textContent = item.description;
|
|
const statusReason = document.createElement("div");
|
|
statusReason.className = "record-description";
|
|
statusReason.textContent = item.status_reason || "";
|
|
title.append(name, description, statusReason);
|
|
const taintNote = makeTaintNote(item);
|
|
if (taintNote) title.append(taintNote);
|
|
|
|
const button = document.createElement("button");
|
|
button.className = "copy-button secondary";
|
|
button.type = "button";
|
|
button.textContent = "Copy";
|
|
button.title = "Copy this console command to the clipboard.";
|
|
button.dataset.command = item.command;
|
|
const commandActions = document.createElement("div");
|
|
commandActions.className = "inline-actions";
|
|
commandActions.append(makeStateBadge(item.status), button);
|
|
head.append(title, commandActions);
|
|
|
|
const code = document.createElement("code");
|
|
code.className = "command-code";
|
|
code.textContent = item.command;
|
|
row.append(head, code);
|
|
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 fillResponsibilityFields(metadata) {
|
|
for (const id of responsibilityFields) {
|
|
const element = document.getElementById(id);
|
|
if (element) element.value = metadata[id] || "";
|
|
}
|
|
}
|
|
|
|
function responsibilityPayload() {
|
|
const payload = {};
|
|
for (const id of responsibilityFields) {
|
|
payload[id] = document.getElementById(id).value.trim();
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function setResponsibilityDirty(dirty) {
|
|
document.getElementById("responsibility-save-button").disabled = !dirty;
|
|
document.getElementById("responsibility-cancel-button").disabled = !dirty;
|
|
const badge = document.getElementById("responsibility-edit-state");
|
|
badge.className = "state " + (dirty ? "redo" : "set");
|
|
badge.textContent = dirty ? "redo" : "set";
|
|
}
|
|
|
|
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);
|
|
renderSectionGates(data.section_gates);
|
|
renderRecords("roles-records", data.roles);
|
|
renderRecords("subsystems-records", data.subsystems);
|
|
renderRecords("integrations-records", data.integrations);
|
|
renderRecords("runbooks-records", data.runbooks);
|
|
renderRecords("artifacts-records", data.artifacts);
|
|
renderCommands("command-list", data.commands);
|
|
renderCommands("runbook-command-list", data.runbook_commands);
|
|
currentMetadata = data.metadata || {};
|
|
fillForm(currentMetadata);
|
|
setResponsibilityDirty(false);
|
|
}
|
|
|
|
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(),
|
|
role_setup_operator_email: document.getElementById("role_setup_operator_email").value.trim(),
|
|
role_platform_custodian_email: document.getElementById("role_platform_custodian_email").value.trim(),
|
|
role_identity_admin_email: document.getElementById("role_identity_admin_email").value.trim(),
|
|
role_openbao_operator_email: document.getElementById("role_openbao_operator_email").value.trim(),
|
|
role_recovery_custodian_email: document.getElementById("role_recovery_custodian_email").value.trim(),
|
|
role_future_quorum_email: document.getElementById("role_future_quorum_email").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_init_output_produced: document.getElementById("openbao_init_output_produced").checked,
|
|
openbao_initialized: document.getElementById("openbao_initialized").checked,
|
|
openbao_post_unseal_verified: document.getElementById("openbao_post_unseal_verified").checked,
|
|
openbao_initial_config_applied: document.getElementById("openbao_initial_config_applied").checked,
|
|
openbao_trial_material_exposed: document.getElementById("openbao_trial_material_exposed").checked,
|
|
openbao_compromise_response_complete: document.getElementById("openbao_compromise_response_complete").checked,
|
|
openbao_unseal_keys_rotated: document.getElementById("openbao_unseal_keys_rotated").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("responsibility-editor").addEventListener("input", () => {
|
|
setResponsibilityDirty(true);
|
|
});
|
|
|
|
document.getElementById("responsibility-cancel-button").addEventListener("click", () => {
|
|
fillResponsibilityFields(currentMetadata);
|
|
setResponsibilityDirty(false);
|
|
document.getElementById("responsibility-editor").open = false;
|
|
});
|
|
|
|
document.getElementById("responsibility-save-button").addEventListener("click", async () => {
|
|
const button = document.getElementById("responsibility-save-button");
|
|
button.disabled = true;
|
|
try {
|
|
const response = await fetch("/api/save-progress", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(responsibilityPayload())
|
|
});
|
|
const result = await response.json();
|
|
if (!response.ok) {
|
|
setMessage("Save failed:\\n" + (result.errors || []).map((item) => "- " + item).join("\\n"), "error");
|
|
setResponsibilityDirty(true);
|
|
return;
|
|
}
|
|
await loadStatus();
|
|
document.getElementById("responsibility-editor").open = false;
|
|
setMessage("Responsibilities saved. No secrets were recorded.", "ok");
|
|
} catch (error) {
|
|
setMessage("Save failed: " + error.message, "error");
|
|
setResponsibilityDirty(true);
|
|
}
|
|
});
|
|
|
|
document.addEventListener("click", async (event) => {
|
|
const button = event.target.closest(".copy-button");
|
|
if (!button) return;
|
|
const command = button.dataset.command || "";
|
|
try {
|
|
await navigator.clipboard.writeText(command);
|
|
button.textContent = "Copied";
|
|
setTimeout(() => { button.textContent = "Copy"; }, 1000);
|
|
} catch (error) {
|
|
setMessage("Copy failed: " + error.message, "error");
|
|
}
|
|
});
|
|
|
|
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())
|