generated from coulomb/repo-seed
bootstrapping guidance ui and missing stuff
This commit is contained in:
@@ -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:
|
||||
|
||||
72
docs/security-bootstrap-age-custody.md
Normal file
72
docs/security-bootstrap-age-custody.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user