2026-08-23 14:26:36 +02:00
#!/usr/bin/env bash
# Rotate every credential class formerly embedded in sso/keycape-config without
# rendering a Secret value. This is an emergency, deliberate-invalidation path.
set -euo pipefail
MODE = " ${ 1 :- } "
if [ [ " $MODE " != "--preflight" && " $MODE " != "--execute" ] ] ; then
echo " usage: $0 --preflight|--execute " >& 2
exit 2
fi
if [ [ " $MODE " = = "--execute" && " ${ KEYCAPE_ACKNOWLEDGE_SESSION_INVALIDATION :- } " != "1" ] ] ; then
echo "refusing execution without KEYCAPE_ACKNOWLEDGE_SESSION_INVALIDATION=1" >& 2
exit 2
fi
for tool in age base64 curl jq kubectl openssl python3 sha256sum tar; do
command -v " $tool " >/dev/null || {
echo " missing required tool: $tool " >& 2
exit 1
}
done
python3 -c 'import bcrypt' 2>/dev/null || {
echo "missing required Python bcrypt module" >& 2
exit 1
}
ROOT_DIR = " $( cd " $( dirname " $0 " ) /.. " && pwd ) "
RECIPIENT_FILE = " ${ NETKINGDOM_AGE_RECIPIENT_FILE :- /home/worsch/net-kingdom/keys/age.pub } "
ARCHIVE_FILE = " ${ KEYCAPE_RECOVERY_ARCHIVE :- $ROOT_DIR /history/KEY-WP-0011-rotated-credentials-2026-08-23.tar.age } "
[ [ -s " $RECIPIENT_FILE " ] ] || {
echo " age recipient file not found: $RECIPIENT_FILE " >& 2
exit 1
}
required_secrets = (
"sso/keycape-config"
"sso/keycape-pi-token"
"sso/authelia-secrets"
"sso/lldap-secrets"
"mfa/privacyidea-config"
)
required_deployments = (
"sso/keycape"
"sso/authelia"
"sso/lldap"
"mfa/privacyidea"
)
for ref in " ${ required_secrets [@] } " ; do
namespace = " ${ ref %%/* } "
name = " ${ ref #*/ } "
kubectl get secret " $name " -n " $namespace " -o name >/dev/null
done
for ref in " ${ required_deployments [@] } " ; do
namespace = " ${ ref %%/* } "
name = " ${ ref #*/ } "
kubectl get deployment " $name " -n " $namespace " -o name >/dev/null
done
jwks_fingerprint( ) {
jwks_uri = " $( curl -fsS https://kc.coulomb.social/.well-known/openid-configuration | jq -er '.jwks_uri' ) "
curl -fsS " $jwks_uri " |
jq -cS '.keys | map({alg,e,kid,kty,n,use})' |
sha256sum |
awk '{print $1}'
}
before_jwks = " $( jwks_fingerprint) "
before_keycape_rv = " $( kubectl get secret keycape-config -n sso -o jsonpath = '{.metadata.resourceVersion}' ) "
before_pi_rv = " $( kubectl get secret privacyidea-config -n mfa -o jsonpath = '{.metadata.resourceVersion}' ) "
before_lldap_rv = " $( kubectl get secret lldap-secrets -n sso -o jsonpath = '{.metadata.resourceVersion}' ) "
before_authelia_rv = " $( kubectl get secret authelia-secrets -n sso -o jsonpath = '{.metadata.resourceVersion}' ) "
echo "preflight: deployments and Secrets exist"
echo " preflight: current public JWKS fingerprint: $before_jwks "
echo " preflight: current resource versions: keycape= $before_keycape_rv privacyidea= $before_pi_rv lldap= $before_lldap_rv authelia= $before_authelia_rv "
if [ [ " $MODE " = = "--preflight" ] ] ; then
exit 0
fi
if [ [ -e " $ARCHIVE_FILE " ] ] ; then
echo " refusing to overwrite recovery archive: $ARCHIVE_FILE " >& 2
exit 1
fi
umask 077
work_dir = " $( mktemp -d /tmp/keycape-recovery.XXXXXX) "
port_forward_pids = ( )
lldap_force_reset_active = false
cleanup( ) {
status = $?
for pid in " ${ port_forward_pids [@] :- } " ; do
kill " $pid " 2>/dev/null || true
wait " $pid " 2>/dev/null || true
done
if [ [ " $lldap_force_reset_active " = = "true" ] ] ; then
kubectl set env deployment/lldap -n sso LLDAP_FORCE_LDAP_USER_PASS_RESET- >/dev/null 2>& 1 || true
fi
case " $work_dir " in
/tmp/keycape-recovery.*)
find " $work_dir " -type f -exec shred -u -- { } + 2>/dev/null || true
rm -rf -- " $work_dir "
; ;
esac
exit " $status "
}
trap cleanup EXIT INT TERM
openssl rand -hex 32 >" $work_dir /lldap-bind-password "
openssl rand -hex 32 >" $work_dir /authelia-keycape-client-secret "
openssl rand -hex 32 >" $work_dir /privacyidea-secret-key "
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out " $work_dir /key.pem " 2>/dev/null
python3 -c '
import bcrypt
import sys
secret = sys.stdin.buffer.read( ) .rstrip( b"\n" )
sys.stdout.buffer.write( bcrypt.hashpw( secret, bcrypt.gensalt( rounds = 12) ) )
' <" $work_dir /authelia-keycape-client-secret " >" $work_dir /authelia-keycape-client-secret.bcrypt "
lldap_password = " $( tr -d '\n' <" $work_dir /lldap-bind-password " ) "
authelia_secret = " $( tr -d '\n' <" $work_dir /authelia-keycape-client-secret " ) "
cat >" $work_dir /config.yaml " <<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_password "
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: " $authelia_secret "
redirectURI: "https://kc.coulomb.social/authorize/callback"
privacyidea:
baseURL: "http://privacyidea.mfa.svc.cluster.local:8080"
adminToken: "ROTATED_DISABLED_ADMIN_TOKEN"
realm: "coulomb"
requireForAll: true
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: "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"
- "https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback"
- "https://bao.coulomb.social/ui/vault/auth/keycape/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
unset lldap_password authelia_secret
printf '%s\n' 'KEY-WP-0011 emergency rotation overlay.' 'Contains only post-exposure credential material; decrypt only through approved NetKingdom age custody.' >" $work_dir /README.txt "
tar -C " $work_dir " -cf " $work_dir /recovery.tar " README.txt config.yaml key.pem lldap-bind-password authelia-keycape-client-secret privacyidea-secret-key
age -R " $RECIPIENT_FILE " -o " $ARCHIVE_FILE " " $work_dir /recovery.tar "
chmod 600 " $ARCHIVE_FILE "
echo " custody: encrypted recovery overlay created at $ARCHIVE_FILE "
patch_field( ) {
namespace = " $1 "
secret_name = " $2 "
field_name = " $3 "
value_file = " $4 "
patch_file = " $work_dir /patch- ${ namespace } - ${ secret_name } - ${ field_name } .json "
encoded = " $( base64 -w0 <" $value_file " ) "
printf '{"data":{"%s":"%s"}}' " $field_name " " $encoded " >" $patch_file "
unset encoded
kubectl patch secret " $secret_name " -n " $namespace " --type= merge --patch-file " $patch_file " >/dev/null
shred -u -- " $patch_file "
}
echo "rotation: invalidating the exposed privacyIDEA admin JWT"
patch_field mfa privacyidea-config PI_SECRET_KEY " $work_dir /privacyidea-secret-key "
printf '%s' 'ROTATED_DISABLED_ADMIN_TOKEN' >" $work_dir /pi-token-disabled "
patch_field sso keycape-pi-token token " $work_dir /pi-token-disabled "
kubectl rollout restart deployment/privacyidea -n mfa >/dev/null
kubectl rollout status deployment/privacyidea -n mfa --timeout= 180s
echo "rotation: resetting the LLDAP admin/bind credential"
patch_field sso lldap-secrets LLDAP_LDAP_USER_PASS " $work_dir /lldap-bind-password "
kubectl set env deployment/lldap -n sso LLDAP_FORCE_LDAP_USER_PASS_RESET = always >/dev/null
lldap_force_reset_active = true
kubectl rollout status deployment/lldap -n sso --timeout= 180s
kubectl port-forward -n sso service/lldap 11717:17170 >" $work_dir /lldap-port-forward.log " 2>& 1 &
port_forward_pids += ( " $! " )
for _ in { 1..30} ; do
curl -fsS -o /dev/null http://127.0.0.1:11717/health >/dev/null 2>& 1 && break
sleep 1
done
printf '{"username":"admin","password":"%s"}' " $( tr -d '\n' <" $work_dir /lldap-bind-password " ) " >" $work_dir /lldap-login.json "
lldap_status = " $( curl -sS -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' --data-binary @" $work_dir /lldap-login.json " http://127.0.0.1:11717/auth/simple/login) "
[ [ " $lldap_status " = = "200" ] ] || {
echo " LLDAP new-credential verification failed with HTTP $lldap_status " >& 2
exit 1
}
echo "verification: LLDAP accepted the replacement bind credential"
kill " ${ port_forward_pids [-1] } " 2>/dev/null || true
wait " ${ port_forward_pids [-1] } " 2>/dev/null || true
unset 'port_forward_pids[-1]'
kubectl set env deployment/lldap -n sso LLDAP_FORCE_LDAP_USER_PASS_RESET- >/dev/null
lldap_force_reset_active = false
kubectl rollout status deployment/lldap -n sso --timeout= 180s
2026-08-23 14:42:44 +02:00
echo "rotation: restarting identity-provisioner consumer"
kubectl rollout restart deployment/identity-provisioner -n sso >/dev/null
kubectl rollout status deployment/identity-provisioner -n sso --timeout= 180s
2026-08-23 14:26:36 +02:00
echo "rotation: updating Authelia and KeyCape consumers"
authelia_patch = " $work_dir /patch-authelia.json "
ldap_b64 = " $( base64 -w0 <" $work_dir /lldap-bind-password " ) "
client_hash_b64 = " $( base64 -w0 <" $work_dir /authelia-keycape-client-secret.bcrypt " ) "
printf '{"data":{"ldap_password":"%s","keycape_client_secret_hash":"%s"}}' " $ldap_b64 " " $client_hash_b64 " >" $authelia_patch "
unset ldap_b64 client_hash_b64
kubectl patch secret authelia-secrets -n sso --type= merge --patch-file " $authelia_patch " >/dev/null
shred -u -- " $authelia_patch "
# The current Authelia deployment injects most secret fields from files, but
# its OIDC client verifier is still code-defined in authelia-config. Keep the
# Secret copy for the intended file-backed migration and update the active
# ConfigMap atomically for the live 4.38 deployment.
kubectl get configmap authelia-config -n sso -o jsonpath = '{.data.configuration\.yml}' >" $work_dir /authelia-configuration.yml "
AUTHELIA_CLIENT_HASH = " $( tr -d '\n' <" $work_dir /authelia-keycape-client-secret.bcrypt " ) " python3 -c '
import os
import re
import sys
source = sys.stdin.read( )
updated, count = re.subn(
r" (?m)^(\s+secret:\s*).* $" ,
lambda match: match.group( 1) + chr( 34) + os.environ[ "AUTHELIA_CLIENT_HASH" ] + chr( 34) ,
source,
)
if count != 1:
raise SystemExit( f"expected one Authelia OIDC client secret field, found {count}" )
sys.stdout.write( updated)
' <" $work_dir /authelia-configuration.yml " >" $work_dir /authelia-configuration.updated.yml "
kubectl create configmap authelia-config -n sso --from-file= configuration.yml= " $work_dir /authelia-configuration.updated.yml " --dry-run= client -o yaml |
kubectl apply -f - >/dev/null
kubectl create secret generic keycape-config -n sso --from-file= config.yaml= " $work_dir /config.yaml " --from-file= key.pem= " $work_dir /key.pem " --dry-run= client -o yaml |
kubectl apply -f - >/dev/null
kubectl rollout restart deployment/authelia deployment/keycape -n sso >/dev/null
kubectl rollout status deployment/authelia -n sso --timeout= 180s
kubectl rollout status deployment/keycape -n sso --timeout= 180s
echo "verification: checking replacement Authelia client credential"
kubectl port-forward -n sso service/authelia 19091:9091 >" $work_dir /authelia-port-forward.log " 2>& 1 &
port_forward_pids += ( " $! " )
for _ in { 1..30} ; do
curl -fsS -o /dev/null http://127.0.0.1:19091/api/health >/dev/null 2>& 1 && break
sleep 1
done
basic = " $( printf 'keycape:%s' " $( tr -d '\n' <" $work_dir /authelia-keycape-client-secret " ) " | base64 -w0) "
printf 'Authorization: Basic %s\nContent-Type: application/x-www-form-urlencoded\n' " $basic " >" $work_dir /authelia-new.headers "
unset basic
printf '%s' 'grant_type=authorization_code&code=definitely-invalid&redirect_uri=https%3A%2F%2Fkc.coulomb.social%2Fauthorize%2Fcallback&client_id=keycape' >" $work_dir /authelia-token.body "
new_client_status = " $( curl -sS -o " $work_dir /authelia-new.response " -w '%{http_code}' -H @" $work_dir /authelia-new.headers " --data-binary @" $work_dir /authelia-token.body " http://127.0.0.1:19091/api/oidc/token) "
new_client_error = " $( jq -r '.error // empty' " $work_dir /authelia-new.response " 2>/dev/null || true ) "
printf 'Authorization: Basic a2V5Y2FwZTp3cm9uZy1yZWNvdmVyeS1wcm9iZQ==\nContent-Type: application/x-www-form-urlencoded\n' >" $work_dir /authelia-wrong.headers "
wrong_client_status = " $( curl -sS -o " $work_dir /authelia-wrong.response " -w '%{http_code}' -H @" $work_dir /authelia-wrong.headers " --data-binary @" $work_dir /authelia-token.body " http://127.0.0.1:19091/api/oidc/token) "
wrong_client_error = " $( jq -r '.error // empty' " $work_dir /authelia-wrong.response " 2>/dev/null || true ) "
if [ [ " $new_client_error " = = "invalid_client" || " $new_client_status " = = "401" ] ] ; then
echo "replacement Authelia client credential was rejected" >& 2
exit 1
fi
if [ [ " $wrong_client_error " != "invalid_client" && " $wrong_client_status " != "401" ] ] ; then
echo "Authelia wrong-client-secret negative check did not reject authentication" >& 2
exit 1
fi
echo "verification: Authelia accepted the replacement client and rejected a wrong secret"
kill " ${ port_forward_pids [-1] } " 2>/dev/null || true
wait " ${ port_forward_pids [-1] } " 2>/dev/null || true
unset 'port_forward_pids[-1]'
echo "verification: checking fail-closed privacyIDEA behavior"
kubectl port-forward -n mfa service/privacyidea 18081:8080 >" $work_dir /privacyidea-port-forward.log " 2>& 1 &
port_forward_pids += ( " $! " )
for _ in { 1..30} ; do
curl -sS -o /dev/null http://127.0.0.1:18081/ >/dev/null 2>& 1 && break
sleep 1
done
printf 'Authorization: Bearer ROTATED_DISABLED_ADMIN_TOKEN\nContent-Type: application/x-www-form-urlencoded\n' >" $work_dir /privacyidea.headers "
printf '%s' 'user=__keycape_recovery_probe__&pass=000000&realm=coulomb' >" $work_dir /privacyidea.body "
pi_status = " $( curl -sS -o " $work_dir /privacyidea.response " -w '%{http_code}' -H @" $work_dir /privacyidea.headers " --data-binary @" $work_dir /privacyidea.body " http://127.0.0.1:18081/validate/check) "
pi_result = " $( jq -r 'if .result | has("value") then (.result.value | tostring) else empty end' " $work_dir /privacyidea.response " 2>/dev/null || true ) "
pi_api_status = " $( jq -r 'if .result | has("status") then (.result.status | tostring) else empty end' " $work_dir /privacyidea.response " 2>/dev/null || true ) "
if [ [ " $pi_result " = = "true" ] ] ||
! { [ [ " $pi_status " = = "200" && " $pi_result " = = "false" ] ] ||
[ [ ( " $pi_status " = = "400" || " $pi_status " = = "401" ) && " $pi_api_status " = = "false" ] ] ; } ; then
echo " privacyIDEA fail-closed probe failed (HTTP $pi_status , result= $pi_result ) " >& 2
exit 1
fi
echo "verification: privacyIDEA rejected the invalid MFA proof"
kill " ${ port_forward_pids [-1] } " 2>/dev/null || true
wait " ${ port_forward_pids [-1] } " 2>/dev/null || true
unset 'port_forward_pids[-1]'
issuer = " $( curl -fsS https://kc.coulomb.social/.well-known/openid-configuration | jq -r '.issuer' ) "
[ [ " $issuer " = = "https://kc.coulomb.social" ] ] || {
echo " unexpected live issuer: $issuer " >& 2
exit 1
}
authorize_status = " $( curl -sS -o /dev/null -w '%{http_code}' 'https://kc.coulomb.social/authorize?client_id=openbao-admin&redirect_uri=http%3A%2F%2F127.0.0.1%3A18200%2Fui%2Fvault%2Fauth%2Fnetkingdom%2Foidc%2Fcallback&response_type=code&scope=openid%20profile%20email%20groups&state=keycape-recovery&code_challenge=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&code_challenge_method=S256' ) "
[ [ " $authorize_status " = = "302" ] ] || {
echo " OpenBao authorization handoff returned HTTP $authorize_status " >& 2
exit 1
}
after_jwks = " $( jwks_fingerprint) "
after_keycape_rv = " $( kubectl get secret keycape-config -n sso -o jsonpath = '{.metadata.resourceVersion}' ) "
after_pi_rv = " $( kubectl get secret privacyidea-config -n mfa -o jsonpath = '{.metadata.resourceVersion}' ) "
after_lldap_rv = " $( kubectl get secret lldap-secrets -n sso -o jsonpath = '{.metadata.resourceVersion}' ) "
after_authelia_rv = " $( kubectl get secret authelia-secrets -n sso -o jsonpath = '{.metadata.resourceVersion}' ) "
[ [ " $after_jwks " != " $before_jwks " ] ] || {
echo "public JWKS fingerprint did not change" >& 2
exit 1
}
[ [ " $after_keycape_rv " != " $before_keycape_rv " &&
" $after_pi_rv " != " $before_pi_rv " &&
" $after_lldap_rv " != " $before_lldap_rv " &&
" $after_authelia_rv " != " $before_authelia_rv " ] ] || {
echo "one or more Secret resource versions did not change" >& 2
exit 1
}
echo "verification: discovery, OpenBao callback admission, readiness, and JWKS rollover passed"
echo " verification: new public JWKS fingerprint: $after_jwks "
echo " verification: new resource versions: keycape= $after_keycape_rv privacyidea= $after_pi_rv lldap= $after_lldap_rv authelia= $after_authelia_rv "
echo "recovery complete"