From 486492c1d3eb2a9aadd2f1bdc744cd334b56d03c Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 8 Sep 2026 17:22:43 +0300 Subject: [PATCH] =?UTF-8?q?:lock:=20fix(identity):=20=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=20=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=20=D0=91=D0=94=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=20=D1=81=D0=BB=D0=B5=D0=B4=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B7=D0=B0=20SECRET=5FKEY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ключ выводился из SECRET_KEY, а ошибка расшифровки глоталась молча. Значит смена ключа подписи — обычное действие после утечки — делала нечитаемыми секреты TOTP сотрудников, токены интеграций, пароль SMTP и ключи S3, и в логе об этом не было ни строки: секреты просто становились пустыми. Теперь ключ живёт своим файлом в томе секретов: его кладёт туда первый старт стека, выводя из secret_key ровно тем же способом, каким это делал сам продукт. Значение от этого не меняется, поэтому работающая установка ничего не теряет — но ключ больше не привязан к SECRET_KEY, и подпись можно ротировать. Человек ключ по-прежнему не вводит: файла с переменными у продукта нет. Молчание убрано: не расшифровавшееся значение пишет предупреждение в лог, а неверный ключ в настройке падает ImproperlyConfigured сразу, а не отдаёт пустой секрет при первой расшифровке. Проверено на живом томе: shell-вывод ключа совпадает с питоновским байт в байт (сверено в образе pgvector/pgvector:pg16); после запуска скрипта сгенерированный ключ равен действующему выводимому; приложение читает его из файла и расшифровывает все пять сохранённых секретов интеграций. Четыре теста. Co-Authored-By: Claude Opus 5 --- apps/backend/chatballs/identity/crypto.py | 37 +++++++++++++-- .../backend/chatballs/identity/test_crypto.py | 46 +++++++++++++++++++ .../chatballs_backend/settings_base.py | 10 ++-- deploy/secrets/generate-instance-secrets.sh | 30 ++++++++++++ 4 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 apps/backend/chatballs/identity/test_crypto.py diff --git a/apps/backend/chatballs/identity/crypto.py b/apps/backend/chatballs/identity/crypto.py index 485c164..7dcf078 100644 --- a/apps/backend/chatballs/identity/crypto.py +++ b/apps/backend/chatballs/identity/crypto.py @@ -1,18 +1,40 @@ import base64 import hashlib +import logging from functools import lru_cache from cryptography.fernet import Fernet, InvalidToken from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.db import models +logger = logging.getLogger(__name__) + @lru_cache(maxsize=1) def _fernet() -> Fernet: + """Ключ шифрования секретов в БД. + + Ключ лежит в томе секретов рядом с остальными: его кладёт туда первый + старт стека (deploy/secrets/generate-instance-secrets.sh), выводя из ключа + подписи ровно тем же способом, что и фолбэк ниже. Значение от этого не + меняется, зато перестаёт следовать за SECRET_KEY: до появления файла смена + ключа подписи молча делала нечитаемым всё зашифрованное — секреты TOTP, + токены интеграций, пароль SMTP, ключи S3. + + Фолбэк остаётся для установок, где файла ещё нет, и для тестов. + """ configured = getattr(settings, "CHATBALLS_FIELD_ENCRYPTION_KEY", "") if configured: - return Fernet(configured.encode() if isinstance(configured, str) else configured) - # Dev/тестовый фолбэк: детерминированный ключ из SECRET_KEY (в production задаётся отдельно). + try: + return Fernet(configured.encode() if isinstance(configured, str) else configured) + except (ValueError, TypeError) as error: + # Иначе неверный ключ вскрылся бы не здесь, а при первой расшифровке — + # пустым секретом вместо внятной ошибки конфигурации. + raise ImproperlyConfigured( + "CHATBALLS_FIELD_ENCRYPTION_KEY должен быть ключом Fernet " + "(44 символа urlsafe-base64, Fernet.generate_key())" + ) from error derived = base64.urlsafe_b64encode(hashlib.sha256(settings.SECRET_KEY.encode()).digest()) return Fernet(derived) @@ -25,8 +47,15 @@ def decrypt_secret(token: str) -> str: try: return _fernet().decrypt(token.encode()).decode() except (InvalidToken, ValueError): - # Повреждённое/неподходящее значение трактуем как отсутствие секрета, - # чтобы проверка TOTP безопасно провалилась, а не падала с 500. + # Значение не расшифровалось — отдаём пустое, чтобы проверка TOTP + # безопасно провалилась, а не падала с 500. Но молчать нельзя: ровно + # так выглядит смена ключа шифрования, и без записи в лог установка + # теряла бы секреты TOTP и токены интеграций без единого следа. + logger.warning( + "Не удалось расшифровать секрет из БД — значение прочитано как пустое. " + "Обычно это значит, что сменился ключ шифрования полей " + "(CHATBALLS_FIELD_ENCRYPTION_KEY или выведенный из CHATBALLS_SECRET_KEY)." + ) return "" diff --git a/apps/backend/chatballs/identity/test_crypto.py b/apps/backend/chatballs/identity/test_crypto.py new file mode 100644 index 0000000..1be3926 --- /dev/null +++ b/apps/backend/chatballs/identity/test_crypto.py @@ -0,0 +1,46 @@ +"""Ключ шифрования секретов в БД (chatballs.identity.crypto).""" + +from django.core.exceptions import ImproperlyConfigured +from django.test import SimpleTestCase, override_settings + +from chatballs.identity import crypto + + +class FieldEncryptionKeyTests(SimpleTestCase): + def setUp(self) -> None: + crypto._fernet.cache_clear() + self.addCleanup(crypto._fernet.cache_clear) + + def test_round_trip_with_the_derived_key(self) -> None: + # Пустой ключ — установка, до которой файл секрета ещё не доехал. + with override_settings(CHATBALLS_FIELD_ENCRYPTION_KEY=""): + self.assertEqual(crypto.decrypt_secret(crypto.encrypt_secret("s3cret")), "s3cret") + + def test_wrong_key_is_reported_instead_of_passing_silently(self) -> None: + # Ровно так выглядит смена ключа шифрования. Значение не вернуть, но + # установка не должна терять секреты без единого следа в логе. + with override_settings(CHATBALLS_FIELD_ENCRYPTION_KEY="", SECRET_KEY="первый"): + token = crypto.encrypt_secret("s3cret") + crypto._fernet.cache_clear() + + with override_settings(CHATBALLS_FIELD_ENCRYPTION_KEY="", SECRET_KEY="второй"): + with self.assertLogs("chatballs.identity.crypto", level="WARNING") as logs: + self.assertEqual(crypto.decrypt_secret(token), "") + + self.assertIn("ключ шифрования", "\n".join(logs.output)) + + def test_malformed_configured_key_fails_at_startup_not_at_first_use(self) -> None: + with override_settings(CHATBALLS_FIELD_ENCRYPTION_KEY="не-ключ-fernet"): + with self.assertRaises(ImproperlyConfigured): + crypto.encrypt_secret("s3cret") + + def test_explicit_key_detaches_secrets_from_the_signing_key(self) -> None: + # Задан явно — смена SECRET_KEY больше не делает данные нечитаемыми. + from cryptography.fernet import Fernet + + key = Fernet.generate_key().decode() + with override_settings(CHATBALLS_FIELD_ENCRYPTION_KEY=key, SECRET_KEY="первый"): + token = crypto.encrypt_secret("s3cret") + crypto._fernet.cache_clear() + with override_settings(CHATBALLS_FIELD_ENCRYPTION_KEY=key, SECRET_KEY="второй"): + self.assertEqual(crypto.decrypt_secret(token), "s3cret") diff --git a/apps/backend/chatballs_backend/settings_base.py b/apps/backend/chatballs_backend/settings_base.py index 3775139..02787cd 100644 --- a/apps/backend/chatballs_backend/settings_base.py +++ b/apps/backend/chatballs_backend/settings_base.py @@ -158,9 +158,13 @@ DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Chatballs /dev/null 2>&1; then + # Вывести ключ нечем. Файла не будет, продукт выведет его сам — значение то + # же самое, просто связка с secret_key сохранится до появления openssl. + echo "openssl not found — field_encryption_key left derived from secret_key" + return 0 + fi + # base64url(sha256(secret_key)) — совпадает с chatballs.identity.crypto. + printf %s "$(cat "$DIR/secret_key")" \ + | openssl dgst -sha256 -binary \ + | openssl base64 -A \ + | tr '+/' '-_' > "$file" + chmod 444 "$file" + echo "generated field_encryption_key" +} + +ensure_field_encryption_key + # Coturn читает секрет не из аргумента, а из конфига: значение не светится # в списке процессов и не дублируется в compose. if [ ! -s "$DIR/turnserver-secret.conf" ]; then