fix(security): reconcile KeyCape bootstrap with scoped factor custody
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
tegwick 2026-10-13 19:46:53 +02:00
parent 19a1c75d30
commit 7d4bb1653e
17 changed files with 528 additions and 613 deletions

View file

@ -101,7 +101,7 @@ Hostnames: kc.coulomb.social (KeyCape), auth.coulomb.social (Authelia), lldap.co
### In Progress (this session)
- [ ] Run `bootstrap-realm.sh` on live cluster (requires T04 applied)
- [ ] Run `keycape/create-pi-token.sh` then `keycape/create-secrets.sh` (inject real PI token)
- [ ] Establish RPF-WP-0040 scoped factor delivery, then first-bootstrap `keycape/create-secrets.sh` only if config is absent (old admin-token helpers retired).
- [ ] Restart KeyCape with updated keycape-config
- [ ] Enroll a TOTP token for pi-admin via pink-account.coulomb.social
- [ ] Test end-to-end login via kc.coulomb.social

View file

@ -10,9 +10,8 @@
# 2. lldap (needs: secrets/lldap/secrets.env)
# 3. authelia (needs: lldap/secrets.env → LLDAP_LDAP_USER_PASS)
# 4. privacyidea (needs: secrets/privacyidea/secrets.env)
# 5. keycape SKIPPED — requires PI_ADMIN_TOKEN from post-T04 bootstrap.
# Run sso-mfa/k8s/keycape/create-pi-token.sh, then
# sso-mfa/k8s/keycape/create-secrets.sh manually.
# 5. keycape SKIPPED — requires owner-established RPF-WP-0040 factor delivery.
# create-secrets.sh is first-creation only, never live replacement.
#
# After each successful component, creds-state.yaml is updated.
@ -93,9 +92,9 @@ if [[ ! -f "$SECRETS_DIR/privacyidea/secrets.env" ]]; then
fi
run_component "privacyidea" "privacyidea"
# ── Step 5: KeyCape — SKIPPED (requires PI_ADMIN_TOKEN) ──────────────────────
# ── Step 5: KeyCape — SKIPPED (requires scoped factor delivery) ──────────────────────
echo "──────────────────────────────────────────────"
echo "SKIPPED: keycape (requires PI_ADMIN_TOKEN from post-T04 bootstrap)"
echo "SKIPPED: keycape (requires RPF-WP-0040 scoped factor delivery)"
echo ""
echo " After privacyIDEA is Running and bootstrapped:"
echo " 1. TIME-SENSITIVE: run enckey-bootstrap.sh while pod is live:"
@ -104,10 +103,9 @@ echo ""
echo " 2. Create the pi-admin user:"
echo " bash $K8S_DIR/privacyidea/bootstrap-admin.sh"
echo ""
echo " 3. Generate the PI admin token for KeyCape:"
echo " bash $K8S_DIR/keycape/create-pi-token.sh"
echo " 3. Establish scoped factor custody and ESO delivery through railiance-platform RPF-WP-0040."
echo ""
echo " 4. Apply KeyCape secrets:"
echo " 4. Create KeyCape bootstrap secrets only if keycape-config is absent:"
echo " bash $K8S_DIR/keycape/create-secrets.sh $SECRETS_DIR"
echo ""
echo " 5. Update state:"

View file

@ -287,22 +287,18 @@ fi
# ── Phase 6: Apply keycape secrets ────────────────────────────────────────────
step "6 — Apply KeyCape secrets (requires pi-admin)"
step "6 — Create KeyCape bootstrap secrets (requires scoped factor delivery)"
if [[ "$(state_get_nested keycape)" == "true" ]]; then
ok "keycape secrets already applied — skipping"
else
if [[ "$DRY_RUN" == false ]]; then
log "fetching PI admin token..."
(cd "$K8S_DIR/keycape" && bash create-pi-token.sh "$SECRETS_DIR")
ok "PI admin token fetched"
log "applying keycape secrets..."
(cd "$K8S_DIR/keycape" && bash create-secrets.sh "$SECRETS_DIR")
state_set_nested "keycape" "true"
ok "keycape secrets applied"
else
echo " [dry-run] would run create-pi-token.sh + keycape/create-secrets.sh"
echo " [dry-run] would verify RPF-WP-0040 delivery and create first-bootstrap keycape-config"
fi
fi

View file

@ -262,68 +262,15 @@ AUTHELIA_SESSION_SECRET)
;;
AUTHELIA_KEYCAPE_CLIENT_SECRET)
header
echo ""
echo "Impact: Authelia↔KeyCape OIDC client secret."
echo " Must be updated atomically in:"
echo " 1. Authelia K8s Secret (bcrypt hash of the new plaintext)"
echo " 2. KeyCape K8s Secret (plaintext, stored as authelia.clientSecret)"
echo " Both pods must be restarted before the new secret takes effect."
echo ""
prepare_value authelia b64
confirm "Apply rotation?" || exit 0
store_value authelia
echo " Updating Authelia secrets (bcrypt hash)..."
(cd "$K8S_DIR/authelia" && quiet bash create-secrets.sh "$SECRETS_DIR")
echo " Updating KeyCape secrets (plaintext)..."
(cd "$K8S_DIR/keycape" && quiet bash create-secrets.sh "$SECRETS_DIR")
echo " Restarting Authelia and KeyCape..."
quiet kubectl rollout restart deployment authelia -n sso
quiet kubectl rollout restart deployment keycape -n sso
quiet kubectl rollout status deployment authelia -n sso
quiet kubectl rollout status deployment keycape -n sso
update_last_rotated "AUTHELIA_KEYCAPE_CLIENT_SECRET"
post_rotation_reminder
echo "ERROR: legacy KeyCape replacement rotation is retired; no credentials were changed." >&2
echo "Use an owner-reviewed configuration-preserving rotation with rollback and consumer acceptance (NK-WP-0049-T02)." >&2
exit 1
;;
KEYCAPE_RSA_KEY)
header
echo ""
echo " ⚠ WARNING: Rotating the KeyCape RSA signing key immediately invalidates"
echo " ALL issued JWT tokens. This causes a brief authentication outage."
echo " All downstream applications will reject existing tokens until they"
echo " refresh against the new JWKS endpoint."
echo ""
echo " Coordination required:"
echo " 1. Notify downstream application owners of the planned outage window."
echo " 2. Delete secrets/keycape/key.pem to trigger regeneration."
echo " 3. Re-run keycape/create-secrets.sh."
echo " 4. Restart KeyCape pod."
echo " 5. Confirm downstream apps recover (they should auto-refresh JWKS)."
echo ""
confirm "Proceed with RSA key rotation? (causes auth outage)" || exit 0
KEY_FILE="$SECRETS_DIR/keycape/key.pem"
if [[ -f "$KEY_FILE" ]]; then
shred -u "$KEY_FILE"
echo " Old key shredded."
fi
echo " Regenerating RSA-2048 signing key..."
mkdir -p "$(dirname "$KEY_FILE")"
openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null
chmod 600 "$KEY_FILE"
echo " Updating KeyCape secrets..."
(cd "$K8S_DIR/keycape" && quiet bash create-secrets.sh "$SECRETS_DIR")
echo " Restarting KeyCape pod..."
quiet kubectl rollout restart deployment keycape -n sso
quiet kubectl rollout status deployment keycape -n sso
echo ""
if [[ "$NON_INTERACTIVE" == false ]]; then
echo " ✔ RSA key rotated. Store the new key in your password manager: net-kingdom/KeyCape/jwt-signing-key"
else
echo " ✔ RSA key rotated."
fi
update_last_rotated "KEYCAPE_RSA_KEY"
post_rotation_reminder
echo "ERROR: legacy KeyCape replacement rotation is retired; no credentials were changed." >&2
echo "Use an owner-reviewed configuration-preserving rotation with rollback and consumer acceptance (NK-WP-0049-T02)." >&2
exit 1
;;
BREAKGLASS_PASSWORD)

View file

@ -106,7 +106,7 @@ else
val="$(nested_val "$component")"
note=""
[[ "$component" == "keycape" && "$val" == "false" ]] && \
note=" (requires PI_ADMIN_TOKEN — post-T04)"
note=" (requires RPF-WP-0040 scoped factor delivery)"
printf " %-28s %s%s\n" "$component" "$(status_icon "$val")" "$note"
done
echo ""

View file

@ -129,16 +129,16 @@ EOF
# ── KeyCape ────────────────────────────────────────────────────────────────────
# KeyCape has no generated secrets here:
# key.pem — RSA signing key, generated by keycape/create-secrets.sh on first run
# pi_admin_token — generated by keycape/create-pi-token.sh AFTER privacyIDEA is bootstrapped
# Factor JWT — delivered through RPF-WP-0040 ESO, never stored in this directory.
# We create the directory and a placeholder file so operators know to check it.
cat > "$OUT_DIR/keycape/secrets.env" <<EOF
# KeyCape secrets — KeePassXC group: net-kingdom/KeyCape
# Entry: jwt-signing-key → binary attachment key.pem (generated by keycape/create-secrets.sh)
# Entry: pi-admin-token → password=PI_ADMIN_TOKEN (generated by keycape/create-pi-token.sh)
# Factor credential: owner-managed ESO mount; no personal admin token.
#
# This file is a placeholder. No values to set here manually.
# Both secrets are generated by the respective create-*.sh scripts.
# First-bootstrap create-secrets.sh generates only a missing signing key.
EOF
# ── Break-glass ────────────────────────────────────────────────────────────────

View file

@ -151,6 +151,8 @@ cd postgresql && ./create-secrets.sh && cd ..
cd privacyidea && ./create-secrets.sh && cd ..
cd lldap && ./create-secrets.sh && cd ..
cd authelia && ./create-secrets.sh && cd ..
# Restore owner-managed RPF-WP-0040 factor delivery before first KeyCape creation.
# If keycape-config already exists, use the reviewed configuration-preserving restore.
cd keycape && ./create-secrets.sh && cd ..
# 4. Apply workloads in restore order
@ -169,8 +171,8 @@ kubectl rollout status deployment/keycape -n sso --timeout=60s
# 6. Re-run bootstrap scripts if PVC data was lost
cd privacyidea && ./enckey-bootstrap.sh && ./bootstrap-admin.sh && ./bootstrap-realm.sh
cd ../lldap && ./bootstrap-users.sh && ./break-glass.sh
cd ../keycape && ./create-pi-token.sh && ./create-secrets.sh
kubectl rollout restart deployment/keycape -n sso
# Verify owner-managed factor renewal, ESO delivery and mounted reload (RPF-WP-0040).
# Do not mint a personal admin token or replace existing KeyCape configuration.
# 7. Verify
./verify-t04.sh && ./verify-t05.sh && ./verify-t06.sh --user platform-root && ./verify-t07.sh && ./verify-t08.sh

View file

@ -64,68 +64,52 @@ After building, update `deployment.yaml` line:
image: keycape:v0.1 # replace with your actual tag
```
## Apply order
## First-bootstrap creation and factor custody
```bash
# 1. Create Secrets (config.yaml + key.pem)
# Run this AFTER T04 bootstrap if you want the privacyIDEA token included.
# If T04 is not yet done, run it now and re-run after create-pi-token.sh.
cd sso-mfa/k8s/keycape
chmod +x create-secrets.sh create-pi-token.sh
bash ./create-secrets.sh
KeyCape uses the scoped `keycape-factor-reader` provider identity. Railiance
Platform RPF-WP-0040 owns its renewable credential, the `keycape-factor-read`
ExternalSecret, and the mounted `/etc/keycape-factor/admin-token` file. See the
[owner custody contract](../../../../railiance-platform/docs/net-kingdom-credential-custody-contract.md).
Neither a personal pi-admin JWT nor a local `pi_admin_token` file belongs in
KeyCape configuration. `create-pi-token.sh` and `refresh-pi-token-live.sh` are
retired refusal entry points; they perform no login or changes.
# 2. Apply manifests
kubectl apply -f deployment.yaml
kubectl apply -f middleware.yaml
kubectl apply -f ingress.yaml
Under an approved first-bootstrap procedure, establish owner factor delivery
before running `bash sso-mfa/k8s/keycape/create-secrets.sh /absolute/private/secrets`.
The helper requires Python 3 with PyYAML, OpenSSL and kubectl; owned private
directories/files; literal generator-format LLDAP and Authelia credentials;
Ready factor delivery; and an absent
`keycape-config`. It preserves an existing valid private RSA key or atomically
creates a missing one. It sends Secret data only over kubectl stdin, suppresses
child output, and uses API creation without a last-applied annotation. Existing
configuration is refused; concurrent creation cannot overwrite it. A failed API
call can have an unknown remote outcome: reconcile metadata before retrying.
Retain any newly generated private key for recovery, including after a failure.
# 3. Wait for pod to be ready
kubectl rollout status deployment/keycape -n sso --timeout=60s
```
The helper does not restart workloads or establish native login acceptance.
Deployment and fresh factor/login checks belong to the reviewed bootstrap or
recovery ceremony. Do not use it to restore an existing installation: preserve
its current clients, policy, signing key and independently held backup.
## Post-deploy: inject privacyIDEA admin token
## Factor credential renewal and recovery
If T04 was not complete when you ran `create-secrets.sh`, the privacyIDEA admin
token is a placeholder. After T04 bootstrap is done:
```bash
# 1. Fetch the token from privacyIDEA and store it
chmod +x create-pi-token.sh
./create-pi-token.sh
# 2. Re-run create-secrets.sh to update keycape-config with the real token
bash ./create-secrets.sh
# 3. Restart KeyCape to pick up the new Secret
kubectl rollout restart deployment/keycape -n sso
```
If the browser flow reaches the KeyCape OTP screen and then reports
`mfa check error`, refresh the live privacyIDEA token without printing it:
```bash
cd sso-mfa/k8s/keycape
KEYCAPE_PI_REALM=coulomb KUBECTL="${KUBECTL:-kubectl}" \
bash ./refresh-pi-token-live.sh platform-root
```
The helper prompts for the `pi-admin` password, writes the token only into
Kubernetes Secrets, and restarts KeyCape. The current live privacyIDEA realm is
`coulomb`; use `KEYCAPE_PI_REALM=netkingdom` only for an explicit future realm
migration. The helper also restores `privacyidea.requireForAll: true`, which
keeps KeyCape from using the admin token-list API as the MFA-required check.
For expired factor credentials or `mfa check error`, use the owner renewal and
recovery procedure in the custody contract: inspect the renewer and ESO status,
repair the underlying provider/custody failure, then verify mounted delivery and
native factor acceptance. A fresh credential is not predecessor revocation.
Never restore the retired personal-admin-token path, change realm/MFA policy as
part of a token refresh, or replace the full KeyCape configuration.
## OIDC client registration
Downstream applications are registered in the `clients:` block in
`keycape/create-secrets.sh`. The NetKingdom bootstrap console and Railiance
OpenBao admin clients are code-defined there; operators should not create
those clients manually in a separate UI. After changing the block:
```bash
bash ./create-secrets.sh # regenerates keycape-config Secret
kubectl rollout restart deployment/keycape -n sso
```
`bootstrap-config.json` is the initial configuration only. The helper fills its
empty LLDAP/Authelia credential fields in memory and serializes ordinary YAML
for KeyCape and the scoped configuration update tools. Its registrations are not
a complete inventory of current clients.
Live client changes require a scoped owner procedure that preserves the rest of
the configuration and verifies the affected consumer. The legacy client-secret
and RSA replacement rotations refuse before mutation; coordinated rotation,
rollback custody and native multi-consumer acceptance remain NK-WP-0049-T02 work.
The `openbao-admin` client is intentionally a public PKCE client for the
current operator flow. It registers the OpenBao CLI callback URIs and the
@ -245,9 +229,9 @@ chosen; see NK-WP-0046.
| Secret name | Keys | Purpose |
|-------------|------|---------|
| `keycape-config` | `config.yaml` | Full KeyCape configuration (LLDAP URL + creds, Authelia URL + client secret, privacyIDEA URL + admin token, OIDC clients) |
| `keycape-config` | `config.yaml` | KeyCape configuration (LLDAP credentials, Authelia client secret, privacyIDEA URL + scoped credential-file reference, OIDC clients) |
| | `key.pem` | RSA-2048 private key for signing OIDC tokens issued to downstream applications |
| `keycape-pi-token` | `pi_admin_token` | privacyIDEA admin JWT — created by `create-pi-token.sh`, referenced in `config.yaml` |
| `keycape-factor-read` | `admin-token` | Scoped provider JWT delivered by owner-managed ESO; mounted and reloaded by KeyCape |
Store `key.pem` in KeePassXC as a binary attachment. If it is lost, all active
sessions become invalid (tokens cannot be verified) and all applications must

View file

@ -0,0 +1,123 @@
{
"issuer": "https://kc.coulomb.social",
"port": 8080,
"tokenLifetime": "15m",
"privateKeyPem": "/etc/keycape/key.pem",
"environment": "production",
"lldap": {
"url": "ldap://lldap.sso.svc.cluster.local:3890",
"bindDN": "uid=admin,ou=people,dc=netkingdom,dc=local",
"bindPW": "",
"baseDN": "dc=netkingdom,dc=local",
"userOU": "ou=people",
"groupOU": "ou=groups"
},
"authelia": {
"baseURL": "http://authelia.sso.svc.cluster.local:9091",
"browserBaseURL": "https://auth.coulomb.social",
"tokenBaseURL": "http://authelia.sso.svc.cluster.local:9091",
"clientId": "keycape",
"clientSecret": "",
"redirectURI": "https://kc.coulomb.social/authorize/callback"
},
"privacyidea": {
"baseURL": "http://privacyidea.mfa.svc.cluster.local:8080",
"realm": "coulomb",
"requireForAll": true,
"adminTokenFile": "/etc/keycape-factor/admin-token"
},
"clients": [
{
"clientId": "demo-app",
"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": "user-engine-portal",
"displayName": "User Engine Portal",
"redirectUris": [
"https://users.92-205-62-239.nip.io/oidc/callback",
"https://users.coulomb.social/oidc/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"
},
{
"clientId": "openbao-admin",
"displayName": "Railiance OpenBao Admin",
"redirectUris": [
"http://localhost:8250/oidc/callback",
"http://127.0.0.1:8250/oidc/callback",
"http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback"
],
"allowedScopes": [
"openid",
"profile",
"email",
"groups"
],
"grantTypes": [
"authorization_code"
],
"clientType": "public"
},
{
"clientId": "rapp-qonto-client",
"displayName": "rapp-qonto workload",
"allowedScopes": [
"qonto:read"
],
"grantTypes": [
"client_credentials"
],
"clientType": "confidential",
"secretRef": "env:KEYCAPE_RAPP_QONTO_CLIENT_SECRET",
"serviceSubject": "rapp-qonto",
"tenant": "tenant:friendly:binky",
"roles": [
"qonto-reader"
]
}
]
}

View file

@ -1,118 +1,6 @@
#!/usr/bin/env bash
# create-pi-token.sh — fetch a privacyIDEA admin JWT and store it for KeyCape
#
# Usage:
# ./create-pi-token.sh [secrets-dir]
#
# Run this script AFTER T04 bootstrap (privacyIDEA admin account created).
# It authenticates to the privacyIDEA API, fetches a long-lived admin JWT,
# and writes it to secrets/keycape/pi_admin_token.
#
# After running this script, re-run create-secrets.sh to update the
# keycape-config K8s Secret with the real token, then restart KeyCape:
# ./create-secrets.sh
# kubectl rollout restart deployment/keycape -n sso
#
# The privacyIDEA admin token may expire depending on the live privacyIDEA
# policy/configuration. Refresh it if KeyCape reports "mfa check error".
# Store the current token in KeePassXC as:
# net-kingdom/KeyCape/pi-admin-token
#
# Requires: kubectl, curl, jq
# Retired: KeyCape uses the scoped, renewable factor-reader lane.
set +x
set -euo pipefail
SECRETS_DIR="${1:-../../bootstrap/secrets}"
PI_ENV="$SECRETS_DIR/privacyidea/secrets.env"
TOKEN_FILE="$SECRETS_DIR/keycape/pi_admin_token"
if [[ ! -f "$PI_ENV" ]]; then
echo "ERROR: $PI_ENV not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2
exit 1
fi
read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; }
PI_ADMIN_PASSWORD=$(read_env "$PI_ENV" PI_ADMIN_PASSWORD)
if [[ -z "$PI_ADMIN_PASSWORD" ]]; then
echo "ERROR: PI_ADMIN_PASSWORD is empty in $PI_ENV" >&2
exit 1
fi
# Fetch token by exec-ing into the privacyIDEA pod (localhost call, bypasses
# NetworkPolicy which restricts external ingress to the service).
PI_POD=$(kubectl get pod -n mfa \
-l app.kubernetes.io/name=privacyidea \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
if [[ -z "$PI_POD" ]]; then
# Fall back to public hostname if pod is not directly accessible
PI_POD=""
fi
echo "Fetching privacyIDEA admin token..."
if [[ -n "$PI_POD" ]]; then
echo " Method: kubectl exec into $PI_POD (avoids NetworkPolicy restriction)"
# Pass the password via env var. Retry up to 3 times — the PostgreSQL connection
# pool can return 500 if the idle SSL connection was dropped; it recovers on retry.
TOKEN=""
for _ATTEMPT in 1 2 3; do
TOKEN=$(kubectl exec -n mfa "$PI_POD" -- \
env PI_ADMIN_PASSWORD="${PI_ADMIN_PASSWORD}" \
python3 -c '
import urllib.request, urllib.parse, json, os, sys
pw = os.environ["PI_ADMIN_PASSWORD"]
data = urllib.parse.urlencode({"username": "pi-admin", "password": pw}).encode()
req = urllib.request.Request("http://localhost:8080/auth", data=data)
try:
with urllib.request.urlopen(req, timeout=10) as r:
body = json.load(r)
print(body["result"]["value"]["token"])
except Exception as e:
print(str(e), file=__import__("sys").stderr)
sys.exit(1)
' 2>/dev/null || echo "")
if [[ -n "$TOKEN" ]]; then break; fi
echo " Attempt $_ATTEMPT failed (likely transient DB connection drop) — retrying in 5s..."
sleep 5
done
else
echo " Method: public URL https://pink.coulomb.social"
TOKEN=$(curl -sf \
-X POST "https://pink.coulomb.social/auth" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=pi-admin&password=${PI_ADMIN_PASSWORD}" \
| python3 -c "import sys,json; data=json.load(sys.stdin); print(data['result']['value']['token'])" \
2>/dev/null || echo "")
fi
if [[ -z "$TOKEN" ]]; then
echo "ERROR: failed to fetch token from privacyIDEA." >&2
echo " Verify that privacyIDEA is Running and the pi-admin account exists." >&2
echo " Check: kubectl logs -n mfa \$(kubectl get pod -n mfa -l app.kubernetes.io/name=privacyidea -o name | head -1)" >&2
exit 1
fi
mkdir -p "$(dirname "$TOKEN_FILE")"
echo -n "$TOKEN" > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
# Create the keycape-pi-token K8s Secret (KeyCape reads it at startup)
echo "Creating K8s Secret: keycape-pi-token (namespace: sso)"
kubectl create secret generic keycape-pi-token \
--namespace=sso \
--from-literal=token="$TOKEN" \
--dry-run=client -o yaml | kubectl apply -f -
echo " Done."
echo ""
echo "Token written to: $TOKEN_FILE"
echo ""
echo "IMPORTANT: Store this token in KeePassXC → net-kingdom/KeyCape/pi-admin-token"
echo " as a password entry. It cannot be recovered without re-authenticating."
echo ""
echo "Next steps:"
echo " 1. Re-run create-secrets.sh to update keycape-config with the real token."
echo " 2. Restart KeyCape: kubectl rollout restart deployment/keycape -n sso"
printf '%s\n' 'ERROR: personal privacyIDEA admin-token delivery is retired.' 'Use railiance-platform RPF-WP-0040 scoped factor custody, ESO delivery and renewal recovery.' 'This helper performs no login, credential read, Secret change or restart.' >&2
exit 1

View file

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Create first-bootstrap config with private inputs and scoped factor delivery.
Never replace an existing Secret, mint a provider token or rotate a signing key.
Child output is captured, including Kubernetes error bodies containing Secret data.
"""
import base64
import contextlib
import json
import os
from pathlib import Path
import re
import secrets
import stat
import subprocess
import sys
import yaml
class Refused(Exception):
pass
def private(info, directory=False):
kind = stat.S_ISDIR if directory else stat.S_ISREG
if not kind(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
raise Refused()
if not directory and info.st_nlink != 1:
raise Refused()
@contextlib.contextmanager
def custody(root):
root = Path(os.path.abspath(root))
if any(p.is_symlink() for p in (root, *root.parents)):
raise Refused()
descriptors = []
try:
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
fd = os.open(root, flags); descriptors.append(fd)
private(os.fstat(fd), True)
children = {}
for name in ('lldap', 'authelia', 'keycape'):
child = os.open(name, flags, dir_fd=fd); descriptors.append(child)
private(os.fstat(child), True)
children[name] = child
yield children
finally:
for fd in reversed(descriptors): os.close(fd)
def read_file(parent, name):
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
try:
private(os.fstat(fd))
raw = os.read(fd, 65537)
if len(raw) > 65536: raise Refused()
return raw
finally:
os.close(fd)
def env_value(parent, name):
values = [line.split('=', 1)[1] for line in read_file(parent, 'secrets.env').decode().splitlines()
if line.startswith(name + '=')]
# Consume the generator's literal format, never execute a shell env file.
if len(values) != 1 or not re.fullmatch(r'[A-Za-z0-9]{32,128}', values[0]):
raise Refused()
return values[0]
def run(args, payload=None):
result = subprocess.run(args, input=payload, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, timeout=60, check=False)
if result.returncode or len(result.stdout) > 65536: raise Refused()
return result.stdout
def signing_key(parent):
try:
key = read_file(parent, 'key.pem')
except FileNotFoundError:
key = run(['openssl', 'genrsa', '2048'])
run(['openssl', 'rsa', '-check', '-noout'], key)
# Publish atomically without replacement, so concurrent creation cannot
# overwrite a pre-existing key. Keep this private key after API failure.
temp = '.key-' + secrets.token_hex(12)
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent)
try:
with os.fdopen(fd, 'wb') as file:
file.write(key); file.flush(); os.fsync(file.fileno())
os.link(temp, 'key.pem', src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False)
os.fsync(parent)
finally:
os.unlink(temp, dir_fd=parent)
run(['openssl', 'rsa', '-check', '-noout'], key)
return key
def main():
if len(sys.argv) > 2: raise Refused()
here = Path(__file__).resolve().parent
root = sys.argv[1] if len(sys.argv) == 2 else here / '../../bootstrap/secrets'
with custody(root) as dirs:
bind_password = env_value(dirs['lldap'], 'LLDAP_LDAP_USER_PASS')
client_secret = env_value(dirs['authelia'], 'AUTHELIA_KEYCAPE_CLIENT_SECRET')
config = json.loads((here / 'bootstrap-config.json').read_text())
config['lldap']['bindPW'] = bind_password
config['authelia']['clientSecret'] = client_secret
# Existence is not an update grant. API create also closes the race
# between this preflight and publication; AlreadyExists stays a failure.
existing = run(['kubectl', 'get', 'secret', 'keycape-config', '-n', 'sso', '--ignore-not-found', '-o', 'name'])
if existing.strip(): raise Refused()
ready = run(['kubectl', 'get', 'externalsecret', 'keycape-factor-read', '-n', 'sso',
'-o', 'jsonpath={.status.conditions[?(@.type=="Ready")].status}'])
factor = run(['kubectl', 'get', 'secret', 'keycape-factor-read', '-n', 'sso', '-o', 'name'])
if ready.strip() != b'True' or factor.strip() != b'secret/keycape-factor-read': raise Refused()
key = signing_key(dirs['keycape'])
data = {'config.yaml': yaml.safe_dump(config, sort_keys=False).encode(), 'key.pem': key}
manifest = {'apiVersion': 'v1', 'kind': 'Secret', 'type': 'Opaque',
'metadata': {'name': 'keycape-config', 'namespace': 'sso'},
'data': {name: base64.b64encode(value).decode() for name, value in data.items()}}
response = run(['kubectl', 'create', '-o', 'name', '-f', '-'], json.dumps(manifest).encode())
if response.strip() != b'secret/keycape-config': raise Refused()
print('KeyCape bootstrap Secret created with mounted factor credential configuration.')
print('Preserve the private signing key in approved custody. Native login and factor acceptance remain required.')
if __name__ == '__main__':
try:
main()
except (Refused, OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
print('ERROR: KeyCape bootstrap refused or failed. Existing configuration is never replaced; '
'check private inputs and RPF-WP-0040 delivery using metadata-only diagnostics. '
'If creation was attempted, its remote outcome must be reconciled before retry.', file=sys.stderr)
sys.exit(1)

View file

@ -1,175 +1,7 @@
#!/usr/bin/env bash
# create-secrets.sh — create the keycape-config K8s Secret
#
# Usage:
# ./create-secrets.sh [secrets-dir]
#
# Creates ONE Secret in the sso namespace:
# keycape-config — config.yaml (full KeyCape config) + key.pem (RSA signing key)
#
# The privacyIDEA admin token is a separate Secret (keycape-pi-token) created
# by create-pi-token.sh AFTER privacyIDEA is bootstrapped (T04 complete).
# The PI admin token is read from that Secret at startup via config.yaml.
#
# Re-run this script to:
# - Rotate the Authelia client secret (update secrets/authelia/secrets.env first)
# - Add or modify OIDC client registrations (edit CLIENTS block below)
# - Rotate the RSA signing key (delete and regenerate secrets/keycape/key.pem)
# First creation only. Existing KeyCape configuration requires a scoped owner update.
set +x
set -euo pipefail
SECRETS_DIR="${1:-../../bootstrap/secrets}"
KEYCAPE_ENV="$SECRETS_DIR/keycape/secrets.env"
LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env"
AUTHELIA_ENV="$SECRETS_DIR/authelia/secrets.env"
KEY_FILE="$SECRETS_DIR/keycape/key.pem"
for f in "$KEYCAPE_ENV" "$LLDAP_ENV" "$AUTHELIA_ENV"; do
if [[ ! -f "$f" ]]; then
echo "ERROR: $f not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2
exit 1
fi
done
read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; }
LLDAP_BIND_PW=$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS)
AUTHELIA_CLIENT_SECRET=$(read_env "$AUTHELIA_ENV" AUTHELIA_KEYCAPE_CLIENT_SECRET)
if [[ -z "$LLDAP_BIND_PW" || -z "$AUTHELIA_CLIENT_SECRET" ]]; then
echo "ERROR: could not read LLDAP_LDAP_USER_PASS or AUTHELIA_KEYCAPE_CLIENT_SECRET" >&2
exit 1
fi
# The privacyIDEA admin token is read from a separate Secret at runtime.
# Placeholder here — create-pi-token.sh populates the real value.
PI_ADMIN_TOKEN="PENDING_create-pi-token.sh"
if [[ -f "$SECRETS_DIR/keycape/pi_admin_token" ]]; then
PI_ADMIN_TOKEN=$(cat "$SECRETS_DIR/keycape/pi_admin_token")
echo "INFO: Using privacyIDEA admin token from $SECRETS_DIR/keycape/pi_admin_token"
fi
# ── RSA signing key ───────────────────────────────────────────────────────────
if [[ ! -f "$KEY_FILE" ]]; then
echo "Generating RSA-2048 signing key for KeyCape JWT tokens..."
mkdir -p "$(dirname "$KEY_FILE")"
openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null
chmod 600 "$KEY_FILE"
echo " Generated: $KEY_FILE"
echo " IMPORTANT: Store this key in KeePassXC → net-kingdom/KeyCape/jwt-signing-key"
echo " as a binary attachment. It cannot be recovered if lost."
else
echo "INFO: Using existing key: $KEY_FILE"
fi
KEY_CONTENT=$(cat "$KEY_FILE")
# ── Build config.yaml ─────────────────────────────────────────────────────────
# Edit the OIDC clients block below to register downstream applications.
# Re-run this script after any change.
CONFIG_YAML=$(cat <<EOF
issuer: "https://kc.coulomb.social"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "/etc/keycape/key.pem"
environment: "production"
lldap:
url: "ldap://lldap.sso.svc.cluster.local:3890"
bindDN: "uid=admin,ou=people,dc=netkingdom,dc=local"
bindPW: "${LLDAP_BIND_PW}"
baseDN: "dc=netkingdom,dc=local"
userOU: "ou=people"
groupOU: "ou=groups"
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"
privacyidea:
baseURL: "http://privacyidea.mfa.svc.cluster.local:8080"
adminToken: "${PI_ADMIN_TOKEN}"
realm: "coulomb"
requireForAll: true
# ── OIDC client registrations ─────────────────────────────────────────────────
# clientType: "public" for SPAs/native apps (PKCE, no client secret)
# "confidential" for server-side apps (client secret required)
clients:
- clientId: "demo-app"
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: user-engine-portal
displayName: User Engine Portal
redirectUris:
- https://users.92-205-62-239.nip.io/oidc/callback
- https://users.coulomb.social/oidc/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"
- clientId: "openbao-admin"
displayName: "Railiance OpenBao Admin"
redirectUris:
- "http://localhost:8250/oidc/callback"
- "http://127.0.0.1:8250/oidc/callback"
- "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback"
allowedScopes: ["openid", "profile", "email", "groups"]
grantTypes: ["authorization_code"]
clientType: "public"
- clientId: "rapp-qonto-client"
displayName: "rapp-qonto workload"
allowedScopes: ["qonto:read"]
grantTypes: ["client_credentials"]
clientType: "confidential"
secretRef: "env:KEYCAPE_RAPP_QONTO_CLIENT_SECRET"
serviceSubject: "rapp-qonto"
tenant: "tenant:friendly:binky"
roles: ["qonto-reader"]
EOF
)
echo "Creating K8s Secret: keycape-config (namespace: sso)"
kubectl create secret generic keycape-config \
--namespace=sso \
--from-literal=config.yaml="$CONFIG_YAML" \
--from-literal=key.pem="$KEY_CONTENT" \
--dry-run=client -o yaml | kubectl apply -f -
echo ""
echo "Done. Secret keycape-config created in namespace: sso"
echo ""
if [[ "$PI_ADMIN_TOKEN" == "PENDING_create-pi-token.sh" ]]; then
echo "WARN: privacyIDEA admin token is a placeholder."
echo " After T04 bootstrap is complete, run:"
echo " ./create-pi-token.sh"
echo " Then re-run this script to update keycape-config."
echo ""
fi
echo "Next: apply deployment.yaml, middleware.yaml, ingress.yaml"
umask 077
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec python3 "$SCRIPT_DIR/create-secrets.py" "$@"

View file

@ -1,180 +1,6 @@
#!/usr/bin/env bash
# refresh-pi-token-live.sh — refresh KeyCape's privacyIDEA admin token.
#
# This is an attended repair for "mfa check error" when the privacyIDEA admin
# JWT embedded in keycape-config has expired. It prompts for the pi-admin
# password, fetches a fresh token inside the privacyIDEA pod, updates the
# keycape-pi-token Secret, patches keycape-config without printing secrets, and
# restarts KeyCape.
#
# Usage:
# bash refresh-pi-token-live.sh [username]
#
# Optional environment:
# KUBECTL=/path/to/kubectl
# KEYCAPE_PI_REALM=coulomb|netkingdom # defaults to coulomb, the live privacyIDEA realm
# KEYCAPE_PI_REQUIRE_FOR_ALL=true|false # defaults to true to avoid admin token-list checks
# Retired: KeyCape uses the scoped, renewable factor-reader lane.
set +x
set -euo pipefail
USERNAME="${1:-platform-root}"
KUBECTL="${KUBECTL:-kubectl}"
SSO_NAMESPACE="${SSO_NAMESPACE:-sso}"
MFA_NAMESPACE="${MFA_NAMESPACE:-mfa}"
KEYCAPE_DEPLOYMENT="${KEYCAPE_DEPLOYMENT:-keycape}"
KEYCAPE_SECRET="${KEYCAPE_SECRET:-keycape-config}"
KEYCAPE_TOKEN_SECRET="${KEYCAPE_TOKEN_SECRET:-keycape-pi-token}"
if [[ -r /dev/tty ]]; then
printf "privacyIDEA pi-admin password: " > /dev/tty
IFS= read -r -s PI_ADMIN_PASSWORD < /dev/tty
printf "\n" > /dev/tty
else
printf "privacyIDEA pi-admin password: " >&2
IFS= read -r -s PI_ADMIN_PASSWORD
printf "\n" >&2
fi
if [[ -z "$PI_ADMIN_PASSWORD" ]]; then
echo "[FAIL] Empty pi-admin password." >&2
exit 1
fi
PI_POD="$(
"$KUBECTL" get pod -n "$MFA_NAMESPACE" \
-l app.kubernetes.io/name=privacyidea \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true
)"
if [[ -z "$PI_POD" ]]; then
echo "[FAIL] No running privacyIDEA pod in namespace $MFA_NAMESPACE." >&2
exit 1
fi
echo "Fetching fresh privacyIDEA admin token inside pod $PI_POD ..."
PI_TOKEN="$(
"$KUBECTL" exec -n "$MFA_NAMESPACE" "$PI_POD" -- \
env PI_ADMIN_PASSWORD="$PI_ADMIN_PASSWORD" \
python3 -c '
import json
import os
import sys
import urllib.parse
import urllib.request
payload = urllib.parse.urlencode({
"username": "pi-admin",
"password": os.environ["PI_ADMIN_PASSWORD"],
}).encode()
req = urllib.request.Request("http://localhost:8080/auth", data=payload)
try:
with urllib.request.urlopen(req, timeout=10) as response:
body = json.load(response)
print(body["result"]["value"]["token"])
except Exception as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
' 2>/dev/null
)"
unset PI_ADMIN_PASSWORD
if [[ -z "$PI_TOKEN" ]]; then
echo "[FAIL] Could not fetch a privacyIDEA admin token." >&2
exit 1
fi
tmpdir="$(mktemp -d)"
cleanup() {
rm -rf "$tmpdir"
}
trap cleanup EXIT
current_config="$(
"$KUBECTL" get secret "$KEYCAPE_SECRET" -n "$SSO_NAMESPACE" \
-o jsonpath='{.data.config\.yaml}' | base64 -d
)"
current_realm="$(
CONFIG_YAML="$current_config" python3 -c '
import os
import sys
try:
import yaml
except ImportError:
print("PyYAML is required: install python3-yaml", file=sys.stderr)
sys.exit(1)
config = yaml.safe_load(os.environ["CONFIG_YAML"]) or {}
privacyidea = config.get("privacyidea") or {}
if not isinstance(privacyidea, dict):
print("")
else:
print(str(privacyidea.get("realm") or "").strip())
'
)"
selected_realm="${KEYCAPE_PI_REALM:-coulomb}"
selected_require_for_all="${KEYCAPE_PI_REQUIRE_FOR_ALL:-true}"
if [[ -z "${KEYCAPE_PI_REALM:-}" && -n "$current_realm" && "$current_realm" != "$selected_realm" ]]; then
echo "[WARN] KeyCape currently points privacyIDEA at realm '$current_realm'; repairing to '$selected_realm'." >&2
fi
if [[ "$selected_realm" != "coulomb" && "$selected_realm" != "netkingdom" ]]; then
echo "[FAIL] Refusing unsupported privacyIDEA realm: $selected_realm" >&2
exit 1
fi
if [[ "$selected_require_for_all" != "true" && "$selected_require_for_all" != "false" ]]; then
echo "[FAIL] KEYCAPE_PI_REQUIRE_FOR_ALL must be true or false." >&2
exit 1
fi
echo "Selected privacyIDEA realm for KeyCape: $selected_realm"
echo "Selected privacyIDEA requireForAll for KeyCape: $selected_require_for_all"
"$KUBECTL" get secret "$KEYCAPE_SECRET" -n "$SSO_NAMESPACE" \
-o jsonpath='{.data.key\.pem}' | base64 -d > "$tmpdir/key.pem"
chmod 600 "$tmpdir/key.pem"
CONFIG_YAML="$current_config" PI_TOKEN="$PI_TOKEN" PI_REALM="$selected_realm" PI_REQUIRE_FOR_ALL="$selected_require_for_all" \
python3 -c '
import os
import sys
try:
import yaml
except ImportError:
print("PyYAML is required: install python3-yaml", file=sys.stderr)
sys.exit(1)
config = yaml.safe_load(os.environ["CONFIG_YAML"]) or {}
if not isinstance(config, dict):
print("KeyCape config.yaml must decode to a YAML mapping.", file=sys.stderr)
sys.exit(1)
privacyidea = config.setdefault("privacyidea", {})
if not isinstance(privacyidea, dict):
print("KeyCape privacyidea config must decode to a YAML mapping.", file=sys.stderr)
sys.exit(1)
privacyidea["adminToken"] = os.environ["PI_TOKEN"]
privacyidea["realm"] = os.environ["PI_REALM"]
privacyidea["requireForAll"] = os.environ["PI_REQUIRE_FOR_ALL"] == "true"
sys.stdout.write(yaml.safe_dump(config, sort_keys=False))
' > "$tmpdir/config.yaml"
echo "Applying refreshed KeyCape config Secret ..."
"$KUBECTL" create secret generic "$KEYCAPE_TOKEN_SECRET" \
--namespace="$SSO_NAMESPACE" \
--from-literal=token="$PI_TOKEN" \
--dry-run=client -o yaml | "$KUBECTL" apply -f -
"$KUBECTL" create secret generic "$KEYCAPE_SECRET" \
--namespace="$SSO_NAMESPACE" \
--from-file=config.yaml="$tmpdir/config.yaml" \
--from-file=key.pem="$tmpdir/key.pem" \
--dry-run=client -o yaml | "$KUBECTL" apply -f -
unset PI_TOKEN
echo "Restarting KeyCape ..."
"$KUBECTL" rollout restart "deployment/$KEYCAPE_DEPLOYMENT" -n "$SSO_NAMESPACE"
"$KUBECTL" rollout status "deployment/$KEYCAPE_DEPLOYMENT" -n "$SSO_NAMESPACE" --timeout=90s
echo ""
echo "[OK] KeyCape privacyIDEA token refreshed. Retry the OIDC login with a fresh URL."
printf '%s\n' 'ERROR: personal privacyIDEA admin-token delivery is retired.' 'Use railiance-platform RPF-WP-0040 scoped factor custody, ESO delivery and renewal recovery.' 'This helper performs no login, credential read, Secret change or restart.' >&2
exit 1

View file

@ -62,9 +62,7 @@ class PortalClientTests(unittest.TestCase):
rollout.replacement(self.fixture(clients)[0])
def test_bootstrap_retains_same_public_registration(self):
source = Path(__file__).with_name('create-secrets.sh').read_text()
raw = source.split('CONFIG_YAML=$(cat <<EOF\n', 1)[1].split('\nEOF', 1)[0]
clients = yaml.safe_load(raw)['clients']
clients = json.loads(Path(__file__).with_name('bootstrap-config.json').read_text())['clients']
self.assertEqual([c for c in clients if c['clientId'] == rollout.portal.CLIENT_ID],
[rollout.portal.CLIENT])

View file

@ -0,0 +1,142 @@
"""Synthetic custody tests; no real cluster or credential access."""
import base64
import json
import os
from pathlib import Path
import subprocess
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / 'sso-mfa/k8s/keycape'
PASSWORD = 'SyntheticOnlyCredential12345678901234567890'
@pytest.fixture
def setup(tmp_path):
root = tmp_path / 'private'; root.mkdir(mode=0o700)
for component, key in [('lldap', 'LLDAP_LDAP_USER_PASS'), ('authelia', 'AUTHELIA_KEYCAPE_CLIENT_SECRET'), ('keycape', None)]:
folder = root / component; folder.mkdir(mode=0o700)
file = folder / 'secrets.env'; file.write_text(f'{key}={PASSWORD}\n' if key else '# placeholder\n'); file.chmod(0o600)
binary = tmp_path / 'bin'; binary.mkdir()
(binary / 'kubectl').write_text('''#!/usr/bin/python3
import json,os,sys
args=sys.argv[1:]
raw=sys.stdin.read() if args[-1:]==['-'] else ''
with open(os.environ['CALLS'],'a') as f:f.write(json.dumps({'args':args,'stdin':raw})+'\\n')
if os.environ.get('FAIL')==args[0]:
print(os.environ['SYNTHETIC']);print(os.environ['SYNTHETIC'],file=sys.stderr);sys.exit(1)
if args[:3]==['get','secret','keycape-config']:
print(os.environ.get('EXISTING',''),end='')
elif args[:2]==['get','externalsecret']:
print(os.environ.get('READY','True'),end='')
elif args[:3]==['get','secret','keycape-factor-read']:
print(os.environ.get('FACTOR','secret/keycape-factor-read'),end='')
elif args[0]=='create':
print('secret/keycape-config',end='')
else: sys.exit(3)
''')
(binary / 'kubectl').chmod(0o700)
for name in ('curl', 'sleep'):
(binary / name).write_text('#!/bin/sh\nexit 97\n'); (binary / name).chmod(0o700)
env={'PATH':str(binary)+':'+os.environ['PATH'],'HOME':str(tmp_path),'CALLS':str(tmp_path/'calls'),'SYNTHETIC':PASSWORD}
return root, env
def invoke(setup, script='create-secrets.sh', **extra):
root, env=setup
return subprocess.run(['bash','-x',str(SCRIPTS/script),str(root)],input=PASSWORD+'\n',
env={**env,**extra},capture_output=True,text=True,timeout=15)
def calls(setup):
file=Path(setup[1]['CALLS'])
return [json.loads(line) for line in file.read_text().splitlines()] if file.exists() else []
@pytest.mark.parametrize('script',['create-pi-token.sh','refresh-pi-token-live.sh'])
def test_retired_admin_token_paths_refuse_before_read_or_cluster_access(setup,script):
result=invoke(setup,script)
assert result.returncode!=0
assert 'RPF-WP-0040' in result.stdout+result.stderr
assert PASSWORD not in result.stdout+result.stderr
assert not calls(setup)
assert not (setup[0]/'keycape/pi_admin_token').exists()
def test_new_config_uses_mounted_scoped_credential_without_secret_argv_or_output(setup):
result=invoke(setup)
assert result.returncode==0, result.stderr
assert PASSWORD not in result.stdout+result.stderr
observed=calls(setup)
assert PASSWORD not in json.dumps([c['args'] for c in observed])
writes=[c for c in observed if c['args'][0] not in ['get']]
assert len(writes)==1 and writes[0]['args']==['create','-o','name','-f','-']
secret=json.loads(writes[0]['stdin'])
assert 'annotations' not in secret['metadata']
config=yaml.safe_load(base64.b64decode(secret['data']['config.yaml']))
assert config['privacyidea'].get('adminToken','')==''
assert config['privacyidea']['adminTokenFile']=='/etc/keycape-factor/admin-token'
assert config['privacyidea']['realm']=='coulomb'
assert config['privacyidea']['requireForAll'] is True
assert config['lldap']['bindPW']==PASSWORD
assert config['authelia']['clientSecret']==PASSWORD
key=setup[0]/'keycape/key.pem'
assert key.stat().st_mode & 0o077==0
assert base64.b64decode(secret['data']['key.pem'])==key.read_bytes()
checked=subprocess.run(['openssl','rsa','-check','-noout'],input=key.read_bytes(),capture_output=True)
assert checked.returncode==0
@pytest.mark.parametrize('extra',[{'EXISTING':'secret/keycape-config'},{'READY':'False'},{'READY':''},{'FACTOR':''},{'FAIL':'get'}])
def test_existing_config_or_missing_delivery_refuses_without_local_or_remote_writes(setup,extra):
result=invoke(setup,**extra)
assert result.returncode!=0
assert not (setup[0]/'keycape/key.pem').exists()
assert all(c['args'][0]=='get' for c in calls(setup))
assert PASSWORD not in result.stdout+result.stderr
@pytest.mark.parametrize('defect',['public','public-parent','public-root','symlink','parent-symlink','hardlink','duplicate','shell','missing'])
def test_bad_custody_refused_before_cluster_access(setup,defect):
root,_=setup; file=root/'lldap/secrets.env'; marker=root/'executed'
if defect=='public':file.chmod(0o644)
elif defect=='public-parent':file.parent.chmod(0o755)
elif defect=='public-root':root.chmod(0o755)
elif defect=='symlink':
target=file.with_name('other');file.rename(target);file.symlink_to(target)
elif defect=='parent-symlink':
target=root/'other';file.parent.rename(target);(root/'lldap').symlink_to(target)
elif defect=='hardlink':os.link(file,file.with_name('alias'))
elif defect=='duplicate':file.write_text(file.read_text()*2)
elif defect=='shell':file.write_text(f'LLDAP_LDAP_USER_PASS=$(touch {marker})\n')
else:file.unlink()
result=invoke(setup)
assert result.returncode!=0
assert not marker.exists()
assert not calls(setup)
assert PASSWORD not in result.stdout+result.stderr
def test_creation_failure_retains_private_key_for_retry_without_leaking(setup):
result=invoke(setup,FAIL='create')
assert result.returncode!=0
assert PASSWORD not in result.stdout+result.stderr
key=setup[0]/'keycape/key.pem'; before=key.read_bytes()
assert key.stat().st_mode & 0o077==0
assert invoke(setup).returncode==0
assert key.read_bytes()==before
@pytest.mark.parametrize('defect',['symlink','hardlink','public','invalid'])
def test_existing_invalid_signing_key_is_not_replaced(setup,defect):
key=setup[0]/'keycape/key.pem'; key.write_text('invalid');key.chmod(0o600)
if defect=='symlink':
target=key.with_name('other');key.rename(target);key.symlink_to(target)
elif defect=='hardlink':os.link(key,key.with_name('alias'))
elif defect=='public':key.chmod(0o644)
result=invoke(setup)
assert result.returncode!=0
assert key.read_text()=='invalid'
assert not any(c['args'][0]=='create' for c in calls(setup))

View file

@ -12,7 +12,7 @@ BOOT = ROOT / 'sso-mfa/bootstrap'
CASES = {
'PI_SECRET_KEY': 'privacyidea', 'PI_DB_PASSWORD': 'privacyidea',
'LLDAP_JWT_SECRET': 'lldap', 'AUTHELIA_SESSION_SECRET': 'authelia',
'AUTHELIA_KEYCAPE_CLIENT_SECRET': 'authelia', 'BREAKGLASS_PASSWORD': 'breakglass',
'BREAKGLASS_PASSWORD': 'breakglass',
}
SYNTHETIC = 'A' * 64
@ -183,3 +183,19 @@ def test_declining_rotation_preserves_custody_file_and_cluster(rotation):
assert file.read_bytes() == before
assert not calls(rotation)
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
@pytest.mark.parametrize('key',['AUTHELIA_KEYCAPE_CLIENT_SECRET','KEYCAPE_RSA_KEY'])
@pytest.mark.parametrize('attended',[False,True])
def test_retired_keycape_replacement_refuses_before_any_credential_change(rotation,key,attended):
repo,boot,secrets,env=rotation
signing=secrets/'keycape/key.pem';signing.write_text('synthetic-existing-private-key');signing.chmod(0o600)
before={p:p.read_bytes() for p in secrets.rglob('*') if p.is_file()}
args=['bash','-x',str(boot/'creds-rotate.sh')]
if not attended:args.append('--non-interactive')
result=subprocess.run([*args,str(secrets)],input='yes\n',env={**env,'SECRET':key},capture_output=True,text=True,timeout=10)
assert result.returncode!=0
assert not calls(rotation)
assert {p:p.read_bytes() for p in secrets.rglob('*') if p.is_file()}==before
assert 'last_rotated_' not in (boot/'creds-state.yaml').read_text()
assert 'NK-WP-0049-T02' in result.stderr

View file

@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: netkingdom
created: "2026-10-12"
updated: "2026-10-12"
updated: "2026-10-13"
flavor: implementation
depends_on: []
state_hub_workstream_id: "17d315f0-829e-57c7-85cc-3a7914152804"
@ -74,13 +74,15 @@ signal deferral and arbitrary-child copy retention are explicitly not solved by
this helper. Evidence: Custodian `custody-entrypoints.json`.
Concrete uncovered callers remain (T02 stays progress):
- The separate `keycape/create-pi-token.sh` still uses credential-bearing argv,
ambient/public-URL fallback, raw child-output handling and client-side Secret
apply. Its token lifecycle and current consumer authority need reconciliation.
- The KeyCape personal-admin token mint/refresh paths are retired by the October
13 repair below. Historical token copies still need owner inventory/revocation;
the old `privacyidea/check-user-mfa-state.sh` also consumes that legacy Secret
with insecure curl/argv handling and still requires reconciliation.
- Legacy trigger account/Secret inventory and owner-approved revocation are still
required; removing automatic creation does not revoke historical credentials.
- RSA rotation rollback/custody and native multi-consumer acceptance remain open;
the output repair below is not lifecycle closure.
- RSA and Authelia-to-KeyCape client-secret rotation need configuration-preserving
updates, retained rollback custody and native multi-consumer acceptance. The
legacy replacement paths now refuse before mutation; this is not rotation closure.
- Delegated create/apply helpers still need full tracing/error/Secret-annotation
output review; transport capture and native effect evidence remain mandatory.
@ -125,6 +127,30 @@ handling. These do not prove the deployed image/PostgreSQL or effective MFA.
Existing objects are not deleted or rotated. Current callers and the native
acceptance gate remain open. Evidence: Custodian `admin-bootstrap.json`.
2026-10-13 KeyCape caller reconciliation: RPF-WP-0040 is finished and its
owner contract/deployment use the scoped renewable factor-reader credential with
ESO and `adminTokenFile`. Retired `create-pi-token.sh` and
`refresh-pi-token-live.sh` before input/credential/cluster access; neither personal
admin JWT delivery nor automatic realm/MFA-policy replacement is a current
consumer requirement. Existing provider identities/Secrets were not revoked.
The first-bootstrap config generator now uses that mounted-file source, private
literal input parsing, captured child diagnostics, stdin-only Secret data and
atomic private signing-key publication without replacement. It requires Ready
factor delivery and refuses existing config; API creation cannot overwrite a
concurrent creator and adds no client-side apply annotation. A failed API call
leaves its remote outcome unverified and retains the private key for recovery.
Existing installations require a scoped owner update preserving clients/policy.
Bootstrap callers and recovery guidance no longer invoke retired token helpers.
Legacy RSA/client-secret replacement rotations refuse before local or remote
mutation: the former destroyed rollback custody before attempting publication,
and both would overwrite newer live configuration. The remaining coordinated
rotation work stays here, not silently completed by refusal. Native recovery,
current deployed consumer acceptance and other create/apply/SOPS callers remain
open. Source verification and its limits: Custodian
`docs/evidence/2026-10-13-netkingdom-security/keycape-caller-reconciliation.json`.
## Verify owner-approved recovery and current native callers
```task