fix(security): keep rotation credentials out of command output
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
parent
681c5b0ea1
commit
6ae4708ea0
6 changed files with 429 additions and 104 deletions
12
Makefile
12
Makefile
|
|
@ -145,17 +145,9 @@ creds-verify: ## Check all expected K8s secrets exist and update creds-state.yam
|
|||
creds-status: ## Print human-readable credential state from creds-state.yaml
|
||||
bash sso-mfa/bootstrap/creds-status.sh
|
||||
|
||||
creds-rotate: export SECRET := $(SECRET)
|
||||
creds-rotate: ## Guided rotation for one secret: make creds-rotate SECRET=<name>
|
||||
@[[ -n "$(SECRET)" ]] \
|
||||
|| (echo "Usage: make creds-rotate SECRET=<name>"; \
|
||||
echo ""; \
|
||||
echo "Known secrets:"; \
|
||||
echo " PI_SECRET_KEY PI_PEPPER PI_DB_PASSWORD LLDAP_JWT_SECRET"; \
|
||||
echo " LLDAP_LDAP_USER_PASS AUTHELIA_SESSION_SECRET"; \
|
||||
echo " AUTHELIA_KEYCAPE_CLIENT_SECRET KEYCAPE_RSA_KEY"; \
|
||||
echo " BREAKGLASS_PASSWORD"; \
|
||||
exit 1)
|
||||
SECRET=$(SECRET) bash sso-mfa/bootstrap/creds-rotate.sh
|
||||
@bash sso-mfa/bootstrap/creds-rotate.sh
|
||||
|
||||
## ── Agent-driven credential lifecycle (NK-WP-0005) ──────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -224,3 +224,37 @@ signature verification or installed-toolchain promotion is claimed.
|
|||
Source basis: [SOPS age identity loading](https://github.com/getsops/sops/blob/v3.13.3/age/keysource.go)
|
||||
loads explicit and ambient identities together, so setting `SOPS_AGE_KEY_FILE`
|
||||
alone is insufficient to exclude ambient credentials.
|
||||
|
||||
|
||||
## Rotation output boundary (2026-10-12 source repair)
|
||||
|
||||
`creds-rotate.sh` keeps generated values out of stdout/stderr and disables shell
|
||||
tracing. The selected plaintext secrets directory and service subdirectory must
|
||||
be owned by the operator with no group/other access; `secrets.env` must be an
|
||||
owned private regular file with one link and exactly one target assignment.
|
||||
Symlink paths, missing keys and duplicate target assignments are refused before
|
||||
cluster work. The helper replaces the file atomically with mode 0600 and never
|
||||
places the new value in its command arguments. It accepts the generated
|
||||
alphanumeric credential format. This is file hygiene, not process isolation.
|
||||
|
||||
Approved rotations preserve the new candidate in that private service file.
|
||||
Transfer it through approved custody, verify real consumer authentication and
|
||||
old-value refusal, refresh the encrypted recovery handoff, then remove plaintext.
|
||||
A failed apply can leave a new local candidate while some services still use the
|
||||
old value. Reconcile that partial state; do not infer completion from file contents.
|
||||
Service-command diagnostics are suppressed because they can contain values;
|
||||
use approved metadata-only diagnostics to investigate failures.
|
||||
|
||||
Database rotation stops before local mutation unless exactly one database pod
|
||||
is discovered. SQL is sent on stdin with SQL-error failure enabled. Unattended
|
||||
LLDAP admin-password rotation is refused because the server-update step is
|
||||
manual. The attended path requires acknowledgment of that step before dependent
|
||||
Secret updates; cancelling retains the local candidate and requires reconciliation.
|
||||
Break-glass Secret creation reads the private file instead of a plaintext argv
|
||||
literal and applies server-side without forcing field conflicts. This does not
|
||||
remove historical client-side annotations or prove native consumer adoption.
|
||||
|
||||
The last-rotated timestamp records successful script steps only. No live rotation
|
||||
was run for these source tests. CUST-WP-0073-T05 remains founder-deferred. RSA
|
||||
rotation recovery/rollback, delegated helper argv/annotation handling and
|
||||
`privacyidea/bootstrap-admin.sh` still require source and native acceptance.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@
|
|||
# automatically (--non-interactive)
|
||||
# 5. After completion, updates creds-state.yaml
|
||||
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
|
@ -28,17 +30,47 @@ POSITIONAL_ARGS=()
|
|||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--non-interactive" ]]; then
|
||||
NON_INTERACTIVE=true
|
||||
elif [[ "$arg" == --* ]]; then
|
||||
echo "ERROR: unknown rotation option." >&2
|
||||
exit 1
|
||||
else
|
||||
POSITIONAL_ARGS+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
SECRETS_DIR="${POSITIONAL_ARGS[0]:-$SCRIPT_DIR/secrets}"
|
||||
if (( ${#POSITIONAL_ARGS[@]} > 1 )); then
|
||||
echo "ERROR: expected one private secrets directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
SECRETS_DIR="$(realpath -sm -- "${POSITIONAL_ARGS[0]:-$SCRIPT_DIR/secrets}")"
|
||||
SECRET="${SECRET:-}"
|
||||
|
||||
rnd_hex() { openssl rand -hex "$1"; }
|
||||
rnd_b64() { openssl rand -base64 "$1" | tr -d '\n/+=' | head -c "$2"; }
|
||||
|
||||
# Suppress all child diagnostics: service tools may echo credentials on failure.
|
||||
quiet() {
|
||||
if ! "$@" >/dev/null 2>&1; then
|
||||
echo "ERROR: rotation step failed; inspect service state through approved metadata-only tooling." >&2
|
||||
echo "The private local file may contain the candidate value; rotation is not marked complete." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_value() {
|
||||
local component="$1" format="$2"
|
||||
ENV_FILE="$SECRETS_DIR/$component/secrets.env"
|
||||
python3 "$SCRIPT_DIR/rotation-env.py" check "$SECRETS_DIR" "$component" "$SECRET"
|
||||
if [[ "$format" == hex ]]; then NEW_VAL="$(rnd_hex 32 2>/dev/null)"
|
||||
else NEW_VAL="$(rnd_b64 32 40 2>/dev/null)"; fi
|
||||
echo "Candidate generated; values are never printed."
|
||||
}
|
||||
|
||||
store_value() {
|
||||
printf '%s' "$NEW_VAL" | python3 "$SCRIPT_DIR/rotation-env.py" update "$SECRETS_DIR" "$1" "$SECRET"
|
||||
echo "Candidate stored in the selected private service secrets.env file."
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local prompt="${1:-Continue?}"
|
||||
if [[ "$NON_INTERACTIVE" == true ]]; then
|
||||
|
|
@ -74,14 +106,15 @@ update_last_rotated() {
|
|||
|
||||
post_rotation_reminder() {
|
||||
echo ""
|
||||
echo "Complete native authentication/consumer verification and custody handoff before removing local plaintext."
|
||||
if [[ "$NON_INTERACTIVE" == false ]]; then
|
||||
echo "Post-rotation checklist:"
|
||||
echo " ✓ Update your personal password manager entry for this secret"
|
||||
echo " ✓ Run: make creds-bundle (refresh offsite backup)"
|
||||
echo " ✓ Run: make creds-verify (confirm cluster state)"
|
||||
echo " - Transfer the candidate from the private local file into approved custody"
|
||||
echo " - Run: make creds-bundle (refresh offsite backup)"
|
||||
echo " - Run: make creds-verify (confirm cluster state)"
|
||||
else
|
||||
echo "Post-rotation (agent mode):"
|
||||
echo " ✓ creds-state.yaml updated with last_rotated timestamp"
|
||||
echo " - Script steps succeeded; this timestamp is not native credential validation"
|
||||
echo " Run: make creds-bundle (refresh offsite backup)"
|
||||
echo " Run: make creds-verify (confirm cluster state)"
|
||||
fi
|
||||
|
|
@ -96,18 +129,15 @@ PI_SECRET_KEY)
|
|||
echo "Impact: Flask/privacyIDEA app secret — rotates all active PI sessions."
|
||||
echo " All privacyIDEA users will be logged out."
|
||||
echo ""
|
||||
echo "New value:"
|
||||
NEW_VAL="$(rnd_hex 32)"
|
||||
echo " PI_SECRET_KEY=$NEW_VAL"
|
||||
prepare_value privacyidea hex
|
||||
confirm "Apply rotation?" || exit 0
|
||||
echo ""
|
||||
ENV_FILE="$SECRETS_DIR/privacyidea/secrets.env"
|
||||
[[ -f "$ENV_FILE" ]] && sed -i "s|^PI_SECRET_KEY=.*|PI_SECRET_KEY=$NEW_VAL|" "$ENV_FILE"
|
||||
store_value privacyidea
|
||||
echo " 1. Updating K8s Secret privacyidea-config (namespace: mfa)..."
|
||||
(cd "$K8S_DIR/privacyidea" && bash create-secrets.sh "$SECRETS_DIR")
|
||||
(cd "$K8S_DIR/privacyidea" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
echo " 2. Restarting privacyIDEA pod..."
|
||||
kubectl rollout restart deployment privacyidea -n mfa
|
||||
kubectl rollout status deployment privacyidea -n mfa
|
||||
quiet kubectl rollout restart deployment privacyidea -n mfa
|
||||
quiet kubectl rollout status deployment privacyidea -n mfa
|
||||
update_last_rotated "PI_SECRET_KEY"
|
||||
post_rotation_reminder
|
||||
;;
|
||||
|
|
@ -135,30 +165,30 @@ PI_DB_PASSWORD)
|
|||
echo " 2. K8s Secret privacyidea-config (PI_SQLALCHEMY_DATABASE_URI)"
|
||||
echo " Privacyidea pod must be restarted after both are updated."
|
||||
echo ""
|
||||
echo "New value:"
|
||||
NEW_VAL="$(rnd_b64 32 40)"
|
||||
echo " PI_DB_PASSWORD=$NEW_VAL"
|
||||
prepare_value privacyidea b64
|
||||
confirm "Apply rotation?" || exit 0
|
||||
echo ""
|
||||
ENV_FILE="$SECRETS_DIR/privacyidea/secrets.env"
|
||||
[[ -f "$ENV_FILE" ]] && sed -i "s|^PI_DB_PASSWORD=.*|PI_DB_PASSWORD=$NEW_VAL|" "$ENV_FILE"
|
||||
echo " 1. Updating PostgreSQL password..."
|
||||
PG_POD=$(kubectl get pod -n databases -l postgresql=net-kingdom-pg -o name 2>/dev/null | head -1)
|
||||
if [[ -n "$PG_POD" ]]; then
|
||||
kubectl exec -n databases "$PG_POD" -- \
|
||||
psql -U postgres -c "ALTER USER privacyidea WITH PASSWORD '$NEW_VAL';"
|
||||
echo " ✔ PostgreSQL password updated"
|
||||
else
|
||||
echo " WARN: Could not find PostgreSQL pod — update manually:"
|
||||
echo " kubectl exec -n databases <pg-pod> -- psql -U postgres -c \"ALTER USER privacyidea WITH PASSWORD '$NEW_VAL';\""
|
||||
echo " 1. Locating PostgreSQL before changing the private file..."
|
||||
if ! PG_POD=$(command kubectl get pod -n databases -l postgresql=net-kingdom-pg -o name 2>/dev/null); then
|
||||
echo "ERROR: PostgreSQL discovery failed; rotation stopped." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Ambiguous discovery is not permission to change an arbitrary database pod.
|
||||
if [[ ! "$PG_POD" =~ ^pod/[a-z0-9][a-z0-9.-]*$ ]]; then
|
||||
echo "ERROR: expected exactly one PostgreSQL pod; rotation stopped before mutation." >&2
|
||||
exit 1
|
||||
fi
|
||||
store_value privacyidea
|
||||
printf "ALTER USER privacyidea WITH PASSWORD '%s';\n" "$NEW_VAL" | \
|
||||
quiet kubectl exec -i -n databases "$PG_POD" -- psql -U postgres -v ON_ERROR_STOP=1
|
||||
echo " PostgreSQL password update command succeeded."
|
||||
echo ""
|
||||
echo " 2. Updating K8s Secret privacyidea-config..."
|
||||
(cd "$K8S_DIR/privacyidea" && bash create-secrets.sh "$SECRETS_DIR")
|
||||
(cd "$K8S_DIR/privacyidea" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
echo ""
|
||||
echo " 3. Restarting privacyIDEA pod..."
|
||||
kubectl rollout restart deployment privacyidea -n mfa
|
||||
kubectl rollout status deployment privacyidea -n mfa
|
||||
quiet kubectl rollout restart deployment privacyidea -n mfa
|
||||
quiet kubectl rollout status deployment privacyidea -n mfa
|
||||
update_last_rotated "PI_DB_PASSWORD"
|
||||
post_rotation_reminder
|
||||
;;
|
||||
|
|
@ -168,22 +198,23 @@ LLDAP_JWT_SECRET)
|
|||
echo ""
|
||||
echo "Impact: LLDAP JWT signing key — all LLDAP sessions will be invalidated."
|
||||
echo ""
|
||||
echo "New value:"
|
||||
NEW_VAL="$(rnd_hex 32)"
|
||||
echo " LLDAP_JWT_SECRET=$NEW_VAL"
|
||||
prepare_value lldap hex
|
||||
confirm "Apply rotation?" || exit 0
|
||||
ENV_FILE="$SECRETS_DIR/lldap/secrets.env"
|
||||
[[ -f "$ENV_FILE" ]] && sed -i "s|^LLDAP_JWT_SECRET=.*|LLDAP_JWT_SECRET=$NEW_VAL|" "$ENV_FILE"
|
||||
store_value lldap
|
||||
echo " Updating K8s Secret lldap-secrets (namespace: sso)..."
|
||||
(cd "$K8S_DIR/lldap" && bash create-secrets.sh "$SECRETS_DIR")
|
||||
(cd "$K8S_DIR/lldap" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
echo " Restarting LLDAP pod..."
|
||||
kubectl rollout restart deployment lldap -n sso
|
||||
kubectl rollout status deployment lldap -n sso
|
||||
quiet kubectl rollout restart deployment lldap -n sso
|
||||
quiet kubectl rollout status deployment lldap -n sso
|
||||
update_last_rotated "LLDAP_JWT_SECRET"
|
||||
post_rotation_reminder
|
||||
;;
|
||||
|
||||
LLDAP_LDAP_USER_PASS)
|
||||
if [[ "$NON_INTERACTIVE" == true || ! -t 0 ]]; then
|
||||
echo "ERROR: LDAP password rotation requires an attended manual server update; automation is unavailable." >&2
|
||||
exit 1
|
||||
fi
|
||||
header
|
||||
echo ""
|
||||
echo "Impact: LLDAP admin + LDAP bind password — used by Authelia and KeyCape."
|
||||
|
|
@ -193,30 +224,23 @@ LLDAP_LDAP_USER_PASS)
|
|||
echo " 3. KeyCape K8s Secret (config.yaml lldap.bindPW)"
|
||||
echo " Restart all three pods after updating."
|
||||
echo ""
|
||||
echo "New value:"
|
||||
NEW_VAL="$(rnd_b64 32 40)"
|
||||
echo " LLDAP_LDAP_USER_PASS=$NEW_VAL"
|
||||
prepare_value lldap b64
|
||||
confirm "Apply rotation?" || exit 0
|
||||
ENV_FILE="$SECRETS_DIR/lldap/secrets.env"
|
||||
[[ -f "$ENV_FILE" ]] && sed -i "s|^LLDAP_LDAP_USER_PASS=.*|LLDAP_LDAP_USER_PASS=$NEW_VAL|" "$ENV_FILE"
|
||||
echo " 1. Updating LLDAP admin password via API..."
|
||||
store_value lldap
|
||||
echo " 1. Manually updating the LLDAP admin password..."
|
||||
echo " WARN: Automated LLDAP password update not implemented."
|
||||
if [[ "$NON_INTERACTIVE" == false ]]; then
|
||||
echo " Log in to https://lldap.coulomb.social and change the admin password manually."
|
||||
confirm "Confirm you have updated the LLDAP admin password?" || { echo "Aborting."; exit 1; }
|
||||
else
|
||||
echo " [non-interactive] Skipping LLDAP web UI step — update manually:"
|
||||
echo " Log in to https://lldap.coulomb.social and set admin password to: $NEW_VAL"
|
||||
fi
|
||||
echo " Use the candidate in the private lldap/secrets.env file to update the server."
|
||||
echo " If cancelled, reconcile that local candidate with the unchanged server before reuse."
|
||||
confirm "Confirm you have updated the LLDAP admin password?" || { echo "Aborting; local candidate retained for recovery."; exit 1; }
|
||||
echo " 2. Updating Authelia secrets..."
|
||||
(cd "$K8S_DIR/authelia" && bash create-secrets.sh "$SECRETS_DIR")
|
||||
(cd "$K8S_DIR/authelia" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
echo " 3. Updating KeyCape secrets..."
|
||||
(cd "$K8S_DIR/keycape" && bash create-secrets.sh "$SECRETS_DIR")
|
||||
(cd "$K8S_DIR/keycape" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
echo " 4. Restarting Authelia and KeyCape pods..."
|
||||
kubectl rollout restart deployment authelia -n sso
|
||||
kubectl rollout restart deployment keycape -n sso
|
||||
kubectl rollout status deployment authelia -n sso
|
||||
kubectl rollout status deployment keycape -n sso
|
||||
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 "LLDAP_LDAP_USER_PASS"
|
||||
post_rotation_reminder
|
||||
;;
|
||||
|
|
@ -227,15 +251,12 @@ AUTHELIA_SESSION_SECRET)
|
|||
echo "Impact: Authelia session cookie encryption key."
|
||||
echo " All active Authelia sessions will be invalidated (users re-prompted)."
|
||||
echo ""
|
||||
echo "New value:"
|
||||
NEW_VAL="$(rnd_hex 32)"
|
||||
echo " AUTHELIA_SESSION_SECRET=$NEW_VAL"
|
||||
prepare_value authelia hex
|
||||
confirm "Apply rotation?" || exit 0
|
||||
ENV_FILE="$SECRETS_DIR/authelia/secrets.env"
|
||||
[[ -f "$ENV_FILE" ]] && sed -i "s|^AUTHELIA_SESSION_SECRET=.*|AUTHELIA_SESSION_SECRET=$NEW_VAL|" "$ENV_FILE"
|
||||
(cd "$K8S_DIR/authelia" && bash create-secrets.sh "$SECRETS_DIR")
|
||||
kubectl rollout restart deployment authelia -n sso
|
||||
kubectl rollout status deployment authelia -n sso
|
||||
store_value authelia
|
||||
(cd "$K8S_DIR/authelia" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
quiet kubectl rollout restart deployment authelia -n sso
|
||||
quiet kubectl rollout status deployment authelia -n sso
|
||||
update_last_rotated "AUTHELIA_SESSION_SECRET"
|
||||
post_rotation_reminder
|
||||
;;
|
||||
|
|
@ -249,21 +270,18 @@ AUTHELIA_KEYCAPE_CLIENT_SECRET)
|
|||
echo " 2. KeyCape K8s Secret (plaintext, stored as authelia.clientSecret)"
|
||||
echo " Both pods must be restarted before the new secret takes effect."
|
||||
echo ""
|
||||
echo "New value:"
|
||||
NEW_VAL="$(rnd_b64 32 40)"
|
||||
echo " AUTHELIA_KEYCAPE_CLIENT_SECRET=$NEW_VAL"
|
||||
prepare_value authelia b64
|
||||
confirm "Apply rotation?" || exit 0
|
||||
ENV_FILE="$SECRETS_DIR/authelia/secrets.env"
|
||||
[[ -f "$ENV_FILE" ]] && sed -i "s|^AUTHELIA_KEYCAPE_CLIENT_SECRET=.*|AUTHELIA_KEYCAPE_CLIENT_SECRET=$NEW_VAL|" "$ENV_FILE"
|
||||
store_value authelia
|
||||
echo " Updating Authelia secrets (bcrypt hash)..."
|
||||
(cd "$K8S_DIR/authelia" && bash create-secrets.sh "$SECRETS_DIR")
|
||||
(cd "$K8S_DIR/authelia" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
echo " Updating KeyCape secrets (plaintext)..."
|
||||
(cd "$K8S_DIR/keycape" && bash create-secrets.sh "$SECRETS_DIR")
|
||||
(cd "$K8S_DIR/keycape" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
echo " Restarting Authelia and KeyCape..."
|
||||
kubectl rollout restart deployment authelia -n sso
|
||||
kubectl rollout restart deployment keycape -n sso
|
||||
kubectl rollout status deployment authelia -n sso
|
||||
kubectl rollout status deployment keycape -n sso
|
||||
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
|
||||
;;
|
||||
|
|
@ -294,10 +312,10 @@ KEYCAPE_RSA_KEY)
|
|||
openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null
|
||||
chmod 600 "$KEY_FILE"
|
||||
echo " Updating KeyCape secrets..."
|
||||
(cd "$K8S_DIR/keycape" && bash create-secrets.sh "$SECRETS_DIR")
|
||||
(cd "$K8S_DIR/keycape" && quiet bash create-secrets.sh "$SECRETS_DIR")
|
||||
echo " Restarting KeyCape pod..."
|
||||
kubectl rollout restart deployment keycape -n sso
|
||||
kubectl rollout status deployment keycape -n sso
|
||||
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"
|
||||
|
|
@ -311,21 +329,21 @@ KEYCAPE_RSA_KEY)
|
|||
BREAKGLASS_PASSWORD)
|
||||
header
|
||||
echo ""
|
||||
echo "Impact: Break-glass emergency access password. Low blast radius."
|
||||
echo " Rotate freely — no downstream dependencies."
|
||||
echo "Impact: break-glass emergency access; verify the current recovery consumer after rotation."
|
||||
echo ""
|
||||
echo "New value:"
|
||||
NEW_VAL="$(rnd_b64 32 40)"
|
||||
echo " BREAKGLASS_PASSWORD=$NEW_VAL"
|
||||
prepare_value breakglass b64
|
||||
confirm "Apply rotation?" || exit 0
|
||||
ENV_FILE="$SECRETS_DIR/breakglass/secrets.env"
|
||||
[[ -f "$ENV_FILE" ]] && sed -i "s|^BREAKGLASS_PASSWORD=.*|BREAKGLASS_PASSWORD=$NEW_VAL|" "$ENV_FILE"
|
||||
store_value breakglass
|
||||
echo " Updating break-glass K8s Secret (namespace: sso)..."
|
||||
BG_SECRET="break-glass"
|
||||
kubectl create secret generic "$BG_SECRET" \
|
||||
if ! command kubectl create secret generic "$BG_SECRET" \
|
||||
--namespace=sso \
|
||||
--from-literal=BREAKGLASS_PASSWORD="$NEW_VAL" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
--from-env-file="$ENV_FILE" \
|
||||
--dry-run=client -o yaml 2>/dev/null | \
|
||||
command kubectl apply --server-side --field-manager=netkingdom-rotation -f - >/dev/null 2>&1; then
|
||||
echo "ERROR: break-glass Secret update failed; local candidate retained, rotation not marked complete." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
if [[ "$NON_INTERACTIVE" == false ]]; then
|
||||
echo " Update your password manager: net-kingdom/Break-glass/break-glass"
|
||||
|
|
@ -335,7 +353,7 @@ BREAKGLASS_PASSWORD)
|
|||
;;
|
||||
|
||||
*)
|
||||
echo "ERROR: Unknown secret: '$SECRET'"
|
||||
echo "ERROR: unknown secret selector."
|
||||
echo ""
|
||||
echo "Known secrets:"
|
||||
echo " PI_SECRET_KEY Flask/PI app secret (session rotation)"
|
||||
|
|
|
|||
83
sso-mfa/bootstrap/rotation-env.py
Normal file
83
sso-mfa/bootstrap/rotation-env.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate and atomically update a private generated credential file.
|
||||
|
||||
The caller supplies the new value on stdin, never argv. This protects the
|
||||
handoff/output boundary; it does not isolate a hostile process with the same UID.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import sys
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 5 or sys.argv[1] not in ('check', 'update'):
|
||||
raise Refused()
|
||||
action, root_arg, component, key = sys.argv[1:]
|
||||
if component not in ('privacyidea', 'lldap', 'authelia', 'breakglass'):
|
||||
raise Refused()
|
||||
if not re.fullmatch(r'[A-Z][A-Z0-9_]+', key):
|
||||
raise Refused()
|
||||
root = Path(os.path.abspath(root_arg))
|
||||
# Refuse symlink ancestors rather than resolving them silently.
|
||||
for part in (root, *root.parents):
|
||||
if part.is_symlink(): raise Refused()
|
||||
root_fd = parent_fd = file_fd = None
|
||||
temporary = None
|
||||
try:
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
root_fd = os.open(root, flags)
|
||||
private(os.fstat(root_fd), directory=True)
|
||||
parent_fd = os.open(component, flags, dir_fd=root_fd)
|
||||
private(os.fstat(parent_fd), directory=True)
|
||||
file_fd = os.open('secrets.env', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent_fd)
|
||||
original = os.fstat(file_fd)
|
||||
private(original)
|
||||
raw = os.read(file_fd, 65537)
|
||||
if len(raw) > 65536: raise Refused()
|
||||
lines = raw.decode('utf-8').splitlines(keepends=True)
|
||||
matches = [i for i, line in enumerate(lines) if line.startswith(key + '=')]
|
||||
if len(matches) != 1: raise Refused()
|
||||
if action == 'check': return
|
||||
value = sys.stdin.buffer.read(257).decode('ascii').strip()
|
||||
if not re.fullmatch(r'[A-Za-z0-9]{32,128}', value): raise Refused()
|
||||
lines[matches[0]] = key + '=' + value + '\n'
|
||||
temporary = '.rotation-' + secrets.token_hex(12)
|
||||
output_fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600, dir_fd=parent_fd)
|
||||
with os.fdopen(output_fd, 'w', encoding='utf-8') as output:
|
||||
output.writelines(lines); output.flush(); os.fsync(output.fileno())
|
||||
current = os.stat('secrets.env', dir_fd=parent_fd, follow_symlinks=False)
|
||||
if (current.st_dev, current.st_ino, current.st_mtime_ns, current.st_size) != (
|
||||
original.st_dev, original.st_ino, original.st_mtime_ns, original.st_size):
|
||||
raise Refused()
|
||||
os.replace(temporary, 'secrets.env', src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
||||
temporary = None
|
||||
os.fsync(parent_fd)
|
||||
finally:
|
||||
if temporary is not None and parent_fd is not None:
|
||||
os.unlink(temporary, dir_fd=parent_fd)
|
||||
for fd in (file_fd, parent_fd, root_fd):
|
||||
if fd is not None: os.close(fd)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (Refused, OSError, ValueError, UnicodeError):
|
||||
print('ERROR: private credential file validation/update failed; no values are logged.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
185
tests/test_rotation_output.py
Normal file
185
tests/test_rotation_output.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""Rotation entry-point tests use synthetic files and fake cluster commands only."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
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',
|
||||
}
|
||||
SYNTHETIC = 'A' * 64
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rotation(tmp_path):
|
||||
repo = tmp_path / 'repo'
|
||||
boot = repo / 'sso-mfa/bootstrap'; boot.mkdir(parents=True)
|
||||
for name in ('creds-rotate.sh', 'rotation-env.py'):
|
||||
if (BOOT / name).exists(): shutil.copyfile(BOOT / name, boot / name)
|
||||
shutil.copyfile(ROOT / 'Makefile', repo / 'Makefile')
|
||||
(boot / 'creds-state.yaml').write_text('version: 2\n')
|
||||
secrets = tmp_path / 'private'; secrets.mkdir(mode=0o700)
|
||||
for component in ('privacyidea', 'lldap', 'authelia', 'keycape', 'breakglass'):
|
||||
directory = secrets / component; directory.mkdir(mode=0o700)
|
||||
entries = [f'{key}=old-value' for key, value in CASES.items() if value == component]
|
||||
if component == 'lldap': entries.append('LLDAP_LDAP_USER_PASS=old-value')
|
||||
env_file = directory / 'secrets.env'
|
||||
env_file.write_text('\n'.join(entries) + '\n'); env_file.chmod(0o600)
|
||||
helper = repo / f'sso-mfa/k8s/{component}'; helper.mkdir(parents=True)
|
||||
(helper / 'create-secrets.sh').write_text(
|
||||
'#!/bin/bash\nprintf "%s\\n" "$SYNTHETIC" >&2\nexit "${HELPER_FAIL:-0}"\n')
|
||||
binary = tmp_path / 'bin'; binary.mkdir()
|
||||
(binary / 'openssl').write_text('#!/bin/sh\nprintf "%s\\n" "$SYNTHETIC"\n')
|
||||
(binary / 'kubectl').write_text('''#!/usr/bin/python3
|
||||
import json, os, sys
|
||||
args = sys.argv[1:]
|
||||
payload = sys.stdin.read() if ('-i' in args or args[-1:] == ['-']) else ''
|
||||
with open(os.environ['CALLS'], 'a') as file: file.write(json.dumps({'args': args, 'stdin': payload}) + '\\n')
|
||||
if args[0] == 'get':
|
||||
if not os.environ.get('NO_POD'): print(os.environ.get('POD_NAMES', 'pod/disposable-pg'))
|
||||
else:
|
||||
print(os.environ['SYNTHETIC'])
|
||||
print(os.environ['SYNTHETIC'], file=sys.stderr)
|
||||
if os.environ.get('CLUSTER_FAIL'): sys.exit(1)
|
||||
''')
|
||||
for command in binary.iterdir(): command.chmod(0o700)
|
||||
env = {'PATH': str(binary) + ':' + os.environ['PATH'], 'HOME': str(tmp_path),
|
||||
'KUBECONFIG': str(tmp_path / 'nonexistent'), 'CALLS': str(tmp_path / 'calls'),
|
||||
'SYNTHETIC': SYNTHETIC}
|
||||
return repo, boot, secrets, env
|
||||
|
||||
|
||||
def run(rotation, key, **extra):
|
||||
repo, boot, secrets, env = rotation
|
||||
return subprocess.run(['bash', '-x', str(boot / 'creds-rotate.sh'), '--non-interactive', str(secrets)],
|
||||
env={**env, 'SECRET': key, **extra}, capture_output=True, text=True, timeout=10)
|
||||
|
||||
|
||||
def calls(rotation):
|
||||
file = Path(rotation[3]['CALLS'])
|
||||
return [json.loads(line) for line in file.read_text().splitlines()] if file.exists() else []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', CASES)
|
||||
def test_success_never_prints_generated_values_or_child_diagnostics(rotation, key):
|
||||
result = run(rotation, key)
|
||||
assert result.returncode == 0
|
||||
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
|
||||
output = rotation[2] / CASES[key] / 'secrets.env'
|
||||
assert output.stat().st_mode & 0o077 == 0
|
||||
assert key + '=' + SYNTHETIC[:40] in output.read_text()
|
||||
assert f'last_rotated_{key}:' in (rotation[1] / 'creds-state.yaml').read_text()
|
||||
|
||||
|
||||
def test_database_absence_cannot_publish_or_claim_rotation(rotation):
|
||||
before = (rotation[2] / 'privacyidea/secrets.env').read_bytes()
|
||||
result = run(rotation, 'PI_DB_PASSWORD', NO_POD='1')
|
||||
assert result.returncode != 0
|
||||
assert (rotation[2] / 'privacyidea/secrets.env').read_bytes() == before
|
||||
assert all(call['args'][0] == 'get' for call in calls(rotation))
|
||||
assert 'last_rotated_' not in (rotation[1] / 'creds-state.yaml').read_text()
|
||||
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
|
||||
|
||||
|
||||
def test_database_password_is_stdin_not_command_argument(rotation):
|
||||
result = run(rotation, 'PI_DB_PASSWORD')
|
||||
assert result.returncode == 0
|
||||
sql = next(call for call in calls(rotation) if call['args'][0] == 'exec')
|
||||
assert bool(SYNTHETIC[:40] in json.dumps(sql['args'])) is False
|
||||
assert SYNTHETIC[:40] in sql['stdin']
|
||||
assert 'ON_ERROR_STOP=1' in sql['args']
|
||||
|
||||
|
||||
def test_unimplemented_ldap_manual_step_refuses_unattended_rotation(rotation):
|
||||
before = (rotation[2] / 'lldap/secrets.env').read_bytes()
|
||||
result = run(rotation, 'LLDAP_LDAP_USER_PASS')
|
||||
assert result.returncode != 0
|
||||
assert not calls(rotation)
|
||||
assert (rotation[2] / 'lldap/secrets.env').read_bytes() == before
|
||||
assert 'last_rotated_' not in (rotation[1] / 'creds-state.yaml').read_text()
|
||||
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect', ['missing', 'duplicate', 'symlink', 'parent-symlink', 'public', 'public-parent', 'public-root', 'hardlink', 'missing-key'])
|
||||
def test_invalid_custody_file_refused_before_cluster(rotation, defect):
|
||||
file = rotation[2] / 'privacyidea/secrets.env'
|
||||
if defect == 'missing': file.unlink()
|
||||
elif defect == 'duplicate': file.write_text('PI_SECRET_KEY=one\nPI_SECRET_KEY=two\n')
|
||||
elif defect == 'missing-key': file.write_text('OTHER_KEY=one\n')
|
||||
elif defect == 'public': file.chmod(0o644)
|
||||
elif defect == 'public-parent': file.parent.chmod(0o755)
|
||||
elif defect == 'public-root': rotation[2].chmod(0o755)
|
||||
elif defect == 'hardlink': os.link(file, file.with_name('alias'))
|
||||
elif defect == 'symlink':
|
||||
target = file.with_name('target'); file.rename(target); file.symlink_to(target)
|
||||
else:
|
||||
directory = file.parent; target = directory.with_name('moved'); directory.rename(target); directory.symlink_to(target)
|
||||
result = run(rotation, 'PI_SECRET_KEY')
|
||||
assert result.returncode != 0
|
||||
assert not calls(rotation)
|
||||
assert 'last_rotated_' not in (rotation[1] / 'creds-state.yaml').read_text()
|
||||
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure', ['HELPER_FAIL', 'CLUSTER_FAIL'])
|
||||
def test_apply_failure_is_sanitized_and_not_marked_complete(rotation, failure):
|
||||
result = run(rotation, 'PI_SECRET_KEY', **{failure: '1'})
|
||||
assert result.returncode != 0
|
||||
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
|
||||
assert 'last_rotated_' not in (rotation[1] / 'creds-state.yaml').read_text()
|
||||
|
||||
|
||||
def test_breakglass_no_plaintext_literal_or_client_apply_annotation(rotation):
|
||||
result = run(rotation, 'BREAKGLASS_PASSWORD')
|
||||
assert result.returncode == 0
|
||||
observed = calls(rotation)
|
||||
assert bool(SYNTHETIC[:40] in json.dumps([c['args'] for c in observed])) is False
|
||||
assert any('--server-side' in c['args'] for c in observed)
|
||||
|
||||
|
||||
def test_unknown_selector_is_not_echoed_or_evaluated_by_make(rotation):
|
||||
repo, _, _, env = rotation
|
||||
payload = 'unknown;touch selector-executed'
|
||||
result = subprocess.run(['make', '--no-print-directory', 'creds-rotate', 'SECRET=' + payload],
|
||||
cwd=repo, env=env, capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode != 0
|
||||
assert not (repo / 'selector-executed').exists()
|
||||
assert payload not in result.stdout + result.stderr
|
||||
assert not calls(rotation)
|
||||
|
||||
|
||||
def test_multiple_database_pods_refused_without_local_update(rotation):
|
||||
file = rotation[2] / 'privacyidea/secrets.env'
|
||||
before = file.read_bytes()
|
||||
result = run(rotation, 'PI_DB_PASSWORD', POD_NAMES='pod/one\npod/two')
|
||||
assert result.returncode != 0
|
||||
assert file.read_bytes() == before
|
||||
assert all(call['args'][0] == 'get' for call in calls(rotation))
|
||||
|
||||
|
||||
def test_database_command_failure_has_no_completion_or_consumer_restart(rotation):
|
||||
result = run(rotation, 'PI_DB_PASSWORD', CLUSTER_FAIL='1')
|
||||
assert result.returncode != 0
|
||||
assert [call['args'][0] for call in calls(rotation)] == ['get', 'exec']
|
||||
assert 'last_rotated_' not in (rotation[1] / 'creds-state.yaml').read_text()
|
||||
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
|
||||
|
||||
|
||||
def test_declining_rotation_preserves_custody_file_and_cluster(rotation):
|
||||
_, boot, secrets, env = rotation
|
||||
file = secrets / 'privacyidea/secrets.env'
|
||||
before = file.read_bytes()
|
||||
result = subprocess.run(['bash', '-x', str(boot / 'creds-rotate.sh'), str(secrets)],
|
||||
env={**env, 'SECRET': 'PI_SECRET_KEY'}, input='n\n',
|
||||
capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode == 0
|
||||
assert file.read_bytes() == before
|
||||
assert not calls(rotation)
|
||||
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
|
||||
|
|
@ -74,10 +74,11 @@ 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):
|
||||
- `sso-mfa/bootstrap/creds-rotate.sh` logs newly generated values and embeds a
|
||||
password in a printed SQL command. Its source can be repaired without executing
|
||||
founder-deferred rotations; no such rotation has been run.
|
||||
- `sso-mfa/k8s/privacyidea/bootstrap-admin.sh` prints the trigger-admin password.
|
||||
- `sso-mfa/k8s/privacyidea/bootstrap-admin.sh` prints the trigger-admin password
|
||||
and publishes a fresh generated Secret even when the existing account retained
|
||||
its previous password. Its command pipelines can misreport failures as creation.
|
||||
- RSA rotation rollback/custody and native multi-consumer acceptance remain open;
|
||||
the output repair below is not lifecycle closure.
|
||||
- Delegated create/apply helpers still need full tracing/error/Secret-annotation
|
||||
output review; transport capture and native effect evidence remain mandatory.
|
||||
|
||||
|
|
@ -93,6 +94,18 @@ Existing HOME copies were not deleted; native recovery and the remaining direct
|
|||
editor/rotate/delegated paths are unverified. Evidence is Custodian
|
||||
`sops-make-entrypoints.json`; T02 remains progress, not source-surface closure.
|
||||
|
||||
2026-10-12 rotation caller repair: 20 initial regressions failed against prior
|
||||
source. New values and delegated diagnostics are suppressed, xtrace disabled,
|
||||
private owned no-symlink/single-link service files validated and replaced
|
||||
atomically, and Make passes SECRET as data. Database discovery fails closed
|
||||
before local mutation; SQL travels on stdin with ON_ERROR_STOP. Unattended LDAP
|
||||
password rotation is refused while the server update remains manual. Break-glass
|
||||
uses file input/server-side apply rather than argv literals/client annotations.
|
||||
Additional refusal/failure/cancel regressions cover final source. Successful
|
||||
script timestamps are not native consumer/revocation evidence; partial updates
|
||||
retain the local candidate for recovery. No live rotation occurred. Evidence:
|
||||
Custodian `rotation-output.json`. T02 and the founder-deferred rotation stay open.
|
||||
|
||||
## Verify owner-approved recovery and current native callers
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue