bootstrapping guidance ui and missing stuff

This commit is contained in:
2026-05-24 17:04:15 +02:00
parent 1d0b0e7330
commit d555a33695
10 changed files with 913 additions and 36 deletions

View File

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

View File

@@ -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.

View File

@@ -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;

View File

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

View File

@@ -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/<component>/<filename>.age.
# Encrypts each *.env file (and pi.enc if present) to
# secrets.enc/<component>/<filename>.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"

View File

@@ -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.
```

View File

@@ -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
)

View File

@@ -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

View File

@@ -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"<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">
@@ -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:
<div class="layout">
<form id="approval-form">
<section class="panel">
<h2>Bootstrap key custody</h2>
<p class="notice">The custodian age public key encrypts bootstrap bundles. The private key is ceremony material and must not be pasted into this UI.</p>
<div class="choice-list">
<div class="choice"><span class="step-number">1</span><span><strong>Register public recipient</strong><span>Paste only the custodian public age recipient, for example <code>age1...</code>. This value is safe to store and lets tools encrypt new bootstrap bundles.</span></span></div>
<div class="choice"><span class="step-number">2</span><span><strong>Record private-key custody</strong><span>Record a non-secret reference such as <code>KeePassXC: custodian/age/private</code> or <code>offline USB label</code>. The actual private key is provided only during an unlock/apply ceremony.</span></span></div>
<div class="choice"><span class="step-number">3</span><span><strong>Use trial before custody</strong><span>Trial mode may use throwaway values to document the process. Custody mode encrypts real generated secrets immediately and shreds plaintext after apply.</span></span></div>
</div>
<div class="grid" style="margin-top: 14px;">
<label class="field">
<span class="label">Mode</span>
<select id="bootstrap_mode">
<option value="trial">Trial</option>
<option value="custody">Custody</option>
</select>
</label>
<label class="field">
<span class="label">Public key fingerprint</span>
<input id="custodian_age_public_key_fingerprint" type="text" readonly title="Derived from the public age recipient; useful for comparing with your password safe entry.">
</label>
<label class="field">
<span class="label">Custodian public age key</span>
<input id="custodian_age_public_key" type="text" autocomplete="off" placeholder="age1..." title="Public recipient only. Never paste AGE-SECRET-KEY material here.">
</label>
<label class="field">
<span class="label">Private key custody reference</span>
<input id="custodian_age_private_key_reference" type="text" autocomplete="off" placeholder="KeePassXC: custodian/age/private" title="Non-secret pointer to where the private key is held; not the key itself.">
</label>
</div>
<div class="choice-list" style="margin-top: 14px;">
<label class="choice"><input id="custodian_age_public_key_confirmed" type="checkbox"><span><strong>Public key confirmed</strong><span>The public recipient matches the custodian key material you intend to use.</span></span></label>
<label class="choice"><input id="custodian_age_private_key_confirmed" type="checkbox"><span><strong>Private key location confirmed</strong><span>You know where the private key is stored and can unlock it intentionally later.</span></span></label>
</div>
</section>
<section class="panel">
<h2>Credential home</h2>
<p class="notice">Guide mode. OpenBao stores and audits secrets after the ceremony; it does not create the king account.</p>
<div class="choice-list">
<div class="choice"><span class="step-number">1</span><span><strong>Open LLDAP as bootstrap admin</strong><span>LLDAP has no public registration. Log in as <code>admin</code> using <code>LLDAP_LDAP_USER_PASS</code> from your password safe entry <code>net-kingdom/LLDAP/admin</code>. That value was generated during installation and injected into the <code>lldap-secrets</code> Kubernetes Secret.</span><span class="inline-actions"><a class="button-link" href="https://lldap.coulomb.social" target="_blank" rel="noreferrer" title="Open the LLDAP admin UI. This path uses password auth only and must be restricted before production.">Open LLDAP</a></span></span></div>
<div class="choice"><span class="step-number">2</span><span><strong>Create dedicated account</strong><span>Create a dedicated <code>platform-root</code> or <code>king</code> user. Suggested values: username <code>platform-root</code>, notification contact <code>bernd.worsch@gmail.com</code>. Add it to <code>net-kingdom-admins</code> for the current lightweight path. Do not use <code>tegwick</code> as this account.</span></span></div>
<div class="choice"><span class="step-number">3</span><span><strong>Enroll MFA</strong><span>Use <code>pi-admin</code> only to confirm the LLDAP resolver, realm, and self-enrollment policy. Then log in to privacyIDEA self-service as the account recorded above, usually <code>platform-root</code>, for the QR code or setup key. If self-service is not ready, use admin-assisted token assignment as a fallback and record that as the enrollment source.</span><span class="inline-actions"><a class="button-link" href="https://pink-account.coulomb.social" target="_blank" rel="noreferrer" title="Open privacyIDEA self-service to enroll or test the platform-root OTP factor.">Open self-service</a><a class="button-link secondary" href="https://pink.coulomb.social" target="_blank" rel="noreferrer" title="Open privacyIDEA admin. Use pi-admin here only to check resolver, realm, policy, or fallback token assignment.">Open admin</a></span></span></div>
<div class="choice"><span class="step-number">4</span><span><strong>Confirm identity path</strong><span>KeyCape is an OIDC issuer, not a dashboard; its root path returning 404 is expected. The login check starts the dedicated bootstrap-console OIDC client and should return to this console. If it never reaches the callback page, KeyCape may still need the public Authelia redirect config, this callback URI registration, or a browser OTP prompt. Mark OIDC verified only after the browser flow works for the same account.</span><span class="inline-actions"><a class="button-link" href="/oidc/start" target="_blank" rel="noreferrer" title="Start the bootstrap-console OIDC authorization flow through KeyCape. It should return to this local console callback without storing tokens.">Start OIDC login check</a><a class="button-link secondary" href="https://kc.coulomb.social/.well-known/openid-configuration" target="_blank" rel="noreferrer" title="Open KeyCape OIDC discovery JSON. This proves the issuer metadata is published.">Open discovery</a><a class="button-link secondary" href="https://kc.coulomb.social/healthz" target="_blank" rel="noreferrer" title="Open the KeyCape health endpoint. This proves the service process responds.">Open health</a></span></span></div>
<div class="choice"><span class="step-number">5</span><span><strong>Then OpenBao</strong><span>After custody approval, the OpenBao ceremony creates unseal shares, root-token disposition, policies, and temporary admin access.</span></span></div>
</div>
<div class="grid" style="margin-top: 14px;">
<label class="field">
<span class="label">Account home</span>
<input id="identity_account_home" type="text" autocomplete="off" value="lldap">
</label>
<label class="field">
<span class="label">Account reference</span>
<input id="identity_account_reference" type="text" autocomplete="off" placeholder="platform-root@lldap">
</label>
<label class="field">
<span class="label">Admin group</span>
<input id="identity_group_reference" type="text" autocomplete="off" value="net-kingdom-admins">
</label>
</div>
<div class="choice-list" style="margin-top: 14px;">
<label class="choice"><input id="identity_account_created" type="checkbox"><span><strong>Account created</strong><span>The dedicated identity account exists in LLDAP. No password is stored here.</span></span></label>
<label class="choice"><input id="identity_group_confirmed" type="checkbox"><span><strong>Admin group assigned</strong><span>The account is a member of <code>net-kingdom-admins</code> in LLDAP.</span></span></label>
<label class="choice"><input id="password_safe_confirmed" type="checkbox"><span><strong>Password stored</strong><span>The account password is stored in your password safe or offline custody packet. No value is stored here.</span></span></label>
<label class="choice"><input id="oidc_login_verified" type="checkbox"><span><strong>OIDC login verified</strong><span>The account can complete the NetKingdom login path through KeyCape after MFA enrollment.</span></span></label>
</div>
</section>
<section class="panel">
<h2>King credential</h2>
<p class="notice">Local non-secret metadata only. OpenBao initialization stays manual.</p>
@@ -907,8 +1356,9 @@ def ui_html() -> str:
<input id="approval_phrase" type="text" autocomplete="off" placeholder="approve custody mode">
</div>
<div class="actions">
<button id="approve-button" type="submit">Approve custody mode</button>
<button class="secondary" id="refresh-button" type="button">Refresh</button>
<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 mode only after all kit gates are satisfied.">Approve custody mode</button>
<button class="secondary" id="refresh-button" type="button" title="Reload the local metadata and gate status from disk.">Refresh</button>
</div>
<div id="message" class="message" role="status">Waiting for local approval.</div>
</section>
@@ -919,6 +1369,10 @@ def ui_html() -> str:
<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>
@@ -928,7 +1382,20 @@ def ui_html() -> str:
</main>
<script>
const fields = [
"bootstrap_mode",
"custodian_age_public_key",
"custodian_age_public_key_fingerprint",
"custodian_age_public_key_confirmed",
"custodian_age_private_key_reference",
"custodian_age_private_key_confirmed",
"credential_label",
"identity_account_home",
"identity_account_reference",
"identity_account_created",
"identity_group_reference",
"identity_group_confirmed",
"password_safe_confirmed",
"oidc_login_verified",
"setup_operator",
"notification_contact",
"mfa_class",
@@ -994,6 +1461,7 @@ def ui_html() -> str:
document.getElementById("next-action").textContent = data.next_action;
document.getElementById("metadata-path").textContent = data.metadata_path;
renderGates("gates", data.gates);
renderGates("key-gates", data.key_custody_gates);
renderGates("kit-gates", data.kit_gates);
fillForm(data.metadata || {});
}
@@ -1003,7 +1471,19 @@ def ui_html() -> str:
.map((input) => input.value);
const mode = document.querySelector("[name='custody_mode']:checked");
return {
bootstrap_mode: document.getElementById("bootstrap_mode").value,
custodian_age_public_key: document.getElementById("custodian_age_public_key").value.trim(),
custodian_age_public_key_confirmed: document.getElementById("custodian_age_public_key_confirmed").checked,
custodian_age_private_key_reference: document.getElementById("custodian_age_private_key_reference").value.trim(),
custodian_age_private_key_confirmed: document.getElementById("custodian_age_private_key_confirmed").checked,
credential_label: document.getElementById("credential_label").value.trim(),
identity_account_home: document.getElementById("identity_account_home").value.trim(),
identity_account_reference: document.getElementById("identity_account_reference").value.trim(),
identity_account_created: document.getElementById("identity_account_created").checked,
identity_group_reference: document.getElementById("identity_group_reference").value.trim(),
identity_group_confirmed: document.getElementById("identity_group_confirmed").checked,
password_safe_confirmed: document.getElementById("password_safe_confirmed").checked,
oidc_login_verified: document.getElementById("oidc_login_verified").checked,
setup_operator: document.getElementById("setup_operator").value.trim(),
notification_contact: document.getElementById("notification_contact").value.trim(),
mfa_class: document.getElementById("mfa_class").value,
@@ -1046,6 +1526,29 @@ def ui_html() -> str:
}
});
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.", ""));
});
@@ -1073,26 +1576,41 @@ def make_ui_handler(metadata_path: Path) -> type[BaseHTTPRequestHandler]:
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:
if self.path == "/" or self.path == "/index.html":
body = ui_html().encode("utf-8")
self.send_response(HTTPStatus.OK.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(body)))
self.end_headers()
self.wfile.write(body)
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/" or parsed.path == "/index.html":
self.send_html(HTTPStatus.OK, ui_html())
return
if self.path == "/api/status":
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 != "/api/approve-custody":
if self.path not in {"/api/approve-custody", "/api/save-progress"}:
self.send_error(HTTPStatus.NOT_FOUND.value)
return
try:
@@ -1112,6 +1630,13 @@ def make_ui_handler(metadata_path: Path) -> type[BaseHTTPRequestHandler]:
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)

View File

@@ -132,6 +132,58 @@ key must come from the authority that will verify login, not from the local
metadata console. Custody approval now requires explicit non-secret
confirmation that the factor was enrolled with its real verifier.
**2026-05-24:** Clarified credential placement in the UI and custody docs:
the dedicated king account currently belongs in the lightweight NetKingdom
identity path (LLDAP user, Authelia login, privacyIDEA MFA, KeyCape OIDC).
OpenBao is the secrets/audit/admin-policy custody service after the ceremony,
not the place where the human password or OTP seed lives.
**2026-05-24:** Expanded the local UI toward a NetKingdom control surface:
the bootstrap flow now has action buttons for LLDAP, privacyIDEA, and KeyCape,
plus non-secret progress saving for account creation, MFA enrollment, OIDC
verification, and custody approval.
**2026-05-24:** Clarified the LLDAP first-user path in the UI and docs:
LLDAP has no registration flow; the operator logs in as bootstrap `admin`
using `LLDAP_LDAP_USER_PASS` from `net-kingdom/LLDAP/admin`, then creates the
dedicated `platform-root` or `king` account and assigns the current lightweight
admin group.
**2026-05-24:** Added explicit non-secret UI confirmations for the account
having been created, assigned to `net-kingdom-admins`, stored in the password
safe/offline packet, and later verified through the login path. Automated
LLDAP detection is deferred because it would require authenticated access to
LLDAP and should be built as an audited integration.
**2026-05-24:** Improved the KeyCape login-check path: the local bootstrap UI
now acts as the `demo-app` OIDC callback, exposes `/oidc/start` and
`/oidc/callback`, and adds hover-help text to the external action buttons.
The live KeyCape rollout still needs the updated `keycape-config` Secret
applied from decrypted `sso-mfa/bootstrap/secrets/` inputs. If the browser
flow reaches Authelia but never presents an OTP challenge, KeyCape needs a
browser MFA prompt surface before this gate can be marked verified.
**2026-05-24:** Filed `KEY-WP-0003` in the KeyCape repo for the current OIDC
verification blocker. The immediate error
`redirect_uri does not match any registered URI` means the local bootstrap
callback is not yet registered in live KeyCape. The follow-up KeyCape work also
covers the browser OTP challenge needed after Authelia password login.
**2026-05-24:** Implemented `KEY-WP-0003` in source. KeyCape now supports a
dedicated `netkingdom-bootstrap-console` client, split browser/server Authelia
URLs, and a browser OTP challenge before issuing the final OIDC code. The local
control surface now uses that dedicated client. Live verification remains
pending until the updated KeyCape image and regenerated `keycape-config` Secret
are rolled out.
**2026-05-24:** Stepped back from ad hoc secret rollout and added the
custodian age-key bootstrap model to the control surface. The UI now records
the custodian public age recipient, a derived fingerprint, and a non-secret
private-key custody reference while refusing to treat the private key as normal
metadata. It also detects encrypted bootstrap bundle presence and plaintext
`sso-mfa/bootstrap/secrets/` exposure. This is the intended foundation for
trial-mode, custody-mode, unlock/apply, and later OpenBao handover flows.
### T04 - Complete Railiance OpenBao Bootstrap Ceremony
```task