From d555a33695325c8531cc3a321d95a0d6e17cd42a Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 24 May 2026 17:04:15 +0200 Subject: [PATCH] bootstrapping guidance ui and missing stuff --- docs/platform-root-custody.md | 48 ++ docs/security-bootstrap-age-custody.md | 72 +++ .../security-bootstrap-king-credential-kit.md | 27 + .../king-credential-metadata.example.json | 14 + sso-mfa/bootstrap/encrypt-secrets.sh | 59 +- sso-mfa/k8s/keycape/README.md | 30 + sso-mfa/k8s/keycape/create-secrets.sh | 16 + tools/security-bootstrap-console/README.md | 68 +++ .../security_bootstrap_console.py | 563 +++++++++++++++++- ...-custody-and-openbao-identity-bootstrap.md | 52 ++ 10 files changed, 913 insertions(+), 36 deletions(-) create mode 100644 docs/security-bootstrap-age-custody.md diff --git a/docs/platform-root-custody.md b/docs/platform-root-custody.md index 3f1b451..c605c7e 100644 --- a/docs/platform-root-custody.md +++ b/docs/platform-root-custody.md @@ -76,6 +76,54 @@ break-glass material. The preferred end state is: - Routine access flows through NetKingdom IAM claims and scoped OpenBao policies. +## Where The King Credential Lives + +The king credential is an identity and custody record, not an OpenBao password. +In the current lightweight NetKingdom stack, the practical placement is: + +| Part | Current home | Notes | +| --- | --- | --- | +| User record | LLDAP | dedicated `platform-root` or `king` user, separate from `tegwick` | +| Password login | Authelia over LLDAP | day-to-day email is notification-only | +| TOTP / token enrollment | privacyIDEA | QR/setup key comes from privacyIDEA self-service | +| privacyIDEA administration | `pi-admin` | setup and repair account, not the king credential | +| IAM Profile / OIDC token | KeyCape | target issuer for normal platform identity claims | +| Secret custody and audit | OpenBao | initialized after custody approval; not the human identity provider | + +OpenBao enters the story after custody approval: it holds platform secrets, +unseal/recovery material handling, audit, policies, and temporary or future +admin auth methods. It should not be used as the place where the human king +password or OTP seed lives. + +LLDAP deliberately has no public registration flow. The first-user process is +administrator-provisioned: + +1. Log in to `https://lldap.coulomb.social` as `admin`. +2. Retrieve `LLDAP_LDAP_USER_PASS` from the operator password safe entry + `net-kingdom/LLDAP/admin`. +3. Create the dedicated `platform-root` or `king` user. +4. Add the user to `net-kingdom-admins` for the current lightweight path. +5. Store the new account password only in the password safe or offline custody + packet. +6. Use `pi-admin` in privacyIDEA to confirm that the LLDAP resolver can see + the new user and that self-enrollment is allowed. +7. Log in to privacyIDEA self-service as `platform-root` and enroll the TOTP + token there. The QR/setup key belongs only in the authenticator and custody + storage, never in this repo, State Hub, chat, or the local control surface. +8. Verify the OIDC login path through a registered KeyCape client. KeyCape is + an issuer, not a dashboard; the root URL may return `404` while discovery, + health, authorize, token, and userinfo endpoints are healthy. + +The local control surface records this as non-secret progress: account +reference, group assignment confirmation, password-safe confirmation, and later +login-path verification. It does not read or store the password, OTP seed, QR +code, or recovery codes. + +Do not use a successful `platform-admin` login as proof of `platform-root` +custody unless the recorded king credential was intentionally changed to +`platform-admin`. `platform-admin` is the later non-root operator path; it is +useful, but it is not the same as proving the platform-root custody identity. + ## Trust Progression The platform moves through explicit trust stages: diff --git a/docs/security-bootstrap-age-custody.md b/docs/security-bootstrap-age-custody.md new file mode 100644 index 0000000..9155397 --- /dev/null +++ b/docs/security-bootstrap-age-custody.md @@ -0,0 +1,72 @@ +# Bootstrap Age Custody + +Status: draft control-surface contract +Date: 2026-05-24 + +## Purpose + +The custodian age keypair is the bootstrap envelope key for NetKingdom security +setup. It is not a login account and it is not the runtime secret manager. + +The public key may be stored in the control surface and used by automation to +encrypt generated bootstrap bundles. The private key is supplied only during an +explicit unlock/apply or recovery ceremony. + +## Minimal Secret Model + +The minimal human-held secret set is: + +| Secret | Home | Notes | +| --- | --- | --- | +| Custodian age private key | Password safe or offline custody packet | Used to decrypt bootstrap bundles for an attended apply/recovery ceremony | +| Platform-root password | Password safe or offline custody packet | Dedicated custody identity, not day-to-day account | +| Platform-root OTP/recovery | Authenticator plus offline recovery | Not stored in Git, State Hub, or bootstrap metadata | +| OpenBao recovery/unseal material | OpenBao custody packet | Created only after custody approval | + +Everything else should be generated, encrypted to the custodian public key, +applied to the cluster, then shredded or rotated into OpenBao once OpenBao is +available. + +## Trial Versus Custody Mode + +Trial mode uses throwaway values to document the steps, UI, failure modes, and +operator instructions. Nothing created in trial mode should be trusted as live +security material. + +Custody mode uses real generated secrets. The expected lifecycle is: + +1. Register the custodian public age recipient in the control surface. +2. Generate or recover bootstrap secrets. +3. Encrypt the bundle to the custodian public key. +4. Apply secrets to the cluster during an attended ceremony. +5. Shred plaintext `sso-mfa/bootstrap/secrets/`. +6. Record only non-secret fingerprints, timestamps, and state transitions. + +## Control Surface Rules + +The NetKingdom control surface may store: + +- custodian public age recipient; +- public-key fingerprint; +- private-key location reference, such as a password-safe entry label; +- encrypted bundle path and file count; +- whether plaintext bootstrap secrets are currently present; and +- non-secret apply/verification status. + +It must not store: + +- `AGE-SECRET-KEY-1...` private key material; +- generated passwords; +- OTP seeds; +- OpenBao root tokens or unseal shares; +- decrypted bootstrap files; or +- screenshots of secret output. + +## Current Tooling Alignment + +`sso-mfa/bootstrap/encrypt-secrets.sh` should accept an age public recipient +directly. Encryption does not require the private key. + +`sso-mfa/bootstrap/decrypt-secrets.sh` requires the private key path and should +be used only in an explicit unlock/apply ceremony. After apply, plaintext files +must be shredded. diff --git a/docs/security-bootstrap-king-credential-kit.md b/docs/security-bootstrap-king-credential-kit.md index c632c7d..67a5127 100644 --- a/docs/security-bootstrap-king-credential-kit.md +++ b/docs/security-bootstrap-king-credential-kit.md @@ -53,12 +53,18 @@ The UI may record: | Field | Example | | --- | --- | | Credential label | `platform-root` | +| Identity account home | `lldap` | +| Identity account reference | `platform-root@lldap` | +| Identity account created | `true` only after the dedicated account exists | +| Identity group reference | `net-kingdom-admins` | +| Identity group confirmed | `true` only after the account is assigned to the group | | Custody posture | `temporary-single-king` or `two-of-three-planned` | | Notification contact | `bernd.worsch@gmail.com` | | Setup operator | `tegwick` | | Created date | `2026-05-24` | | Review date | date for next custody review | | Storage class | `password-safe`, `offline-paper`, `hardware-token`, or similar | +| Password safe confirmed | `true` only after the credential is stored outside this UI | | MFA class | `totp`, `webauthn`, `hardware-token`, or similar | | MFA enrolled confirmed | `true` only after the factor is enrolled with its verifier | | MFA enrollment source | non-secret source label such as `identity-provider` or `hardware-registration` | @@ -86,6 +92,16 @@ Suggested label: `platform-root`. The UI should explain that this is not a normal user and not a day-to-day admin account. It is rare root custody. +### 1a. Create The Identity Account + +In the current lightweight stack, create the dedicated account in LLDAP and +record only a non-secret reference such as `platform-root@lldap`. The password +belongs in the operator password safe or offline custody packet, not in the +bootstrap metadata. + +For the first lightweight path, assign the account to `net-kingdom-admins`. +This is a non-secret membership fact and may be recorded as confirmed. + ### 2. Choose Storage Allowed first-version choices: @@ -116,6 +132,14 @@ must not generate an orphan OTP seed because it would not authenticate anything. The console records only that enrollment completed and where, without storing the seed, QR code, recovery codes, or screenshots. +In the current NetKingdom lightweight stack, `pi-admin` is the privacyIDEA +administrator for checking the LLDAP resolver, realm, and self-enrollment +policy. It is not the king credential. The preferred flow is to log in to the +privacyIDEA self-service portal as `platform-root` and enroll the token there. +If the self-service flow is not working yet, `pi-admin` may assign a token as +an admin-assisted fallback, but the seed and recovery values still remain out +of the bootstrap metadata. + ### 4. Prepare Recovery The operator confirms that recovery codes or equivalent recovery material exist @@ -155,7 +179,10 @@ The software must not fill secret fields. The king credential kit is complete when: - the credential label exists; +- the dedicated identity account exists; +- the required admin group assignment is confirmed; - storage choice is recorded; +- the password is confirmed stored outside this UI; - second factor is enrolled with its real verifier and confirmed; - recovery material is confirmed; - custody mode is selected; diff --git a/examples/security-bootstrap/king-credential-metadata.example.json b/examples/security-bootstrap/king-credential-metadata.example.json index 559862d..b656259 100644 --- a/examples/security-bootstrap/king-credential-metadata.example.json +++ b/examples/security-bootstrap/king-credential-metadata.example.json @@ -1,11 +1,22 @@ { + "bootstrap_mode": "custody", + "custodian_age_public_key": "", + "custodian_age_public_key_confirmed": false, + "custodian_age_private_key_reference": "", + "custodian_age_private_key_confirmed": false, "credential_label": "platform-root", + "identity_account_home": "lldap", + "identity_account_reference": "", + "identity_account_created": false, + "identity_group_reference": "net-kingdom-admins", + "identity_group_confirmed": false, "setup_operator": "tegwick", "notification_contact": "bernd.worsch@gmail.com", "storage_classes": [ "password-safe", "offline-packet" ], + "password_safe_confirmed": false, "mfa_class": "totp", "mfa_enrolled_confirmed": false, "mfa_enrollment_source": "deferred", @@ -19,6 +30,9 @@ "custody_approved_at": "", "custody_approved_by": "", "approval_scope": "", + "oidc_login_verified": false, + "metadata_updated_at": "", + "progress_scope": "", "openbao_preflight_passed": false, "openbao_initialized": false, "root_token_disposition": "", diff --git a/sso-mfa/bootstrap/encrypt-secrets.sh b/sso-mfa/bootstrap/encrypt-secrets.sh index df950f2..9a150c2 100755 --- a/sso-mfa/bootstrap/encrypt-secrets.sh +++ b/sso-mfa/bootstrap/encrypt-secrets.sh @@ -2,13 +2,16 @@ # encrypt-secrets.sh — encrypt secrets/ directory to secrets.enc/ using age # # Usage: -# ./encrypt-secrets.sh [SECRETS_DIR] [AGE_KEY_FILE] +# ./encrypt-secrets.sh [SECRETS_DIR] [AGE_RECIPIENT_OR_KEY_FILE] # -# SECRETS_DIR plaintext secrets directory (default: ./secrets) -# AGE_KEY_FILE age private key file (default: ~/.config/net-kingdom/age.key) +# SECRETS_DIR plaintext secrets directory (default: ./secrets) +# AGE_RECIPIENT_OR_KEY_FILE age public key, public-key file, or private-key +# file with public-key comment +# (default: ~/.config/net-kingdom/age.key) # -# Reads the public key from the age key file and encrypts each *.env file -# (and pi.enc if present) to secrets.enc//.age. +# Encrypts each *.env file (and pi.enc if present) to +# secrets.enc//.age. Prefer passing a public age recipient +# for normal bootstrap; the private key is needed only for decrypt/apply. # # After a successful encrypt, shreds the plaintext secrets directory unless # --no-shred is passed. @@ -19,7 +22,7 @@ set -euo pipefail SECRETS_DIR="${1:-./secrets}" -AGE_KEY="${2:-$HOME/.config/net-kingdom/age.key}" +AGE_RECIPIENT_OR_KEY="${2:-$HOME/.config/net-kingdom/age.key}" NO_SHRED=false for arg in "$@"; do [[ "$arg" == "--no-shred" ]] && NO_SHRED=true; done @@ -29,18 +32,40 @@ if [[ ! -d "$SECRETS_DIR" ]]; then exit 1 fi -if [[ ! -f "$AGE_KEY" ]]; then - echo "ERROR: age key not found: $AGE_KEY" >&2 - echo "Generate with: age-keygen -o $AGE_KEY" >&2 - exit 1 -fi +resolve_recipient() { + local source="$1" + if [[ "$source" == age1* ]]; then + printf '%s\n' "$source" + return 0 + fi + if [[ ! -f "$source" ]]; then + echo "ERROR: age recipient/key file not found: $source" >&2 + echo "Pass an age public recipient such as age1... or a file containing it." >&2 + return 1 + fi + local recipient + recipient=$(grep -m1 '^age1' "$source" || true) + if [[ -n "$recipient" ]]; then + printf '%s\n' "$recipient" + return 0 + fi + recipient=$(grep -m1 'public key:' "$source" | awk '{print $NF}' || true) + if [[ -n "$recipient" ]]; then + printf '%s\n' "$recipient" + return 0 + fi + if grep -q 'AGE-SECRET-KEY-1' "$source"; then + recipient=$(age-keygen -y "$source" 2>/dev/null || true) + if [[ -n "$recipient" ]]; then + printf '%s\n' "$recipient" + return 0 + fi + fi + echo "ERROR: could not resolve an age public recipient from $source" >&2 + return 1 +} -# Extract public key from the private key file -PUBKEY=$(grep 'public key:' "$AGE_KEY" | awk '{print $NF}') -if [[ -z "$PUBKEY" ]]; then - echo "ERROR: could not read public key from $AGE_KEY" >&2 - exit 1 -fi +PUBKEY=$(resolve_recipient "$AGE_RECIPIENT_OR_KEY") ENC_DIR="$(dirname "$SECRETS_DIR")/secrets.enc" mkdir -p "$ENC_DIR" diff --git a/sso-mfa/k8s/keycape/README.md b/sso-mfa/k8s/keycape/README.md index 736067f..172406c 100644 --- a/sso-mfa/k8s/keycape/README.md +++ b/sso-mfa/k8s/keycape/README.md @@ -12,6 +12,12 @@ the full authentication flow: KeyCape is stateless — all state lives in Authelia (sessions), LLDAP (users), and privacyIDEA (MFA tokens). No PVC is required. +The Authelia `baseURL` in `create-secrets.sh` must be the browser-facing +`https://auth.coulomb.social` URL. KeyCape uses it to build the redirect sent +to the user's browser during `/authorize`; a cluster-internal service URL or +relative Authelia path will make the public OIDC login flow land on a 404 even +when discovery and health checks are working. + ## Prerequisites - T04 complete (privacyIDEA is Running and bootstrapped — admin account + enckey done) @@ -114,6 +120,25 @@ clients: clientType: "public" ``` +For the local NetKingdom bootstrap console login check, keep the dedicated +bootstrap client registered with exact local callback URIs: + +```yaml +clients: + - clientId: "netkingdom-bootstrap-console" + displayName: "NetKingdom Bootstrap Console" + redirectUris: + - "http://127.0.0.1:8876/oidc/callback" + - "http://localhost:8876/oidc/callback" + allowedScopes: ["openid", "profile", "email", "groups"] + grantTypes: ["authorization_code"] + clientType: "public" +``` + +The local callback page exchanges the authorization code and displays only +non-secret claims. KeyCape presents a browser OTP challenge between Authelia +password login and the final OIDC redirect whenever privacyIDEA requires MFA. + ## Secrets managed | Secret name | Keys | Purpose | @@ -142,4 +167,9 @@ curl -s https://kc.coulomb.social/.well-known/openid-configuration | jq . # Check issuer matches CP-NK-004 curl -s https://kc.coulomb.social/.well-known/openid-configuration \ | jq -r .issuer # should be: https://kc.coulomb.social + +# Browser login redirect should start at KeyCape and then leave the kc host for +# Authelia. If it redirects to /api/oidc/authorization on kc.coulomb.social, +# regenerate keycape-config and restart KeyCape after confirming the Authelia +# browserBaseURL above. ``` diff --git a/sso-mfa/k8s/keycape/create-secrets.sh b/sso-mfa/k8s/keycape/create-secrets.sh index d99723c..e28bea8 100644 --- a/sso-mfa/k8s/keycape/create-secrets.sh +++ b/sso-mfa/k8s/keycape/create-secrets.sh @@ -80,7 +80,13 @@ lldap: baseDN: "dc=netkingdom,dc=local" authelia: + # Cluster-internal URL for server-side token exchange. baseURL: "http://authelia.sso.svc.cluster.local:9091" + # Browser-facing URL. KeyCape redirects the user's browser here for the + # upstream Authelia password step, so this must not be the cluster-internal + # service URL. + browserBaseURL: "https://auth.coulomb.social" + tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091" clientId: "keycape" clientSecret: "${AUTHELIA_CLIENT_SECRET}" redirectURI: "https://kc.coulomb.social/authorize/callback" @@ -98,10 +104,20 @@ clients: displayName: "Demo Application" redirectUris: - "http://localhost:3000/callback" + - "http://127.0.0.1:8876/oidc/callback" + - "http://localhost:8876/oidc/callback" - "https://demo.coulomb.social/callback" allowedScopes: ["openid", "profile", "email", "groups"] grantTypes: ["authorization_code"] clientType: "public" + - clientId: "netkingdom-bootstrap-console" + displayName: "NetKingdom Bootstrap Console" + redirectUris: + - "http://127.0.0.1:8876/oidc/callback" + - "http://localhost:8876/oidc/callback" + allowedScopes: ["openid", "profile", "email", "groups"] + grantTypes: ["authorization_code"] + clientType: "public" EOF ) diff --git a/tools/security-bootstrap-console/README.md b/tools/security-bootstrap-console/README.md index 67caec7..498b2e2 100644 --- a/tools/security-bootstrap-console/README.md +++ b/tools/security-bootstrap-console/README.md @@ -59,6 +59,74 @@ python3 tools/security-bootstrap-console/security_bootstrap_console.py \ Open `http://127.0.0.1:8765`. +The UI is a guide and approval surface, not the identity provider. Current +lightweight-mode credential placement is: + +- bootstrap bundle encryption: custodian age public key; +- user record: LLDAP (`https://lldap.coulomb.social`); +- MFA enrollment and QR/setup key: privacyIDEA self-service + (`https://pink-account.coulomb.social`); +- privacyIDEA setup/admin repair: `pi-admin` at + `https://pink.coulomb.social`; +- OIDC/IAM Profile token issuer: KeyCape (`https://kc.coulomb.social`); +- secret custody and OpenBao admin policies: OpenBao, after the attended + ceremony. + +The UI opens the external authority in a new browser tab and records only +non-secret progress. It does not embed or prefill secret-bearing forms unless a +future audited integration is built for that authority. + +The custodian age public key is safe to store here and is used as the recipient +for encrypted bootstrap bundles. The private age key is not stored here. Record +only a non-secret private-key custody reference, such as a password-safe entry +label or offline packet label. See +`docs/security-bootstrap-age-custody.md` for the trust model. + +LLDAP has no public registration flow. The first user path is: + +1. Log in to `https://lldap.coulomb.social` as `admin`. +2. Retrieve `LLDAP_LDAP_USER_PASS` from the password safe entry + `net-kingdom/LLDAP/admin`. +3. Create the dedicated `platform-root` or `king` account. +4. Add it to `net-kingdom-admins` for the current lightweight path. +5. Store the new account password only in the password safe/offline custody + packet, not in this metadata file. + +For OTP enrollment, do not create a separate shadow identity in privacyIDEA if +the LLDAP resolver is working. Use `pi-admin` to verify or repair the +privacyIDEA realm, resolver, and self-enrollment policy. Then use +`platform-root` in the self-service portal to generate the QR code or setup +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. + +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. + +KeyCape does not have a dashboard at its root URL; `https://kc.coulomb.social` +returning `404` is expected. Use +`https://kc.coulomb.social/.well-known/openid-configuration` for issuer +discovery or a registered OIDC client to test real login. The bootstrap UI acts +as the local `netkingdom-bootstrap-console` callback at +`http://127.0.0.1:8876/oidc/callback`. +Treat that as a login-path check only: it should force LLDAP password auth and +privacyIDEA MFA, then return to the local callback page. The callback exchanges +the code and shows non-secret claims only; it does not store tokens, OTP values, +or passwords. Mark `OIDC login verified` only for the same identity recorded in +the credential section. + +If the login-check flow redirects to +`https://kc.coulomb.social/api/oidc/authorization...` and lands on a 404, the +KeyCape service is reachable but its browser-facing Authelia redirect config is +not yet rolled out. Regenerate `keycape-config` with +`sso-mfa/k8s/keycape/create-secrets.sh` and restart the KeyCape deployment +after confirming `authelia.browserBaseURL` is `https://auth.coulomb.social`. + +After Authelia password login, KeyCape should show a compact OTP challenge if +privacyIDEA reports that MFA is required. Only then should it issue the final +OIDC authorization code back to the local callback. + Print a blank offline custody packet template: ```bash diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index 60736bf..001a0ee 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -9,10 +9,15 @@ live OpenBao initialization. from __future__ import annotations import argparse +import base64 +import hashlib import html import json import subprocess import sys +import urllib.error +import urllib.parse +import urllib.request from dataclasses import dataclass from datetime import datetime, timezone from http import HTTPStatus @@ -34,6 +39,12 @@ VALID_MFA_ENROLLMENT_SOURCES = { } VALID_CUSTODY_MODES = {"temporary-single-king", "two-of-three-planned", "two-of-three-ready"} CUSTODY_APPROVAL_MODES = {"temporary-single-king", "two-of-three-ready"} +KEYCAPE_ISSUER = "https://kc.coulomb.social" +OIDC_CLIENT_ID = "netkingdom-bootstrap-console" +OIDC_SCOPE = "openid profile email groups" +OIDC_CODE_VERIFIER = "netkingdom-bootstrap-local-oidc-verifier-2026-v1" +AGE_PUBLIC_PREFIX = "age1" +AGE_PRIVATE_MARKER = "AGE-SECRET-KEY-1" @dataclass(frozen=True) @@ -101,6 +112,101 @@ def second_factor_reason(data: dict[str, Any]) -> str: return "Second factor enrollment is confirmed without recording seed material." +def identity_account_ready(data: dict[str, Any]) -> bool: + return ( + yes(data, "identity_account_created") + and bool(data.get("identity_account_reference")) + and yes(data, "identity_group_confirmed") + and bool(data.get("identity_group_reference")) + ) + + +def identity_account_reason(data: dict[str, Any]) -> str: + if not yes(data, "identity_account_created"): + return "Create the dedicated platform-root account in LLDAP first." + if not data.get("identity_account_reference"): + return "Record a non-secret account reference such as platform-root@lldap." + if not yes(data, "identity_group_confirmed"): + return "Confirm the account is assigned to the required LLDAP admin group." + if not data.get("identity_group_reference"): + return "Record the non-secret group reference such as net-kingdom-admins." + return "Dedicated identity account is recorded without storing its password." + + +def password_safe_ready(data: dict[str, Any]) -> bool: + return yes(data, "password_safe_confirmed") + + +def identity_login_ready(data: dict[str, Any]) -> bool: + return yes(data, "oidc_login_verified") + + +def extract_age_public_key(value: Any) -> str: + if value is None: + return "" + text = str(value).strip() + if AGE_PRIVATE_MARKER in text: + return "" + for token in text.replace(",", " ").split(): + clean = token.strip().strip('"').strip("'") + if clean.startswith(AGE_PUBLIC_PREFIX): + return clean + return "" + + +def age_public_key_fingerprint(public_key: str) -> str: + if not public_key: + return "" + digest = hashlib.sha256(public_key.encode("utf-8")).hexdigest() + return f"sha256:{digest[:16]}" + + +def bootstrap_secret_state() -> dict[str, Any]: + root = Path.cwd() + bootstrap_dir = root / "sso-mfa" / "bootstrap" + encrypted_dir = bootstrap_dir / "secrets.enc" + plaintext_dir = bootstrap_dir / "secrets" + encrypted_files = sorted(encrypted_dir.glob("*/*.age")) if encrypted_dir.exists() else [] + plaintext_files = sorted(path for path in plaintext_dir.glob("*/*") if path.is_file()) if plaintext_dir.exists() else [] + return { + "bootstrap_dir": str(bootstrap_dir), + "encrypted_bundle_path": str(encrypted_dir), + "encrypted_bundle_exists": encrypted_dir.exists(), + "encrypted_file_count": len(encrypted_files), + "plaintext_secrets_path": str(plaintext_dir), + "plaintext_secrets_present": plaintext_dir.exists(), + "plaintext_file_count": len(plaintext_files), + } + + +def key_custody_validation(data: dict[str, Any]) -> list[Gate]: + public_key = extract_age_public_key(data.get("custodian_age_public_key")) + state = bootstrap_secret_state() + plaintext_present = bool(state["plaintext_secrets_present"]) + return [ + Gate( + "Custodian public key", + "done" if public_key and yes(data, "custodian_age_public_key_confirmed") else "blocked", + "Register the custodian age public key used to encrypt bootstrap bundles.", + ), + Gate( + "Private key custody", + "done" if data.get("custodian_age_private_key_reference") and yes(data, "custodian_age_private_key_confirmed") else "blocked", + "Record only where the private key is held; never paste it into this UI.", + ), + Gate( + "Encrypted bundle", + "done" if state["encrypted_bundle_exists"] and state["encrypted_file_count"] else "blocked", + "Encrypted bootstrap secrets should live under sso-mfa/bootstrap/secrets.enc/.", + ), + Gate( + "Plaintext exposure", + "blocked" if plaintext_present else "done", + "Plaintext sso-mfa/bootstrap/secrets/ is present; shred it after any apply ceremony." if plaintext_present else "No plaintext bootstrap secrets directory is present.", + ), + ] + + def kit_validation(data: dict[str, Any]) -> list[Gate]: storage_classes = data.get("storage_classes", []) if not isinstance(storage_classes, list): @@ -113,6 +219,11 @@ def kit_validation(data: dict[str, Any]) -> list[Gate]: "done" if data.get("credential_label") else "blocked", "Use a dedicated label such as platform-root.", ), + Gate( + "Identity account", + "done" if identity_account_ready(data) else "blocked", + identity_account_reason(data), + ), Gate( "Setup operator/contact", "done" if data.get("setup_operator") and data.get("notification_contact") else "blocked", @@ -123,11 +234,21 @@ def kit_validation(data: dict[str, Any]) -> list[Gate]: "done" if storage_values & VALID_STORAGE_CLASSES else "blocked", "Select password-safe, offline-packet, hardware-token, or a combination.", ), + Gate( + "Password safe storage", + "done" if password_safe_ready(data) else "blocked", + "Confirm the credential password is stored in the password safe without recording it here.", + ), Gate( "Second factor", "done" if second_factor_ready(data) else "blocked", second_factor_reason(data), ), + Gate( + "Identity login path", + "done" if identity_login_ready(data) else "blocked", + "Verify the dedicated account can complete the NetKingdom login path.", + ), Gate( "Recovery material", "done" if yes(data, "recovery_confirmed") else "blocked", @@ -243,15 +364,27 @@ def next_action(gates: list[Gate]) -> str: def print_status(data: dict[str, Any]) -> None: - gates = build_gates(data) + merged = metadata_template() + merged.update(data) + gates = build_gates(merged) + key_gates = key_custody_validation(merged) + state = bootstrap_secret_state() print("SECURITY BOOTSTRAP") print("") print("Stage") - print(derive_stage(data)) + print(derive_stage(merged)) print("") print("Next safe action") print(next_action(gates)) print("") + print("Key custody") + public_key = extract_age_public_key(merged.get("custodian_age_public_key")) + print(f"- fingerprint: {age_public_key_fingerprint(public_key) or 'not registered'}") + print(f"- encrypted bundle files: {state['encrypted_file_count']} at {state['encrypted_bundle_path']}") + print(f"- plaintext secrets present: {state['plaintext_secrets_present']}") + for gate in key_gates: + print(f"- {gate.status}: {gate.name} - {gate.reason}") + print("") print("Gates") for gate in gates: print(f"- {gate.status}: {gate.name} - {gate.reason}") @@ -316,6 +449,11 @@ def merged_approval_metadata( data.update(existing) text_fields = ( "credential_label", + "bootstrap_mode", + "identity_account_home", + "identity_account_reference", + "identity_group_reference", + "custodian_age_private_key_reference", "setup_operator", "notification_contact", "mfa_class", @@ -326,20 +464,36 @@ def merged_approval_metadata( ) for field in text_fields: if field in payload and payload[field] is not None: - data[field] = str(payload[field]).strip() + value = str(payload[field]).strip() + data[field] = "" if AGE_PRIVATE_MARKER in value else value + if "custodian_age_public_key" in payload: + data["custodian_age_public_key"] = extract_age_public_key(payload["custodian_age_public_key"]) if "storage_classes" in payload: data["storage_classes"] = normalize_storage_classes(payload["storage_classes"]) for field in ( + "custodian_age_public_key_confirmed", + "custodian_age_private_key_confirmed", "recovery_confirmed", "custody_packet_prepared", "no_secret_capture_confirmed", "mfa_enrolled_confirmed", + "identity_account_created", + "identity_group_confirmed", + "oidc_login_verified", + "password_safe_confirmed", ): if field in payload: data[field] = payload[field] is True return data +def save_progress_metadata(existing: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + data = merged_approval_metadata(existing, payload) + data["metadata_updated_at"] = utc_now() + data["progress_scope"] = "Non-secret local bootstrap progress only." + return data + + def validate_custody_approval( data: dict[str, Any], approval_phrase: str, @@ -400,6 +554,9 @@ def print_approve_custody_mode(args: argparse.Namespace, data: dict[str, Any]) - } for key in ( "credential_label", + "identity_account_home", + "identity_account_reference", + "identity_group_reference", "setup_operator", "notification_contact", "mfa_class", @@ -417,6 +574,10 @@ def print_approve_custody_mode(args: argparse.Namespace, data: dict[str, Any]) - "custody_packet_prepared", "no_secret_capture_confirmed", "mfa_enrolled_confirmed", + "identity_account_created", + "identity_group_confirmed", + "oidc_login_verified", + "password_safe_confirmed", ): if getattr(args, field): payload[field] = True @@ -491,10 +652,21 @@ def print_handover_checklist() -> None: def metadata_template() -> dict[str, Any]: return { + "bootstrap_mode": "custody", + "custodian_age_public_key": "", + "custodian_age_public_key_confirmed": False, + "custodian_age_private_key_reference": "", + "custodian_age_private_key_confirmed": False, "credential_label": "platform-root", + "identity_account_home": "lldap", + "identity_account_reference": "", + "identity_account_created": False, + "identity_group_reference": "net-kingdom-admins", + "identity_group_confirmed": False, "setup_operator": "tegwick", "notification_contact": "bernd.worsch@gmail.com", "storage_classes": ["password-safe", "offline-packet"], + "password_safe_confirmed": False, "mfa_class": "totp", "mfa_enrolled_confirmed": False, "mfa_enrollment_source": "deferred", @@ -508,6 +680,9 @@ def metadata_template() -> dict[str, Any]: "custody_approved_at": "", "custody_approved_by": "", "approval_scope": "", + "oidc_login_verified": False, + "metadata_updated_at": "", + "progress_scope": "", "openbao_preflight_passed": False, "openbao_initialized": False, "root_token_disposition": "", @@ -553,19 +728,187 @@ def gate_payload(gate: Gate) -> dict[str, str]: def status_payload(data: dict[str, Any], metadata_path: Path) -> dict[str, Any]: - gates = build_gates(data) + merged = metadata_template() + merged.update(data) + gates = build_gates(merged) + metadata_view = dict(merged) + public_key = extract_age_public_key(metadata_view.get("custodian_age_public_key")) + metadata_view["custodian_age_public_key"] = public_key + metadata_view["custodian_age_public_key_fingerprint"] = age_public_key_fingerprint(public_key) return { "metadata_path": str(metadata_path), - "stage": derive_stage(data), + "stage": derive_stage(merged), "next_action": next_action(gates), "gates": [gate_payload(gate) for gate in gates], - "kit_gates": [gate_payload(gate) for gate in kit_validation(data)], - "metadata": data, + "key_custody_gates": [gate_payload(gate) for gate in key_custody_validation(merged)], + "kit_gates": [gate_payload(gate) for gate in kit_validation(merged)], + "bootstrap_secret_state": bootstrap_secret_state(), + "metadata": metadata_view, "approval_phrase": APPROVAL_PHRASE, "custody_approval_modes": sorted(CUSTODY_APPROVAL_MODES), } +def oidc_code_challenge() -> str: + digest = hashlib.sha256(OIDC_CODE_VERIFIER.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + +def local_oidc_redirect_uri(host: str) -> str: + clean_host = host.strip() or "127.0.0.1:8876" + return f"http://{clean_host}/oidc/callback" + + +def local_oidc_start_url(host: str) -> str: + params = { + "response_type": "code", + "client_id": OIDC_CLIENT_ID, + "redirect_uri": local_oidc_redirect_uri(host), + "scope": OIDC_SCOPE, + "state": "netkingdom-bootstrap-login-check", + "code_challenge": oidc_code_challenge(), + "code_challenge_method": "S256", + } + return f"{KEYCAPE_ISSUER}/authorize?{urllib.parse.urlencode(params)}" + + +def decode_jwt_payload(token: str) -> dict[str, Any]: + parts = token.split(".") + if len(parts) < 2: + return {} + payload = parts[1] + payload += "=" * (-len(payload) % 4) + try: + decoded = base64.urlsafe_b64decode(payload.encode("ascii")) + claims = json.loads(decoded) + except (ValueError, json.JSONDecodeError): + return {} + return claims if isinstance(claims, dict) else {} + + +def exchange_oidc_code(code: str, host: str) -> dict[str, Any]: + form = urllib.parse.urlencode( + { + "grant_type": "authorization_code", + "client_id": OIDC_CLIENT_ID, + "code": code, + "code_verifier": OIDC_CODE_VERIFIER, + "redirect_uri": local_oidc_redirect_uri(host), + } + ).encode("utf-8") + request = urllib.request.Request( + f"{KEYCAPE_ISSUER}/token", + data=form, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("token endpoint returned a non-object JSON payload") + return payload + + +def oidc_result_html(query: str, host: str) -> str: + params = urllib.parse.parse_qs(query) + error = params.get("error", [""])[0] + description = params.get("error_description", [""])[0] + code = params.get("code", [""])[0] + state = params.get("state", [""])[0] + title = "OIDC Login Check" + status = "Waiting for callback result." + rows: list[tuple[str, str]] = [] + note = ( + "No tokens or OTP values are stored by this local page. If token exchange " + "succeeds, only non-secret claims are shown." + ) + + if error: + status = "Login did not complete." + rows.append(("Error", error)) + if description: + rows.append(("Description", description)) + elif not code: + status = "No authorization code was returned." + note = ( + "Start the check from the bootstrap console. If the browser never " + "returns here, KeyCape may still need its public Authelia redirect " + "configuration or a browser OTP prompt." + ) + else: + try: + token_payload = exchange_oidc_code(code, host) + claims = decode_jwt_payload(str(token_payload.get("access_token", ""))) + status = "OIDC login path completed." + rows.extend( + [ + ("State", state or "(none)"), + ("Issuer", str(claims.get("iss", ""))), + ("Audience", str(claims.get("aud", ""))), + ("Subject", str(claims.get("sub", ""))), + ("Username", str(claims.get("preferred_username", ""))), + ("Email", str(claims.get("email", ""))), + ("Groups", json.dumps(claims.get("groups", []))), + ] + ) + note = ( + "Return to the bootstrap console, check OIDC login verified for " + "the same account, and save progress." + ) + except urllib.error.HTTPError as exc: + body = exc.read(1000).decode("utf-8", "replace") + status = "Authorization returned, but token exchange failed." + rows.extend( + [ + ("HTTP status", str(exc.code)), + ("Endpoint", f"{KEYCAPE_ISSUER}/token"), + ("Response", body), + ] + ) + note = ( + "This usually means the live KeyCape config has not yet registered " + "this local callback URI, the code expired, or the OTP browser " + "prompt path is still incomplete." + ) + except Exception as exc: # noqa: BLE001 - local diagnostic page + status = "Authorization returned, but token exchange could not run." + rows.append(("Error", str(exc))) + + table_rows = "\n".join( + f"{html.escape(label)}{html.escape(value)}" + for label, value in rows + ) + return f""" + + + + + {html.escape(title)} + + + +
+
+

{html.escape(status)}

+

{html.escape(note)}

+ {table_rows}
+ Return to bootstrap console +
+
+ +""" + + def ui_html() -> str: return """ @@ -703,6 +1046,17 @@ def ui_html() -> str: font-weight: 650; line-height: 1.2; } + .step-number { + display: inline-grid; + place-items: center; + width: 20px; + height: 20px; + border: 1px solid var(--line); + border-radius: 999px; + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 12px; + background: var(--hi); + } .choice span { color: var(--muted); display: block; @@ -724,6 +1078,12 @@ def ui_html() -> str: align-items: center; margin-top: 16px; } + .inline-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + } button { min-height: 42px; border: 1px solid var(--ink); @@ -739,6 +1099,23 @@ def ui_html() -> str: background: var(--paper); color: var(--ink); } + .button-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + border: 1px solid var(--ink); + border-radius: 4px; + background: var(--ink); + color: #ffffff; + font-weight: 650; + padding: 7px 11px; + text-decoration: none; + } + .button-link.secondary { + background: var(--paper); + color: var(--ink); + } button:disabled { cursor: wait; opacity: 0.65; @@ -797,6 +1174,11 @@ def ui_html() -> str: font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 13px; } + a { + color: var(--ink); + text-decoration-thickness: 1px; + text-underline-offset: 3px; + } @media (max-width: 820px) { header { padding: 18px; } main { padding: 16px; } @@ -832,6 +1214,73 @@ def ui_html() -> str:
+
+

Bootstrap key custody

+

The custodian age public key encrypts bootstrap bundles. The private key is ceremony material and must not be pasted into this UI.

+
+
1Register public recipientPaste only the custodian public age recipient, for example age1.... This value is safe to store and lets tools encrypt new bootstrap bundles.
+
2Record private-key custodyRecord a non-secret reference such as KeePassXC: custodian/age/private or offline USB label. The actual private key is provided only during an unlock/apply ceremony.
+
3Use trial before custodyTrial mode may use throwaway values to document the process. Custody mode encrypts real generated secrets immediately and shreds plaintext after apply.
+
+
+ + + + +
+
+ + +
+
+ +
+

Credential home

+

Guide mode. OpenBao stores and audits secrets after the ceremony; it does not create the king account.

+
+
1Open LLDAP as bootstrap adminLLDAP has no public registration. Log in as admin using LLDAP_LDAP_USER_PASS from your password safe entry net-kingdom/LLDAP/admin. That value was generated during installation and injected into the lldap-secrets Kubernetes Secret.Open LLDAP
+
2Create dedicated accountCreate a dedicated platform-root or king user. Suggested values: username platform-root, notification contact bernd.worsch@gmail.com. Add it to net-kingdom-admins for the current lightweight path. Do not use tegwick as this account.
+
3Enroll MFAUse pi-admin only to confirm the LLDAP resolver, realm, and self-enrollment policy. Then log in to privacyIDEA self-service as the account recorded above, usually platform-root, for the QR code or setup key. If self-service is not ready, use admin-assisted token assignment as a fallback and record that as the enrollment source.Open self-serviceOpen admin
+
4Confirm identity pathKeyCape is an OIDC issuer, not a dashboard; its root path returning 404 is expected. The login check starts the dedicated bootstrap-console OIDC client and should return to this console. If it never reaches the callback page, KeyCape may still need the public Authelia redirect config, this callback URI registration, or a browser OTP prompt. Mark OIDC verified only after the browser flow works for the same account.Start OIDC login checkOpen discoveryOpen health
+
5Then OpenBaoAfter custody approval, the OpenBao ceremony creates unseal shares, root-token disposition, policies, and temporary admin access.
+
+
+ + + +
+
+ + + + +
+
+

King credential

Local non-secret metadata only. OpenBao initialization stays manual.

@@ -907,8 +1356,9 @@ def ui_html() -> str:
- - + + +
Waiting for local approval.
@@ -919,6 +1369,10 @@ def ui_html() -> str:

Bootstrap gates

+
+

Key custody

+
+

Kit gates

@@ -928,7 +1382,20 @@ def ui_html() -> str: