diff --git a/Makefile b/Makefile index e70e609..12a43ae 100644 --- a/Makefile +++ b/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= - @[[ -n "$(SECRET)" ]] \ - || (echo "Usage: make creds-rotate SECRET="; \ - 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) ────────────────────────── diff --git a/docs/security-bootstrap-age-custody.md b/docs/security-bootstrap-age-custody.md index 7729618..bdf5cd6 100644 --- a/docs/security-bootstrap-age-custody.md +++ b/docs/security-bootstrap-age-custody.md @@ -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. diff --git a/sso-mfa/bootstrap/creds-rotate.sh b/sso-mfa/bootstrap/creds-rotate.sh index fb450f2..7ae56ce 100755 --- a/sso-mfa/bootstrap/creds-rotate.sh +++ b/sso-mfa/bootstrap/creds-rotate.sh @@ -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 -- 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)" diff --git a/sso-mfa/bootstrap/rotation-env.py b/sso-mfa/bootstrap/rotation-env.py new file mode 100644 index 0000000..6471a54 --- /dev/null +++ b/sso-mfa/bootstrap/rotation-env.py @@ -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) diff --git a/tests/test_rotation_output.py b/tests/test_rotation_output.py new file mode 100644 index 0000000..4667e6c --- /dev/null +++ b/tests/test_rotation_output.py @@ -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 diff --git a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md index ea04c5e..a00a6eb 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -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