Close OpenBao OIDC admin bootstrap path

This commit is contained in:
2026-06-01 21:20:53 +02:00
parent ed2cc17165
commit c48e076429
15 changed files with 374 additions and 86 deletions

View File

@@ -23,7 +23,7 @@ Validate non-secret kit metadata:
```bash
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
--metadata /tmp/security-bootstrap.json \
--metadata .local/security-bootstrap.json \
validate-king-kit
```
@@ -31,7 +31,7 @@ Approve custody mode from the CLI:
```bash
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
--metadata /tmp/security-bootstrap.json \
--metadata .local/security-bootstrap.json \
approve-custody-mode \
--mode temporary-single-king \
--mfa-enrolled-confirmed \
@@ -69,12 +69,14 @@ from local metadata and plaintext bootstrap-secret presence.
Serve the local approval UI:
```bash
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
--metadata /tmp/security-bootstrap.json \
web-ui
make security-bootstrap-ui
```
Open `http://127.0.0.1:8765`.
Open `http://127.0.0.1:8876`.
The Make target stores non-secret progress in `.local/security-bootstrap.json`.
That directory is intentionally ignored by Git so local setup state survives
UI/server restarts without being committed.
The web UI is structured as:
@@ -85,7 +87,8 @@ The web UI is structured as:
3. **Integration & Tests** - OIDC and OpenBao preflight checks, with every
operator command shown as a copyable console block.
4. **Usecases & Runbooks** - guided routines for key-material compromise,
trial-output exposure, and generating replacement unseal keys.
trial-output exposure, replacement unseal keys, and OpenBao token
revocation.
5. **Artefacts & Locations** - final non-secret overview of established
artefacts and where to find their custody references.
@@ -106,6 +109,12 @@ mark the trial output as exposed, stop treating the generated unseal shares or
root token as production material, then either rotate unseal keys after unseal
or reset the trial environment before any live secrets are migrated.
The **OpenBao token revocation** runbook includes a self-revoke action for the
token currently stored in the OpenBao pod token helper and an accessor-based
revocation action for accidentally disclosed tokens. The accessor path prompts
inside the pod for a root/sudo-capable OpenBao token and avoids placing token
values on the local command line.
The UI is a guide and approval surface, not the identity provider. Current
lightweight-mode credential placement is:
@@ -147,6 +156,16 @@ key and verify the factor. Admin-assisted token assignment is a fallback only;
record it as the MFA enrollment source, but never record the seed, QR code, or
recovery codes in this UI.
If the live privacyIDEA instance has lost the `coulomb` realm, LLDAP resolver,
or self-service policies, open **Usecases & Runbooks** and copy **Repair
privacyIDEA realm and self-service**. The action is attended: it prompts for
the `pi-admin` password and the LLDAP bind/admin password, writes them only to a
private temporary directory, runs
`sso-mfa/k8s/privacyidea/repair-realm-live.sh`, removes the temporary files on
exit, and then runs `sso-mfa/k8s/verify-t06.sh`. The UI does not store either
password, and TOTP enrollment or re-enrollment remains a human step in
`https://pink-account.coulomb.social`.
After doing that, return to the control surface, set account reference
`platform-root@lldap`, check `Account created`, `Admin group assigned`, and
`Password stored`, then save progress.
@@ -207,10 +226,10 @@ Optional non-secret metadata can be supplied:
```bash
python3 tools/security-bootstrap-console/security_bootstrap_console.py metadata-template \
> /tmp/security-bootstrap.json
> .local/security-bootstrap.json
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
--metadata /tmp/security-bootstrap.json \
--metadata .local/security-bootstrap.json \
status
```

View File

@@ -29,8 +29,8 @@ from typing import Any
DEFAULT_STAGE = "S1 - Low-trust assembly"
STAGE_ORDER = ("S1", "S2", "S3", "S4", "S5", "S6")
DEFAULT_METADATA_PATH = Path("/tmp/net-kingdom-security-bootstrap.json")
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_METADATA_PATH = REPO_ROOT / ".local/security-bootstrap.json"
APPROVAL_PHRASE = "approve custody mode"
VALID_STORAGE_CLASSES = {"password-safe", "offline-packet", "hardware-token"}
VALID_MFA_CLASSES = {"totp", "webauthn", "hardware-token"}
@@ -48,6 +48,7 @@ OIDC_SCOPE = "openid profile email groups"
OIDC_CODE_VERIFIER = "netkingdom-bootstrap-local-oidc-verifier-2026-v1"
KEYCAPE_OPENBAO_CLIENT_ID = "openbao-admin"
KEYCAPE_OPENBAO_CLIENT_CONFIG = REPO_ROOT / "sso-mfa/k8s/keycape/create-secrets.sh"
PRIVACYIDEA_REALM_REPAIR = REPO_ROOT / "sso-mfa/k8s/privacyidea/repair-realm-live.sh"
KEYCAPE_OPENBAO_CLIENT_REDIRECTS = (
"http://localhost:8250/oidc/callback",
"http://127.0.0.1:8250/oidc/callback",
@@ -1410,18 +1411,21 @@ def admin_identity_command_payloads(data: dict[str, Any]) -> list[dict[str, str]
refresh_pi_token_command = (
"set -euo pipefail\n"
f"cd {keycape_dir}\n"
f"KUBECTL={kubectl_bin} bash ./refresh-pi-token-live.sh platform-root\n"
f"KEYCAPE_PI_REALM=coulomb KUBECTL={kubectl_bin} bash ./refresh-pi-token-live.sh platform-root\n"
)
login_command = (
"# Terminal 1: bridge the browser callback to the bao CLI running in the OpenBao pod.\n"
"kubectl -n openbao port-forward pod/openbao-0 8250:8250\n\n"
"# Terminal 2: run the pod-bundled bao CLI, then copy the printed login URL into your local browser if needed.\n"
"kubectl exec -it -n openbao openbao-0 -- sh -lc '\n"
"# Terminal 1: start the pod-bundled bao CLI and wait until it prints the login URL.\n"
f"{kubectl_bin} exec -it -n openbao openbao-0 -- sh -lc '\n"
" export BAO_ADDR=http://127.0.0.1:8200\n"
" bao login -method=oidc -path=keycape role=platform-admin \\\n"
" skip_browser=true listenaddress=0.0.0.0 callbackhost=127.0.0.1 port=8250\n"
" bao token lookup\n"
"'"
"'\n\n"
"# Terminal 2: start this only after Terminal 1 is waiting for OIDC authentication.\n"
f"{kubectl_bin} -n openbao port-forward pod/openbao-0 8250:8250\n\n"
"# Browser: open the URL printed by Terminal 1. If Firefox already failed at\n"
"# 127.0.0.1:8250 before the port-forward was ready, reload that callback URL\n"
"# or restart Terminal 1 to get a fresh login URL."
)
return [
@@ -1714,7 +1718,20 @@ def runbook_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
if not initial_config_applied:
restore_location = "Template: prepare now; execute after initial OpenBao configuration exists and before live secrets move in."
token_revocation_location = "Template: revoke the current helper token after attended checks, or revoke a disclosed token by accessor using a root/sudo-capable token."
if not initialized:
token_revocation_location = "Template: prepare now; execution needs an initialized OpenBao instance."
return [
{
"name": "privacyIDEA realm repair",
"description": "Recreate the coulomb realm, LLDAP resolver, and self-service policies without storing live passwords.",
"subsystem": "privacyIDEA",
"responsibility": "identity-admin",
"email": role_email(data, "role_identity_admin_email"),
"location": "Template: run the attended realm repair action, then enroll or re-enroll platform-root TOTP in the repaired realm.",
"state": "template",
},
add_taint(
{
"name": "Key material compromised",
@@ -1763,6 +1780,18 @@ def runbook_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
},
openbao_downstream_taint if initialized else {},
),
add_taint(
{
"name": "OpenBao token revocation",
"description": "Retire short-lived, temporary, or accidentally disclosed OpenBao tokens without putting token values on the local command line.",
"subsystem": "Railiance OpenBao",
"responsibility": "openbao-ceremony-operator",
"email": role_email(data, "role_openbao_operator_email"),
"location": token_revocation_location,
"state": "template",
},
openbao_downstream_taint if initialized else {},
),
]
@@ -1823,6 +1852,17 @@ def runbook_command_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
platform_admin_token_command = token_prompt_command(
"bao token create -policy=platform-admin -period=24h -orphan"
)
revoke_self_token_command = (
"kubectl exec -it -n openbao openbao-0 -- sh -lc '\n"
" export BAO_ADDR=http://127.0.0.1:8200\n"
" bao token lookup >/dev/null\n"
" bao token revoke -self\n"
"'"
)
revoke_accessor_command = interactive_token_command(
'printf "Token accessor to revoke: " >&2; read -r TARGET_ACCESSOR; '
'bao token revoke -accessor "$TARGET_ACCESSOR"; unset TARGET_ACCESSOR'
)
rotate_init_command = interactive_token_command(
"bao operator rotate-keys -init -key-shares=3 -key-threshold=2"
)
@@ -1880,8 +1920,14 @@ def runbook_command_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
"7. Destroy the isolated environment and record only non-secret evidence in this UI.\n"
"RESTORE_DRILL"
)
privacyidea_realm_command = f"bash {shlex.quote(str(PRIVACYIDEA_REALM_REPAIR))}"
return [
action(
"Repair privacyIDEA realm and self-service",
"Prompt for pi-admin and LLDAP bind passwords, recreate the coulomb realm, LLDAP resolver, self-enrollment policy, and passthrough bootstrap policy, then run T06 verification. The wrapper keeps passwords in a private temporary directory that is removed on exit.",
privacyidea_realm_command,
),
action(
"OpenBao status",
"Show seal, initialization, storage, and HA state for the OpenBao pod. This command does not require a token.",
@@ -1918,6 +1964,18 @@ def runbook_command_payloads(data: dict[str, Any]) -> list[dict[str, str]]:
platform_admin_token_command,
downstream_taint,
),
action(
"Revoke current OpenBao token",
"Revoke the token currently stored in the OpenBao pod token helper, usually immediately after an attended verification flow.",
revoke_self_token_command,
downstream_taint,
),
action(
"Revoke OpenBao token by accessor",
"Prompt inside the pod for a root/sudo-capable token and the disclosed token accessor, then revoke that accessor without placing the token value on the local command line.",
revoke_accessor_command,
downstream_taint,
),
action(
"Start unseal-key rotation",
"Run once to start a new 3-share, threshold-2 rotation. If rotation is already in progress, do not rerun init; check status and submit existing shares.",
@@ -3288,7 +3346,6 @@ def ui_html() -> str:
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";
if (item.status) commandActions.append(makeStateBadge(item.status));
@@ -3477,7 +3534,9 @@ def ui_html() -> str:
document.addEventListener("click", async (event) => {
const button = event.target.closest(".copy-button");
if (!button) return;
const command = button.dataset.command || "";
const row = button.closest(".command-row");
const code = row ? row.querySelector(".command-code") : null;
const command = code ? code.textContent : "";
try {
await navigator.clipboard.writeText(command);
button.textContent = "Copied";
@@ -3642,6 +3701,8 @@ def make_ui_handler(metadata_path: Path) -> type[BaseHTTPRequestHandler]:
def serve_web_ui(args: argparse.Namespace) -> int:
metadata_path = args.metadata or DEFAULT_METADATA_PATH
if not metadata_path.exists():
write_metadata(metadata_path, metadata_template())
handler = make_ui_handler(metadata_path)
httpd = ThreadingHTTPServer((args.host, args.port), handler)
host = html.escape(args.host)
@@ -3736,6 +3797,9 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
metadata_commands = {"status", "validate-king-kit", "approve-custody-mode", "web-ui"}
if args.command in metadata_commands and args.metadata is None:
args.metadata = DEFAULT_METADATA_PATH
data = load_metadata(args.metadata)
if args.command == "status":