Криптография из stdlib без самодеятельности: argon2 для паролей, константное время и инвариант nonce в GCM
Пароли — argon2id или bcrypt с целью ~100мс, никогда голый sha256. Секреты сравниваются через subtle.ConstantTimeCompare: ранний выход равенства течёт длиной префикса. Ключи — из crypto/rand; AES-GCM держится на уникальности nonce — повтор отдаёт ключ аутентификации.
Уведомление об утечке ушло через восемь дней после того, как дамп базы всплыл на форуме. Команда чувствовала себя в безопасности: API-ключи никогда не хранились открытым текстом — каждый прогонялся через sha256 перед записью в таблицу keys. Потом клиент сообщил о списаниях по ключу, который якобы был защищён. Пост-мортем восстановил атаку за один вечер. Ключи состояли из 28 символов: фиксированный продуктовый префикс плюс короткий суффикс от math/rand, засеянного один раз на старте процесса — около 40 бит реальной энтропии. Атакующий перебрал это пространство против слитых дайджестов на скорости GPU (sha256 считается примерно по десять миллиардов попыток в секунду на одной карте) и за ночь восстановил большинство активных ключей. Любое из двух однострочных исправлений убило бы атаку: генерировать ключи из crypto/rand с 256 битами энтропии — или хэшировать низкоэнтропийные через argon2id, чтобы каждая попытка стоила 100 миллисекунд вместо 100 пикосекунд. У команды не было ни того ни другого — потому что sha256 ощущался как хэширование.
Каждой задаче — свой примитив
Когда на код-ревью вы видите задачу с секретом — хранение пароля, генерация токена, сравнение — правильный вопрос не «верна ли эта криптография?», а «тот ли это примитив для данной задачи?». Именно этот рефлекс тренирует урок.
Сеньорская крипто-дисциплина в Go — это не «знать криптографию», а «отказаться её изобретать». Одобренная поверхность — дерево crypto/* из stdlib плюс golang.org/x/crypto, который сопровождает та же команда с той же планкой качества. Каждая задача с секретами в сервисе отображается ровно в один из горстки ответов, и инженерная работа — это маршрутизация, а не дизайн: хранение паролей, генерация токенов, сравнение секретов, шифрование данных, TLS. В момент, когда дизайн требует составить примитивы так, как ни один stdlib-API напрямую не предлагает — шифровать самодельным режимом, выводить ключи цепочкой sha256 на коленке, — это сигнал остановиться и позвать дизайн-ревью, потому что ошибка на этом слое тихая: код работает, тесты зелёные, а провал виден только в чужом разборе инцидента.
Пароли: стоимость и есть фича
sha256 и sha512 спроектированы быстрыми — в этом весь смысл для подписей и проверок целостности, и в этом же фатальный изъян для паролей. Один современный GPU считает порядка 10–20 миллиардов SHA-256 в секунду; восьмисимвольный человеческий пароль падает за минуты, с солью или без (соль ломает заранее посчитанные таблицы, но никак не перебор по конкретной цели). Парольные хэш-функции переворачивают цель: они намеренно дорогие. Ответ stdlib живёт в x/crypto — bcrypt, scrypt и argon2, причём argon2id сегодня рекомендация по умолчанию (RFC 9106 предлагает 64 МиБ памяти, 1 проход, параллелизм 4):
import "golang.org/x/crypto/argon2"
func hashPassword(password, salt []byte) []byte {
// Первая рекомендация RFC 9106: 1 проход, 64 МиБ, 4 потока, ключ 32 байта.
// Подбирайте параметры, пока один вызов не стоит ~100мс-1с на прод-железе.
return argon2.IDKey(password, salt, 1, 64*1024, 4, 32)
}Честное правило тюнинга: целиться примерно в 100 мс – 1 с на хэш на реальных серверах, а затем посчитать память. Стоимость по памяти — фича против GPU (64 МиБ на попытку душат их параллелизм) и ловушка для вас: 50 одновременных логинов по 64 МиБ — это 3,2 ГБ на одном поде. Продовые ответы — очередь или rate limit на логины, а не снижение памяти до уровня, где атакующему снова удобно. У той же ловушки есть рантайм-специфичная форма в других экосистемах — crypto.scrypt в Node по умолчанию ограничен 32 МиБ через maxmem и отвергает более крупные параметры, — но в x/crypto/scrypt в Go никакого предохранителя нет вовсе: он аллоцирует 128 * N * r байт на вызов и спокойно даст незатроттленному эндпоинту логина уронить ваш же сервис по OOM. Храните параметры рядом с хэшем (стандартные кодированные форматы это делают) и перехэшируйте при успешном логине, когда параметры устаревают.
Коллега предлагает хранить пароли как sha256(salt + password), аргументируя тем, что соль ломает rainbow-таблицы. В чём реальная оставшаяся слабость?
Сравнивайте секреты за константное время
Прежде чем написать == для сравнения токена или API-ключа, спросите себя: сможет ли кто-то измерить, сколько времени занимает это сравнение? При достаточном числе запросов — сможет.
Обычный == на строках и bytes.Equal выходят на первом отличающемся байте. Время сравнения становится пропорциональным длине совпавшего префикса — это измеримый сигнал. Атака конкретна: против проверки токена атакующий фиксирует первый байт, пробует все 256 значений и оставляет то, у которого медиана ответа медленнее; затем второй байт, и так далее. 32-байтовый секрет падает примерно за 256 * 32 направленных попыток вместо 2^256 — тысячи запросов, а не вечность. Сетевой джиттер не спасает: он усредняется за несколько тысяч сэмплов на попытку в LAN или между соседними сервисами.
import (
"crypto/sha256"
"crypto/subtle"
)
func tokenEqual(presented, stored []byte) bool {
// Сначала хэшируем обе стороны: входы становятся равной длины,
// и ранний выход ConstantTimeCompare при несовпадении длин
// уже ничего не сообщает о самом секрете.
hp, hs := sha256.Sum256(presented), sha256.Sum256(stored)
return subtle.ConstantTimeCompare(hp[:], hs[:]) == 1
}subtle.ConstantTimeCompare просматривает каждый байт независимо от того, где расхождения — но мгновенно возвращает 0 при разной длине, что течёт длиной секрета. Предварительное хэширование обеих сторон, как выше, закрывает это и является стандартной идиомой для MAC, API-токенов и подписей вебхуков.
▸Почему это работает
Почему разница в доли микросекунды считается эксплуатируемой? Потому что количество сэмплов контролирует атакующий. Разница 0,5 мкс на байт невидима в одном запросе и однозначна в медиане из 50 000 — а внутренние сервисы, ретраи и мультиплексирование HTTP/2 дают атакующему ровно такие объёмы. Удалённые тайминг-атаки, восстанавливающие ключи через сеть, демонстрируются с начала 2000-х. Защита стоит один вызов функции, поэтому честная планка для её пропуска — «никогда»: вы не докажете, что топология деплоя останется шумной навсегда.
Случайность: crypto/rand для всего, что атакующий не должен угадать
math/rand v1 — детерминированный генератор с сидом: восстановите сид (часто это таймстемп) — и вы восстановили каждый токен, который он когда-либо выдал; это в точности утечка из приёма-крючка. Go 1.22 тихо поднял дно: math/rand и math/rand/v2 теперь работают на ChaCha8, засеянном энтропией ОС, так что случайное использование больше не мгновенно фатально. Нюанс, который стоит держать честным: это укрепление дефолта, а не контракт — пакет по-прежнему документирует себя как непригодный для безопасности, а явный сид возвращает полную предсказуемость. Правило остаётся одним предложением: ключи, токены, идентификаторы сессий, соли и nonce берутся из crypto/rand, точка. С Go 1.24 crypto/rand.Read документированно не может вернуть ошибку (программа падает вместо выдачи слабых байт), а rand.Text() даёт готовую случайную токен-строку.
AES-GCM: уникальность nonce — это инвариант
GCM — это шифрование в режиме CTR плюс аутентификатор GHASH, и обе половины зависят от одного инварианта: пара (ключ, nonce) используется ровно для одного сообщения. Повторите nonce — и один и тот же keystream шифрует два сообщения: XOR шифртекстов равен XOR открытых текстов, что против структурированных данных (JSON-тела, фиксированные заголовки) обычно раскрывает оба. Хуже того, повтор nonce раскрывает ключ аутентификации GHASH — классическая «запретная атака», — после чего атакующий подделывает шифртексты, которые ваш сервис аутентифицирует и принимает. Это катастрофический класс: не «одно сообщение ослаблено», а компрометация уровня ключа. Безопасный паттерн механичен:
func encrypt(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key) // ключ 32 байта -> AES-256
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize()) // 12 байт
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
// Seal дописывает к первому аргументу: на выходе nonce || шифртекст || тег,
// чтобы при расшифровке nonce можно было отделить от начала.
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}Случайные 12-байтовые nonce безопасны до границы дней рождения: рекомендация NIST ограничивает один ключ 2^32 сообщениями при случайных nonce. Для большинства сервисов это недостижимо с запасом; для высоконагруженных пайплайнов это требование ротации ключей, которое записывают заранее, а не обнаруживают.
Сервис шифрует сообщения AES-GCM, но переиспользует один nonce для всех сообщений под одним ключом. Что реально получает атакующий, перехватив трафик?
TLS: ничего не делайте — и проверьте, что ничего не сделали
Дефолты TLS в Go хорошие: современные минимальные версии, разумные сайферсьюты, полная проверка сертификатов. Продовый режим отказа — не слабые дефолты, а люди, которые их понижают: InsecureSkipVerify: true, добавленный «временно» под самоподписанный стейджинговый сертификат, уезжает в прод и молча отключает весь смысл TLS. Дешёвый и действенный контроль — grep-тест: CI падает на любом InsecureSkipVerify: true вне тестовых файлов. Легитимная нужда, которую он обычно маскирует — доверие внутреннему CA, — имеет правильный ответ: добавить CA в RootCAs клиентской конфигурации. Тот же скепсис — к скопированным блокам CipherSuites и MinVersion из блогов десятилетней давности: их удаление обычно и есть апгрейд безопасности.
- 01Объясни механизм тайминг-атаки на сравнение токена через == и полное stdlib-исправление.
- 02Сформулируй инвариант nonce в AES-GCM, что ломается при его нарушении и безопасный продовый паттерн вместе с его пределом.
Дисциплина этого урока — отказ: каждая задача с секретами маршрутизируется в примитив stdlib или x/crypto, используемый ровно как задокументировано, а всё, что требует ручной композиции примитивов, — триггер дизайн-ревью, а не задача на кодинг. Пароли — самый ясный случай: sha256 быстр по дизайну, GPU перебирает десятки миллиардов в секунду, а соль лишь разделяет цели — поэтому хранение значит argon2id (или bcrypt), настроенный примерно на 100мс–1с на хэш, с честной арифметикой памяти: 64 МиБ на каждый одновременный логин умножаются в гигабайты под всплеском, и отвечать на это нужно rate-limit-ом, а не ослаблением параметров. Сравнение секретов никогда не использует == или bytes.Equal: ранний выход делает время пропорциональным совпавшему префиксу, и несколько тысяч сэмплов на попытку превращают это в побайтовое восстановление; subtle.ConstantTimeCompare поверх sha256-дайджестов обеих сторон закрывает и тайминг-канал, и утечку длины. Случайность для всего состязательного — ключей, токенов, солей, nonce — идёт из crypto/rand, хотя Go 1.22 перевёл math/rand на ChaCha8; этот апгрейд — укреплённый дефолт, а не контракт безопасности. AES-GCM удерживает конфиденциальность и аутентичность, только пока каждая пара (ключ, nonce) шифрует одно сообщение: повтор течёт XOR-ами открытых текстов и ключом GHASH, открывая подделку, поэтому паттерн — свежий случайный 12-байтовый nonce, приклеенный к каждому шифртексту, и ротация ключа до бюджета дней рождения в 2^32 сообщений. TLS не требует от вас ничего, кроме сдержанности — оставьте дефолты, держите grep по InsecureSkipVerify в CI, а доверие внутреннему CA решайте через RootCAs, а не отключением проверки. Теперь, когда встретишь sha256 на поле пароля, голый == на токене или nonce из счётчика вместо crypto/rand, — ты знаешь ровно, какой примитив взять и почему неверный уже является инцидентом в ожидании разбора.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.