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

5
.gitignore vendored
View File

@@ -6,6 +6,10 @@ secrets/
!sso-mfa/bootstrap/secrets.enc/**/*.age
*.kdbx
# Local non-secret operator progress; keep workstation-specific setup state out
# of Git while preserving it across UI/server restarts.
.local/
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -181,4 +185,3 @@ cython_debug/
# PyPI configuration file
.pypirc

View File

@@ -3,6 +3,9 @@ SHELL := /usr/bin/env bash
# Operator age public key — used as bundle encryption recipient
OPERATOR_AGE_PUBKEY := $(shell cat keys/age.pub 2>/dev/null | tr -d '[:space:]')
SECURITY_BOOTSTRAP_METADATA ?= $(if $(METADATA),$(METADATA),.local/security-bootstrap.json)
SECURITY_BOOTSTRAP_HOST ?= $(if $(HOST),$(HOST),127.0.0.1)
SECURITY_BOOTSTRAP_PORT ?= $(if $(PORT),$(PORT),8876)
# ── Help ──────────────────────────────────────────────────────────────────────
help: ## Show this help
@@ -156,22 +159,22 @@ iam-profile-conformance-test: ## Run IAM Profile v0.2 conformance fixture tests
playbook-contract-test: ## Run Playbook Capability Contract fixture tests
python3 -m pytest tools/playbook-capability-contract/tests
security-bootstrap-console: ## Show guided security bootstrap status and safe actions
python3 tools/security-bootstrap-console/security_bootstrap_console.py status
security-bootstrap-console: security-bootstrap-metadata-init ## Show guided security bootstrap status and safe actions
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
status
security-bootstrap-king-kit: ## Print the king credential kit checklist
python3 tools/security-bootstrap-console/security_bootstrap_console.py king-kit
security-bootstrap-validate-kit: ## Validate non-secret king credential metadata: make security-bootstrap-validate-kit METADATA=/tmp/security-bootstrap.json
@[[ -n "$(METADATA)" ]] || (echo "Usage: make security-bootstrap-validate-kit METADATA=/path/to/non-secret.json"; exit 1)
security-bootstrap-validate-kit: ## Validate non-secret king credential metadata
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
--metadata "$(METADATA)" \
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
validate-king-kit
security-bootstrap-approve-custody: ## Approve custody mode metadata: make security-bootstrap-approve-custody METADATA=/tmp/security-bootstrap.json ARGS="--mfa-enrolled-confirmed --mfa-enrollment-source identity-provider --recovery-confirmed --custody-packet-prepared --no-secret-capture-confirmed"
@[[ -n "$(METADATA)" ]] || (echo "Usage: make security-bootstrap-approve-custody METADATA=/path/to/non-secret.json ARGS='--mfa-enrolled-confirmed --mfa-enrollment-source identity-provider --recovery-confirmed --custody-packet-prepared --no-secret-capture-confirmed'"; exit 1)
security-bootstrap-approve-custody: ## Approve custody mode metadata: make security-bootstrap-approve-custody ARGS="--mfa-enrolled-confirmed --mfa-enrollment-source identity-provider --recovery-confirmed --custody-packet-prepared --no-secret-capture-confirmed"
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
--metadata "$(METADATA)" \
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
approve-custody-mode \
--mode "$(if $(MODE),$(MODE),temporary-single-king)" \
$(ARGS)
@@ -183,12 +186,25 @@ security-bootstrap-openbao-preflight: ## Show safe OpenBao preflight commands
python3 tools/security-bootstrap-console/security_bootstrap_console.py openbao-preflight \
--railiance-path ../railiance-platform
security-bootstrap-ui: ## Serve local custody approval UI: make security-bootstrap-ui METADATA=/tmp/security-bootstrap.json PORT=8765
security-bootstrap-metadata-init: ## Create durable local non-secret bootstrap metadata if missing
@mkdir -p "$$(dirname "$(SECURITY_BOOTSTRAP_METADATA)")"
@if [[ -f "$(SECURITY_BOOTSTRAP_METADATA)" ]]; then \
echo "✔ Metadata already exists: $(SECURITY_BOOTSTRAP_METADATA)"; \
elif [[ -f /tmp/net-kingdom-security-bootstrap.json ]]; then \
cp /tmp/net-kingdom-security-bootstrap.json "$(SECURITY_BOOTSTRAP_METADATA)"; \
echo "✔ Imported previous /tmp bootstrap metadata to $(SECURITY_BOOTSTRAP_METADATA)"; \
else \
python3 tools/security-bootstrap-console/security_bootstrap_console.py metadata-template \
> "$(SECURITY_BOOTSTRAP_METADATA)"; \
echo "✔ Created metadata: $(SECURITY_BOOTSTRAP_METADATA)"; \
fi
security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody approval UI on localhost:8876: make security-bootstrap-ui
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
--metadata "$(if $(METADATA),$(METADATA),/tmp/net-kingdom-security-bootstrap.json)" \
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
web-ui \
--host "$(if $(HOST),$(HOST),127.0.0.1)" \
--port "$(if $(PORT),$(PORT),8765)"
--host "$(SECURITY_BOOTSTRAP_HOST)" \
--port "$(SECURITY_BOOTSTRAP_PORT)"
.PHONY: help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \
check-secrets creds-init creds-generate creds-bundle creds-apply creds-verify \
@@ -198,4 +214,4 @@ security-bootstrap-ui: ## Serve local custody approval UI: make security-bootstr
security-bootstrap-console security-bootstrap-king-kit \
security-bootstrap-validate-kit security-bootstrap-approve-custody \
security-bootstrap-custody-packet security-bootstrap-openbao-preflight \
security-bootstrap-ui
security-bootstrap-metadata-init security-bootstrap-ui

View File

@@ -114,7 +114,7 @@ data:
- groups
grant_types:
- authorization_code
token_endpoint_auth_method: client_secret_post
token_endpoint_auth_method: client_secret_basic
response_types:
- code
response_modes:

View File

@@ -98,6 +98,21 @@ bash ./create-secrets.sh
kubectl rollout restart deployment/keycape -n sso
```
If the browser flow reaches the KeyCape OTP screen and then reports
`mfa check error`, refresh the live privacyIDEA token without printing it:
```bash
cd sso-mfa/k8s/keycape
KEYCAPE_PI_REALM=coulomb KUBECTL="${KUBECTL:-kubectl}" \
bash ./refresh-pi-token-live.sh platform-root
```
The helper prompts for the `pi-admin` password, writes the token only into
Kubernetes Secrets, and restarts KeyCape. The current live privacyIDEA realm is
`coulomb`; use `KEYCAPE_PI_REALM=netkingdom` only for an explicit future realm
migration. The helper also restores `privacyidea.requireForAll: true`, which
keeps KeyCape from using the admin token-list API as the MFA-required check.
## OIDC client registration
Downstream applications are registered in the `clients:` block in

View File

@@ -42,6 +42,9 @@ OPENBAO_POD="${OPENBAO_POD:-openbao-0}"
oidc_client_secret="keycape-public-pkce-compatibility-value" \
default_role="platform-admin"
# Keep array-valued groups in groups_claim/bound_claims only. OpenBao
# claim_mappings copy scalar claim values into metadata and will fail if the
# groups array is mapped there.
cat >/tmp/openbao-platform-admin-role.json <<'"'"'ROLE_JSON'"'"'
{
"role_type": "oidc",
@@ -57,8 +60,7 @@ OPENBAO_POD="${OPENBAO_POD:-openbao-0}"
},
"claim_mappings": {
"email": "email",
"preferred_username": "username",
"groups": "groups"
"preferred_username": "username"
},
"policies": ["platform-admin"],
"ttl": "1h"

View File

@@ -54,7 +54,7 @@ spec:
# 2026-05-24: direct-imported into railiance01 k3s for the
# bootstrap-console OIDC/MFA rollout. Use IfNotPresent while the
# HTTP registry push/pull path is being cleaned up.
image: 92.205.130.254:32166/coulomb/key-cape:main-06d20c3
image: 92.205.130.254:32166/coulomb/key-cape:main-nonce-0601
imagePullPolicy: IfNotPresent
ports:

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Patch or verify the KeyCape openbao-admin client in a live Secret.
"""Patch or verify non-secret KeyCape live config requirements.
The script reads a Kubernetes Secret JSON object from stdin. It never prints the
decoded KeyCape config or private key; stdout is either a JSON merge patch for
@@ -32,6 +32,11 @@ OPENBAO_CLIENT = {
"clientType": "public",
}
LLDAP_REQUIRED = {
"userOU": "ou=people",
"groupOU": "ou=groups",
}
def load_config() -> dict[str, Any]:
secret = json.load(sys.stdin)
@@ -83,8 +88,28 @@ def upsert_client(config: dict[str, Any]) -> dict[str, Any]:
return config
def lldap_errors(config: dict[str, Any]) -> list[str]:
lldap = config.get("lldap")
if not isinstance(lldap, dict):
return ["lldap must be a mapping"]
return [
f"lldap.{key} should be {expected!r}"
for key, expected in LLDAP_REQUIRED.items()
if lldap.get(key) != expected
]
def enforce_lldap_defaults(config: dict[str, Any]) -> dict[str, Any]:
lldap = config.get("lldap")
if not isinstance(lldap, dict):
lldap = {}
config["lldap"] = lldap
lldap.update(LLDAP_REQUIRED)
return config
def render_patch(config: dict[str, Any]) -> None:
updated = upsert_client(config)
updated = enforce_lldap_defaults(upsert_client(config))
config_text = yaml.safe_dump(updated, sort_keys=False)
encoded = base64.b64encode(config_text.encode("utf-8")).decode("ascii")
json.dump({"data": {"config.yaml": encoded}}, sys.stdout, separators=(",", ":"))
@@ -92,12 +117,12 @@ def render_patch(config: dict[str, Any]) -> None:
def verify(config: dict[str, Any]) -> None:
errors = client_errors(config)
errors = client_errors(config) + lldap_errors(config)
if errors:
for error in errors:
print(f"[FAIL] {error}")
raise SystemExit(1)
print("[PASS] openbao-admin client has required CLI redirects, scopes, grant type, and public PKCE profile")
print("[PASS] openbao-admin client and LLDAP OU lookup settings are present")
def main() -> None:

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Patch the live KeyCape config Secret with the code-defined OpenBao CLI client.
# Patch the live KeyCape config Secret with non-secret code-defined settings:
# the OpenBao CLI client and LLDAP OU lookup paths.
# This does not require decrypted bootstrap secrets and does not print existing
# Secret values.
@@ -14,4 +15,4 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
| python3 "$SCRIPT_DIR/openbao-client-config.py" patch \
| "$KUBECTL" patch secret "$SECRET" -n "$NAMESPACE" --type merge --patch-file /dev/stdin
echo "Patched $NAMESPACE/$SECRET with the openbao-admin client definition."
echo "Patched $NAMESPACE/$SECRET with the openbao-admin client and LLDAP OU lookup settings."

View File

@@ -12,7 +12,8 @@
#
# Optional environment:
# KUBECTL=/path/to/kubectl
# KEYCAPE_PI_REALM=coulomb|netkingdom
# KEYCAPE_PI_REALM=coulomb|netkingdom # defaults to coulomb, the live privacyIDEA realm
# KEYCAPE_PI_REQUIRE_FOR_ALL=true|false # defaults to true to avoid admin token-list checks
set -euo pipefail
@@ -94,49 +95,67 @@ current_config="$(
current_realm="$(
CONFIG_YAML="$current_config" python3 -c '
import os
import re
import sys
match = re.search(r"(?m)^ realm:\s*[\"'\'']?([^\"'\'']+)", os.environ["CONFIG_YAML"])
print(match.group(1).strip() if match else "")
try:
import yaml
except ImportError:
print("PyYAML is required: install python3-yaml", file=sys.stderr)
sys.exit(1)
config = yaml.safe_load(os.environ["CONFIG_YAML"]) or {}
privacyidea = config.get("privacyidea") or {}
if not isinstance(privacyidea, dict):
print("")
else:
print(str(privacyidea.get("realm") or "").strip())
'
)"
selected_realm="${KEYCAPE_PI_REALM:-}"
if [[ -z "$selected_realm" && -n "$current_realm" ]]; then
selected_realm="$current_realm"
fi
if [[ -z "$selected_realm" ]]; then
selected_realm="coulomb"
selected_realm="${KEYCAPE_PI_REALM:-coulomb}"
selected_require_for_all="${KEYCAPE_PI_REQUIRE_FOR_ALL:-true}"
if [[ -z "${KEYCAPE_PI_REALM:-}" && -n "$current_realm" && "$current_realm" != "$selected_realm" ]]; then
echo "[WARN] KeyCape currently points privacyIDEA at realm '$current_realm'; repairing to '$selected_realm'." >&2
fi
if [[ "$selected_realm" != "coulomb" && "$selected_realm" != "netkingdom" ]]; then
echo "[FAIL] Refusing unsupported privacyIDEA realm: $selected_realm" >&2
exit 1
fi
if [[ "$selected_require_for_all" != "true" && "$selected_require_for_all" != "false" ]]; then
echo "[FAIL] KEYCAPE_PI_REQUIRE_FOR_ALL must be true or false." >&2
exit 1
fi
echo "Selected privacyIDEA realm for KeyCape: $selected_realm"
echo "Selected privacyIDEA requireForAll for KeyCape: $selected_require_for_all"
"$KUBECTL" get secret "$KEYCAPE_SECRET" -n "$SSO_NAMESPACE" \
-o jsonpath='{.data.key\.pem}' | base64 -d > "$tmpdir/key.pem"
chmod 600 "$tmpdir/key.pem"
CONFIG_YAML="$current_config" PI_TOKEN="$PI_TOKEN" PI_REALM="$selected_realm" \
CONFIG_YAML="$current_config" PI_TOKEN="$PI_TOKEN" PI_REALM="$selected_realm" PI_REQUIRE_FOR_ALL="$selected_require_for_all" \
python3 -c '
import json
import os
import re
import sys
config = os.environ["CONFIG_YAML"]
token = json.dumps(os.environ["PI_TOKEN"])
realm = json.dumps(os.environ["PI_REALM"])
config, token_count = re.subn(r"(?m)^ adminToken:.*$", " adminToken: " + token, config)
config, realm_count = re.subn(r"(?m)^ realm:.*$", " realm: " + realm, config)
if token_count != 1 or realm_count != 1:
print("Could not patch exactly one adminToken and one realm field.", file=sys.stderr)
try:
import yaml
except ImportError:
print("PyYAML is required: install python3-yaml", file=sys.stderr)
sys.exit(1)
sys.stdout.write(config)
config = yaml.safe_load(os.environ["CONFIG_YAML"]) or {}
if not isinstance(config, dict):
print("KeyCape config.yaml must decode to a YAML mapping.", file=sys.stderr)
sys.exit(1)
privacyidea = config.setdefault("privacyidea", {})
if not isinstance(privacyidea, dict):
print("KeyCape privacyidea config must decode to a YAML mapping.", file=sys.stderr)
sys.exit(1)
privacyidea["adminToken"] = os.environ["PI_TOKEN"]
privacyidea["realm"] = os.environ["PI_REALM"]
privacyidea["requireForAll"] = os.environ["PI_REQUIRE_FOR_ALL"] == "true"
sys.stdout.write(yaml.safe_dump(config, sort_keys=False))
' > "$tmpdir/config.yaml"
echo "Applying refreshed KeyCape config Secret ..."

View File

@@ -68,11 +68,13 @@ fi
# ── Authenticate ──────────────────────────────────────────────────────────────
echo ""
echo "Authenticating to privacyIDEA at $PI_URL ..."
AUTH_RESPONSE=$(curl -sf -X POST "$PI_URL/auth" \
if ! AUTH_RESPONSE=$(PI_ADMIN_PASS="$PI_ADMIN_PASS" python3 -c '
import json
import os
print(json.dumps({"username": "pi-admin", "password": os.environ["PI_ADMIN_PASS"]}))
' | curl -sS -X POST "$PI_URL/auth" \
-H "Content-Type: application/json" \
-d "{\"username\":\"pi-admin\",\"password\":\"$PI_ADMIN_PASS\"}" 2>/dev/null || echo "CURL_FAILED")
if [[ "$AUTH_RESPONSE" == "CURL_FAILED" ]]; then
--data-binary @- 2>/dev/null); then
echo "ERROR: Could not reach $PI_URL — is the cluster up and privacyIDEA running?" >&2
echo " Run verify-t04.sh to diagnose." >&2
exit 1
@@ -94,10 +96,10 @@ pi_api() {
# BadRequest if Content-Type: application/json is sent on a bodyless GET.
local method="$1"; local path="$2"; local body="${3:-}"
if [[ -n "$body" ]]; then
curl -sf -X "$method" "$PI_URL$path" \
printf '%s' "$body" | curl -sf -X "$method" "$PI_URL$path" \
-H "Authorization: $PI_TOKEN" \
-H "Content-Type: application/json" \
-d "$body" 2>/dev/null || echo "CURL_FAILED"
--data-binary @- 2>/dev/null || echo "CURL_FAILED"
else
curl -sf -X "$method" "$PI_URL$path" \
-H "Authorization: $PI_TOKEN" \
@@ -136,13 +138,13 @@ echo "Step 1: Creating LDAP resolver '$RESOLVER_NAME' ..."
# LLDAP uses standard inetOrgPerson attributes.
USERINFO='{"username": "uid", "phone": "telephoneNumber", "mobile": "mobile", "email": "mail", "surname": "sn", "givenname": "givenName"}'
RESOLVER_BODY=$(python3 -c "
import json, sys
RESOLVER_BODY=$(LLDAP_BIND_PW="$LLDAP_BIND_PW" python3 -c "
import json, os
body = {
'type': 'ldapresolver',
'LDAPURI': '$(echo "$LLDAP_URL" | sed "s/'/'\\''/g")',
'BINDDN': '$(echo "$LLDAP_BIND_DN" | sed "s/'/'\\''/g")',
'BINDPW': sys.argv[1],
'BINDPW': os.environ['LLDAP_BIND_PW'],
'LDAPBASE': '$LLDAP_BASE_DN',
'LOGINNAMEATTRIBUTE': 'uid',
'LDAPSEARCHFILTER': '(objectClass=inetOrgPerson)',
@@ -153,7 +155,7 @@ body = {
'NOSCHEMAS': True
}
print(json.dumps(body))
" "$LLDAP_BIND_PW")
")
RESP=$(pi_api POST "/resolver/$RESOLVER_NAME" "$RESOLVER_BODY")
check_result "LDAP resolver '$RESOLVER_NAME' created/updated" "$RESP" || true

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# repair-realm-live.sh - attended repair for privacyIDEA realm bootstrap state.
#
# This wrapper prompts for live passwords, writes them only to a private
# temporary directory, runs the idempotent realm bootstrap, and removes the
# temporary files on exit.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
SSO_MFA_K8S_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
PI_URL="${PI_URL:-https://pink.coulomb.social}"
if [[ ! -t 0 ]]; then
echo "ERROR: repair-realm-live.sh needs an interactive terminal for password prompts." >&2
exit 1
fi
export PATH="/home/worsch/.local/bin:$PATH"
umask 077
tmp="$(mktemp -d)"
cleanup() {
rm -rf "$tmp"
unset PI_ADMIN_PASSWORD LLDAP_LDAP_USER_PASS
}
trap cleanup EXIT
mkdir -p "$tmp/privacyidea" "$tmp/lldap"
printf "privacyIDEA pi-admin password: " >&2
read -rs PI_ADMIN_PASSWORD
printf "\n" >&2
printf "LLDAP bind/admin password: " >&2
read -rs LLDAP_LDAP_USER_PASS
printf "\n" >&2
printf "PI_ADMIN_PASSWORD=%q\n" "$PI_ADMIN_PASSWORD" > "$tmp/privacyidea/secrets.env"
printf "LLDAP_LDAP_USER_PASS=%q\n" "$LLDAP_LDAP_USER_PASS" > "$tmp/lldap/secrets.env"
bash "$SCRIPT_DIR/bootstrap-realm.sh" "$tmp" "$PI_URL"
if ! bash "$SSO_MFA_K8S_DIR/verify-t06.sh" "$tmp"; then
cat >&2 <<'WARN'
[WARN] verify-t06 still reports failures. If realm, resolver, policies, and
self-service pass but KeyCape token checks fail, run the KeyCape privacyIDEA
MFA token repair action after platform-root enrollment.
WARN
fi
cat <<'OK'
[OK] privacyIDEA coulomb realm repair command finished. Enroll or re-enroll
platform-root TOTP in privacyIDEA next.
OK

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":

View File

@@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: netkingdom
created: "2026-05-24"
updated: "2026-05-26"
updated: "2026-06-01"
depends_on:
- NK-WP-0006
- NK-WP-0012
@@ -407,7 +407,7 @@ remain part of the user-onboarding readiness work in `NET-WP-0017`.
```task
id: NET-WP-0015-T06
status: in_progress
status: done
priority: medium
state_hub_task_id: "ef97f3cb-9792-4b9d-bd2b-8871d368a50f"
```
@@ -416,12 +416,32 @@ Replace temporary operator tokens with NetKingdom IAM-backed OpenBao admin
auth when the issuer and claim mapping are ready. The OpenBao root token must
not be the normal admin path.
**2026-05-26:** The KeyCape `openbao-admin` client is code-defined, patched
**2026-05-26:** The KeyCape `openbao-admin` client was code-defined, patched
into the live `keycape-config` Secret, rolled out, and verified without
requiring decrypted bootstrap secrets. This task remains in progress because
OpenBao `auth/keycape` still needs the fixed helper command to complete and
the MFA-backed `bao login -method=oidc -path=keycape role=platform-admin` path
still needs verification.
requiring decrypted bootstrap secrets. At that point, OpenBao `auth/keycape`
still needed the fixed helper command and the MFA-backed
`bao login -method=oidc -path=keycape role=platform-admin` path still needed
verification.
**2026-06-01:** Added a guided bootstrap runbook action for the live
privacyIDEA state-loss case encountered during OpenBao OIDC login testing. The
new action recreates the `coulomb` realm, `lldap-coulomb` resolver,
self-enrollment policy, and phase-one passthrough policy by prompting for
`pi-admin` and LLDAP bind/admin passwords, writing them only to temporary
files through `repair-realm-live.sh`, and running `bootstrap-realm.sh` plus
`verify-t06.sh`. TOTP enrollment/re-enrollment and the final MFA-backed
OpenBao login verification remain operator steps.
**2026-06-01:** Closed after the `platform-root` MFA-backed OpenBao OIDC login
completed through KeyCape and the resulting token lookup showed
`platform-admin` in both token policy fields. The remaining OpenBao hardening,
audit, escrow, reset/rotation, and reopening gates continue under T07/T08 and
`NET-WP-0017`.
**2026-06-01:** Added OpenBao token revocation to the guided
Usecases & Runbooks section. The UI now includes a self-revoke card for the
current pod token-helper token and an accessor-based revocation card for
disclosed tokens, both keeping OpenBao token values off the local command line.
### T07 - Verify Recovery, Audit, And Rotation

View File

@@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: netkingdom
created: "2026-05-26"
updated: "2026-05-29"
updated: "2026-06-01"
depends_on:
- NET-WP-0015
- NET-WP-0016
@@ -40,8 +40,9 @@ first non-root onboarding dry run must prove the lifecycle model.
- Trial unseal shares were rotated.
- The KeyCape `openbao-admin` client is live and verified, including the public
`https://kc.coulomb.social` route and certificate.
- OpenBao OIDC auth configuration is applied; MFA-backed OpenBao admin login is
still pending.
- OpenBao OIDC auth configuration is applied; MFA-backed OpenBao admin login
completed successfully and the resulting token lookup showed the
`platform-admin` policy for `platform-root`.
- Declarative/durable audit handling, residual taint closeout, cleanup/rotation,
and the first ordinary-user onboarding dry run are still pending.
@@ -51,7 +52,7 @@ first non-root onboarding dry run must prove the lifecycle model.
```task
id: NET-WP-0017-T01
status: in_progress
status: done
priority: high
state_hub_task_id: "9b087bbd-631b-4316-b94d-a8265a05b065"
```
@@ -74,6 +75,51 @@ the OpenBao `auth/keycape` OIDC configuration and `platform-admin` role. The
remaining T01 gate is the human browser login with MFA and a token lookup that
shows the expected OpenBao `platform-admin` policy.
**2026-06-01:** Added a guided console recovery action for the observed
privacyIDEA state-loss blocker: if the live instance lacks the `coulomb` realm,
LLDAP resolver, or self-service policies, the operator can run **Repair
privacyIDEA realm and self-service** from **Usecases & Runbooks**. The action
does not store secrets; it calls `repair-realm-live.sh`, prompts live, creates
temporary env files for `bootstrap-realm.sh`, removes them on exit, and then
runs `verify-t06.sh`. After repair, `platform-root` TOTP
enrollment/re-enrollment and the MFA-backed `bao login` proof are still
required.
**2026-06-01:** Fixed the follow-up OpenBao OIDC token exchange
`user not found` error caused by live `keycape-config` drift: the Secret had
lost the non-secret LLDAP lookup fields `userOU: ou=people` and
`groupOU: ou=groups`. The KeyCape live patch helper now enforces those fields
alongside the `openbao-admin` client, the live Secret was patched, KeyCape was
restarted, and `verify-openbao-client.sh` passes again.
**2026-06-01:** Deployed a KeyCape runtime lookup fix for the remaining
`user not found` token-exchange failure after config drift was ruled out. The
LDAP adapter now treats provisioning metadata validation failures as runtime
warnings instead of blocking token issuance for an otherwise resolved LLDAP
user. The patched image `main-runtime-lookup-0601` is live and
`verify-openbao-client.sh` passes after rollout.
**2026-06-01:** Deployed the follow-up KeyCape OIDC nonce fix after OpenBao
rejected the exchanged ID token with `invalid id_token nonce`. KeyCape now
persists the original authorization `nonce` through pending state and the
authorization-code session, then emits it in the ID token. The patched image
`main-nonce-0601` is live, reports 1/1 ready, and `verify-openbao-client.sh`
passes after rollout.
**2026-06-01:** Fixed the next OpenBao role configuration failure,
`error converting claim 'groups' to string`. KeyCape correctly emits `groups`
as an array for `groups_claim`; OpenBao only failed because the role also copied
that array through scalar `claim_mappings`. The helper now leaves groups in
`groups_claim`/`bound_claims` and maps only scalar `email` and
`preferred_username` metadata.
**2026-06-01:** The operator reached the OpenBao success page, "Signed in via
your OIDC provider", after reapplying the corrected role. The follow-up
terminal proof showed `token_policies`/`policies` containing `platform-admin`,
`token_meta_role: platform-admin`, and `token_meta_username: platform-root`.
T01 is closed; the pasted short-lived token should be treated as disclosed and
revoked or allowed to expire after the check.
### T02 - Close OpenBao Audit And Recovery Production Gates
```task