Прикладная криптография: симметрика, асимметрика, хеши, подписи, KDF
Криптография — единственная часть прикладной безопасности, где математика даёт настоящие гарантии. И одновременно та часть, где эти гарантии чаще всего теряются: не потому, что кто-то взломал AES, а потому, что ключ лежал рядом с шифртекстом, nonce повторился, тег не проверили или «зашифровали» пароль вместо того, чтобы его хешировать.
Вывод, который стоит принять до всех деталей: вы не проектируете криптосистему — вы её собираете из готовых блоков и отвечаете за стыки. Алгоритмы годами ломают специалисты по криптоанализу, и они держатся. Стыки ломают за один спринт разработчики, которые не знали, что nonce нельзя переиспользовать.
Статья написана с позиции защищающейся стороны. Уязвимый код здесь показан для того, чтобы вы узнали его в своём репозитории и починили; проверять что-либо можно только на системах, которыми вы владеете, либо при письменном разрешении владельца с зафиксированным объёмом работ. В OWASP Top 10 2021 тема проходит как A02:2021 Cryptographic Failures (см. OWASP Top 10), базовые слабости — CWE-327 «Use of a Broken or Risky Cryptographic Algorithm» и CWE-311 «Missing Encryption of Sensitive Data». Каждый примитив разбираем по одной схеме: уязвимый код → почему это работает → как чинить → как проверить.
Что криптография даёт и чего не даёт
Половина инцидентов происходит от того, что от примитива ждали свойства, которого у него нет.
| Свойство | Что означает | Чем достигается | Чем НЕ достигается |
|---|---|---|---|
| Конфиденциальность | посторонний не прочтёт содержимое | AEAD, гибридная схема | base64, обфускация, ECB |
| Целостность | изменение будет замечено | MAC, AEAD-тег, подпись | CRC, длина, «расшифровалось — значит целое» |
| Аутентичность источника | сообщение создал владелец ключа | HMAC (общий секрет), подпись (публичная проверка) | IP-адрес, заголовок, поле user_id внутри данных |
| Неотрекаемость | автор не сможет отказаться | асимметричная подпись | HMAC — обе стороны могут его создать |
| Свежесть | это не повтор старого сообщения | nonce, метка времени, счётчик | сама по себе подпись |
| Прямая секретность | вчерашний трафик не раскроется при краже ключа завтра | эфемерный обмен ключами | долговременный RSA-ключ для шифрования |
Чего криптография не даёт вовсе: она не заменяет авторизацию (шифрование поля не мешает запросить чужую строку — этим занимается Авторизация: RBAC, ABAC, ACL); не спасает от компрометации живого процесса — если приложение расшифровывает данные, то и злоумышленник с RCE тоже; не делает персональные данные «не персональными» — обратимое шифрование это мера защиты, а не обезличивание, инженерная сторона вопроса разобрана в статье Приватность и соответствие; и не скрывает метаданные — длина, частота и время запросов остаются видны.
Модель злоумышленника: против чего доказывают стойкость
Принцип Керкгоффса (1883): стойкость обязана зависеть только от ключа, а не от секретности алгоритма. Скрытый самописный алгоритм — не дополнительная защита, а отсутствие рецензирования.
Формальные игры, которые стоит знать по именам. IND-CPA: злоумышленник может попросить зашифровать любые тексты и всё равно не отличает шифрование одного выбранного сообщения от другого — отсюда требование вероятностности, детерминированное шифрование этому свойству не удовлетворяет никогда. IND-CCA2: то же, но злоумышленнику доступен ещё и «оракул расшифровки»; именно здесь живут padding-оракулы, и AEAD-режимы дают это свойство из коробки.
Стоимость атаки измеряется в операциях: перебор ключа длиной n бит — O(2 в степени n) времени и O(1) памяти; поиск коллизии хеша длиной n бит — O(2 в степени n/2) по парадоксу дней рождения; дискретный логарифм на кривой размера n — тоже O(2 в степени n/2). Ориентир на 2026 год: 2 в 128 степени операций недостижимо, 2 в 80 степени — на грани, 2 в 64 степени — доступно организации со средним бюджетом. Отсюда таблица эквивалентных размеров ключей из NIST SP 800-57 Part 1 Rev. 5:
| Стойкость, бит | Симметричный ключ | RSA (модуль) | Эллиптическая кривая | Хеш |
|---|---|---|---|---|
| 112 | 3DES (выведен) | 2048 | 224 | SHA-224 |
| 128 | AES-128 | 3072 | 256 (P-256, X25519) | SHA-256 |
| 192 | AES-192 | 7680 | 384 (P-384) | SHA-384 |
| 256 | AES-256 | 15360 | 512 (P-521) | SHA-512 |
Практическая мораль: RSA-2048 держится «на 112 битах», а не «на 2048». Наращивать RSA до 15360 бит бессмысленно — дешевле перейти на эллиптические кривые.
Фундамент: случайность
Все гарантии стоят на генераторе случайных чисел. Слабый генератор обнуляет остальное — ключ можно не подбирать, его можно предсказать.
# УЯЗВИМО: генератор для симуляций использован для секретов. CWE-338.
import random, string, time
def new_reset_token() -> str:
alphabet = string.ascii_letters + string.digits
return "".join(random.choice(alphabet) for _ in range(16))
def new_api_key(user_id: int) -> str:
random.seed(user_id * 31 + int(time.time())) # ещё хуже: сид предсказуем
return "".join(random.choice(alphabet) for _ in range(32))
Почему это работает. random в Python — Mersenne Twister MT19937: он не скрывает своё состояние, и по 624 последовательным 32-битным выходам оно восстанавливается полностью, после чего все прошлые и будущие значения вычисляются точно. Аналоги в других экосистемах — Math.random() в JavaScript, rand() в C, java.util.Random, mt_rand() в PHP. Второй пример хуже первого: сид выводится из известных величин, и множество возможных ключей сжимается до перебираемого за секунды. Исторический урок того же класса — дефект генератора в Debian OpenSSL (CVE-2008-0166): удалённая строка сократила энтропию до значения PID, и все ключи за два года оказались в списке из нескольких десятков тысяч вариантов.
Как чинить.
import secrets
def new_reset_token() -> str:
return secrets.token_urlsafe(32) # 32 байта = 256 бит энтропии
def new_api_key() -> str:
return "sk_live_" + secrets.token_hex(32) # префикс помогает секрет-сканерам
// Go: crypto/rand, а не math/rand. Ошибку читателя нельзя игнорировать.
func NewToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err // при отказе источника энтропии нужно падать, а не продолжать
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
В браузере и в Node — crypto.getRandomValues(). Под капотом везде getrandom(2) или аналог, отдающий байты из ядерного CSPRNG, заполненного аппаратными источниками (NIST SP 800-90A/B/C).
Как проверить. Grep по репозиторию на Math.random, import random, rand(), new Random(, mt_rand, uuid1 рядом со словами token, key, secret, nonce, salt. Правила статического анализа: bandit B311 для Python, gosec G404 для Go, eslint-plugin-security для JS. Тест на длину и алфавит токена ловит регрессии формата, но не доказывает криптостойкость: статистические батареи вроде dieharder проходит и MT19937 — проверять надо источник, а не выход.
Симметричное шифрование: только AEAD
Симметрика — случай, когда обе стороны знают один ключ. Она быстрая (единицы гигабайт в секунду с AES-NI) и покрывает почти все прикладные задачи шифрования.
# УЯЗВИМО сразу по трём осям. Типовое легаси-«шифрование полей».
from Crypto.Cipher import AES # pycryptodome
from Crypto.Util.Padding import pad
KEY = b"my-super-secret-key-32-bytes!!!!" # 1. ключ-константа в коде, CWE-321
def encrypt_field(plaintext: str) -> bytes:
cipher = AES.new(KEY, AES.MODE_ECB) # 2. ECB, CWE-327
return cipher.encrypt(pad(plaintext.encode(), 16))
# 3. нет аутентификации, CWE-353
Почему это работает. ECB шифрует каждый 16-байтовый блок независимо и детерминированно: одинаковые блоки открытого текста дают одинаковые блоки шифртекста. Для структурированных данных по шифртексту читаются структура и повторы, а блоки переставляются, вырезаются и склеиваются из разных записей. Отсутствие тега означает, что любое изменение байт останется незамеченным и приложение примет мусор за данные.
Отдельная ловушка — «CBC, а целостность проверим потом». Схема MAC-then-Encrypt или вовсе без MAC даёт классический padding-оракул (Vaudenay, 2002): сервис по-разному реагирует на неверное дополнение и неверные данные — временем ответа, кодом, текстом в логе, — и этого достаточно, чтобы расшифровать шифртекст байт за байтом без знания ключа. Разновидности той же темы — POODLE и Lucky Thirteen, обе про TLS (см. Транспортная безопасность).
Как чинить. Правило одной строки: в прикладном коде используется только AEAD — режим, который шифрует и аутентифицирует одновременно.
# ПРАВИЛЬНО: AES-256-GCM с уникальным nonce и связанными данными.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
NONCE_LEN = 12 # 96 бит — рекомендация NIST SP 800-38D
def seal(key: bytes, plaintext: bytes, aad: bytes) -> bytes:
"""Возвращает nonce || ciphertext || tag одним блобом."""
nonce = os.urandom(NONCE_LEN)
return nonce + AESGCM(key).encrypt(nonce, plaintext, aad)
def unseal(key: bytes, blob: bytes, aad: bytes) -> bytes:
"""Тег проверяется ВНУТРИ decrypt, до выдачи данных наружу."""
nonce, ct = blob[:NONCE_LEN], blob[NONCE_LEN:]
return AESGCM(key).decrypt(nonce, ct, aad) # InvalidTag → исключение
Сложность: O(n) по времени от длины данных, O(n) по памяти для однопроходного API (или O(1) сверх буфера при потоковой обработке чанками). Накладные расходы фиксированы — 28 байт на сообщение: 12 байт nonce и 16 байт тега.
Три вещи важнее выбора шифра:
- Nonce уникален для пары «ключ и сообщение». Для AES-GCM со случайным 96-битным nonce безопасный предел — около 2 в 32 степени сообщений на один ключ (та же вероятность коллизии по дням рождения). Дальше нужна ротация. Если сообщений заведомо много, берите XChaCha20-Poly1305 с 192-битным случайным nonce или AES-GCM-SIV (RFC 8452), где повтор nonce не обваливает схему, а лишь раскрывает факт повтора текста.
- AAD связывает шифртекст с его местом. Без него валидный шифртекст переносится в чужую строку или чужому арендатору: ключ тот же, тег сойдётся. В AAD кладут
tenant_id, имя таблицы и колонки, идентификатор строки, версию схемы. - Тег проверяется до расшифровки. Никогда не пишите «расшифруем, а потом сверим» — библиотека уже делает правильно, задача в том, чтобы не сломать это ручной оптимизацией.
Выбор между AES-GCM и ChaCha20-Poly1305 (RFC 8439) чисто практический: есть аппаратный AES-NI — быстрее AES-GCM; нет (мобильные ARM без крипторасширений, встраиваемые устройства) — быстрее ChaCha20, к тому же он естественно константен по времени, потому что не использует таблиц подстановки.
Как проверить.
# tests/test_crypto_tamper.py — минимум, который обязан быть в проекте
def test_tampering_is_detected(key):
blob = bytearray(seal(key, b"balance=100", aad=b"acct:42"))
blob[20] ^= 0x01 # меняем один бит шифртекста
with pytest.raises(InvalidTag):
unseal(key, bytes(blob), aad=b"acct:42")
def test_aad_binds_context(key):
blob = seal(key, b"balance=100", aad=b"acct:42")
with pytest.raises(InvalidTag):
unseal(key, blob, aad=b"acct:43") # перенос в чужой контекст
def test_ciphertext_is_probabilistic(key):
assert seal(key, b"x", b"c") != seal(key, b"x", b"c") # ECB такой тест валит
Дополнительно — прогон официальных тестовых векторов (KAT) из NIST CAVP или приложений RFC: он ловит подмену реализации.
Хеш-функции: где они уместны и где нет
Криптографическая хеш-функция обязана давать три свойства: устойчивость к прообразу, ко второму прообразу и к коллизиям. Коллизии всегда дешевле из-за парадокса дней рождения — отсюда O(2 в степени n/2).
Практические следствия: MD5 и SHA-1 не применяются нигде, где важна устойчивость к коллизиям — подписи, сертификаты, идентификаторы содержимого, дедупликация, проверка целостности загрузок; коллизия с выбранным префиксом означает, что можно подготовить «безобидный» и «вредоносный» документы с одним хешем. MD5 как контрольная сумма от случайного повреждения формально допустим, но на ревью неотличим от дефекта — берите CRC32 (честно некриптографический) или BLAKE3. Рабочая лошадь — SHA-256/SHA-512; SHA-3/SHAKE — другая конструкция (губка), полезна как алгоритмическое разнообразие; BLAKE3 — самый быстрый из криптостойких, удобен для контрольных сумм больших артефактов.
# УЯЗВИМО: «подпись» вебхука хешем от конкатенации секрета и тела. CWE-328/CWE-347.
import hashlib
def sign_webhook(secret: bytes, body: bytes) -> str:
return hashlib.sha256(secret + body).hexdigest()
def verify_webhook(secret: bytes, body: bytes, provided: str) -> bool:
return sign_webhook(secret, body) == provided # ещё и сравнение через ==
Почему это работает. SHA-256, SHA-512, SHA-1 и MD5 построены по схеме Меркла — Дамгора и уязвимы к удлинению сообщения (length extension): зная H(secret || body) и длину секрета, можно вычислить H(secret || body || padding || suffix), не зная сам секрет. То есть к телу вебхука дописывается произвольный суффикс с валидной «подписью». Второй дефект — сравнение оператором ==: оно завершается на первом несовпавшем байте, и разница во времени ответа позволяет подбирать подпись байт за байтом (CWE-208). Конструкции, к удлинению не уязвимые, — SHA-512/256, SHA-3, BLAKE2, BLAKE3; но правильный ответ не в смене хеша, а в отказе от самодельного MAC.
Как чинить.
# ПРАВИЛЬНО: HMAC (RFC 2104) плюс сравнение за постоянное время.
import hmac, hashlib, time
def sign_webhook(secret: bytes, timestamp: str, body: bytes) -> str:
payload = timestamp.encode() + b"." + body # канонизация с разделителем
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
def verify_webhook(secret: bytes, timestamp: str, body: bytes, provided: str) -> bool:
if abs(time.time() - int(timestamp)) > 300: # окно против повторов
return False
expected = sign_webhook(secret, timestamp, body)
return hmac.compare_digest(expected, provided) # постоянное время
В Go это hmac.Equal (он же subtle.ConstantTimeCompare), в Node — crypto.timingSafeEqual, в Java — MessageDigest.isEqual.
Два нюанса ломают даже правильный HMAC. Канонизация: подписывать нужно однозначно сериализованные данные; если подпись считается по json.dumps(payload), а проверяется по повторной сериализации с другим порядком ключей, всё развалится — или, что хуже, две разные структуры дадут одну строку, и подпись перенесётся с одной на другую. Подписывайте байты, которые реально пришли, а не результат round-trip. Свежесть: HMAC не защищает от повтора, нужны метка времени в подписываемых данных и окно допуска либо nonce с хранением использованных значений.
Как проверить. Тест: подпись не проходит для тела с дописанным суффиксом. Тест: сообщение с валидной подписью, но меткой времени часовой давности отвергается. Мутационный тест: замените compare_digest на == — если ни один тест и ни один линтер не упал, у вас нет проверки на этот класс дефектов. Grep: hashlib.sha256(secret, sha256(key +, == signature.
Асимметричная криптография: когда общего секрета нет
Асимметрика решает задачу, недоступную симметрике: договориться с тем, с кем вы никогда не обменивались секретом, и доказать авторство третьей стороне. Интуиция — односторонняя функция с лазейкой: вычислить в одну сторону легко, обратно вычислительно невозможно, если не знаешь секрет. Для RSA это умножение простых против факторизации, для эллиптических кривых — умножение точки на число против дискретного логарифма (алгебраическая база — в статье Абстрактная алгебра). Ключевая практическая характеристика: асимметрика в сотни раз медленнее симметрики и ограничена по объёму данных, поэтому напрямую ею почти ничего не шифруют.
# УЯЗВИМО: RSA как «шифровальщик данных» плюс устаревшее дополнение.
from Crypto.Cipher import PKCS1_v1_5
def encrypt_document(pubkey, document: bytes) -> list[bytes]:
cipher = PKCS1_v1_5.new(pubkey)
# режем документ на куски по 245 байт и шифруем RSA каждый — «ECB для RSA»
return [cipher.encrypt(document[i:i+245]) for i in range(0, len(document), 245)]
Почему это работает. Три отдельных дефекта. Дополнение PKCS#1 v1.5 для шифрования уязвимо к атаке Блайхенбахера (1998): сервис, различающий «ошибку дополнения» и «ошибку разбора», работает как оракул расшифровки; спустя двадцать лет та же атака вернулась под именем ROBOT (2017), потому что реализации всё ещё различали ветки. Поблочное RSA-шифрование воспроизводит все пороки ECB. Целостности нет вовсе. Отдельно — «учебниковый RSA» без дополнения: он мультипликативен и детерминирован, в прикладном коде его быть не должно.
Как чинить. Правильная схема называется гибридным шифрованием (KEM/DEM): асимметрикой согласуют симметричный ключ, а данные шифруют AEAD.
Это ровно то, что стандартизовано как HPKE (RFC 9180) — готовая схема, которую стоит брать целиком вместо ручной сборки. Если нужен именно RSA (обычно из-за требований контрагента) — только RSA-OAEP с SHA-256 и только для ключа, не для данных.
# ПРАВИЛЬНО: подпись Ed25519 (RFC 8032). Детерминирована, без выбора параметров.
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.exceptions import InvalidSignature
def sign(sk: Ed25519PrivateKey, message: bytes) -> bytes:
return sk.sign(message) # никаких хешей и nonce снаружи
def verify(pk, message: bytes, sig: bytes) -> bool:
try:
pk.verify(sig, message) # исключение, а не False — важно не проглотить
return True
except InvalidSignature:
return False
Почему Ed25519, а не ECDSA, если выбор свободен: ECDSA требует уникального случайного nonce на каждую подпись, и повтор nonce раскрывает секретный ключ элементарной алгеброй. Так исторически терялись ключи — от прошивки игровой консоли в 2010 году до кошельков на Android с дефектным SecureRandom в 2013-м. Ed25519 выводит nonce детерминированно из ключа и сообщения, поэтому класс дефектов отсутствует конструктивно; для ECDSA тот же приём стандартизован в RFC 6979.
| Задача | Рекомендация 2026 | Приемлемо | Не применять |
|---|---|---|---|
| Обмен ключами | X25519 | ECDH P-256/P-384 | «сырой» DH с малыми группами, статический DH без эфемерности |
| Подпись | Ed25519 | ECDSA P-256 с RFC 6979, RSA-PSS 3072+ | RSA PKCS#1 v1.5, DSA, RSA < 2048 |
| Асимметричное шифрование | HPKE | RSA-OAEP SHA-256, 3072+ | RSA PKCS#1 v1.5, учебниковый RSA |
| Шифрование данных | AES-256-GCM, ChaCha20-Poly1305 | AES-256-CBC + HMAC (Encrypt-then-MAC) | ECB, CBC без MAC, 3DES, RC4 |
Как проверить. Подпись, снятая с сообщения A, не проходит проверку на сообщении B. Проверка возвращает отказ, а не «истину по умолчанию», при пустой подписи, обрезанной подписи и подписи чужим ключом (CWE-347). Алгоритм проверки задан на стороне верификатора, а не берётся из проверяемого сообщения — типовой дефект alg, разобранный в статье JWT и токены. Инвентаризация: openssl pkey -in key.pem -text -noout покажет реальный тип и размер ключа, а не тот, что записан в вики.
Выведение ключей: KDF решают три разные задачи
Самая частая путаница в прикладной криптографии. «Получить ключ» — это три разные задачи с тремя разными инструментами.
или мастер-ключ из KMS| D[HKDF] B -->|Ничего нет| E[CSPRNG напрямую] C --> C1[Argon2id — по умолчанию] C --> C2[scrypt — если Argon2 недоступен] C --> C3[PBKDF2-HMAC-SHA256 — когда требует регулятор] C1 --> C4{Зачем ключ?} C4 -->|Проверять пароль| C5[Хранить PHC-строку] C4 -->|Шифровать данные пользователя| C6[Выводить ключ, не хранить его] D --> D1[Extract: сжать энтропию в PRK] D1 --> D2[Expand: развернуть в N ключей
с разным полем info] D2 --> D3[Отдельный ключ на каждое назначение] E --> E1[32 байта из os.urandom
и сразу в KMS или в хранилище секретов]
# УЯЗВИМО: три классические ошибки в трёх строках.
key_from_password = hashlib.sha256(password.encode()).digest() # 1
key_from_ecdh = shared_secret[:32] # 2
mac_key = enc_key = master_key # 3
Почему это работает. (1) SHA-256 считается за наносекунды, поэтому перебор паролей идёт со скоростью миллиардов вариантов в секунду на одной видеокарте; пароль как источник ключа требует намеренно дорогой функции с настраиваемой стоимостью по памяти. (2) Общий секрет ECDH — координата точки на кривой, а не равномерно распределённые байты: у неё есть алгебраическая структура и смещения, обрезать её нельзя, распределение нужно «выпрямить». (3) Один ключ на шифрование, MAC, подпись cookie и blind-индекс — нарушение доменного разделения: взаимодействие примитивов на общем ключе никем не доказано стойким, а компрометация одного назначения выносит все остальные.
Как чинить.
# Задача 1: ключ из пароля пользователя.
from argon2.low_level import hash_secret_raw, Type
def key_from_password(password: str, salt: bytes) -> bytes:
return hash_secret_raw(
secret=password.encode(),
salt=salt, # 16+ байт из CSPRNG, уникальна, хранится рядом
time_cost=3, # число проходов
memory_cost=64 * 1024, # 64 МиБ — главный параметр против GPU и ASIC
parallelism=4,
hash_len=32,
type=Type.ID, # Argon2id
)
Параметры подбираются измерением на целевом железе: OWASP даёт ориентир Argon2id с 19 МиБ, t=2, p=1 как минимум, но правильный способ — задать бюджет (например, 250 мс на проверку на продовом CPU) и подобрать под него память и проходы, зафиксировав в конфигурации. Стоимость атаки растёт линейно по памяти, а память — самый дорогой ресурс для специализированного железа. Хранение паролей и формат PHC-строки разобраны в статье Аутентификация.
# Задача 2: много независимых ключей из одного ключевого материала.
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
def derive(master: bytes, purpose: str, salt: bytes | None = None) -> bytes:
"""HKDF (RFC 5869): extract + expand. info задаёт доменное разделение."""
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=salt, # не секрет, но полезен
info=f"acme/v1/{purpose}".encode(), # ключевой момент
).derive(master)
enc_key = derive(master, "field-encryption")
mac_key = derive(master, "webhook-signature")
index_key = derive(master, "blind-index")
Поле info — не украшение: оно гарантирует, что ключи разных назначений вычислительно независимы, и зная один, невозможно получить другой. Кладите туда имя приложения, версию схемы и назначение — тогда смена версии автоматически меняет все производные ключи. По стоимости HKDF это O(1) (пара вызовов HMAC), поэтому выводить ключ на каждую операцию дешевле, чем хранить производные. Argon2id, наоборот, стоит O(t·m) по времени и O(m) по памяти — его вызывают один раз за сессию, а результат держат в памяти процесса.
Как проверить. derive(master, "a") != derive(master, "b"), и оба отличаются от master. Время проверки пароля попадает в целевой бюджет на CI-раннере, сопоставимом с продом; выход за границы валит тест и напоминает пересмотреть параметры. Прогон векторов из приложения A к RFC 5869 ловит подмену HKDF самодельной реализацией. Grep: sha256(password, md5(password, shared_secret[:.
Управление ключами: где живёт настоящая сложность
Алгоритмы стандартизованы, а ключи — нет. Именно здесь теряется большинство гарантий.
Разделение «шифрует новое» и «расшифровывает старое» делает ротацию возможной без единовременного перешифрования всей базы. Для этого в каждой записи хранится kid — идентификатор ключа.
Конвертное шифрование решает сразу три проблемы: ротацию, ограничение радиуса поражения и работу с объёмами, которые нельзя гонять через KMS.
функция ЗАШИФРОВАТЬ_ПОЛЕ(открытый_текст, контекст):
DEK ← CSPRNG(32) # ключ данных, живёт секунды
nonce ← CSPRNG(12)
шифртекст ← AEAD_Seal(DEK, nonce, открытый_текст, AAD = контекст)
обёртка ← KMS.Encrypt(KEK_id, DEK, EncryptionContext = контекст)
затереть DEK в памяти
вернуть (KEK_id, обёртка, nonce, шифртекст)
функция РАСШИФРОВАТЬ_ПОЛЕ(запись, контекст):
DEK ← KMS.Decrypt(запись.обёртка, EncryptionContext = контекст)
вернуть AEAD_Open(DEK, запись.nonce, запись.шифртекст, AAD = контекст)
Сложность: O(n) по времени от объёма данных плюс один сетевой вызов KMS на операцию. Кэш DEK на пачку записей снижает число вызовов до O(записей / размер пачки) ценой увеличения радиуса поражения — при утечке кэша раскрывается вся пачка, а не одна строка. Это осознанный компромисс, который фиксируют в архитектурном решении, а не получают случайно.
Важная честная оговорка про «затереть DEK»: в управляемых языках гарантированно стереть ключ из памяти нельзя — сборщик мусора копирует объекты, строки интернируются, страницы уходят в своп. Отсюда практические меры: отключить своп на узлах с ключами, запретить core dump (RLIMIT_CORE = 0), не логировать структуры целиком, а в критичных местах использовать нативные буферы.
Дисциплина, которая важнее алгоритма:
- Один ключ — одно назначение. Ключ шифрования полей не подписывает вебхуки; разделение через HKDF с разным
infoили через разные ключи в KMS. - Криптопериод задан явно. NIST SP 800-57 рекомендует ограничивать срок использования ключа для шифрования и отдельно — срок, в течение которого он ещё может расшифровывать.
- Ротация отрепетирована. Ключ, который «нельзя ротировать без даунтайма», не ротируют никогда; проверяется учением на staging.
- Ключи не лежат в репозитории, в образе и в конфиг-мапах — см. Секреты и ключи.
- Разделение обязанностей. У того, кто имеет доступ к дампу базы, не должно быть права
kms:Decryptна соответствующий KEK — иначе конвертное шифрование защищает только от потери диска. - Аварийная процедура написана заранее: отзыв, ротация, перешифрование, оценка того, что могло быть расшифровано, уведомления.
Шифрование в хранилище: что оно реально даёт
Здесь чаще всего возникает разрыв между галочкой в отчёте и реальной защитой.
| Уровень | От чего защищает | От чего НЕ защищает |
|---|---|---|
| Шифрование диска, TDE | физическая кража носителя, утечка бэкапа | SQL-инъекция, украденный пароль БД, инсайдер с доступом к БД |
| Шифрование поля (AEAD + KMS) | дамп базы, доступ аналитика к реплике, ошибочный экспорт | компрометация сервиса, имеющего право расшифровывать |
| Клиентское шифрование (ключ у пользователя) | компрометация сервера целиком | потерю ключа пользователем, отсутствие серверного поиска |
TDE — это про соответствие требованиям и про потерянные диски, а не про защиту от прикладных атак. Если модель угроз включает «дамп таблицы через инъекцию» (см. Инъекции), нужен уровень поля.
Классическая сопутствующая задача — поиск по зашифрованному. Решается «слепым индексом»: рядом со стойким шифртекстом хранится отдельная колонка с HMAC от канонизированного значения, и по ней строится обычный индекс.
def blind_index(index_key: bytes, raw: str, bits: int = 32) -> bytes:
"""HMAC от канонизированного значения, усечённый до bits бит."""
normalized = "".join(ch for ch in raw if ch.isdigit()) # канонизация обязательна
full = hmac.new(index_key, normalized.encode(), hashlib.sha256).digest()
return full[: bits // 8] # усечение создаёт «корзины» и размывает частотность
Что здесь надо понимать честно. Индекс даёт только точное совпадение — диапазоны, префиксы и сортировка требуют других схем и почти всегда протекают. Детерминированность неизбежно утекает: видно, что две строки содержат одно значение, и видна частотность; усечение до 32 бит создаёт коллизии намеренно — одна корзина на много значений, поиск возвращает кандидатов, приложение доотбирает их после расшифровки. Ключ индекса — отдельный (derive(master, "blind-index")) и не должен попадать в аналитический контур вместе с данными. И главное: «просто SHA-256 от паспорта» вместо HMAC — дефект, пространство номеров перебирается целиком за минуты.
Побочные каналы и типичные ошибки
Стойкий алгоритм в неудачной реализации протекает через время выполнения, потребление энергии и кеш процессора. Прикладному разработчику доступны три реалистичные меры: не реализовывать примитивы самому (библиотечные AES, X25519 и Poly1305 написаны без ветвлений по секретным данным и без табличных обращений, зависящих от ключа); сравнивать секреты за постоянное время; не превращать сервис в оракул — одинаковый ответ, код и сопоставимое время на всех ветках отказа: неверный тег, неверное дополнение, неизвестный kid, просроченный ключ.
Сводный список того, что чаще всего встречается на ревью:
- Ключ в репозитории или в образе (CWE-321) — ловится секрет-сканером в CI (см. Безопасность цепочки поставок).
- Повтор nonce — особенно при горизонтальном масштабировании со счётчиком в памяти процесса или при восстановлении состояния из бэкапа.
- Шифрование вместо хеширования пароля: обратимость означает, что компрометация ключа раскрывает все пароли сразу.
- Хеш вместо HMAC для индексов, идентификаторов и подписей.
- Проверка подписи, результат которой игнорируется:
verify()возвращаетFalse, а код продолжает работу, или исключение проглоченоexcept Exception: pass. - Алгоритм берётся из проверяемых данных:
alg: none, выбор кривой из заголовка, версия схемы без белого списка. - Собственный протокол поверх примитивов — «зашифруем, потом подпишем, потом ещё раз зашифруем»: стойкость такой комбинации никто не доказывал.
- Нет
kidи версии схемы в формате хранения: ротация и миграция становятся невозможными. - Логирование ключей и открытых текстов — трассировки, метрики с лейблами, отладочные дампы запросов.
- Ошибка расшифровки обрабатывается как «вернуть пустое значение»: нарушение целостности — это инцидент, а не пустая строка.
Как убедиться, что в проекте всё в порядке
Чеклист для ревью и внутреннего аудита. Все проверки выполняются на собственных системах или при письменном разрешении владельца.
- Составлен инвентарь: где какой примитив, какой ключ, где хранится, кто может им пользоваться — это же основа будущей постквантовой миграции
- В коде нет
MODE_ECB,DES,3DES,RC4,MD5,SHA1в контексте подписей и целостности,PKCS1_v1_5для шифрования - Все секреты генерируются CSPRNG; небезопасные генераторы не встречаются рядом со словами
token,key,secret,nonce,salt - Шифрование прикладных данных — только AEAD; политика выбора nonce задокументирована; AAD включает контекст записи
- MAC — это HMAC или Poly1305, не «хеш от секрета»; сравнение за постоянное время; есть защита от повтора
- Пароли — Argon2id (или scrypt/PBKDF2 при внешних требованиях) с измеренными параметрами
- Разные назначения используют разные ключи, полученные HKDF с разным
info - У каждой зашифрованной записи есть
kidи версия схемы; ротация протестирована на staging - Ошибки расшифровки и проверки подписи приводят к отказу и алерту, а не к пустому значению; наружу деталей нет
- Зависимости с криптографией отслеживаются (
pip-audit,npm audit,govulncheck,cargo audit) — см. Безопасная разработка - Есть прогон официальных тестовых векторов для каждого используемого примитива
# 1. Поиск заведомо сломанных примитивов и самодельных конструкций
grep -rnE "MODE_ECB|AES/ECB|DESede|RC4|Blowfish|md5\(|sha1\(" --include="*.py" \
--include="*.java" --include="*.go" --include="*.ts" src/
# 2. Небезопасные источники случайности рядом с секретами
grep -rnE "(Math\.random|random\.(choice|randint)|mt_rand|new Random\()" src/ \
| grep -iE "token|key|secret|nonce|salt|iv"
# 3. Готовые наборы правил статического анализа
semgrep --config p/secrets --config p/security-audit src/
bandit -r src/ -ll # Python
gosec ./... # Go
Постквантовая перспектива: что делать уже сейчас
Квантовый компьютер достаточного масштаба сломает всю асимметрику, основанную на факторизации и дискретном логарифме (алгоритм Шора), — RSA, DH, ECDH, ECDSA, Ed25519. Симметрика и хеши пострадают мягче: алгоритм Гровера даёт квадратичное ускорение перебора, что эквивалентно потере половины бит стойкости, поэтому AES-256 остаётся приемлемым, а AES-128 переходит в разряд «на грани».
Практическая проблема называется harvest now, decrypt later: трафик и резервные копии, перехваченные сегодня, могут быть расшифрованы позже. Значит, данные с длительным сроком конфиденциальности требуют внимания уже сейчас. Стандартизовано в августе 2024 года: FIPS 203 — ML-KEM (ранее Kyber), механизм инкапсуляции ключа и замена ECDH; FIPS 204 — ML-DSA (ранее Dilithium), подпись общего назначения; FIPS 205 — SLH-DSA (ранее SPHINCS+), подпись на хешах как консервативный запасной вариант.
Реалистичный план для прикладной команды: инвентаризация (где используется асимметрика и с каким сроком жизни защищаемых данных — без этого миграция не планируется); криптоагильность (формат хранения и протоколы несут идентификатор алгоритма и версию, чтобы замена примитива не требовала переписывания — то же требование делает возможной обычную ротацию); гибридные схемы на транспорте (X25519 в паре с ML-KEM уже включены по умолчанию в браузерах и в ряде TLS-библиотек — если один компонент падёт, второй держит, подробности в статье Транспортная безопасность); и никаких самодельных PQC-реализаций — обновлять зависимости и включать готовые режимы, а не интегрировать референсные реализации вручную.
Ориентиры по срокам публикует сам NIST в черновике IR 8547: постепенный отказ от классической асимметрики к 2030 году и полный запрет к 2035-му для регулируемых систем. Для российского контура действуют собственные стандарты — ГОСТ Р 34.10-2012 (подпись), ГОСТ Р 34.11-2012 «Стрибог» (хеш), ГОСТ Р 34.12-2015 «Кузнечик» (блочный шифр). Вопрос о необходимости сертифицированных средств криптографической защиты решается не инженерным путём: это область регуляторных требований, и разбирать её нужно с юристами и профильным подразделением. Инженерная часть проста — не реализовывать эти алгоритмы самостоятельно, брать сертифицированные библиотеки и фиксировать версии.
Итог
Прикладная криптография сводится к небольшому набору решений, которые принимают один раз и не пересматривают по вдохновению.
- Не изобретайте. AEAD, HMAC, HKDF, Argon2id, X25519, Ed25519, HPKE. Собственная схема из правильных примитивов — почти всегда дефект.
- Шифрование без аутентификации — не шифрование. ECB и голый CBC в диффе означают, что проверку целостности либо забыли, либо сделали неправильно.
- Хеш — не MAC, MAC — не подпись, подпись — не шифрование. Каждый примитив даёт ровно одно свойство; смешение — источник половины ошибок.
- Ключ выводится, а не берётся: из пароля — медленным KDF, из ключевого материала — HKDF с явным
info, из ниоткуда — CSPRNG. - Nonce и соль не секретны, но обязаны быть уникальными. Повтор nonce обваливает конфиденциальность целиком.
- Ключи важнее алгоритмов. Генерация, хранение, разделение назначений, ротация, отзыв, уничтожение — это и есть криптосистема; всё остальное уже стандартизовано. И криптоагильность (
kid, версия схемы, идентификатор алгоритма в формате) закладывается сразу — именно она позволит мигрировать на постквантовые примитивы без переписывания хранилища.
Источники
- OWASP Cryptographic Storage Cheat Sheet, Key Management Cheat Sheet, Password Storage Cheat Sheet
- OWASP ASVS — раздел про криптографию как готовый набор требований к приёмке
- NIST: SP 800-38D (GCM), SP 800-57 Part 1 Rev. 5, SP 800-131A Rev. 2, SP 800-90A Rev. 1, SP 800-108 Rev. 1
- RFC: 2104 — HMAC, 5869 — HKDF, 8017 — PKCS#1 v2.2, OAEP и PSS, 8032 — EdDSA, 8439 — ChaCha20-Poly1305, 8452 — AES-GCM-SIV, 9106 — Argon2, 9180 — HPKE, 6979 — детерминированный ECDSA
- Постквантовые стандарты: FIPS 203, FIPS 204, FIPS 205
- Marc Stevens et al. The first collision for full SHA-1, 2017; Gaëtan Leurent, Thomas Peyrin. SHA-1 is a Shambles, 2020; ROBOT Attack, 2017
- Serge Vaudenay. Security Flaws Induced by CBC Padding, EUROCRYPT 2002 — первоисточник про padding-оракул
- Jean-Philippe Aumasson. Serious Cryptography, 2nd ed., No Starch Press, 2024; Niels Ferguson, Bruce Schneier, Tadayoshi Kohno. Cryptography Engineering, Wiley, 2010
- Дэн Бонэ, Виктор Шоуп. A Graduate Course in Applied Cryptography — свободно доступный учебник с формальными определениями стойкости
- CWE: 327, 328, 329, 330, 338, 321, 326, 347, 208, 311
- Смежные материалы портала: Криптография в блокчейне, Кошельки и ключи, Безопасность в пайплайне
Что дальше
Мы разобрали примитивы и то, как собрать из них правильную конструкцию внутри приложения. Дальше — самая массовая криптосистема на планете: TLS. Как устроено рукопожатие, что на самом деле проверяет клиент в сертификате, зачем нужен взаимный mTLS между сервисами и почему пиннинг чаще ломает приложение, чем спасает его.