fix(security): keep rotation credentials out of command output
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: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
tegwick 2026-10-12 12:54:10 +02:00
parent 681c5b0ea1
commit 6ae4708ea0
6 changed files with 429 additions and 104 deletions

View file

@ -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) ──────────────────────────

View file

@ -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.

View file

@ -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)"

View 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)

View 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

View file

@ -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