From c8dacd0d940a4bde035e4ea79ea129a2f8a111bf Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Tue, 11 Aug 2026 07:59:36 -0500 Subject: [PATCH] Reject invalid SOCIAL_TOKEN_ENCRYPTION_KEY instead of 500ing. Placeholder Fernet values like replace-with-fernet-key now raise a clear SocialCryptoError in the portal, and prod env examples no longer ship a fake key. --- .env.prod.example | 7 ++++-- site/social/crypto.py | 56 ++++++++++++++++++++++++++++++++++++------- site/social/views.py | 18 ++++++++++---- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/.env.prod.example b/.env.prod.example index 278f749..b191a8e 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -77,7 +77,10 @@ PCM_RETURN_ADDRESS='{"firstName":"Monica","lastName":"Dhillon","address":"replac # LinkedIn Auth redirect (prod): https://YOUR_DOMAIN/portal/social/accounts/linkedin/callback/ META_APP_ID= META_APP_SECRET= -SOCIAL_TOKEN_ENCRYPTION_KEY=replace-with-fernet-key +# Must be a real Fernet key (44 chars). Generate: +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# Do NOT use a placeholder — invalid keys break LinkedIn/Meta token save. +SOCIAL_TOKEN_ENCRYPTION_KEY= # Ollama for social drafting (reachable from app hosts) OLLAMA_BASE_URL=http://10.0.0.128:11434 @@ -116,7 +119,7 @@ GUNICORN_BIND=0.0.0.0:8000 # EMAIL_HOST_PASSWORD=replace-me # SMTP2GO_SMS_API_KEY=replace-me # LOB_API_KEY=replace-me -# SOCIAL_TOKEN_ENCRYPTION_KEY=replace-with-fernet-key +# SOCIAL_TOKEN_ENCRYPTION_KEY= # real Fernet.generate_key() output, not a placeholder # OLLAMA_BASE_URL=http://10.0.0.128:11434 # NOMINATIM_BASE_URL=http://10.0.0.128:8089 # NOMINATIM_COUNTRY_CODES=us diff --git a/site/social/crypto.py b/site/social/crypto.py index 5cd6423..f92655d 100644 --- a/site/social/crypto.py +++ b/site/social/crypto.py @@ -1,17 +1,55 @@ -"""Token encryption helpers for SocialAccount.""" +"""Token encryption helpers for SocialAccount / SocialAppCredentials.""" from cryptography.fernet import Fernet, InvalidToken from django.conf import settings +# Copied from .env.prod.example / deploy templates — not valid Fernet material. +_PLACEHOLDER_KEYS = frozenset( + { + "replace-with-fernet-key", + "changeme", + "change-me", + "todo", + } +) + + +class SocialCryptoError(ValueError): + """Raised when SOCIAL_TOKEN_ENCRYPTION_KEY is missing or not a Fernet key.""" + + +def _normalize_key(raw: str | bytes | None) -> bytes | None: + if raw is None: + return None + if isinstance(raw, bytes): + text = raw.decode("utf-8", errors="ignore").strip() + else: + text = str(raw).strip() + if not text or text.lower() in _PLACEHOLDER_KEYS: + return None + return text.encode() + def _fernet() -> Fernet: - key = settings.SOCIAL_TOKEN_ENCRYPTION_KEY - if not key: - # Dev-only fallback — generate is not stable across restarts; set the env var. - key = Fernet.generate_key().decode() - if isinstance(key, str): - key = key.encode() - return Fernet(key) + key = _normalize_key(settings.SOCIAL_TOKEN_ENCRYPTION_KEY) + if key is None: + if settings.DEBUG: + # Dev-only unstable fallback — set SOCIAL_TOKEN_ENCRYPTION_KEY for real use. + return Fernet(Fernet.generate_key()) + raise SocialCryptoError( + "SOCIAL_TOKEN_ENCRYPTION_KEY is not set. Generate one with: " + 'python -c "from cryptography.fernet import Fernet; ' + 'print(Fernet.generate_key().decode())" ' + "and add it to the beta/prod app env, then redeploy/restart." + ) + try: + return Fernet(key) + except (ValueError, TypeError) as exc: + raise SocialCryptoError( + "SOCIAL_TOKEN_ENCRYPTION_KEY is invalid (must be 32 url-safe " + "base64-encoded bytes from Fernet.generate_key()). " + "Replace the placeholder in the beta/prod env and restart." + ) from exc def encrypt_tokens(plaintext: str) -> str: @@ -25,3 +63,5 @@ def decrypt_tokens(ciphertext: str) -> str: return _fernet().decrypt(ciphertext.encode()).decode() except InvalidToken as exc: raise ValueError("Unable to decrypt social tokens") from exc + except SocialCryptoError: + raise diff --git a/site/social/views.py b/site/social/views.py index b665108..d1cbc8d 100644 --- a/site/social/views.py +++ b/site/social/views.py @@ -11,7 +11,7 @@ from django.utils.safestring import mark_safe from django.views.decorators.http import require_GET, require_http_methods, require_POST from messaging.services import parse_scheduled_for -from social.crypto import encrypt_tokens +from social.crypto import SocialCryptoError, encrypt_tokens from social.models import ( Platform, SocialAccount, @@ -143,7 +143,11 @@ def account_list(request): ) app.client_id = client_id if client_secret: - app.encrypted_client_secret = encrypt_tokens(client_secret) + try: + app.encrypted_client_secret = encrypt_tokens(client_secret) + except SocialCryptoError as exc: + messages.error(request, str(exc)) + return redirect(f"{request.path}?connect=linkedin") elif not app.encrypted_client_secret: messages.error( request, @@ -178,12 +182,18 @@ def account_list(request): elif platform == Platform.LINKEDIN: token_blob["author_urn"] = external_id + try: + encrypted = encrypt_tokens(json.dumps(token_blob)) + except SocialCryptoError as exc: + messages.error(request, str(exc)) + return redirect(f"{request.path}?connect={platform}") + account, created = SocialAccount.objects.update_or_create( platform=platform, external_id=external_id, defaults={ "label": label, - "encrypted_tokens": encrypt_tokens(json.dumps(token_blob)), + "encrypted_tokens": encrypted, "is_active": True, "owner": request.user, }, @@ -273,7 +283,7 @@ def linkedin_oauth_callback(request): "owner": request.user, }, ) - except LinkedInOAuthError as exc: + except (LinkedInOAuthError, SocialCryptoError) as exc: messages.error(request, str(exc)) return redirect("social:account_list") except Exception: