bcrypt для Go: полное руководство по хешированию паролей
Безопасность пользовательских данных — одна из ключевых задач современной веб-разработки. Хранение паролей в открытом виде недопустимо, а простое хеширование через MD5 или SHA-1 давно признано небезопасным из-за уязвимости к атакам перебором. Библиотека bcrypt для Go предоставляет надёжный и проверенный временем алгоритм адаптивного хеширования паролей, который устойчив как к атакам по словарю, так и к атакам с использованием радужных таблиц.
Алгоритм bcrypt был разработан в 1999 году Нилом Провосом и Дэвидом Мазьером на основе шифра Blowfish. Его ключевая особенность — адаптивная сложность: вы можете увеличивать количество итераций хеширования, делая перебор паролей всё более затратным по мере роста вычислительных мощностей. В Go-экосистеме стандартная библиотека golang.org/x/crypto/bcrypt является официальным расширением и поддерживается командой разработчиков Go.
В этой статье мы подробно разберём все аспекты работы с bcrypt в Go: от установки и базового использования до продвинутых техник и типичных ошибок. Вы узнаете, как правильно хешировать пароли, сравнивать их с хешами, выбирать оптимальную стоимость (cost) и интегрировать bcrypt в реальные приложения.
Установка
Библиотека bcrypt для Go не входит в стандартную библиотеку, но является частью официального подмодуля x/crypto. Установка выполняется одной командой:
go get golang.org/x/crypto/bcrypt
После выполнения этой команды в вашем go.mod появится зависимость, и вы сможете импортировать пакет в свой код:
import "golang.org/x/crypto/bcrypt"
Убедитесь, что у вас установлен Go версии 1.13 или выше (рекомендуется 1.21+). Если вы используете модули (а в 2024 году это стандарт), зависимость подтянется автоматически.
Быстрый старт: минимальный рабочий пример
Давайте начнём с самого простого примера: хеширование пароля и его верификация. Это основа, которую вы будете использовать в любом проекте.
package main
import (
"fmt"
"log"
"golang.org/x/crypto/bcrypt"
)
func main() {
// Исходный пароль пользователя
password := []byte("mySecretPassword123!")
// Хешируем пароль со стоимостью по умолчанию (10)
hashedPassword, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Хеш пароля: %s\n", hashedPassword)
// Проверяем правильный пароль
err = bcrypt.CompareHashAndPassword(hashedPassword, password)
if err != nil {
fmt.Println("Пароль не совпадает!")
} else {
fmt.Println("✓ Пароль совпадает!")
}
// Проверяем неправильный пароль
wrongPassword := []byte("wrongPassword")
err = bcrypt.CompareHashAndPassword(hashedPassword, wrongPassword)
if err != nil {
fmt.Println("✗ Пароль не совпадает (как и ожидалось)")
}
}
Этот код делает три вещи: генерирует хеш, проверяет корректный пароль и убеждается, что неверный пароль отвергается. Обратите внимание: GenerateFromPassword автоматически генерирует случайную соль и встраивает её в хеш, поэтому каждый раз вы будете получать разные хеши для одного и того же пароля.
Основные методы и функции
Пакет bcrypt предоставляет небольшой, но очень продуманный API. Рассмотрим все основные функции с примерами.
1. GenerateFromPassword
Сигнатура: func GenerateFromPassword(password []byte, cost int) ([]byte, error)
Описание: Основная функция для хеширования пароля. Принимает пароль в виде среза байтов и стоимость (cost) — логарифмический показатель количества итераций. Возвращает хеш в стандартном формате bcrypt (содержит соль, стоимость и сам хеш).
package main
import (
"fmt"
"log"
"golang.org/x/crypto/bcrypt"
)
func main() {
password := []byte("userPassword123")
// Используем стоимость 12 (более безопасно, но медленнее)
hash, err := bcrypt.GenerateFromPassword(password, 12)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Длина хеша: %d байт\n", len(hash))
fmt.Printf("Хеш: %s\n", hash)
// Вывод: $2a$12$... (начинается с $2a$ или $2b$)
}
2. CompareHashAndPassword
Сигнатура: func CompareHashAndPassword(hashedPassword, password []byte) error
Описание: Сравнивает хеш с паролем. Возвращает nil, если пароль соответствует хешу, или ошибку bcrypt.ErrMismatchedHashAndPassword в противном случае. Важно: функция использует constant-time comparison для защиты от атак по времени.
func checkPassword(hashedPassword, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
return err == nil
}
func main() {
hash := "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
fmt.Println(checkPassword(hash, "correctPassword")) // true
fmt.Println(checkPassword(hash, "wrongPassword")) // false
}
3. Cost
Сигнатура: func Cost(hashedPassword []byte) (int, error)
Описание: Извлекает значение стоимости (cost) из существующего хеша. Полезно, если вы хотите проверить, не устарел ли хеш, и перехешировать пароль с более высокой стоимостью.
func main() {
hash := []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy")
cost, err := bcrypt.Cost(hash)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Текущая стоимость хеша: %d\n", cost)
// Если стоимость меньше 12, рекомендуем обновить
if cost < 12 {
fmt.Println("Рекомендуется обновить хеш с более высокой стоимостью")
}
}
4. DefaultCost
Сигнатура: const DefaultCost = 10
Описание: Константа, определяющая стоимость по умолчанию (10). Это хороший баланс между безопасностью и производительностью для большинства приложений. В 2024 году рекомендуется использовать стоимость 12-14 для новых проектов.
func main() {
fmt.Printf("Стоимость по умолчанию: %d\n", bcrypt.DefaultCost)
// Эквивалентные вызовы:
hash1, _ := bcrypt.GenerateFromPassword([]byte("pass"), bcrypt.DefaultCost)
hash2, _ := bcrypt.GenerateFromPassword([]byte("pass"), 10)
// hash1 и hash2 будут иметь одинаковую стоимость, но разные хеши (из-за соли)
fmt.Printf("hash1: %s\nhash2: %s\n", hash1, hash2)
}
5. MaxCost
Сигнатура: const MaxCost = 31
Описание: Максимально допустимое значение стоимости. На практике значения выше 20 делают хеширование чрезвычайно медленным (минуты на современном CPU). Используется для валидации входных параметров.
func validateCost(cost int) error {
if cost < bcrypt.MinCost || cost > bcrypt.MaxCost {
return fmt.Errorf("cost must be between %d and %d", bcrypt.MinCost, bcrypt.MaxCost)
}
return nil
}
func main() {
fmt.Printf("Максимальная стоимость: %d\n", bcrypt.MaxCost)
// Попытка использовать слишком высокую стоимость
_, err := bcrypt.GenerateFromPassword([]byte("test"), 32)
if err != nil {
fmt.Printf("Ошибка: %v\n", err) // bcrypt: cost out of range
}
}
6. MinCost
Сигнатура: const MinCost = 4
Описание: Минимально допустимое значение стоимости. Использование стоимости ниже 4 считается небезопасным. Обычно не используется на практике, но может пригодиться для тестов.
func main() {
// Быстрое хеширование для тестов (НЕ ИСПОЛЬЗОВАТЬ В ПРОДАКШЕНЕ!)
hash, _ := bcrypt.GenerateFromPassword([]byte("test"), bcrypt.MinCost)
fmt.Printf("Минимальная стоимость: %d\n", bcrypt.MinCost)
fmt.Printf("Хеш с минимальной стоимостью: %s\n", hash)
}
7. HashVersion
Сигнатура: const HashVersion = '$2a$' (также есть '$2b$' и '$2x$')
Описание: Префикс, определяющий версию алгоритма bcrypt. $2a$ — оригинальная версия, $2b$ — исправленная версия, которая корректно обрабатывает пароли длиннее 72 символов. Современная библиотека Go использует $2a$, но корректно работает со всеми версиями.
func main() {
hash, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
hashStr := string(hash)
fmt.Printf("Версия хеша: %s\n", hashStr[:4]) // $2a$
// Проверка, что хеш начинается с корректного префикса
if len(hashStr) > 4 && (hashStr[:4] == "$2a$" || hashStr[:4] == "$2b$") {
fmt.Println("Корректный формат bcrypt")
}
}
8. ErrHashTooShort
Сигнатура: var ErrHashTooShort = errors.New("bcrypt: hash is too short")
Описание: Ошибка, возвращаемая, если переданный хеш имеет недостаточную длину. Полезна для валидации входных данных.
func validateHash(hash string) error {
_, err := bcrypt.Cost([]byte(hash))
if errors.Is(err, bcrypt.ErrHashTooShort) {
return fmt.Errorf("некорректный хеш: слишком короткий")
}
return err
}
func main() {
err := validateHash("short")
if err != nil {
fmt.Printf("Ошибка валидации: %v\n", err)
}
err = validateHash("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy")
if err == nil {
fmt.Println("Хеш корректен")
}
}
9. ErrMismatchedHashAndPassword
Сигнатура: var ErrMismatchedHashAndPassword = errors.New("crypto/bcrypt: hashedPassword is not the hash of the given password")
Описание: Ошибка, возвращаемая функцией CompareHashAndPassword, когда пароль не соответствует хешу. Важно: не раскрывайте детали ошибки пользователю — просто сообщайте "неверный пароль".
func login(hashedPassword, providedPassword string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(providedPassword))
if err != nil {
// Безопасное логирование без раскрытия деталей
log.Printf("Ошибка аутентификации: %v", err)
return false
}
return true
}
Реальный практический пример: сервис аутентификации пользователей
Теперь давайте создадим полноценный сервис для регистрации и аутентификации пользователей. Этот пример включает работу с базой данных (в памяти для простоты), обработку ошибок и рекомендации по безопасности.
package main
import (
"errors"
"fmt"
"log"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// User представляет модель пользователя
type User struct {
ID int
Username string
PasswordHash string
CreatedAt time.Time
}
// UserStore — потокобезопасное хранилище пользователей в памяти
type UserStore struct {
mu sync.RWMutex
users map[string]*User
nextID int
}
// NewUserStore создаёт новое хранилище
func NewUserStore() *UserStore {
return &UserStore{
users: make(map[string]*User),
nextID: 1,
}
}
// RegisterUser регистрирует нового пользователя
func (s *UserStore) RegisterUser(username, password string) (*User, error) {
// Валидация входных данных
if len(username) < 3 || len(username) > 50 {
return nil, errors.New("username must be between 3 and 50 characters")
}
if len(password) < 8 {
return nil, errors.New("password must be at least 8 characters")
}
if len(password) > 72 {
return nil, errors.New("password must not exceed 72 characters (bcrypt limitation)")
}
s.mu.Lock()
defer s.mu.Unlock()
// Проверка, что пользователь не существует
if _, exists := s.users[username]; exists {
return nil, errors.New("username already exists")
}
// Хеширование пароля с адаптивной стоимостью
// В 2024 году рекомендуется cost = 12-14 для production
cost := 12