2. bcrypt, argon2 и выбор алгоритма
Эволюция и выбор: почему нет серебряной пули
«Есть два способа проектирования программ: сделать систему настолько простой, чтобы в ней явно не было недостатков, либо настолько сложной, чтобы в ней не было явных недостатков».
— C.A.R. Hoare
Выбор криптографического алгоритма хэширования паролей — это вечный инженерный компромисс между теоретической стойкостью к атакам, накладными расходами на сервере и устойчивостью к параллельному взлому на специализированном аппаратном обеспечении (GPU, FPGA, ASIC).
Сегодня общепризнанным стандартом индустрии является Argon2id, однако bcrypt продолжает нести боевое дежурство в миллионах существующих систем. Понимание их внутреннего устройства, микроархитектурных различий (L1-кэш против пропускной способности DRAM) и поведения в планировщике рантайма Go жизненно необходимо каждому бэкенд-инженеру для осознанного проектирования подсистемы аутентификации.
flowchart TD
A["Вход: пароль + соль"] --> B["Инициализация состояния"]
B --> C["Экспоненциальное расширение ключа"]
C --> D["Шифрование константы"]
D --> E["Вывод: 184 бита хеша"]
style A fill:#132d29,stroke:#2dd4bf,color:#f8fafc
style C fill:#78350f,stroke:#fbbf24,color:#f8fafc
bcrypt: Наследие Blowfish и экспоненциальная сложность
Алгоритм bcrypt был создан в 1999 году Нильсом Провосом и Дэвидом Мазьером для операционной системы OpenBSD. В его основу лег блочный шифр Blowfish Брюса Шнайера, модифицированный в схему с расширением ключа под названием Eksblowfish (Exact Key Setup Blowfish).
Главное достоинство bcrypt — регулируемый параметр сложности cost. Инициализация внутреннего состояния требует ровно 2^cost ($2^{\text{cost}}$) итераций шифрования. Каждое увеличение параметра cost на единицу строго удваивает время вычисления хэша на процессоре.
Механика работы: Алгоритм оперирует внутренними таблицами подстановок (S-boxes) суммарным объемом около 4 КБ. При инициализации ключа таблицы многократно перезаписываются псевдослучайными значениями, после чего зашифровывается 192-битная тестовая константа "OrpheanBeholderScryDoubt".
Объем рабочего состояния в 4 КБ идеально укладывается в быстрый кэш первого уровня L1d современных процессоров (обычно 32–48 КБ на ядро). В Go реализация находится в официальном расширении стандартной библиотеки golang.org/x/crypto/bcrypt (включая функцию bcrypt.GenerateFromPassword). Она написана на чистом Go, что гарантирует мгновенную кросс-компиляцию под любую архитектуру, но не задействует аппаратные инструкции SIMD так агрессивно, как ассемблерные сишные аналоги.
Argon2: Память, потоки и устойчивость к GPU
В 2015 году международный консилиум криптографов завершил конкурс Password Hashing Competition (PHC). Безоговорочным победителем стал алгоритм Argon2, позже стандартизированный IETF в спецификации RFC 9106.
Argon2 создавался с прицелом на нейтрализацию современных ферм GPU и заказных чипов ASIC. Для этого алгоритм ввел три независимых параметра масштабирования:
- Объем оперативной памяти (
memory): Количество килобайт RAM, выделяемых под массив блоков. - Параллелизм (
parallelism/threads): Количество независимых вычислительных дорожек (lanes). - Время (
time/iterations): Количество итерационных проходов по памяти.
Алгоритм существует в трех модификациях:
Argon2d(Data-dependent): Порядок адресации памяти зависит от значений в предыдущих блоках. Это дает максимальную защиту от атак на GPU, но теоретически уязвимо к атакам по сторонним каналам (Side-Channel Cache Timing Attacks). Применяется для PoW в криптовалютах.Argon2i(Data-independent): Порядок обхода памяти генерируется независимым генератором. Устойчив к утечкам через кэш CPU, но требует больше проходов против атак на GPU.Argon2id(Hybrid): Золотая середина. На первом проходе ведет себя какArgon2i, защищая от атак по времени, а на последующих проходах переключается в режимArgon2d, связывая руки GPU. Строго рекомендуется для хэширования паролей.
flowchart LR
A["Параметры: память, потоки, итерации"] --> B["Выделение массива памяти"]
B --> C["Заполнение блоков: зависимость от предыдущих"]
C --> D["Сжатие: Blake2b хеширование"]
D --> E["Финальный хеш"]
style B fill:#7f1d1d,stroke:#f87171,color:#f8fafc
style D fill:#132d29,stroke:#2dd4bf,color:#f8fafc
Механика сжатия: Алгоритм аллоцирует непрерывную матрицу 1-килобайтных блоков. Внутри блоков применяется сверхбыстрая функция перестановки и сжатия на базе криптографического примитива Blake2b. Это превращает алгоритм в классическую memory-bound задачу: скорость вычисления упирается не в гигагерцы тактовой частоты процессора, а в физическую пропускную способность шины оперативной памяти DDR4/DDR5.
Механическое сочувствие: влияние на CPU, кэш и планировщик
Проверка паролей — тяжелейший стресс-тест для рантайма Go. Рассмотрим анатомию физических процессов в процессоре:
- Escape Analysis и давление на кучу: Выделение памяти под массив Argon2 (например, 64 МБ) гарантированно уходит в кучу (heap). При пиковой нагрузке в 100 RPS это генерирует гигабайты временных аллокаций. Сборщик мусора Go перегружается (происходит череда циклов
Minor GCи затяжные фазы Stop-The-World), вымывая строки кэша L1/L2. - Аппаратные промахи кэша (CPU Cache Thrashing): В то время как 4 КБ таблиц bcrypt уютно помещаются в L1-кэше ядра процессора, 64 МБ Argon2 физически не влезают даже в общий кэш третьего уровня L3 (обычно 16–32 МБ на сокет). Процессор непрерывно простаивает в циклах ожидания выборки из контроллера DRAM (Memory Stall Cycles), резко снижая показатель IPC (Instructions Per Cycle).
- Нагрузка на планировщик GMP: Массовый одновременный запуск горутин с Argon2 парализует планировщик. Пул системных потоков $M$ разрастается, вызывая жесткую конкуренцию за ядра процессора.
// ✅ Промышленная обертка: ограничение параллелизма через семафор и пул буферов
type PasswordHasher struct {
sem chan struct{} // Семафор для ограничения одновременных тяжелых расчетов
pool *sync.Pool // Пул временных буферов для разгрузки GC
}
func NewHasher(maxParallel int) *PasswordHasher {
return &PasswordHasher{
sem: make(chan struct{}, maxParallel),
pool: &sync.Pool{
New: func() any {
// Предвыделенный буфер памяти
b := make([]byte, 64*1024)
return &b
},
},
}
}
func (h *PasswordHasher) Hash(ctx context.Context, password []byte) (string, error) {
// 🔒 Ограничиваем число одновременно работающих ядер CPU
select {
case h.sem <- struct{}{}:
defer func() { <-h.sem }()
case <-ctx.Done():
return "", ctx.Err()
}
salt := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return "", fmt.Errorf("salt gen: %w", err)
}
// 🔒 Вычисление Argon2id: 3 итерации, 64 МБ памяти, 4 потока, 32 байта хэша
hash := argon2.IDKey(password, salt, 3, 64*1024, 4, 32)
// Сериализация и безопасная очистка...
return fmt.Sprintf("$argon2id$v=19$m=65536,t=3,p=4$%s", base64.RawStdEncoding.EncodeToString(hash)), nil
}
Практический выбор и настройка под железо
| Инженерный критерий | bcrypt | argon2id |
|---|---|---|
| Зрелость и проверка временем | 25+ лет в строю, дефолт в Unix | 9+ лет, официальный победитель PHC |
| Аппаратная стойкость | Средняя (уязвима для специализированных GPU-ферм) | Абсолютная (Memory-Hard барьер) |
| Многопоточность | Однопоточный (1 ядро CPU на хэш) | Настраиваемый параллелизм (использует $N$ ядер) |
| Лимит длины пароля | Критический дефект: обрезает до 72 байт | Без ограничений по длине |
| Рекомендация | Поддержка легаси-баз данных | Все новые проекты, высокие требования безопасности |
Инженерный чеклист подбора параметров Argon2id для продакшена:
- Базовый профиль:
memory=64MB(memory = 64 * 1024КБ RAM),iterations=3,parallelism=4. - Бенчмарк под нагрузкой: Запустите тест
go test -benchна сервере с целевыми CPU. Добейтесь времени вычисления в диапазоне 150–300 мс на операцию. - Баланс памяти: Увеличивайте объем
memoryдо тех пор, пока хватает свободной оперативной памяти ноды Kubernetes без риска ухода в своп. - Ограничение рантайма: Установите
GOMEMLIMITи защитите эндпоинт авторизации семафором, чтобы отсечь атаки на исчерпание памяти. - Внешняя конфигурация: Выносите параметры KDF в конфигурационные файлы приложения (ConfigMap), а не хардкодьте в коде.
Итог
- bcrypt остается надежным рабочим инструментом для существующих систем, но фундаментально уступает современным стандартам из-за жесткого лимита длины пароля в 72 байта и отсутствия требований к оперативной памяти.
- Argon2id — золотой стандарт криптографии паролей (RFC 9106). Его гибридный дизайн нейтрализует параллельные вычисления на GPU и ASIC за счет принудительного использования пропускной способности оперативной памяти (Memory Hardness).
- Механическое сочувствие к рантайму: Хэширование паролей — тяжелейшая операция. В Go она должна защищаться семафорами ограничения параллелизма, переиспользованием буферов через
sync.Poolи контролем мягкого лимита памятиGOMEMLIMIT. - Стратегия ленивой миграции: Обновляйте алгоритмы и параметры сложности прозрачно для пользователя в момент успешного входа в систему.
- Сравнение только за константное время: Функция
crypto/subtle.ConstantTimeCompare— единственный легитимный способ проверки подлинности криптографических хэшей.
Мы завершили детальный разбор хранения паролей. Но как передавать полномочия пользователя между микросервисами после успешной аутентификации?
В следующей лекции мы вскроем анатомию самого популярного формата распределенных токенов: 3. JWT. Устройство и подводные камни.