Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 9 из 25 ~18 мин +20 XP

Пароли: bcrypt вместо MD5

bcrypt в Go — стандартный ответ на вопрос, что делать с паролями пользователей: хеширование паролей через golang.org/x/crypto/bcrypt делает утечку базы дорогой для нападающего, а логин — простым для вас. Разбираем GenerateFromPassword и CompareHashAndPassword, соль внутри хеша, стоимость cost и почему MD5 и SHA256 для паролей не годятся вовсе.

Содержание урока · 9 разделов

Сценарий, от которого защищается весь урок: база утекла. Дамп попал в чужие руки вместе с колонкой users.password — и теперь единственное, что стоит между паролями пользователей и их взломом, — то, как вы эту колонку заполняли. Открытый текст: взломано мгновенно, все переустановки паролей и письма с извинениями не спасут репутацию. MD5 или SHA256: пароли не открытым текстом, но оба алгоритма проектировались быть быстрыми, и в этом их приговор: на видеокарте перебираются миллиарды вариантов в секунду, а словарь частых паролей прогоняется по дампу за минуты. Хеширование паролей golang решает это иначе: для паролей нужны медленные хеши с настройкой медленности и встроенной солью — bcrypt из официального расширения golang.org/x/crypto. Он ничего не шифрует: хеш нельзя развернуть обратно, а проверка работает пересчётом. В этом уроке — регистрация с GenerateFromPassword, вход с CompareHashAndPassword, разбор формата хеша и подбор стоимости. Здесь же и граница темы: JWT из прошлых уроков выдаётся только после того, как пароль сошёлся, — bcrypt это самое начало цепочки логина.

Регистрация: хешируем пароль перед сохранением

GO
func register(w http.ResponseWriter, r *http.Request) {
        var in struct {
                Login    string
                Password string
        }
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
                http.Error(w, "битое тело запроса", http.StatusBadRequest)
                return
        }

        hash, err := bcrypt.GenerateFromPassword(
                []byte(in.Password), bcrypt.DefaultCost,
        )
        if err != nil {
                http.Error(w, "не удалось хешировать пароль", http.StatusInternalServerError)
                return
        }

        err = saveUser(r.Context(), in.Login, string(hash))
        if err != nil {
                http.Error(w, "логин занят", http.StatusConflict)
                return
        }
        w.WriteHeader(http.StatusCreated)
}

GenerateFromPassword принимает пароль байтами и стоимость; внутри она сама генерирует случайную соль и возвращает готовый хеш строкой из шестидесяти символов — отдельную колонку под соль заводить не нужно. В базу идёт string(hash) и больше ничего: пароль в открытом виде не существует нигде, кроме полученного запроса.

bcrypt go: соль, cost и формат хеша#

Хеш bcrypt — строка вроде $2a$10$N9qo8uLOickgx2ZMRZoMye... — и она читается слева направо как анкета. Первые четыре символа — версия алгоритма, дальше два знака стоимости: десятка из примера выше — это cost. Затем двадцать два символа соли в собственной кодировке bcrypt и тридцать один символ собственно хеша: вместе ровно шестьдесят. Соль здесь — самое важное инженерное решение: она случайна для каждого вызова GenerateFromPassword, поэтому один и тот же пароль двух пользователей даёт два разных хеша, и радужные таблицы, заранее посчитанные для миллиона популярных паролей, становятся бесполезны — нападающему пришлось бы считать их заново для каждой соли. Стоимость cost управляет медленностью: это степень двойки в раундах подготовки ключа, каждый плюс один к cost удваивает время хеширования. В пакете заданы границы: bcrypt.MinCost, bcrypt.DefaultCost и bcrypt.MaxCost; по умолчанию берётся десятая стоимость — десятки миллисекунд на типичном сервере, точное число меряйте у себя. Границы пакета защищают и от наивной экономии: cost ниже минимума или выше максимума вернёт ошибку недопустимой стоимости.

Вход: CompareHashAndPassword и пересчёт#

Проверка пароля не разворачивает хеш — она пересчитывает. CompareHashAndPassword читает из сохранённого хеша соль и стоимость, прогоняет присланный пароль через те же шаги и сравнивает результаты: совпало — ошибка nil, не совпало — bcrypt.ErrMismatchedHashAndPassword. Из механики пересчёта следуют три практических правила. Первое: никогда не сравнивайте хеши сами — ни строками, ни повторным хешированием с ручной сверкой: пакет сравнивает в постоянном времени, чтобы утечка по времени не подсказывала наполовину угаданные символы; собственное сравнение этот слой защиты выбрасывает. Второе: соль отдельно не хранят и не передают — она уже внутри хеша, и второй колонки в схеме таблицы не заводить. Третье: ответ на неверный логин и неверный пароль — одинаковый, «неверный логин или пароль» со статусом 401: уточнение «такого пользователя нет» превращает эндпоинт в перечислитель зарегистрированных логинов. Ошибку пакета наружу не пробрасываем — она для лога, а не для клиента; честные ответы API уже разбирались в уроке про ошибки токенов, и принцип здесь тот же.

Логин: сверка пароля с хешем

GO
func login(w http.ResponseWriter, r *http.Request) {
        var in struct {
                Login    string
                Password string
        }
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
                http.Error(w, "битое тело запроса", http.StatusBadRequest)
                return
        }

        hash, err := loadHash(r.Context(), in.Login)
        if err != nil {
                http.Error(w, "неверный логин или пароль", http.StatusUnauthorized)
                return
        }
        err = bcrypt.CompareHashAndPassword(hash, []byte(in.Password))
        if err != nil {
                http.Error(w, "неверный логин или пароль", http.StatusUnauthorized)
                return
        }

        // пароль сошёлся: здесь выдаётся пара access/refresh из прошлого урока
        access, refresh, err := issuePair(loadUserID(r.Context(), in.Login), "user")
        if err != nil {
                http.Error(w, "не удалось выпустить пару", http.StatusInternalServerError)
                return
        }
        writeJSON(w, http.StatusOK, map[string]string{
                "access": access, "refresh": refresh,
        })
}

Обе ветки отказа отвечают одинаково: нет пользователя и не сошёлся пароль клиент не различает — это лишает перечислитель логинов обратной связи. Ошибка пакета уходит в лог, а не в ответ. На успехе выдаётся пара access/refresh из прошлого урока: bcrypt закрывает первый шаг логина, JWT — второй.

Цена хеширования и подбор cost под себя#

Медленность bcrypt — не побочный эффект, а платёж за безопасность, и её стоит измерить руками: тест с GenerateFromPassword на своём железе за минуту покажет, что DefaultCost стоит у вас пятьдесят или сто миллисекунд. Отсюда два практических следствия. Первое — бюджет входа: десятки миллисекунд на логин незаметны человеку, но заметны на пике нагрузки, поэтому генерацию хеша не ставят в горячий путь каждого запроса — она живёт в регистрации и смене пароля, а логин проверяет, что тоже стоит ресурсов, и ограничивается rate limiter-ом. Второе — рост стоимости со временем: железо дешевеет, и cost, безопасный пять лет назад, сегодня слабее; рабочая схема — при успешном входе сравнивать cost сохранённого хеша с текущим стандартом команды и, если он отстал, тихо пересчитывать хеш на новом cost. У bcrypt есть и жёсткий потолок: пароль длиннее семидесяти двух байт алгоритм не принимает, современные версии x/crypto возвращают на этом ошибку ErrPasswordTooLong, так что проверку длины лучше сделать до вызова, а не ловить её на регистрации. Честная оговорка про соседей по x/crypto: argon2id там же и считается современнее — он стойче к GPU-атакам по памяти, но bcrypt остаётся рабочей классикой с двадцатилетней историей и живёт во всех легаси-схемах; менять его на argon2 стоит осознанно, а не по привычке к новизне.

Вариация: переезд со SHA-256 без сброса паролей#

Наследство встречается чаще зелёного поля: в таблице лежат старые SHA-256, а сбрасывать пароли всех пользователей разом нельзя. Рабочий мост — двойное хеширование со флагом схемы в отдельной колонке. Новые записи живут как bcrypt от sha-256-хеша пароля; при входе код сначала считает SHA-256 от присланного пароля, потом сверяет его CompareHashAndPassword с сохранённым bcrypt-хешем. Старые записи проверяются прямым сравнением SHA-256, и каждый успешный вход по ним немедленно пересчитывается на bcrypt со снятием флага. Через пару месяцев активности флаги исчезают, старый столбец удаляется, и схема становится обычной. Граница метода: это временный переход, а не архитектура — держать два механизма вечно значит удваивать поверхность проверки, поэтому срок жизни моста фиксируется в задаче, а не в воспоминаниях.

Чем пароли хешировать: сравнение по сути#

АлгоритмСольМедленностьГодность для паролей
MD5нет в схеме паролеймикросекунды, ломается GPUнет: снят даже для контрольных сумм
SHA-256нужно городить самомубыстрый — это его назначениенет: скорость против вас
bcryptвстроена в хеш, случайнанастраивается cost, удвоение на единицуда: рабочая классика
argon2idвстроенанастраивается памятью и временемда: современный выбор x/crypto

Правила паролей, которые легко сломать#

  • Пароль не логируется никогда — ни на регистрации, ни на входе, ни в ошибках; в логах логин допустим, пароль нет.
  • Одинаковый ответ на незнакомый логин и неверный пароль: 401 и «неверный логин или пароль».
  • Соль не хранится отдельно: она внутри хеша bcrypt, вторая колонка не нужна.
  • Сравнение паролей — только CompareHashAndPassword: ручное сравнение строк выбрасывает защиту по времени.
  • Длина пароля проверяется до хеширования: потолок bcrypt — 72 байта, современные версии пакета вернут ErrPasswordTooLong.
  • Cost хеша растёт со временем: при входе старый слабый cost тихо пересчитывается на текущий.

Проверка: стоимость по умолчанию

Какой cost bcrypt использует по умолчанию в пакете x/crypto?

Проверка: два пользователя, один пароль

Два пользователя зарегистрировались с одинаковым паролем. Что лежит в базе?

Проверка: успешное сравнение

Что возвращает bcrypt.CompareHashAndPassword на совпавшем пароле?

Чужой пароль против хеша

Прочитайте код и выберите, что он выведет:

hash, _ := bcrypt.GenerateFromPassword(
        []byte("правильный-пароль"), bcrypt.DefaultCost)

err := bcrypt.CompareHashAndPassword(hash, []byte("другой-пароль"))
// что в err и что увидит клиент в login-хендлере урока?

Ссылка на предыдущий урок здесь прямая: пара access/refresh из урока про refresh-токены выдаётся в login-хендлере сразу после успешного CompareHashAndPassword — аутентификация по паролю и аутентификация по токену склеиваются именно на этой строке. Дальше логин обрастает обвязкой: в уроке про middleware аутентификации пара станет обязательной на защищённых маршрутах, а итоговый проект курса соберёт всё вместе — регистрацию с bcrypt, выдачу пары и роли. Для хранения хешей в настоящей базе пригодится курс по GORM — там модель User со строковым полем под шестидесятисимвольный хеш ложится на таблицу в одну строку. Карта всех тем — на странице курса jwt-go. Честная оговорка про запуск: bcrypt запускается и без сервера — локальный Go, go get golang.org/x/crypto/bcrypt и короткий тест генерации со сравнением покажут вам цену DefaultCost на вашем железе быстрее любого текста.

Допишите регистрацию и вход

Восстановите вызовы bcrypt: генерация хеша с дефолтной стоимостью и сравнение на входе.

// регистрация hash, err := bcrypt.(password, bcrypt.) // вход err = bcrypt.CompareHashAndPassword(hash, password)

Соберите проверку пароля на входе

Расставьте строки login-хендлера: от загрузки хеша до тихого отказа.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: bcrypt

Термин

bcrypt

нажмите, чтобы перевернуть
Определение

Медленный хеш для паролей из golang.org/x/crypto/bcrypt: встроенная случайная соль, стоимость cost, потолок пароля в 72 байта. Хеш — строка из 60 символов вида $2a$10$...

1 / 5

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • bcrypt из golang.org/x/crypto — медленный хеш для паролей: соль внутри строки, cost настраивает медленность, хеш занимает 60 символов.
  • Регистрация — GenerateFromPassword с DefaultCost; в базу идёт string(hash) и ничего больше.
  • Вход — CompareHashAndPassword: пересчёт по соли из хеша, совпадение даёт nil, расхождение — ErrMismatchedHashAndPassword.
  • Ручное сравнение хешей недопустимо: пакет сравнивает в постоянном времени против утечки по таймингу.
  • Одинаковый ответ на неверный логин и неверный пароль — без перечисления зарегистрированных логинов.
  • Потолок bcrypt — 72 байта пароля, ErrPasswordTooLong в современных версиях; cost растёт со временем пересчётом при входе.

Домашнее задание

Соберите регистрацию и логин с bcrypt локально: GenerateFromPassword на регистрации, CompareHashAndPassword на входе, пары access/refresh из прошлого урока на успехе. Измерьте время DefaultCost на своём железе, затем проверьте, что неверный пароль и незнакомый логин отвечают одинаково. Добавьте пересчёт хеша при входе, если cost хеша ниже текущего стандарта команды.

Личные заметкиПусто
Хранятся только в вашем браузере (JWT · Пароли: bcrypt вместо MD5)

Вопросы и ответы по уроку

bcrypt или argon2id — что выбрать для нового проекта?

Оба из x/crypto и оба рабочие: argon2id современнее и стойче к GPU-атакам за счёт требования памяти, bcrypt — классика с двадцатилетней историей и простыми настройками. Если команда уже живёт с bcrypt — менять без причины не нужно; для нового проекта argon2id оправдан, а главное в любом случае — не MD5 и не SHA-256.

Почему нельзя просто SHA-256 с солью?

SHA-256 быстрый по замыслу, и скорость играет против вас: миллион вариантов на видеокарте считаются мгновенно, соль лишь убивает радужные таблицы, но не сам перебор. Парольные хеши специально медленные и настраиваемые — bcrypt и argon2 придуманы именно для этого.

Что делать с паролем длиннее 72 байт?

bcrypt не принимает такие пароли, современные версии x/crypto возвращают ErrPasswordTooLong. Лучшее решение — проверять длину до хеширования и отдавать внятную ошибку. Обрезать пароль молча не стоит: пользователь думает, что защищён длинной фразой, а работает только её кусок.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Telegram25 уроков~12 чновичок25 уроков уже доступны

Telegram-боты на Go: от эха до полноценного сервиса

Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.

  • Создавать бота через BotFather и хранить токен безопасно
  • Получать обновления через long polling и webhook
  • Отвечать сообщениями, командами и клавиатурами
  • Строить многошаговые диалоги с сохранением состояния