diff --git a/.gitignore b/.gitignore index 8ba0b2d..669ac0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # ── Secrets (never commit) ───────────────────────────────────────────────────── sso-mfa/bootstrap/secrets/ *.age +!sso-mfa/bootstrap/secrets.enc/**/*.age *.kdbx # ---> Python diff --git a/sso-mfa/bootstrap/decrypt-secrets.sh b/sso-mfa/bootstrap/decrypt-secrets.sh new file mode 100755 index 0000000..84774a5 --- /dev/null +++ b/sso-mfa/bootstrap/decrypt-secrets.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# decrypt-secrets.sh — decrypt secrets.enc/ to secrets/ using age +# +# Usage: +# ./decrypt-secrets.sh [OUTPUT_DIR] [AGE_KEY_FILE] +# +# OUTPUT_DIR where to write plaintext secrets (default: ./secrets) +# AGE_KEY_FILE age private key file (default: ~/.config/net-kingdom/age.key) +# +# Decrypts all *.age files in secrets.enc/ to OUTPUT_DIR for use by +# create-secrets.sh scripts. Shred OUTPUT_DIR when done: +# find secrets/ -type f -exec shred -u {} \; && rm -rf secrets/ +# +# The age key must be present on the machine. Keep it outside the repo: +# ~/.config/net-kingdom/age.key + +set -euo pipefail + +OUTPUT_DIR="${1:-./secrets}" +AGE_KEY="${2:-$HOME/.config/net-kingdom/age.key}" + +ENC_DIR="$(dirname "$OUTPUT_DIR")/secrets.enc" + +if [[ ! -d "$ENC_DIR" ]]; then + echo "ERROR: encrypted secrets directory not found: $ENC_DIR" >&2 + echo "Expected secrets.enc/ next to the output directory." >&2 + exit 1 +fi + +if [[ ! -f "$AGE_KEY" ]]; then + echo "ERROR: age key not found: $AGE_KEY" >&2 + echo "Copy your age key to $AGE_KEY or pass the path as the second argument." >&2 + exit 1 +fi + +if [[ -e "$OUTPUT_DIR" ]]; then + echo "ERROR: $OUTPUT_DIR already exists. Remove it first or choose a different path." >&2 + exit 1 +fi + +echo "Decrypting $ENC_DIR → $OUTPUT_DIR/" +echo "" + +count=0 +for component_dir in "$ENC_DIR"/*/; do + component=$(basename "$component_dir") + mkdir -p "$OUTPUT_DIR/$component" + for f in "$component_dir"*.age; do + [[ -f "$f" ]] || continue + fname=$(basename "${f%.age}") + out="$OUTPUT_DIR/$component/$fname" + age -d -i "$AGE_KEY" -o "$out" "$f" + echo " decrypted: secrets.enc/$component/$(basename "$f") → $component/$fname" + count=$((count + 1)) + done +done + +echo "" +echo "$count file(s) decrypted to $OUTPUT_DIR/" +echo "" +echo "Use create-secrets.sh scripts, then shred:" +echo " find $OUTPUT_DIR -type f -exec shred -u {} \\; && rm -rf $OUTPUT_DIR" diff --git a/sso-mfa/bootstrap/encrypt-secrets.sh b/sso-mfa/bootstrap/encrypt-secrets.sh new file mode 100755 index 0000000..df950f2 --- /dev/null +++ b/sso-mfa/bootstrap/encrypt-secrets.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# encrypt-secrets.sh — encrypt secrets/ directory to secrets.enc/ using age +# +# Usage: +# ./encrypt-secrets.sh [SECRETS_DIR] [AGE_KEY_FILE] +# +# SECRETS_DIR plaintext secrets directory (default: ./secrets) +# AGE_KEY_FILE age private key file (default: ~/.config/net-kingdom/age.key) +# +# Reads the public key from the age key file and encrypts each *.env file +# (and pi.enc if present) to secrets.enc//.age. +# +# After a successful encrypt, shreds the plaintext secrets directory unless +# --no-shred is passed. +# +# Run after gen-secrets.sh to store secrets safely in the repo. +# Commit secrets.enc/ to git. + +set -euo pipefail + +SECRETS_DIR="${1:-./secrets}" +AGE_KEY="${2:-$HOME/.config/net-kingdom/age.key}" +NO_SHRED=false +for arg in "$@"; do [[ "$arg" == "--no-shred" ]] && NO_SHRED=true; done + +if [[ ! -d "$SECRETS_DIR" ]]; then + echo "ERROR: secrets directory not found: $SECRETS_DIR" >&2 + echo "Run gen-secrets.sh first." >&2 + exit 1 +fi + +if [[ ! -f "$AGE_KEY" ]]; then + echo "ERROR: age key not found: $AGE_KEY" >&2 + echo "Generate with: age-keygen -o $AGE_KEY" >&2 + exit 1 +fi + +# Extract public key from the private key file +PUBKEY=$(grep 'public key:' "$AGE_KEY" | awk '{print $NF}') +if [[ -z "$PUBKEY" ]]; then + echo "ERROR: could not read public key from $AGE_KEY" >&2 + exit 1 +fi + +ENC_DIR="$(dirname "$SECRETS_DIR")/secrets.enc" +mkdir -p "$ENC_DIR" + +echo "Encrypting secrets → $ENC_DIR/" +echo "Recipient: $PUBKEY" +echo "" + +count=0 +for component_dir in "$SECRETS_DIR"/*/; do + component=$(basename "$component_dir") + mkdir -p "$ENC_DIR/$component" + for f in "$component_dir"*; do + [[ -f "$f" ]] || continue + fname=$(basename "$f") + out="$ENC_DIR/$component/$fname.age" + age -r "$PUBKEY" -o "$out" "$f" + echo " encrypted: $component/$fname → secrets.enc/$component/$fname.age" + count=$((count + 1)) + done +done + +echo "" +echo "$count file(s) encrypted to $ENC_DIR/" +echo "" + +if [[ "$NO_SHRED" == false ]]; then + echo "Shredding plaintext secrets..." + find "$SECRETS_DIR" -type f -exec shred -u {} \; + rm -rf "$SECRETS_DIR" + echo "Done. Plaintext secrets shredded." +else + echo "(--no-shred: plaintext kept at $SECRETS_DIR)" +fi +echo "" +echo "Next: commit secrets.enc/ to git." diff --git a/sso-mfa/bootstrap/secrets.enc/authelia/secrets.env.age b/sso-mfa/bootstrap/secrets.enc/authelia/secrets.env.age new file mode 100644 index 0000000..90cb763 Binary files /dev/null and b/sso-mfa/bootstrap/secrets.enc/authelia/secrets.env.age differ diff --git a/sso-mfa/bootstrap/secrets.enc/breakglass/secrets.env.age b/sso-mfa/bootstrap/secrets.enc/breakglass/secrets.env.age new file mode 100644 index 0000000..93205c9 Binary files /dev/null and b/sso-mfa/bootstrap/secrets.enc/breakglass/secrets.env.age differ diff --git a/sso-mfa/bootstrap/secrets.enc/keycape/secrets.env.age b/sso-mfa/bootstrap/secrets.enc/keycape/secrets.env.age new file mode 100644 index 0000000..a4d40e8 Binary files /dev/null and b/sso-mfa/bootstrap/secrets.enc/keycape/secrets.env.age differ diff --git a/sso-mfa/bootstrap/secrets.enc/lldap/secrets.env.age b/sso-mfa/bootstrap/secrets.enc/lldap/secrets.env.age new file mode 100644 index 0000000..f5f7f84 --- /dev/null +++ b/sso-mfa/bootstrap/secrets.enc/lldap/secrets.env.age @@ -0,0 +1,7 @@ +age-encryption.org/v1 +-> X25519 yR2D3J78/vw1ohcdXCLy5IOoIuG+FtRs7Eiswk3gKyo +c9axBYTsFS4Gqb3Zdv5Gtk+/yEtKNH21iFLU1U3mxNs +--- Kc/0n9icRSyEEcAHJJdx2Vcv5CgjLucU8FdZArV3C2U +9eYdT -GiΑ%0x y=0O֫豃־Q"-[gߋ3eV3wt1C$rj2\z=IW7>=K8JUTGl"bv{g3@-:Ƚ2;PrUHA-띰Zx.x}@EM+H +Ӛ$;<>ie1xtCU4҂Oz +O{qԏqE١?S]2 ^1}RXKl Iz ,E'(Ӝ1ZS&=ZWퟲL O˜~:l!am+Ÿ혲6TlǢE.11Plrz hmGtv(, \ No newline at end of file diff --git a/sso-mfa/bootstrap/secrets.enc/postgres/secrets.env.age b/sso-mfa/bootstrap/secrets.enc/postgres/secrets.env.age new file mode 100644 index 0000000..dd0a3c4 Binary files /dev/null and b/sso-mfa/bootstrap/secrets.enc/postgres/secrets.env.age differ diff --git a/sso-mfa/bootstrap/secrets.enc/privacyidea/secrets.env.age b/sso-mfa/bootstrap/secrets.enc/privacyidea/secrets.env.age new file mode 100644 index 0000000..194a3a1 Binary files /dev/null and b/sso-mfa/bootstrap/secrets.enc/privacyidea/secrets.env.age differ diff --git a/sso-mfa/k8s/network-policies/netpol-databases.yaml b/sso-mfa/k8s/network-policies/netpol-databases.yaml index f6bd257..c77e3d4 100644 --- a/sso-mfa/k8s/network-policies/netpol-databases.yaml +++ b/sso-mfa/k8s/network-policies/netpol-databases.yaml @@ -93,6 +93,8 @@ spec: ports: - port: 5432 protocol: TCP + - port: 8000 # CloudNativePG instance manager HTTP API (used for status extraction) + protocol: TCP - port: 9187 # CloudNativePG metrics exporter protocol: TCP --- diff --git a/sso-mfa/k8s/postgresql/cluster.yaml b/sso-mfa/k8s/postgresql/cluster.yaml index 4830e37..fb317fe 100644 --- a/sso-mfa/k8s/postgresql/cluster.yaml +++ b/sso-mfa/k8s/postgresql/cluster.yaml @@ -1,9 +1,10 @@ # CloudNativePG Cluster — net-kingdom-pg # -# Creates a PostgreSQL 16 cluster with two application databases: -# keycloak_db (owner: keycloak) +# Creates a PostgreSQL 16 cluster with one application database: # privacyidea_db (owner: privacyidea) # +# Note: keycloak_db removed — Keycloak replaced by Authelia+LLDAP+KeyCape (T05). +# # Prerequisites: # - CloudNativePG operator installed (see README.md) # - K8s Secrets created (see create-secrets.sh) @@ -27,33 +28,19 @@ spec: imageName: ghcr.io/cloudnative-pg/postgresql:16 # ── Bootstrap ──────────────────────────────────────────────────────────────── - # Creates keycloak_db with owner keycloak. privacyidea_db and the - # privacyidea role are created in postInitSQL (runs as superuser). - # managed.roles below reconciles passwords for both users continuously. + # Creates privacyidea_db with owner privacyidea. + # managed.roles below reconciles the password continuously from K8s Secret. bootstrap: initdb: - database: keycloak_db - owner: keycloak + database: privacyidea_db + owner: privacyidea secret: - name: net-kingdom-pg-keycloak-app - postInitSQL: - - "CREATE ROLE privacyidea WITH LOGIN;" - - "CREATE DATABASE privacyidea_db OWNER privacyidea;" - - "REVOKE CONNECT ON DATABASE privacyidea_db FROM PUBLIC;" - - "REVOKE CONNECT ON DATABASE keycloak_db FROM PUBLIC;" - - "GRANT CONNECT ON DATABASE keycloak_db TO keycloak;" - - "GRANT CONNECT ON DATABASE privacyidea_db TO privacyidea;" + name: net-kingdom-pg-privacyidea-app # ── Managed roles ──────────────────────────────────────────────────────────── - # Operator reconciles these passwords continuously from K8s Secrets. - # This ensures password rotation in KeePassXC/Vault propagates to PG. + # Operator reconciles the password continuously from K8s Secret. managed: roles: - - name: keycloak - ensure: present - login: true - passwordSecret: - name: net-kingdom-pg-keycloak-app - name: privacyidea ensure: present login: true diff --git a/sso-mfa/k8s/postgresql/create-secrets.sh b/sso-mfa/k8s/postgresql/create-secrets.sh index e166337..ca4645b 100755 --- a/sso-mfa/k8s/postgresql/create-secrets.sh +++ b/sso-mfa/k8s/postgresql/create-secrets.sh @@ -7,10 +7,11 @@ # is the output directory produced by sso-mfa/bootstrap/gen-secrets.sh # (default: ../../bootstrap/secrets). # -# Creates two K8s Secrets in the databases namespace: -# net-kingdom-pg-keycloak-app — keycloak DB credentials +# Creates one K8s Secret in the databases namespace: # net-kingdom-pg-privacyidea-app — privacyIDEA DB credentials # +# Note: net-kingdom-pg-keycloak-app removed — Keycloak replaced by Authelia+LLDAP+KeyCape (T05). +# # These secrets must exist before applying cluster.yaml. # Re-run this script whenever you rotate passwords in KeePassXC / gen-secrets.sh. @@ -24,36 +25,23 @@ if [[ ! -d "$SECRETS_DIR" ]]; then exit 1 fi -PG_SECRETS="$SECRETS_DIR/postgres/secrets.env" PI_SECRETS="$SECRETS_DIR/privacyidea/secrets.env" -if [[ ! -f "$PG_SECRETS" ]]; then - echo "ERROR: $PG_SECRETS not found" >&2 - exit 1 -fi if [[ ! -f "$PI_SECRETS" ]]; then echo "ERROR: $PI_SECRETS not found" >&2 exit 1 fi -# Source the generated env files (they contain KEY=VALUE pairs, no export) +# Source the generated env file (KEY=VALUE pairs, no export) # Use a subshell to avoid polluting the current environment. -PG_KC_PASS=$(bash -c "source $PG_SECRETS 2>/dev/null; echo \$PG_KEYCLOAK_PASSWORD") PI_DB_PASS=$(bash -c "source $PI_SECRETS 2>/dev/null; echo \$PI_DB_PASSWORD") -if [[ -z "$PG_KC_PASS" || -z "$PI_DB_PASS" ]]; then - echo "ERROR: could not read passwords from secrets files." >&2 - echo "Check that gen-secrets.sh ran successfully and the files are intact." >&2 +if [[ -z "$PI_DB_PASS" ]]; then + echo "ERROR: could not read PI_DB_PASSWORD from $PI_SECRETS" >&2 + echo "Check that gen-secrets.sh ran successfully and the file is intact." >&2 exit 1 fi -echo "Creating K8s Secret: net-kingdom-pg-keycloak-app" -kubectl create secret generic net-kingdom-pg-keycloak-app \ - --namespace=databases \ - --from-literal=username=keycloak \ - --from-literal=password="$PG_KC_PASS" \ - --dry-run=client -o yaml | kubectl apply -f - - echo "Creating K8s Secret: net-kingdom-pg-privacyidea-app" kubectl create secret generic net-kingdom-pg-privacyidea-app \ --namespace=databases \ @@ -62,8 +50,8 @@ kubectl create secret generic net-kingdom-pg-privacyidea-app \ --dry-run=client -o yaml | kubectl apply -f - echo "" -echo "Done. Secrets created in namespace: databases" +echo "Done. Secret created in namespace: databases" echo "" echo "Verify:" echo " kubectl get secrets -n databases" -echo " kubectl describe secret net-kingdom-pg-keycloak-app -n databases" +echo " kubectl describe secret net-kingdom-pg-privacyidea-app -n databases" diff --git a/sso-mfa/k8s/verify-t02.sh b/sso-mfa/k8s/verify-t02.sh index 0205eb0..063b9e7 100755 --- a/sso-mfa/k8s/verify-t02.sh +++ b/sso-mfa/k8s/verify-t02.sh @@ -62,9 +62,8 @@ for ns in sso mfa databases; do check "allow-egress-dns in $ns" \ $KUBECTL get networkpolicy allow-egress-dns -n "$ns" done -check "allow-ingress-from-traefik in sso" $KUBECTL get networkpolicy allow-ingress-from-traefik -n sso -check "allow-egress-to-postgres in sso" $KUBECTL get networkpolicy allow-egress-to-postgres -n sso -check "allow-egress-to-privacyidea in sso" $KUBECTL get networkpolicy allow-egress-to-privacyidea -n sso +check "allow-traefik-to-keycape in sso" $KUBECTL get networkpolicy allow-traefik-to-keycape -n sso +check "allow-keycape-egress-to-privacyidea in sso" $KUBECTL get networkpolicy allow-keycape-egress-to-privacyidea -n sso check "allow-ingress-from-traefik in mfa" $KUBECTL get networkpolicy allow-ingress-from-traefik -n mfa check "allow-ingress-from-keycloak in mfa" $KUBECTL get networkpolicy allow-ingress-from-keycloak -n mfa check "allow-egress-to-postgres in mfa" $KUBECTL get networkpolicy allow-egress-to-postgres -n mfa diff --git a/sso-mfa/k8s/verify-t03.sh b/sso-mfa/k8s/verify-t03.sh index 6ffa824..ff2a91a 100755 --- a/sso-mfa/k8s/verify-t03.sh +++ b/sso-mfa/k8s/verify-t03.sh @@ -4,11 +4,13 @@ # Checks: # 1. CloudNativePG operator is installed and running # 2. Cluster net-kingdom-pg is Ready -# 3. Both application databases exist (keycloak_db, privacyidea_db) -# 4. Both application roles exist (keycloak, privacyidea) +# 3. privacyidea_db database exists +# 4. privacyidea role exists # 5. K8s Secrets are present in the databases namespace # 6. (Optional) Scheduled backup CR is present when backup is configured # +# Note: keycloak_db removed — Keycloak replaced by Authelia+LLDAP+KeyCape (T05). +# # Usage: # chmod +x verify-t03.sh # ./verify-t03.sh @@ -19,9 +21,9 @@ PASS=0 FAIL=0 WARN=0 -pass() { echo " [PASS] $1"; ((PASS++)); } -fail() { echo " [FAIL] $1"; ((FAIL++)); } -warn() { echo " [WARN] $1"; ((WARN++)); } +pass() { echo " [PASS] $1"; PASS=$((PASS + 1)); } +fail() { echo " [FAIL] $1"; FAIL=$((FAIL + 1)); } +warn() { echo " [WARN] $1"; WARN=$((WARN + 1)); } section() { echo ""; echo "── $1 ──────────────────────────────────────"; } @@ -76,15 +78,9 @@ section "3. Databases" if [[ -n "$PRIMARY_POD" ]]; then DB_LIST=$(kubectl exec -n databases "$PRIMARY_POD" -- \ - psql -U postgres -tAc "SELECT datname FROM pg_database WHERE datname IN ('keycloak_db','privacyidea_db') ORDER BY datname;" \ + psql -U postgres -tAc "SELECT datname FROM pg_database WHERE datname = 'privacyidea_db';" \ 2>/dev/null || echo "") - if echo "$DB_LIST" | grep -q "keycloak_db"; then - pass "keycloak_db exists" - else - fail "keycloak_db not found" - fi - if echo "$DB_LIST" | grep -q "privacyidea_db"; then pass "privacyidea_db exists" else @@ -99,15 +95,9 @@ section "4. Database roles" if [[ -n "$PRIMARY_POD" ]]; then ROLE_LIST=$(kubectl exec -n databases "$PRIMARY_POD" -- \ - psql -U postgres -tAc "SELECT rolname FROM pg_roles WHERE rolname IN ('keycloak','privacyidea') ORDER BY rolname;" \ + psql -U postgres -tAc "SELECT rolname FROM pg_roles WHERE rolname = 'privacyidea';" \ 2>/dev/null || echo "") - if echo "$ROLE_LIST" | grep -q "keycloak"; then - pass "role keycloak exists" - else - fail "role keycloak not found" - fi - if echo "$ROLE_LIST" | grep -q "privacyidea"; then pass "role privacyidea exists" else @@ -120,7 +110,7 @@ fi # ── 5. K8s Secrets ──────────────────────────────────────────────────────────── section "5. K8s Secrets (databases namespace)" -for secret in net-kingdom-pg-keycloak-app net-kingdom-pg-privacyidea-app; do +for secret in net-kingdom-pg-privacyidea-app; do if kubectl get secret "$secret" -n databases &>/dev/null; then pass "Secret $secret exists" else