From bd448f5afd84f2b66f6a1420d24086041935f53a Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sat, 18 Jul 2026 17:21:42 +0300 Subject: [PATCH] security: rotate local deployment secrets safely --- .env.example | 2 +- README.md | 11 +++++ scripts/rotate-local-secrets.sh | 73 +++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100755 scripts/rotate-local-secrets.sh diff --git a/.env.example b/.env.example index 35aed92..86ff6a6 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,7 @@ SMTP_PASSWORD= SMTP_AUTH=never SMTP_TLS=never SMTP_SSL=false -EMAIL_FROM_NAME=Who Need Help +EMAIL_FROM_NAME="Who Need Help" EMAIL_FROM_ADDRESS=contact@example.com CODEX_SESSION_ID=copy-the-main-local-codex-session-id diff --git a/README.md b/README.md index 6bc70f3..bb5a04c 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,17 @@ set `app.host`, `app.scheme`, and `app.urlPort` to the public URL used in email links, and must set `app.mapTileUrl` to a tile service whose policy and capacity fit the deployment. +Rotate all local application secrets and the existing local PostgreSQL role +without printing the generated values: + +```bash +./scripts/rotate-local-secrets.sh +docker compose up -d --wait +``` + +This preserves the named PostgreSQL volume, but invalidates existing browser +sessions and changes handover codes for active local requests. + ## Tests The reproducible test command builds a dedicated test target and uses the diff --git a/scripts/rotate-local-secrets.sh b/scripts/rotate-local-secrets.sh new file mode 100755 index 0000000..5809b8d --- /dev/null +++ b/scripts/rotate-local-secrets.sh @@ -0,0 +1,73 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +ENV_FILE="$ROOT/.env" +TEST_IMAGE="${TEST_IMAGE:-who-need-help:test}" + +if [ ! -f "$ENV_FILE" ]; then + echo "Missing $ENV_FILE. Copy .env.example to .env first." >&2 + exit 1 +fi + +for command in docker openssl perl; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command is unavailable: $command" >&2 + exit 1 + fi +done + +set -a +. "$ENV_FILE" +set +a + +: "${POSTGRES_USER:?POSTGRES_USER is missing from .env}" +: "${POSTGRES_DB:?POSTGRES_DB is missing from .env}" + +case "$POSTGRES_USER" in + *[!A-Za-z0-9_]* | "") echo "POSTGRES_USER contains unsupported characters." >&2; exit 1 ;; +esac + +case "$POSTGRES_DB" in + *[!A-Za-z0-9_]* | "") echo "POSTGRES_DB contains unsupported characters." >&2; exit 1 ;; +esac + +generate_phoenix_secret() { + docker run --rm "$TEST_IMAGE" mix phx.gen.secret +} + +new_secret_key_base=$(generate_phoenix_secret) +new_handover_secret=$(generate_phoenix_secret) +new_release_cookie=$(generate_phoenix_secret) +new_postgres_password=$(openssl rand -hex 32) +new_database_url="ecto://${POSTGRES_USER}:${new_postgres_password}@db/${POSTGRES_DB}" + +tmp_env=$(mktemp "${ENV_FILE}.rotate.XXXXXX") +trap 'rm -f "$tmp_env"' EXIT HUP INT TERM +chmod 600 "$tmp_env" + +NEW_SECRET_KEY_BASE=$new_secret_key_base \ +NEW_HANDOVER_SECRET=$new_handover_secret \ +NEW_RELEASE_COOKIE=$new_release_cookie \ +NEW_POSTGRES_PASSWORD=$new_postgres_password \ +NEW_DATABASE_URL=$new_database_url \ + perl -pe ' + s/^SECRET_KEY_BASE=.*/SECRET_KEY_BASE=$ENV{NEW_SECRET_KEY_BASE}/; + s/^HANDOVER_SECRET=.*/HANDOVER_SECRET=$ENV{NEW_HANDOVER_SECRET}/; + s/^RELEASE_COOKIE=.*/RELEASE_COOKIE=$ENV{NEW_RELEASE_COOKIE}/; + s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$ENV{NEW_POSTGRES_PASSWORD}/; + s/^DATABASE_URL=.*/DATABASE_URL=$ENV{NEW_DATABASE_URL}/; + ' "$ENV_FILE" >"$tmp_env" + +printf "ALTER ROLE \"%s\" WITH PASSWORD '%s';\n" \ + "$POSTGRES_USER" "$new_postgres_password" | + docker compose --project-directory "$ROOT" exec -T db \ + psql --set ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ + >/dev/null + +mv "$tmp_env" "$ENV_FILE" +chmod 600 "$ENV_FILE" +trap - EXIT HUP INT TERM + +echo "Local application and PostgreSQL secrets rotated without printing their values." +echo "Run docker compose up -d --wait to apply the new application environment."