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
|
- Routine access flows through NetKingdom IAM claims and scoped OpenBao
|
||||||
policies.
|
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
|
## Trust Progression
|
||||||
|
|
||||||
The platform moves through explicit trust stages:
|
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 |
|
| Field | Example |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Credential label | `platform-root` |
|
| 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` |
|
| Custody posture | `temporary-single-king` or `two-of-three-planned` |
|
||||||
| Notification contact | `bernd.worsch@gmail.com` |
|
| Notification contact | `bernd.worsch@gmail.com` |
|
||||||
| Setup operator | `tegwick` |
|
| Setup operator | `tegwick` |
|
||||||
| Created date | `2026-05-24` |
|
| Created date | `2026-05-24` |
|
||||||
| Review date | date for next custody review |
|
| Review date | date for next custody review |
|
||||||
| Storage class | `password-safe`, `offline-paper`, `hardware-token`, or similar |
|
| 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 class | `totp`, `webauthn`, `hardware-token`, or similar |
|
||||||
| MFA enrolled confirmed | `true` only after the factor is enrolled with its verifier |
|
| 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` |
|
| 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
|
The UI should explain that this is not a normal user and not a day-to-day admin
|
||||||
account. It is rare root custody.
|
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
|
### 2. Choose Storage
|
||||||
|
|
||||||
Allowed first-version choices:
|
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,
|
anything. The console records only that enrollment completed and where,
|
||||||
without storing the seed, QR code, recovery codes, or screenshots.
|
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
|
### 4. Prepare Recovery
|
||||||
|
|
||||||
The operator confirms that recovery codes or equivalent recovery material exist
|
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 king credential kit is complete when:
|
||||||
|
|
||||||
- the credential label exists;
|
- the credential label exists;
|
||||||
|
- the dedicated identity account exists;
|
||||||
|
- the required admin group assignment is confirmed;
|
||||||
- storage choice is recorded;
|
- storage choice is recorded;
|
||||||
|
- the password is confirmed stored outside this UI;
|
||||||
- second factor is enrolled with its real verifier and confirmed;
|
- second factor is enrolled with its real verifier and confirmed;
|
||||||
- recovery material is confirmed;
|
- recovery material is confirmed;
|
||||||
- custody mode is selected;
|
- 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",
|
"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",
|
"setup_operator": "tegwick",
|
||||||
"notification_contact": "bernd.worsch@gmail.com",
|
"notification_contact": "bernd.worsch@gmail.com",
|
||||||
"storage_classes": [
|
"storage_classes": [
|
||||||
"password-safe",
|
"password-safe",
|
||||||
"offline-packet"
|
"offline-packet"
|
||||||
],
|
],
|
||||||
|
"password_safe_confirmed": false,
|
||||||
"mfa_class": "totp",
|
"mfa_class": "totp",
|
||||||
"mfa_enrolled_confirmed": false,
|
"mfa_enrolled_confirmed": false,
|
||||||
"mfa_enrollment_source": "deferred",
|
"mfa_enrollment_source": "deferred",
|
||||||
@@ -19,6 +30,9 @@
|
|||||||
"custody_approved_at": "",
|
"custody_approved_at": "",
|
||||||
"custody_approved_by": "",
|
"custody_approved_by": "",
|
||||||
"approval_scope": "",
|
"approval_scope": "",
|
||||||
|
"oidc_login_verified": false,
|
||||||
|
"metadata_updated_at": "",
|
||||||
|
"progress_scope": "",
|
||||||
"openbao_preflight_passed": false,
|
"openbao_preflight_passed": false,
|
||||||
"openbao_initialized": false,
|
"openbao_initialized": false,
|
||||||
"root_token_disposition": "",
|
"root_token_disposition": "",
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
# encrypt-secrets.sh — encrypt secrets/ directory to secrets.enc/ using age
|
# encrypt-secrets.sh — encrypt secrets/ directory to secrets.enc/ using age
|
||||||
#
|
#
|
||||||
# Usage:
|
# 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)
|
# SECRETS_DIR plaintext secrets directory (default: ./secrets)
|
||||||
# AGE_KEY_FILE age private key file (default: ~/.config/net-kingdom/age.key)
|
# 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
|
# Encrypts each *.env file (and pi.enc if present) to
|
||||||
# (and pi.enc if present) to secrets.enc/<component>/<filename>.age.
|
# 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
|
# After a successful encrypt, shreds the plaintext secrets directory unless
|
||||||
# --no-shred is passed.
|
# --no-shred is passed.
|
||||||
@@ -19,7 +22,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SECRETS_DIR="${1:-./secrets}"
|
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
|
NO_SHRED=false
|
||||||
for arg in "$@"; do [[ "$arg" == "--no-shred" ]] && NO_SHRED=true; done
|
for arg in "$@"; do [[ "$arg" == "--no-shred" ]] && NO_SHRED=true; done
|
||||||
|
|
||||||
@@ -29,18 +32,40 @@ if [[ ! -d "$SECRETS_DIR" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$AGE_KEY" ]]; then
|
resolve_recipient() {
|
||||||
echo "ERROR: age key not found: $AGE_KEY" >&2
|
local source="$1"
|
||||||
echo "Generate with: age-keygen -o $AGE_KEY" >&2
|
if [[ "$source" == age1* ]]; then
|
||||||
exit 1
|
printf '%s\n' "$source"
|
||||||
fi
|
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=$(resolve_recipient "$AGE_RECIPIENT_OR_KEY")
|
||||||
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
|
|
||||||
|
|
||||||
ENC_DIR="$(dirname "$SECRETS_DIR")/secrets.enc"
|
ENC_DIR="$(dirname "$SECRETS_DIR")/secrets.enc"
|
||||||
mkdir -p "$ENC_DIR"
|
mkdir -p "$ENC_DIR"
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ the full authentication flow:
|
|||||||
KeyCape is stateless — all state lives in Authelia (sessions), LLDAP (users), and
|
KeyCape is stateless — all state lives in Authelia (sessions), LLDAP (users), and
|
||||||
privacyIDEA (MFA tokens). No PVC is required.
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- T04 complete (privacyIDEA is Running and bootstrapped — admin account + enckey done)
|
- T04 complete (privacyIDEA is Running and bootstrapped — admin account + enckey done)
|
||||||
@@ -114,6 +120,25 @@ clients:
|
|||||||
clientType: "public"
|
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
|
## Secrets managed
|
||||||
|
|
||||||
| Secret name | Keys | Purpose |
|
| 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
|
# Check issuer matches CP-NK-004
|
||||||
curl -s https://kc.coulomb.social/.well-known/openid-configuration \
|
curl -s https://kc.coulomb.social/.well-known/openid-configuration \
|
||||||
| jq -r .issuer # should be: https://kc.coulomb.social
|
| 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"
|
baseDN: "dc=netkingdom,dc=local"
|
||||||
|
|
||||||
authelia:
|
authelia:
|
||||||
|
# Cluster-internal URL for server-side token exchange.
|
||||||
baseURL: "http://authelia.sso.svc.cluster.local:9091"
|
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"
|
clientId: "keycape"
|
||||||
clientSecret: "${AUTHELIA_CLIENT_SECRET}"
|
clientSecret: "${AUTHELIA_CLIENT_SECRET}"
|
||||||
redirectURI: "https://kc.coulomb.social/authorize/callback"
|
redirectURI: "https://kc.coulomb.social/authorize/callback"
|
||||||
@@ -98,10 +104,20 @@ clients:
|
|||||||
displayName: "Demo Application"
|
displayName: "Demo Application"
|
||||||
redirectUris:
|
redirectUris:
|
||||||
- "http://localhost:3000/callback"
|
- "http://localhost:3000/callback"
|
||||||
|
- "http://127.0.0.1:8876/oidc/callback"
|
||||||
|
- "http://localhost:8876/oidc/callback"
|
||||||
- "https://demo.coulomb.social/callback"
|
- "https://demo.coulomb.social/callback"
|
||||||
allowedScopes: ["openid", "profile", "email", "groups"]
|
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||||
grantTypes: ["authorization_code"]
|
grantTypes: ["authorization_code"]
|
||||||
clientType: "public"
|
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
|
EOF
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,74 @@ python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
|||||||
|
|
||||||
Open `http://127.0.0.1:8765`.
|
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:
|
Print a blank offline custody packet template:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ live OpenBao initialization.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
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"}
|
VALID_CUSTODY_MODES = {"temporary-single-king", "two-of-three-planned", "two-of-three-ready"}
|
||||||
CUSTODY_APPROVAL_MODES = {"temporary-single-king", "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)
|
@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."
|
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]:
|
def kit_validation(data: dict[str, Any]) -> list[Gate]:
|
||||||
storage_classes = data.get("storage_classes", [])
|
storage_classes = data.get("storage_classes", [])
|
||||||
if not isinstance(storage_classes, list):
|
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",
|
"done" if data.get("credential_label") else "blocked",
|
||||||
"Use a dedicated label such as platform-root.",
|
"Use a dedicated label such as platform-root.",
|
||||||
),
|
),
|
||||||
|
Gate(
|
||||||
|
"Identity account",
|
||||||
|
"done" if identity_account_ready(data) else "blocked",
|
||||||
|
identity_account_reason(data),
|
||||||
|
),
|
||||||
Gate(
|
Gate(
|
||||||
"Setup operator/contact",
|
"Setup operator/contact",
|
||||||
"done" if data.get("setup_operator") and data.get("notification_contact") else "blocked",
|
"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",
|
"done" if storage_values & VALID_STORAGE_CLASSES else "blocked",
|
||||||
"Select password-safe, offline-packet, hardware-token, or a combination.",
|
"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(
|
Gate(
|
||||||
"Second factor",
|
"Second factor",
|
||||||
"done" if second_factor_ready(data) else "blocked",
|
"done" if second_factor_ready(data) else "blocked",
|
||||||
second_factor_reason(data),
|
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(
|
Gate(
|
||||||
"Recovery material",
|
"Recovery material",
|
||||||
"done" if yes(data, "recovery_confirmed") else "blocked",
|
"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:
|
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("SECURITY BOOTSTRAP")
|
||||||
print("")
|
print("")
|
||||||
print("Stage")
|
print("Stage")
|
||||||
print(derive_stage(data))
|
print(derive_stage(merged))
|
||||||
print("")
|
print("")
|
||||||
print("Next safe action")
|
print("Next safe action")
|
||||||
print(next_action(gates))
|
print(next_action(gates))
|
||||||
print("")
|
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")
|
print("Gates")
|
||||||
for gate in gates:
|
for gate in gates:
|
||||||
print(f"- {gate.status}: {gate.name} - {gate.reason}")
|
print(f"- {gate.status}: {gate.name} - {gate.reason}")
|
||||||
@@ -316,6 +449,11 @@ def merged_approval_metadata(
|
|||||||
data.update(existing)
|
data.update(existing)
|
||||||
text_fields = (
|
text_fields = (
|
||||||
"credential_label",
|
"credential_label",
|
||||||
|
"bootstrap_mode",
|
||||||
|
"identity_account_home",
|
||||||
|
"identity_account_reference",
|
||||||
|
"identity_group_reference",
|
||||||
|
"custodian_age_private_key_reference",
|
||||||
"setup_operator",
|
"setup_operator",
|
||||||
"notification_contact",
|
"notification_contact",
|
||||||
"mfa_class",
|
"mfa_class",
|
||||||
@@ -326,20 +464,36 @@ def merged_approval_metadata(
|
|||||||
)
|
)
|
||||||
for field in text_fields:
|
for field in text_fields:
|
||||||
if field in payload and payload[field] is not None:
|
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:
|
if "storage_classes" in payload:
|
||||||
data["storage_classes"] = normalize_storage_classes(payload["storage_classes"])
|
data["storage_classes"] = normalize_storage_classes(payload["storage_classes"])
|
||||||
for field in (
|
for field in (
|
||||||
|
"custodian_age_public_key_confirmed",
|
||||||
|
"custodian_age_private_key_confirmed",
|
||||||
"recovery_confirmed",
|
"recovery_confirmed",
|
||||||
"custody_packet_prepared",
|
"custody_packet_prepared",
|
||||||
"no_secret_capture_confirmed",
|
"no_secret_capture_confirmed",
|
||||||
"mfa_enrolled_confirmed",
|
"mfa_enrolled_confirmed",
|
||||||
|
"identity_account_created",
|
||||||
|
"identity_group_confirmed",
|
||||||
|
"oidc_login_verified",
|
||||||
|
"password_safe_confirmed",
|
||||||
):
|
):
|
||||||
if field in payload:
|
if field in payload:
|
||||||
data[field] = payload[field] is True
|
data[field] = payload[field] is True
|
||||||
return data
|
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(
|
def validate_custody_approval(
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
approval_phrase: str,
|
approval_phrase: str,
|
||||||
@@ -400,6 +554,9 @@ def print_approve_custody_mode(args: argparse.Namespace, data: dict[str, Any]) -
|
|||||||
}
|
}
|
||||||
for key in (
|
for key in (
|
||||||
"credential_label",
|
"credential_label",
|
||||||
|
"identity_account_home",
|
||||||
|
"identity_account_reference",
|
||||||
|
"identity_group_reference",
|
||||||
"setup_operator",
|
"setup_operator",
|
||||||
"notification_contact",
|
"notification_contact",
|
||||||
"mfa_class",
|
"mfa_class",
|
||||||
@@ -417,6 +574,10 @@ def print_approve_custody_mode(args: argparse.Namespace, data: dict[str, Any]) -
|
|||||||
"custody_packet_prepared",
|
"custody_packet_prepared",
|
||||||
"no_secret_capture_confirmed",
|
"no_secret_capture_confirmed",
|
||||||
"mfa_enrolled_confirmed",
|
"mfa_enrolled_confirmed",
|
||||||
|
"identity_account_created",
|
||||||
|
"identity_group_confirmed",
|
||||||
|
"oidc_login_verified",
|
||||||
|
"password_safe_confirmed",
|
||||||
):
|
):
|
||||||
if getattr(args, field):
|
if getattr(args, field):
|
||||||
payload[field] = True
|
payload[field] = True
|
||||||
@@ -491,10 +652,21 @@ def print_handover_checklist() -> None:
|
|||||||
|
|
||||||
def metadata_template() -> dict[str, Any]:
|
def metadata_template() -> dict[str, Any]:
|
||||||
return {
|
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",
|
"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",
|
"setup_operator": "tegwick",
|
||||||
"notification_contact": "bernd.worsch@gmail.com",
|
"notification_contact": "bernd.worsch@gmail.com",
|
||||||
"storage_classes": ["password-safe", "offline-packet"],
|
"storage_classes": ["password-safe", "offline-packet"],
|
||||||
|
"password_safe_confirmed": False,
|
||||||
"mfa_class": "totp",
|
"mfa_class": "totp",
|
||||||
"mfa_enrolled_confirmed": False,
|
"mfa_enrolled_confirmed": False,
|
||||||
"mfa_enrollment_source": "deferred",
|
"mfa_enrollment_source": "deferred",
|
||||||
@@ -508,6 +680,9 @@ def metadata_template() -> dict[str, Any]:
|
|||||||
"custody_approved_at": "",
|
"custody_approved_at": "",
|
||||||
"custody_approved_by": "",
|
"custody_approved_by": "",
|
||||||
"approval_scope": "",
|
"approval_scope": "",
|
||||||
|
"oidc_login_verified": False,
|
||||||
|
"metadata_updated_at": "",
|
||||||
|
"progress_scope": "",
|
||||||
"openbao_preflight_passed": False,
|
"openbao_preflight_passed": False,
|
||||||
"openbao_initialized": False,
|
"openbao_initialized": False,
|
||||||
"root_token_disposition": "",
|
"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]:
|
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 {
|
return {
|
||||||
"metadata_path": str(metadata_path),
|
"metadata_path": str(metadata_path),
|
||||||
"stage": derive_stage(data),
|
"stage": derive_stage(merged),
|
||||||
"next_action": next_action(gates),
|
"next_action": next_action(gates),
|
||||||
"gates": [gate_payload(gate) for gate in gates],
|
"gates": [gate_payload(gate) for gate in gates],
|
||||||
"kit_gates": [gate_payload(gate) for gate in kit_validation(data)],
|
"key_custody_gates": [gate_payload(gate) for gate in key_custody_validation(merged)],
|
||||||
"metadata": data,
|
"kit_gates": [gate_payload(gate) for gate in kit_validation(merged)],
|
||||||
|
"bootstrap_secret_state": bootstrap_secret_state(),
|
||||||
|
"metadata": metadata_view,
|
||||||
"approval_phrase": APPROVAL_PHRASE,
|
"approval_phrase": APPROVAL_PHRASE,
|
||||||
"custody_approval_modes": sorted(CUSTODY_APPROVAL_MODES),
|
"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:
|
def ui_html() -> str:
|
||||||
return """<!doctype html>
|
return """<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -703,6 +1046,17 @@ def ui_html() -> str:
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
line-height: 1.2;
|
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 {
|
.choice span {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: block;
|
display: block;
|
||||||
@@ -724,6 +1078,12 @@ def ui_html() -> str:
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
.inline-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
button {
|
button {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
border: 1px solid var(--ink);
|
border: 1px solid var(--ink);
|
||||||
@@ -739,6 +1099,23 @@ def ui_html() -> str:
|
|||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: var(--ink);
|
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 {
|
button:disabled {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
@@ -797,6 +1174,11 @@ def ui_html() -> str:
|
|||||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
a {
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
header { padding: 18px; }
|
header { padding: 18px; }
|
||||||
main { padding: 16px; }
|
main { padding: 16px; }
|
||||||
@@ -832,6 +1214,73 @@ def ui_html() -> str:
|
|||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<form id="approval-form">
|
<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">
|
<section class="panel">
|
||||||
<h2>King credential</h2>
|
<h2>King credential</h2>
|
||||||
<p class="notice">Local non-secret metadata only. OpenBao initialization stays manual.</p>
|
<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">
|
<input id="approval_phrase" type="text" autocomplete="off" placeholder="approve custody mode">
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="approve-button" type="submit">Approve custody mode</button>
|
<button class="secondary" id="save-button" type="button" title="Save the visible non-secret progress fields to local metadata.">Save progress</button>
|
||||||
<button class="secondary" id="refresh-button" type="button">Refresh</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>
|
||||||
<div id="message" class="message" role="status">Waiting for local approval.</div>
|
<div id="message" class="message" role="status">Waiting for local approval.</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -919,6 +1369,10 @@ def ui_html() -> str:
|
|||||||
<h2>Bootstrap gates</h2>
|
<h2>Bootstrap gates</h2>
|
||||||
<div id="gates" class="gates"></div>
|
<div id="gates" class="gates"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Key custody</h2>
|
||||||
|
<div id="key-gates" class="gates"></div>
|
||||||
|
</section>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Kit gates</h2>
|
<h2>Kit gates</h2>
|
||||||
<div id="kit-gates" class="gates"></div>
|
<div id="kit-gates" class="gates"></div>
|
||||||
@@ -928,7 +1382,20 @@ def ui_html() -> str:
|
|||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
const fields = [
|
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",
|
"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",
|
"setup_operator",
|
||||||
"notification_contact",
|
"notification_contact",
|
||||||
"mfa_class",
|
"mfa_class",
|
||||||
@@ -994,6 +1461,7 @@ def ui_html() -> str:
|
|||||||
document.getElementById("next-action").textContent = data.next_action;
|
document.getElementById("next-action").textContent = data.next_action;
|
||||||
document.getElementById("metadata-path").textContent = data.metadata_path;
|
document.getElementById("metadata-path").textContent = data.metadata_path;
|
||||||
renderGates("gates", data.gates);
|
renderGates("gates", data.gates);
|
||||||
|
renderGates("key-gates", data.key_custody_gates);
|
||||||
renderGates("kit-gates", data.kit_gates);
|
renderGates("kit-gates", data.kit_gates);
|
||||||
fillForm(data.metadata || {});
|
fillForm(data.metadata || {});
|
||||||
}
|
}
|
||||||
@@ -1003,7 +1471,19 @@ def ui_html() -> str:
|
|||||||
.map((input) => input.value);
|
.map((input) => input.value);
|
||||||
const mode = document.querySelector("[name='custody_mode']:checked");
|
const mode = document.querySelector("[name='custody_mode']:checked");
|
||||||
return {
|
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(),
|
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(),
|
setup_operator: document.getElementById("setup_operator").value.trim(),
|
||||||
notification_contact: document.getElementById("notification_contact").value.trim(),
|
notification_contact: document.getElementById("notification_contact").value.trim(),
|
||||||
mfa_class: document.getElementById("mfa_class").value,
|
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", () => {
|
document.getElementById("refresh-button").addEventListener("click", () => {
|
||||||
loadStatus().then(() => setMessage("Status refreshed.", ""));
|
loadStatus().then(() => setMessage("Status refreshed.", ""));
|
||||||
});
|
});
|
||||||
@@ -1073,26 +1576,41 @@ def make_ui_handler(metadata_path: Path) -> type[BaseHTTPRequestHandler]:
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
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:
|
def do_GET(self) -> None:
|
||||||
if self.path == "/" or self.path == "/index.html":
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
body = ui_html().encode("utf-8")
|
if parsed.path == "/" or parsed.path == "/index.html":
|
||||||
self.send_response(HTTPStatus.OK.value)
|
self.send_html(HTTPStatus.OK, ui_html())
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
if self.path == "/api/status":
|
if parsed.path == "/api/status":
|
||||||
data = load_metadata(metadata_path)
|
data = load_metadata(metadata_path)
|
||||||
if not data:
|
if not data:
|
||||||
data = metadata_template()
|
data = metadata_template()
|
||||||
self.send_json(HTTPStatus.OK, status_payload(data, metadata_path))
|
self.send_json(HTTPStatus.OK, status_payload(data, metadata_path))
|
||||||
return
|
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)
|
self.send_error(HTTPStatus.NOT_FOUND.value)
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
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)
|
self.send_error(HTTPStatus.NOT_FOUND.value)
|
||||||
return
|
return
|
||||||
try:
|
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."]})
|
self.send_json(HTTPStatus.BAD_REQUEST, {"errors": ["JSON body must be an object."]})
|
||||||
return
|
return
|
||||||
existing = load_metadata(metadata_path)
|
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", ""))
|
approval_phrase = str(payload.get("approval_phrase", ""))
|
||||||
approved_by = str(payload.get("approved_by", ""))
|
approved_by = str(payload.get("approved_by", ""))
|
||||||
approved, errors = approve_custody_metadata(existing, payload, approval_phrase, 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
|
metadata console. Custody approval now requires explicit non-secret
|
||||||
confirmation that the factor was enrolled with its real verifier.
|
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
|
### T04 - Complete Railiance OpenBao Bootstrap Ceremony
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|||||||
Reference in New Issue
Block a user