Перевести хранилище паролей с MD5, SHA-1 или bcrypt с низким cost на Argon2id можно без единого письма пользователям. Старые хеши остаются в базе, а замена происходит в момент следующего успешного входа: система проверяет пароль прежним алгоритмом, а затем сразу сохраняет новый хеш. Такой подход называют ленивой миграцией, или перехешированием при входе.
Он опирается на три элемента в схеме данных: поле с алгоритмом, поле с версией хеша и ветку в коде аутентификации, которая решает, когда обновлять запись. Массовых рассылок и принудительных сбросов нет, а значит нет и всплеска обращений в поддержку.
Короткий ответ для тех, кому нужен только план: добавьте в таблицу пользователей поля password_algorithm, password_version и password_updated_at, научите код проверять пароль тем алгоритмом, который записан в поле, и перехешируйте его в Argon2id сразу после успешной проверки. Ниже - структура таблицы, пошаговая логика проверки, параметры Argon2id и bcrypt, разбор ошибок, план отката и чек-лист запуска.
Почему массовый сброс паролей - плохое решение
Сброс выглядит быстрым способом избавиться от слабых хешей: одна команда, и в базе нет ни одного MD5. На практике счет идет по часам работы людей.
Возьмем базу на 10 000 аккаунтов. Даже если в поддержку обратится 5% пользователей, это 500 тикетов. При 10 минутах на обращение получается больше 80 часов рабочего времени, то есть две недели работы одного инженера первой линии. Реальная доля обращений выше: часть пользователей не увидит письмо, часть введет старый пароль по привычке и упрется в блокировку после 5-10 неудачных попыток.
Блокировка аккаунтов бьет по бизнесу сильнее, чем кажется. В корпоративной среде за заблокированной учетной записью стоит остановленная работа сотрудника, в потребительском сервисе - потерянный заказ или бронь. Восстановление доступа через почту или службу поддержки добавляет вторую волну тикетов.
Фишинг. Массовая рассылка "сбросьте пароль по ссылке" создает привычку кликать по письмам сброса. Через месяц подделка такого письма собирает пароли без сопротивления, потому что пользователи уже натренированы действовать быстро, а сама страница входа выглядит знакомой.
Качество новых паролей падает. Принудительная смена выдает предсказуемые схемы: название сезона с годом, имя с цифрами, инкремент в конце прошлого пароля. Хеш в базе становится современным, а сам пароль - слабее прежнего.
Безопасность не растет автоматически. Если база не утекала, старые хеши не скомпрометированы, и слабость MD5 или SHA-1 реализуется только в сценарии утечки. Сброс меняет алгоритм хранения, но не устраняет причину, по которой хеши оказались за пределами сервера.
Рабочий компромисс: адресный сброс для аккаунтов, чьи пароли уже засветились в известных утечках, и ленивая миграция для всех остальных. Проверку по списку скомпрометированных паролей удобно делать при входе: если пароль найден в списке, требуем смену; если нет - перехешируем молча.
Что такое ленивая миграция хешей и как она работает
Механика укладывается в четыре шага при каждом входе. Пользователь отправляет логин и пароль. Приложение читает строку из таблицы и смотрит поле password_algorithm. Хеширование выполняется тем алгоритмом, который указан в поле. Если пароль верен и алгоритм устарел, приложение считает новый хеш, обновляет password_hash, password_algorithm, password_version и password_updated_at.
Важное свойство: перехеширование запускается только после успешной проверки. Злоумышленник не может заставить систему обновить чужой хеш, потому что при неверном пароле код выходит из функции раньше, на этапе сравнения. Это отличает ленивую миграцию от схем, где хеш пересчитывают по внешнему триггеру.
Аналогия - замена рельсов на действующей линии: движение не останавливается, каждый прошедший поезд оставляет за собой обновленный участок пути. Полное покрытие зависит от активности пользователей. Если 70% аудитории заходят раз в неделю, за две недели перехешируется большинство активных аккаунтов. Если сервис корпоративный и часть сотрудников заходит раз в месяц, полный охват растет до двух-трех месяцев и это нормально.
Неактивные аккаунты требуют отдельной политики, иначе их хеши останутся старыми навсегда. Вариантов три. Первый: при входе в аккаунт с устаревшим хешем и без признаков активности за последние 90 дней сначала требовать смену пароля. Второй: для учетных записей, привязанных к внешнему поставщику идентичности, локальный хеш просто удалить, проверка идет на стороне IdP. Третий: удалять заброшенные аккаунты по политике хранения данных, что снижает и поверхность атаки.
Долю перехешированных аккаунтов стоит считать заранее оговоренной метрикой. Без нее невозможно понять, завершилась миграция или зависла на 60 процентах и требует отдельного решения по спящим пользователям.
Структура таблицы для хранения хешей разных алгоритмов
Схема, которая одновременно хранит хеши MD5, SHA-1, bcrypt и Argon2id, отличается от классической тремя дополнительными полями.
| Поле | Тип | Назначение |
|---|---|---|
| password_hash | varchar(255) | Хеш в формате, который понимает библиотека проверки |
| password_algorithm | varchar(16) | md5, sha1, bcrypt, argon2id; по значению код выбирает функцию проверки |
| password_version | smallint | Номер поколения параметров; растет, когда вы поднимаете cost для bcrypt или memory cost для Argon2id |
| password_updated_at | timestamptz | Когда хеш обновлен; нужен для аудита и поиска зависших аккаунтов |
Длина поля. Для MD5 хватает 32 символов, для SHA-1 - 40, для bcrypt - 60, для строки Argon2id в формате PHC - около 100. Закладывайте 255 символов: запас стоит дешево и снимает проблему при смене параметров и переходе на другой формат кодирования соли.
ALTER TABLE users ADD COLUMN password_algorithm varchar(16) NOT NULL DEFAULT 'unknown', ADD COLUMN password_version smallint NOT NULL DEFAULT 0, ADD COLUMN password_updated_at timestamptz; UPDATE users SET password_algorithm = 'md5', password_version = 1 WHERE password_algorithm = 'unknown'; ALTER TABLE users ALTER COLUMN password_algorithm DROP DEFAULT; CREATE INDEX idx_users_pwd_algorithm ON users (password_algorithm);
Порядок применения такой же, как при любой правке схемы на живой базе: сначала добавляете поля со значениями по умолчанию, затем одним UPDATE проставляете алгоритм для существующих строк, и только потом убираете значение по умолчанию. Между шагами старая версия приложения продолжает работать, потому что новые поля она не читает. Для MySQL синтаксис тот же, только вместо timestamptz используется datetime, а значение по умолчанию для текстового поля задается через DEFAULT 'unknown'.
Индекс по password_algorithm нужен не для аутентификации, а для мониторинга: по нему доля перехешированных аккаунтов считается быстро даже на миллионах строк. Типы и длины полей, хранение соли и параметров алгоритма, готовые DDL для PostgreSQL и MySQL разобраны в руководстве по проектированию таблицы пользователей.
Как выглядит строка до и после перехеширования:
| Момент | password_hash | password_algorithm | password_version |
|---|---|---|---|
| До входа | 5f4dcc3b5aa765d61d8327deb882cf99 | md5 | 1 |
| После успешного входа | $argon2id$v=19$m=19456,t=2,p=1$c2FsdHZhbHVl$9xQ... | argon2id | 2 |
Если хеши считает сама СУБД через PASSWORD() или SHA2(), сначала перенесите вычисления в приложение: разбор ошибок и примеры на PHP и Python собраны в материале про хранение паролей в MySQL и MariaDB. SQL-хеширование не дает соли на пользователя в нужном виде и мешает ленивому перехешированию.
Вариант с префиксом хеша вместо отдельного поля
Когда изменить схему нельзя, алгоритм определяют по началу строки: $2y$ и $2b$ означают bcrypt, $argon2id$ - Argon2id, $6$ - sha512-crypt. У формата PHC параметры вшиты в саму строку, поэтому memory cost и time cost можно прочитать без обращения к таблице.
У подхода два слабых места. У MD5 и SHA-1 стандартного префикса нет: hex-строка из 32 символов может быть и MD5, и частью чужого хеша, а эвристика по длине ломается на bcrypt (60 символов) и на base64-вариантах. Второе: логика разбора префиксов расползается по коду, и при смене библиотеки ее приходится переписывать. Отдельное поле надежнее, потому что не зависит от формата хранения и читается одним запросом вместе с остальными данными пользователя.
Логика проверки версии хеша при аутентификации
Порядок действий в обработчике входа выглядит так:
- Найти пользователя по логину и вытащить password_hash, password_algorithm, password_version.
- Выбрать функцию проверки по значению password_algorithm.
- Сравнить пароль с хешем в режиме постоянного времени, без раннего выхода по несовпадению байтов.
- Если пароль неверен, зафиксировать неудачную попытку и вернуть ошибку, не трогая хеш.
- Если пароль верен и алгоритм или версия устарели, посчитать новый хеш и обновить все четыре поля.
def authenticate(login, password):
row = db.fetch_one(
'SELECT id, password_hash, password_algorithm, password_version '
'FROM users WHERE login = %s', login)
if row is None:
return None
algo = row['password_algorithm']
stored = row['password_hash']
if algo == 'argon2id':
ok = ph.verify(stored, password)
elif algo == 'bcrypt':
ok = bcrypt.checkpw(password.encode(), stored.encode())
elif algo == 'sha1':
ok = hmac.compare_digest(hashlib.sha1(password.encode()).hexdigest(), stored)
elif algo == 'md5':
ok = hmac.compare_digest(hashlib.md5(password.encode()).hexdigest(), stored)
else:
log.error('unknown hash algorithm: %s', algo)
return None
if not ok:
log_failed_attempt(row['id'])
return None
if algo != CURRENT_ALGO or row['password_version'] < CURRENT_VERSION:
new_hash = ph.hash(password)
db.execute(
'UPDATE users SET password_hash = %s, password_algorithm = %s, '
'password_version = %s, password_updated_at = now() WHERE id = %s',
new_hash, CURRENT_ALGO, CURRENT_VERSION, row['id'])
log.info('rehash user_id=%s %s -> %s', row['id'], algo, CURRENT_ALGO)
return row['id']
Проверка старых хешей без соли требует внимания к деталям. Сравнивайте через hmac.compare_digest или аналог: обычное равенство строк раскрывает информацию через время ответа. Если в базе лежат SHA-1 и MD5 без соли, это единственный слой защиты до момента перехеширования.
Обновление хеша выполняйте в той же транзакции, что и проверку, либо через условный UPDATE с фильтром по старому значению password_hash. Простой UPDATE без условия создает гонку: параллельный вход с тем же паролем перезапишет свежий хеш результатом второго запроса, и версия в поле перестанет соответствовать фактическим параметрам.
Стоимость хеширования при входе стоит учитывать в бюджете задержки. Argon2id с memory cost 19 MiB занимает десятки миллисекунд, и на пике это заметно. Перехеширование удобно выносить в фоновую очередь: ответ пользователю отдается сразу после проверки, а запись обновляется обработчиком очереди с ключом по user_id и защитой от повторной постановки. Событие перехеширования логируйте без самого хеша и без пароля, только user_id, старый и новый алгоритм, метку времени. Этого достаточно для аудита и разбора инцидентов.
Выбор алгоритма: Argon2id или bcrypt с высоким cost
Argon2id победил в конкурсе Password Hashing Competition в 2015 году и входит в рекомендации OWASP для новых систем. Его сильная сторона - память: memory cost делает перебор на GPU и ASIC дороже, потому что атакующему приходится платить за объем памяти на каждый параллельный поток.
| Параметр | Argon2id | bcrypt |
|---|---|---|
| Опора на память | Да, memory cost задается в KiB | Нет, около 4 KiB на хеш |
| Минимум по OWASP | m=19456 (19 MiB), t=2, p=1 | cost 10 и выше |
| Ограничение входа | Нет практического лимита | 72 байта, длинный пароль обрезается |
| Формат хранения | Строка PHC с параметрами внутри | $2y$ или $2b$ с солью в строке |
| Стоимость памяти на 100 одновременных входов | Около 1,9 ГБ при m=19 MiB | Менее 1 МБ |
Лимит bcrypt в 72 байта обходится предварительным хешированием пароля, например SHA-256 с последующей base64-кодировкой, иначе длинные пароли и парольные фразы теряют энтропию в хвосте.
Что выбрать на практике. Если сервер аутентификации живет в контейнере с жестким лимитом памяти или рядом крутятся другие сервисы, начните с bcrypt cost 12: это около 4096 раундов и примерно 200-300 мс на современном ядре. Cost 12 заметно медленнее прежних cost 4-6 и уже дает ощутимый выигрыш против перебора. Когда появится запас ресурсов, поднимите password_version и перейдите на Argon2id: поле версии заставит код перехешировать аккаунты автоматически при следующих входах.
Для Argon2id начните с m=19456 KiB, t=2, p=1. На сервере с 8 ГБ памяти это позволяет держать около 400 одновременных проверок, чего хватает большинству сервисов. Нагрузочный тест обязателен: измерьте p95 времени входа при реалистичном пике, а не на одном запросе в секунду. Если p95 уходит выше 500 мс, снижайте memory cost, а не количество раундов, и компенсируйте rate limiting. Настройка лимитов и защита от перебора описаны в руководстве по защите хранилища паролей от брутфорса.
Типичные ошибки при миграции и как их избежать
- Забыли про сервисные аккаунты и API-ключи. Они не входят в интерфейс и не перехешируются лениво. Решение: отдельный список технических учетных записей и ручное перехеширование по заранее подготовленному скрипту.
- Не учли пользователей, которые никогда не войдут. Решение: политика для неактивных аккаунтов, удаление локального хеша при внешнем IdP или принудительная смена при первом входе после 90 дней простоя.
- Поставили слишком высокий cost. Логин начинает отваливаться по таймауту на пике нагрузки. Решение: нагрузочный тест до включения на продакшене, запас по памяти не менее двукратного к пиковому числу одновременных входов.
- Не сделали резервную копию перед ALTER TABLE. Решение: снимок базы до правки схемы и проверенный тест восстановления, а не просто факт наличия дампа.
- Тестировали на пустой базе. Решение: копия продакшн-данных с реальным распределением длин хешей и реальными логинами, включая юникод и пробелы.
- Не добавили мониторинг доли перехешированных паролей. Без метрики миграция незаметно встает, и через год в базе по-прежнему половина MD5.
- Выбрали небезопасный алгоритм для новых хешей. SHA-256 без соли, MD5 с солью, самодельные схемы с одним раундом не решают задачу: быстрый алгоритм перебирается на GPU миллиардами вариантов в секунду.
План отката и мониторинг миграции
Главная ловушка отката в том, что после перехеширования старый хеш исчезает. Возврат к прежней версии кода сам по себе не восстановит работу: приложение с проверкой MD5 не поймет строку Argon2id. Отсюда два практичных варианта.
Вариант с теневым полем. На время миграции новый хеш пишется в password_hash_new, а password_hash остается прежним до конца кампании. Откат сводится к переключению флага чтения. Минус очевиден: в базе лежат два хеша, и требования безопасности к ней удваиваются. Плюс: возврат занимает минуту.
Вариант с флагом и резервной копией. Перехеширование управляется настройкой rehash_enabled, которую можно выключить за секунды. Порядок откатa: выключить флаг, оценить масштаб проблемы по логам, при необходимости восстановить базу из копии, сделанной до правки схемы, и вернуть прежний код. Правку самой схемы откатывать не обязательно: лишние поля не мешают старой логике, если она их игнорирует.
Ленивая миграция облегчает откат именно потому, что старые хеши не удаляются пачками: на момент остановки в базе остается смесь алгоритмов, и каждый аккаунт продолжает работать тем способом, который для него записан.
Метрики, за которыми стоит следить: доля аккаунтов с новым алгоритмом, число перехеширований в час, доля неудачных входов, p95 времени проверки пароля, число ошибок вида unknown algorithm. Доля считается одним запросом:
SELECT password_algorithm,
count(*) AS users,
round(100.0 * count(*) / sum(count(*)) OVER (), 2) AS pct
FROM users
GROUP BY password_algorithm
ORDER BY users DESC;
Пороговые значения задавайте заранее. Рост доли неудачных входов больше чем на 1 процент от базовой линии или появление хотя бы одной ошибки неизвестного алгоритма - повод выключить перехеширование и разбираться. Динамику удобно смотреть не только в дашборде, но и в агрегированных логах: аномалии в тексте ошибок быстрее находятся, если прогнать выборку через API языковой модели, например через агрегатор AiTunnel с единым интерфейсом к GPT, Gemini и Claude.
Для тестового стенда и нагрузочной проверки достаточно отдельного сервера с копией базы и тем же объемом памяти, что у продакшена, иначе замеры cost не имеют смысла. Поднять такой стенд можно на облачных ресурсах, например в Timeweb Cloud, где сервер и база конфигурируются под задачу и так же легко удаляются после проверки.
Чек-лист запуска ленивой миграции
- Снимите резервную копию базы и проверьте восстановление на отдельном инстансе.
- Добавьте поля password_algorithm, password_version и password_updated_at, заполните их для существующих строк и уберите значение по умолчанию.
- Создайте индекс по password_algorithm для мониторинга прогресса.
- Обновите код аутентификации: выбор функции проверки по полю алгоритма и сравнение в постоянном времени.
- Добавьте перехеширование после успешной проверки с обновлением версии и метки времени.
- Настройте логирование событий перехеширования без хранения паролей и хешей.
- Закройте отдельным списком сервисные учетные записи и пропишите для них ручной сценарий.
- Определите политику для неактивных аккаунтов: требование смены, удаление локального хеша или удаление учетной записи.
- Проведите нагрузочный тест на копии данных и зафиксируйте p95 времени входа.
- Включите перехеширование сначала на части трафика, затем на всем сервисе.
- Задокументируйте порядок откатa и пороги метрик, при которых он запускается.
Планирование такой кампании мало отличается от любого перехода с изменением живых данных: инвентаризация, окно изменений, тестовый прогон, метрики и обратный сценарий. Расширенный набор чек-листов, расчетов и шаблонов runbook для подобных работ собран в руководстве по миграции данных и пользователей для DevOps, а особенности переноса самой СУБД с проверкой целостности описаны в плане миграции баз данных.
Начните с одного: добавьте три поля в таблицу пользователей и проверку алгоритма в код входа. Этого достаточно, чтобы любой следующий успешный вход пользователя уменьшал число слабых хешей в базе.