Безопасность приложений Прикладная криптография: симметрика, асимметрика, хеши, подписи, KDF
0%

Прикладная криптография: симметрика, асимметрика, хеши, подписи, KDF

Прикладная криптография: симметрика, асимметрика, хеши, подписи, 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-байтовый блок независимо и детерминированно: одинаковые блоки открытого текста дают одинаковые блоки шифртекста. Для структурированных данных по шифртексту читаются структура и повторы, а блоки переставляются, вырезаются и склеиваются из разных записей. Отсутствие тега означает, что любое изменение байт останется незамеченным и приложение примет мусор за данные.

Режимы блочного шифра: ECB, CBC, CTR и что добавляет AEAD

Отдельная ловушка — «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 байт тега.

Раскладка AEAD-сообщения: метаданные, nonce, шифртекст, тег и роль AAD

Три вещи важнее выбора шифра:

  1. Nonce уникален для пары «ключ и сообщение». Для AES-GCM со случайным 96-битным nonce безопасный предел — около 2 в 32 степени сообщений на один ключ (та же вероятность коллизии по дням рождения). Дальше нужна ротация. Если сообщений заведомо много, берите XChaCha20-Poly1305 с 192-битным случайным nonce или AES-GCM-SIV (RFC 8452), где повтор nonce не обваливает схему, а лишь раскрывает факт повтора текста.
  2. AAD связывает шифртекст с его местом. Без него валидный шифртекст переносится в чужую строку или чужому арендатору: ключ тот же, тег сойдётся. В AAD кладут tenant_id, имя таблицы и колонки, идентификатор строки, версию схемы.
  3. Тег проверяется до расшифровки. Никогда не пишите «расшифруем, а потом сверим» — библиотека уже делает правильно, задача в том, чтобы не сломать это ручной оптимизацией.

Выбор между 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 решают три разные задачи

Самая частая путаница в прикладной криптографии. «Получить ключ» — это три разные задачи с тремя разными инструментами.

# УЯЗВИМО: три классические ошибки в трёх строках.
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), не логировать структуры целиком, а в критичных местах использовать нативные буферы.

Дисциплина, которая важнее алгоритма:

  1. Один ключ — одно назначение. Ключ шифрования полей не подписывает вебхуки; разделение через HKDF с разным info или через разные ключи в KMS.
  2. Криптопериод задан явно. NIST SP 800-57 рекомендует ограничивать срок использования ключа для шифрования и отдельно — срок, в течение которого он ещё может расшифровывать.
  3. Ротация отрепетирована. Ключ, который «нельзя ротировать без даунтайма», не ротируют никогда; проверяется учением на staging.
  4. Ключи не лежат в репозитории, в образе и в конфиг-мапах — см. Секреты и ключи.
  5. Разделение обязанностей. У того, кто имеет доступ к дампу базы, не должно быть права kms:Decrypt на соответствующий KEK — иначе конвертное шифрование защищает только от потери диска.
  6. Аварийная процедура написана заранее: отзыв, ротация, перешифрование, оценка того, что могло быть расшифровано, уведомления.

Шифрование в хранилище: что оно реально даёт

Здесь чаще всего возникает разрыв между галочкой в отчёте и реальной защитой.

Уровень От чего защищает От чего НЕ защищает
Шифрование диска, 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, просроченный ключ.

Сводный список того, что чаще всего встречается на ревью:

  1. Ключ в репозитории или в образе (CWE-321) — ловится секрет-сканером в CI (см. Безопасность цепочки поставок).
  2. Повтор nonce — особенно при горизонтальном масштабировании со счётчиком в памяти процесса или при восстановлении состояния из бэкапа.
  3. Шифрование вместо хеширования пароля: обратимость означает, что компрометация ключа раскрывает все пароли сразу.
  4. Хеш вместо HMAC для индексов, идентификаторов и подписей.
  5. Проверка подписи, результат которой игнорируется: verify() возвращает False, а код продолжает работу, или исключение проглочено except Exception: pass.
  6. Алгоритм берётся из проверяемых данных: alg: none, выбор кривой из заголовка, версия схемы без белого списка.
  7. Собственный протокол поверх примитивов — «зашифруем, потом подпишем, потом ещё раз зашифруем»: стойкость такой комбинации никто не доказывал.
  8. Нет kid и версии схемы в формате хранения: ротация и миграция становятся невозможными.
  9. Логирование ключей и открытых текстов — трассировки, метрики с лейблами, отладочные дампы запросов.
  10. Ошибка расшифровки обрабатывается как «вернуть пустое значение»: нарушение целостности — это инцидент, а не пустая строка.

Как убедиться, что в проекте всё в порядке

Чеклист для ревью и внутреннего аудита. Все проверки выполняются на собственных системах или при письменном разрешении владельца.

  • Составлен инвентарь: где какой примитив, какой ключ, где хранится, кто может им пользоваться — это же основа будущей постквантовой миграции
  • В коде нет 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 «Кузнечик» (блочный шифр). Вопрос о необходимости сертифицированных средств криптографической защиты решается не инженерным путём: это область регуляторных требований, и разбирать её нужно с юристами и профильным подразделением. Инженерная часть проста — не реализовывать эти алгоритмы самостоятельно, брать сертифицированные библиотеки и фиксировать версии.

Итог

Прикладная криптография сводится к небольшому набору решений, которые принимают один раз и не пересматривают по вдохновению.

  1. Не изобретайте. AEAD, HMAC, HKDF, Argon2id, X25519, Ed25519, HPKE. Собственная схема из правильных примитивов — почти всегда дефект.
  2. Шифрование без аутентификации — не шифрование. ECB и голый CBC в диффе означают, что проверку целостности либо забыли, либо сделали неправильно.
  3. Хеш — не MAC, MAC — не подпись, подпись — не шифрование. Каждый примитив даёт ровно одно свойство; смешение — источник половины ошибок.
  4. Ключ выводится, а не берётся: из пароля — медленным KDF, из ключевого материала — HKDF с явным info, из ниоткуда — CSPRNG.
  5. Nonce и соль не секретны, но обязаны быть уникальными. Повтор nonce обваливает конфиденциальность целиком.
  6. Ключи важнее алгоритмов. Генерация, хранение, разделение назначений, ротация, отзыв, уничтожение — это и есть криптосистема; всё остальное уже стандартизовано. И криптоагильность (kid, версия схемы, идентификатор алгоритма в формате) закладывается сразу — именно она позволит мигрировать на постквантовые примитивы без переписывания хранилища.

Источники

Что дальше

Мы разобрали примитивы и то, как собрать из них правильную конструкцию внутри приложения. Дальше — самая массовая криптосистема на планете: TLS. Как устроено рукопожатие, что на самом деле проверяет клиент в сертификате, зачем нужен взаимный mTLS между сервисами и почему пиннинг чаще ломает приложение, чем спасает его.

Транспортная безопасность: TLS, сертификаты, mTLS, пиннинг

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов